first commit.
This commit is contained in:
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "cache",
|
||||
"version": "0.1.0",
|
||||
"description": "Caché APCu (APCache)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"apcache": { "files": ["src/Libs/APCache.php"] }
|
||||
},
|
||||
"default": ["apcache"]
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* DuckBrain - Microframework
|
||||
*
|
||||
* APCache is a simple library that makes use of APC cache.
|
||||
* Data is stored in memory and does not disappear until
|
||||
* it expires or is explicitly deleted.
|
||||
*
|
||||
* @author: KJ
|
||||
* @web: https://kj2.me
|
||||
* @license: MIT
|
||||
*/
|
||||
class APCache
|
||||
{
|
||||
/**
|
||||
* Get an item from the cache.
|
||||
*
|
||||
* @param string $key
|
||||
* The key to retrieve.
|
||||
* @return mixed
|
||||
* The stored variable or an array of variables on success; FALSE on failure.
|
||||
*/
|
||||
public static function get(string $key): mixed
|
||||
{
|
||||
return apcu_fetch($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores or overwrites a key in the cache.
|
||||
*
|
||||
* @param string $key
|
||||
* The key to store.
|
||||
* @param mixed $data
|
||||
* The variable to store under the key.
|
||||
* @param int $expire
|
||||
* Expiration time for the key in seconds.
|
||||
* @return bool | array
|
||||
* Returns TRUE on success or FALSE on failure. An array of error keys on failure.
|
||||
*/
|
||||
public static function save(string $key, mixed $data, int $expire = 0): bool|array
|
||||
{
|
||||
return apcu_store($key, $data, $expire);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an item from the cache.
|
||||
*
|
||||
* @param string $key
|
||||
* The key to delete.
|
||||
* @return boolean | array
|
||||
* Returns TRUE on success or FALSE on failure. An array of error keys on failure.
|
||||
*/
|
||||
public static function delete(string $key): bool|array
|
||||
{
|
||||
return apcu_delete($key);
|
||||
}
|
||||
}
|
||||
@@ -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), ''];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "crypto",
|
||||
"version": "0.1.0",
|
||||
"description": "Criptografía y segundo factor (Crypto, Nonce, TwoFactorAuthenticator)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"crypto": { "files": ["src/Libs/Crypto.php"] },
|
||||
"nonce": { "files": ["src/Libs/Nonce.php"] },
|
||||
"twoFactor": { "files": ["src/Libs/TwoFactorAuthenticator.php"] }
|
||||
},
|
||||
"default": ["crypto", "nonce", "twoFactor"]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* Crypto - DuckBrain
|
||||
*
|
||||
* Class created for encrypting/decrypting
|
||||
* using openssl with the AES 256 CBC algorithm.
|
||||
*
|
||||
* Optionally depends on the PRIVATE_KEY constant.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class Crypto
|
||||
{
|
||||
/**
|
||||
* Encrypts using openssl with the AES 256 CBC algorithm
|
||||
* and returns the result in hexadecimal.
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function encryptHex(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return bin2hex(static::encrypt($data, $password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string from hexadecimal
|
||||
* and then using openssl with the AES 256 CBC algorithm.
|
||||
*
|
||||
* @param string $data Hexadecimal string
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function decryptHex(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return static::decrypt(hex2bin($data), $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts using openssl with the AES 256 CBC algorithm
|
||||
* and returns the result in base64.
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function encrypt64(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return base64_encode(static::encrypt($data, $password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a string from base64
|
||||
* and then using openssl with the AES 256 CBC algorithm.
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function decrypt64(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return static::decrypt(base64_decode($data), $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts using openssl with the AES 256 CBC algorithm.
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function encrypt(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return openssl_encrypt(
|
||||
$data,
|
||||
'AES-256-CBC',
|
||||
$password,
|
||||
OPENSSL_RAW_DATA,
|
||||
substr($password, 0, 16)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts using openssl with the AES 256 CBC algorithm.
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function decrypt(string $data, string $password = PRIVATE_KEY): string
|
||||
{
|
||||
return openssl_decrypt(
|
||||
$data,
|
||||
'AES-256-CBC',
|
||||
$password,
|
||||
OPENSSL_RAW_DATA,
|
||||
substr($password, 0, 16)
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* Nonce - DuckBrain
|
||||
*
|
||||
* Simple class to create and verify nonces.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class Nonce
|
||||
{
|
||||
/**
|
||||
* Returns a nonce code.
|
||||
*
|
||||
* @param string $passphrase
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get(string $passphrase = ''): string
|
||||
{
|
||||
return bin2hex(
|
||||
openssl_encrypt(
|
||||
time(),
|
||||
'aes-128-ecb',
|
||||
$passphrase,
|
||||
OPENSSL_RAW_DATA
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a nonce code.
|
||||
*
|
||||
* @param string $nonce
|
||||
* @param string $passphrase
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function check(string $nonce, string $passphrase = ''): bool
|
||||
{
|
||||
$decripted = openssl_decrypt(
|
||||
hex2bin($nonce),
|
||||
'aes-128-ecb',
|
||||
$passphrase,
|
||||
OPENSSL_RAW_DATA
|
||||
);
|
||||
return $decripted > (time() - 1800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* PHP Class for handling Google Authenticator 2-factor authentication.
|
||||
*
|
||||
* @author Michael Kliewe
|
||||
* @copyright 2012 Michael Kliewe
|
||||
* @license http://www.opensource.org/licenses/bsd-license.php BSD License
|
||||
*
|
||||
* @link http://www.phpgangsta.de/
|
||||
*/
|
||||
class TwoFactorAuthenticator // Name changed from PHPGangsta_GoogleAuthenticator to TwoFactorAuthenticator
|
||||
{
|
||||
protected $_codeLength = 6;
|
||||
|
||||
/**
|
||||
* Create new secret.
|
||||
* 16 characters, randomly chosen from the allowed base32 characters.
|
||||
*
|
||||
* @param int $secretLength
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function createSecret(int $secretLength = 16): string
|
||||
{
|
||||
$validChars = $this->_getBase32LookupTable();
|
||||
|
||||
// Valid secret lengths are 80 to 640 bits
|
||||
if ($secretLength < 16 || $secretLength > 128) {
|
||||
throw new Exception('Bad secret length');
|
||||
}
|
||||
$secret = '';
|
||||
$rnd = false;
|
||||
if (function_exists('random_bytes')) {
|
||||
$rnd = random_bytes($secretLength);
|
||||
} elseif (function_exists('mcrypt_create_iv')) {
|
||||
$rnd = mcrypt_create_iv($secretLength, MCRYPT_DEV_URANDOM);
|
||||
} elseif (function_exists('openssl_random_pseudo_bytes')) {
|
||||
$rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong);
|
||||
if (!$cryptoStrong) {
|
||||
$rnd = false;
|
||||
}
|
||||
}
|
||||
if ($rnd !== false) {
|
||||
for ($i = 0; $i < $secretLength; ++$i) {
|
||||
$secret .= $validChars[ord($rnd[$i]) & 31];
|
||||
}
|
||||
} else {
|
||||
throw new Exception('No source of secure random');
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the code, with given secret and point in time.
|
||||
*
|
||||
* @param string $secret
|
||||
* @param int|null $timeSlice
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCode(string $secret, ?int $timeSlice = null): string
|
||||
{
|
||||
if ($timeSlice === null) {
|
||||
$timeSlice = floor(time() / 30);
|
||||
}
|
||||
|
||||
$secretkey = $this->_base32Decode($secret);
|
||||
|
||||
// Pack time into binary string
|
||||
$time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice);
|
||||
// Hash it with users secret key
|
||||
$hm = hash_hmac('SHA1', $time, $secretkey, true);
|
||||
// Use last nipple of result as index/offset
|
||||
$offset = ord(substr($hm, -1)) & 0x0F;
|
||||
// grab 4 bytes of the result
|
||||
$hashpart = substr($hm, $offset, 4);
|
||||
|
||||
// Unpak binary value
|
||||
$value = unpack('N', $hashpart);
|
||||
$value = $value[1];
|
||||
// Only 32 bits
|
||||
$value = $value & 0x7FFFFFFF;
|
||||
|
||||
$modulo = pow(10, $this->_codeLength);
|
||||
|
||||
return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR-Code URL for image, from google charts.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $secret
|
||||
* @param string|null $title
|
||||
* @param array $params
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getQRCodeGoogleUrl(string $name, string $secret, ?string $title = null, array $params = array()): string
|
||||
{
|
||||
$width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200;
|
||||
$height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200;
|
||||
$level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M';
|
||||
|
||||
$urlencoded = urlencode('otpauth://totp/' . $name . '?secret=' . $secret . '');
|
||||
if (isset($title)) {
|
||||
$urlencoded .= urlencode('&issuer=' . urlencode($title));
|
||||
}
|
||||
|
||||
return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size={$width}x{$height}&ecc=$level";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now.
|
||||
*
|
||||
* @param string $secret
|
||||
* @param string $code
|
||||
* @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
|
||||
* @param int|null $currentTimeSlice time slice if we want use other that time()
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null): bool
|
||||
{
|
||||
if ($currentTimeSlice === null) {
|
||||
$currentTimeSlice = floor(time() / 30);
|
||||
}
|
||||
|
||||
if (strlen($code) != 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for ($i = -$discrepancy; $i <= $discrepancy; ++$i) {
|
||||
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
|
||||
if ($this->timingSafeEquals($calculatedCode, $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the code length, should be >=6.
|
||||
*
|
||||
* @param int $length
|
||||
*
|
||||
* @return PHPGangsta_GoogleAuthenticator
|
||||
*/
|
||||
public function setCodeLength(string $length): TwoFactorAuthenticator
|
||||
{
|
||||
$this->_codeLength = $length;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to decode base32.
|
||||
*
|
||||
* @param $secret
|
||||
*
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function _base32Decode($secret): string|bool
|
||||
{
|
||||
if (empty($secret)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$base32chars = $this->_getBase32LookupTable();
|
||||
$base32charsFlipped = array_flip($base32chars);
|
||||
|
||||
$paddingCharCount = substr_count($secret, $base32chars[32]);
|
||||
$allowedValues = array(6, 4, 3, 1, 0);
|
||||
if (!in_array($paddingCharCount, $allowedValues)) {
|
||||
return false;
|
||||
}
|
||||
for ($i = 0; $i < 4; ++$i) {
|
||||
if ($paddingCharCount == $allowedValues[$i] &&
|
||||
substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$secret = str_replace('=', '', $secret);
|
||||
$secret = str_split($secret);
|
||||
$binaryString = '';
|
||||
for ($i = 0; $i < count($secret); $i = $i + 8) {
|
||||
$x = '';
|
||||
if (!in_array($secret[$i], $base32chars)) {
|
||||
return false;
|
||||
}
|
||||
for ($j = 0; $j < 8; ++$j) {
|
||||
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$eightBits = str_split($x, 8);
|
||||
for ($z = 0; $z < count($eightBits); ++$z) {
|
||||
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : '';
|
||||
}
|
||||
}
|
||||
|
||||
return $binaryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array with all 32 characters for decoding from/encoding to base32.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function _getBase32LookupTable(): array
|
||||
{
|
||||
return array(
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
|
||||
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
|
||||
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
|
||||
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
|
||||
'=', // padding char
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A timing safe equals comparison
|
||||
* more info here: http://blog.ircmaxell.com/2014/11/its-all-about-time.html.
|
||||
*
|
||||
* @param string $safeString The internal (safe) value to be checked
|
||||
* @param string $userString The user submitted (unsafe) value
|
||||
*
|
||||
* @return bool True if the two strings are identical
|
||||
*/
|
||||
private function timingSafeEquals(string $safeString, string $userString): bool
|
||||
{
|
||||
if (function_exists('hash_equals')) {
|
||||
return hash_equals($safeString, $userString);
|
||||
}
|
||||
$safeLen = strlen($safeString);
|
||||
$userLen = strlen($userString);
|
||||
|
||||
if ($userLen != $safeLen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = 0;
|
||||
|
||||
for ($i = 0; $i < $userLen; ++$i) {
|
||||
$result |= (ord($safeString[$i]) ^ ord($userString[$i]));
|
||||
}
|
||||
|
||||
// They are only identical strings if $result is exactly 0...
|
||||
return $result === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "htmx",
|
||||
"version": "0.1.0",
|
||||
"description": "Motor de vistas basado en componentes y HTMX",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"htmlComponent": {
|
||||
"files": ["src/Libs/HTMLComponent.php", "src/Libs/Sanitizer"]
|
||||
},
|
||||
"htmx": {
|
||||
"files": ["src/Libs/HTMX.php"],
|
||||
"require": { "htmx:htmlComponent": ">=0.1" }
|
||||
}
|
||||
},
|
||||
"default": ["htmlComponent", "htmx"]
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
use Libs\Sanitizer\SanitizerProxy;
|
||||
|
||||
/**
|
||||
* HTMLComponent - DuckBrain
|
||||
*
|
||||
* Duckbrain Library for HTMX using components.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class HTMLComponent
|
||||
{
|
||||
/**
|
||||
* __construct
|
||||
*
|
||||
* @param array $properties
|
||||
* @param string $content
|
||||
* @param string $viewPath
|
||||
* @param string $extension
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $properties = [],
|
||||
protected string $content = '',
|
||||
protected ?string $viewPath = null,
|
||||
protected string $extension = '.php',
|
||||
) {
|
||||
if (is_null($this->viewPath)) {
|
||||
if (defined('VIEWS_DIR')) {
|
||||
$this->viewPath = rtrim(VIEWS_DIR, '/') . '/';
|
||||
} else {
|
||||
$this->viewPath = ROOT_CORE . '/Views/';
|
||||
}
|
||||
} else {
|
||||
$this->viewPath = rtrim($this->viewPath, '/') . '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the component from a file.
|
||||
*
|
||||
* @param string $component
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function load(string $component): static
|
||||
{
|
||||
$componentRealName = trim(str_replace(':', '/', $component), ' :');
|
||||
if (file_exists($this->viewPath . $componentRealName . $this->extension)) {
|
||||
ob_start();
|
||||
include($this->viewPath . $componentRealName . $this->extension);
|
||||
$this->content = ob_get_clean();
|
||||
$this->parse();
|
||||
} else {
|
||||
throw new \Exception(
|
||||
'"' . $component . '" component not exists.'
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component tag token grammar. Matches an opening tag `<Name attrs>` or a
|
||||
* closing tag `</Name>` where Name is an optional leading-colon namespace
|
||||
* followed by an uppercase letter and at least one word/digit/colon char
|
||||
* (so plain lowercase HTML and single-letter tags are never treated as
|
||||
* components). Mirrors the discovery grammar the previous regex used.
|
||||
*/
|
||||
private const TAG_RE = '/<(\/)?((?::+)?[A-Z][\w1-9:]+)([^>]*)>/';
|
||||
|
||||
/**
|
||||
* Parses and processes the content in search of more components.
|
||||
*
|
||||
* Uses a single left-to-right pass over all component tags with an
|
||||
* explicit stack, so it correctly resolves balanced, same-type nested
|
||||
* components at any depth (which the previous per-name lazy regex could
|
||||
* not) and costs one pass regardless of how many distinct component
|
||||
* types the document uses.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parse(): void
|
||||
{
|
||||
$this->content = $this->parseComponents($this->content, $this->properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every component tag found in $content, rendering each with the
|
||||
* properties inherited from its ancestors merged with its own, and returns
|
||||
* the fully rendered string. Nested components are rendered bottom-up (the
|
||||
* stack assembles each frame's inner content before the frame is rendered).
|
||||
*
|
||||
* @param string $content Content that may contain component tags.
|
||||
* @param array $inherited Properties inherited from ancestor components.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function parseComponents(string $content, array $inherited): string
|
||||
{
|
||||
preg_match_all(self::TAG_RE, $content, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
if (!$tokens) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$cursor = 0;
|
||||
/** @var array<int, array{name: string, props: array, seg: array<int,string>, rawStart: int}> $stack */
|
||||
$stack = [];
|
||||
$top = -1;
|
||||
$length = strlen($content);
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$offset = $token[0][1];
|
||||
$raw = $token[0][0];
|
||||
|
||||
if ($offset > $cursor) {
|
||||
$literal = substr($content, $cursor, $offset - $cursor);
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $literal;
|
||||
} else {
|
||||
$segments[] = $literal;
|
||||
}
|
||||
}
|
||||
$cursor = $offset + strlen($raw);
|
||||
|
||||
$isClose = $token[1][0] === '/';
|
||||
$name = $token[2][0];
|
||||
|
||||
if (!$isClose) {
|
||||
$parentProperties = $top >= 0 ? $stack[$top]['props'] : $inherited;
|
||||
$ownProperties = static::parseProperties($token[3][0]);
|
||||
$stack[] = [
|
||||
'name' => $name,
|
||||
'props' => array_merge($parentProperties, $ownProperties),
|
||||
'seg' => [],
|
||||
'rawStart' => $offset,
|
||||
];
|
||||
$top++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($top >= 0 && $stack[$top]['name'] === $name) {
|
||||
$frame = array_pop($stack);
|
||||
$top--;
|
||||
$inner = implode('', $frame['seg']);
|
||||
$rendered = $this->renderComponent($name, $frame['props'], $inner);
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $rendered;
|
||||
} else {
|
||||
$segments[] = $rendered;
|
||||
}
|
||||
} else {
|
||||
// stray or mis-nested closing tag: keep it verbatim (malformed
|
||||
// markup is left unparsed rather than crashing).
|
||||
if ($top >= 0) {
|
||||
$stack[$top]['seg'][] = $raw;
|
||||
} else {
|
||||
$segments[] = $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($top >= 0) {
|
||||
// unbalanced opening tags: emit the remainder verbatim from the
|
||||
// first unclosed component onward.
|
||||
$segments[] = substr($content, $stack[0]['rawStart']);
|
||||
} elseif ($cursor < $length) {
|
||||
$segments[] = substr($content, $cursor);
|
||||
}
|
||||
|
||||
return implode('', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single component with the given (already inherited+merged)
|
||||
* properties and inner content. The inner content is fully resolved before
|
||||
* it reaches here; load() re-parses the view's own output so components
|
||||
* declared inside a view file keep working.
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
* @param string $inner
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function renderComponent(string $component, array $properties, string $inner): string
|
||||
{
|
||||
$instance = new static($properties);
|
||||
$instance->content = $inner;
|
||||
$instance->load($component);
|
||||
|
||||
return $instance->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string in "key='value' key2='value2'" format into an array.
|
||||
*
|
||||
* @param string $propertiesString
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseProperties(string $propertiesString): array
|
||||
{
|
||||
preg_match_all('/([\w]+)=[\'"](.+)?[\'"]/sU', $propertiesString, $matches, PREG_PATTERN_ORDER);
|
||||
$result = [];
|
||||
foreach ($matches[1] as $index => $property) {
|
||||
$result[$property] = $matches[2][$index];
|
||||
}
|
||||
preg_match_all('/([\w]+) /si', $propertiesString . ' ', $matches, PREG_PATTERN_ORDER);
|
||||
foreach ($matches[1] as $property) {
|
||||
$result[$property] = $property;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function print(): void
|
||||
{
|
||||
echo $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component.
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(string $component, array $properties = []): void
|
||||
{
|
||||
$instance = new static($properties);
|
||||
$instance->load($component);
|
||||
$instance->print();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the component as a string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a property.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function property(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->properties[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a property escaped with htmlspecialchars.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $default
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function escapedProperty(string $key, string $default = ''): string
|
||||
{
|
||||
if (isset($this->properties[$key])) {
|
||||
return SanitizerProxy::apply($this->properties[$key] ?? null);
|
||||
} else {
|
||||
return htmlspecialchars($default);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias function for the property method.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function p(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->property($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias function for the escapedProperty method.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $default
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function e(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->escapedProperty($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* with their values as an associative array.
|
||||
* If no properties are received as arguments, all existing ones are returned.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function propsToArray(...$properties): array
|
||||
{
|
||||
if (empty($properties)) {
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($properties as $property) {
|
||||
$result[$property] = $this->properties[$property] ?? null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* as HTML attributes in property="value" format.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function propsToAtts(...$properties): string
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ($properties as $property) {
|
||||
$attributes[] = $property . '="' .
|
||||
htmlspecialchars($this->property($property, '')) . '"';
|
||||
}
|
||||
|
||||
return ' ' . implode(' ', $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the received array of property names, it returns them
|
||||
* as unary HTML attributes such as checked, required, etc.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function propsToUnary(...$properties): string
|
||||
{
|
||||
$result = ' ';
|
||||
foreach ($properties as $property) {
|
||||
if (isset($this->properties[$property])) {
|
||||
$result .= $property . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treats a property as a definition of a unary HTML property.
|
||||
*
|
||||
* @param string $property Unary property to look for.
|
||||
* @param mixed $result Value to return if the property is defined. If null, returns $property.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function unary(string $property, mixed $result = null): mixed
|
||||
{
|
||||
if (isset($this->properties[$property])) {
|
||||
if (isset($result)) {
|
||||
return $result;
|
||||
} else {
|
||||
return $property;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new property/attribute.
|
||||
* If a property already exists, it replaces it.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addProperty(string $key, mixed $value): void
|
||||
{
|
||||
$this->properties = array_merge($this->properties, [$key => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new properties in bulk.
|
||||
* If a property already exists, it replaces it.
|
||||
*
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addProperties(array $properties): void
|
||||
{
|
||||
$this->properties = array_merge($this->properties, $properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to return the absolute URL from a relative path.
|
||||
*
|
||||
* @param string $path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function route(string $path = '/'): string
|
||||
{
|
||||
if (defined('SITE_URL') && !empty(SITE_URL)) {
|
||||
return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function path(): string
|
||||
{
|
||||
return Router::currentPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* __get
|
||||
*
|
||||
* @param string $index
|
||||
* @return mixed
|
||||
*/
|
||||
public function __get(string $index): mixed
|
||||
{
|
||||
return SanitizerProxy::apply($this->property($index));
|
||||
}
|
||||
|
||||
/**
|
||||
* __isset
|
||||
*
|
||||
* @param string $key
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset(string $key): bool
|
||||
{
|
||||
return isset($this->properties[$key]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* HTMX - DuckBrain
|
||||
*
|
||||
* HTMX component library for DuckBrain.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class HTMX extends HTMLComponent
|
||||
{
|
||||
/**
|
||||
* Valid HTMX swap styles.
|
||||
*/
|
||||
public const SWAP_STYLES = [
|
||||
'innerHTML', 'outerHTML', 'outerSync',
|
||||
'beforebegin', 'before',
|
||||
'afterbegin', 'prepend',
|
||||
'beforeend', 'append',
|
||||
'afterend', 'after',
|
||||
'delete', 'none',
|
||||
'innerMorph', 'outerMorph',
|
||||
'textContent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid HTMX swap option keys (used as key:value after the style).
|
||||
*/
|
||||
public const SWAP_OPTIONS = [
|
||||
'swap', 'settle', 'transition', 'ignoreTitle', 'strip',
|
||||
'focusScroll', 'swapEmpty', 'scroll', 'show', 'showTarget',
|
||||
'scrollTarget', 'target',
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a component
|
||||
*
|
||||
* @param string $component
|
||||
* @param array $properties
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(
|
||||
string $component,
|
||||
array $properties = []
|
||||
): void {
|
||||
$instance = new static($properties);
|
||||
$instance->load($component);
|
||||
$instance->print();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and processes the content in search of more components
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parse(): void
|
||||
{
|
||||
$this->parseHTMX();
|
||||
parent::parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes or displays sections based on comment tags and
|
||||
* HTMX request states.
|
||||
*
|
||||
* Possible comments are:
|
||||
*
|
||||
* | Block | normal | full | partial | Shown if the request is |
|
||||
* |--------------|--------|------+---------+--------------------------------------|
|
||||
* | hx | - | + | + | any HTMX request |
|
||||
* | !hx | + | - | - | not HTMX (plain navigation) |
|
||||
* | hx-full | - | + | - | full page HTMX request |
|
||||
* | !hx-full | + | - | + | not a full HTMX request (plain + swap)|
|
||||
* | hx-partial | - | - | + | partial (targeted) HTMX request |
|
||||
* | !hx-partial | + | + | - | not a partial request (plain + full) |
|
||||
*
|
||||
* "full" includes boosted requests, which are no longer a separate
|
||||
* state (legacy): distinguish them with HTMX::isBoosted() in PHP if
|
||||
* needed.
|
||||
*
|
||||
* "!" negates a block. Nest blocks for AND, and use sibling blocks
|
||||
* with the same content for OR. Nesting two blocks with the same
|
||||
* name is not supported.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function parseHTMX(): void
|
||||
{
|
||||
if (!static::isHtmx()) {
|
||||
$replacement = ['$3', '$3', '$3', '', '', ''];
|
||||
} elseif (static::isPartialRequest()) {
|
||||
$replacement = ['', '$3', '', '$3', '', '$3'];
|
||||
} else {
|
||||
$replacement = ['', '$3', '$3', '$3', '$3', ''];
|
||||
}
|
||||
|
||||
$this->content = trim(preg_replace(
|
||||
[
|
||||
'/([\t \r\n]+)?<!-- !hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- !hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-full -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- !hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-partial -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-full -->([\t \r\n]+)?/siU',
|
||||
'/([\t \r\n]+)?<!-- hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-partial -->([\t \r\n]+)?/siU',
|
||||
],
|
||||
$replacement,
|
||||
$this->content
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes empty lines.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteEmptyLines(): void
|
||||
{
|
||||
$this->content = trim(preg_replace(
|
||||
'/^[ \t]*[\r\n]+/m',
|
||||
'',
|
||||
$this->content
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is htmx or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isHtmx(): bool
|
||||
{
|
||||
return isset($_SERVER['HTTP_HX_REQUEST']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request type of the HTMX request.
|
||||
*
|
||||
* "partial" for targeted swaps, "full" for body-level or
|
||||
* hx-select requests. Empty string if the request is not HTMX.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function requestType(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_REQUEST_TYPE'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is a full page HTMX request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isFullRequest(): bool
|
||||
{
|
||||
return static::requestType() === 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is a partial (targeted) HTMX request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isPartialRequest(): bool
|
||||
{
|
||||
return static::requestType() === 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to an internal relative path by sending the appropriate header
|
||||
* if it's a normal or HTMX request.
|
||||
*
|
||||
* @param string $path
|
||||
* The path relative to the base path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function redirect(string $path): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Redirect: ' . Router::basePath() . ltrim($path, '/'));
|
||||
} else {
|
||||
Router::redirect($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is Boosted or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isBoosted(): bool
|
||||
{
|
||||
return isset($_SERVER['HTTP_HX_BOOSTED']) && boolval($_SERVER['HTTP_HX_BOOSTED']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current browser URL when the HTMX request is made.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function currentURL(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_CURRENT_URL'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTMX target (if it exists).
|
||||
*
|
||||
* The HX-Target header uses the "tagName#id" format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function target(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_TARGET'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HX-Source header: the element that triggered the request,
|
||||
* in "tagName#id" format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function source(): string
|
||||
{
|
||||
return $_SERVER['HTTP_HX_SOURCE'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag name of the element that triggered the request (if it exists).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sourceTag(): string
|
||||
{
|
||||
$source = static::source();
|
||||
$hash = strpos($source, '#');
|
||||
|
||||
return $hash === false ? $source : substr($source, 0, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the element that triggered the request (if it exists).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function sourceId(): string
|
||||
{
|
||||
$source = static::source();
|
||||
$hash = strpos($source, '#');
|
||||
|
||||
return $hash === false ? '' : substr($source, $hash + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when it is an HTMX request, it returns the HTMX hx-swap-oob property.
|
||||
*
|
||||
* @param string $value Swap value: "true" for the default swap,
|
||||
* or a swap style with optional options
|
||||
* (e.g. "beforeend" or "outerHTML").
|
||||
* @param bool $excludeBoosted Exclude if the request is boosted (default: false)
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
|
||||
{
|
||||
if ($excludeBoosted && static::isBoosted()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($value !== 'true') {
|
||||
$value = static::validateSwap($value);
|
||||
}
|
||||
|
||||
return static::isHtmx() ? 'hx-swap-oob="' . $value . '"' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when it is an HTMX request, it returns the HTMX hx-select-oob property.
|
||||
*
|
||||
* @param string $selector
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function selectOob(string $selector): string
|
||||
{
|
||||
return static::isHtmx() ? 'hx-select-oob="' . $selector . '"' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the given content in an HTMX <hx-partial> element, which is
|
||||
* swapped into its own target alongside the main response.
|
||||
*
|
||||
* Returns an empty string on non-HTMX requests, since partials are only
|
||||
* meaningful in a swap response. Note that, if the response contains
|
||||
* only partials, the main swap is skipped unless the triggering element
|
||||
* uses the "swapEmpty:true" swap option.
|
||||
*
|
||||
* @param string $content HTML content to deliver to the target.
|
||||
* @param string $target CSS selector of the target element.
|
||||
* @param string $swap Swap style (default: innerHTML).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function partial(
|
||||
string $content,
|
||||
string $target,
|
||||
string $swap = 'innerHTML'
|
||||
): string {
|
||||
if (!static::isHtmx()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$swap = static::validateSwap($swap);
|
||||
|
||||
return '<hx-partial hx-target="' . htmlspecialchars($target)
|
||||
. '" hx-swap="' . htmlspecialchars($swap) . '">'
|
||||
. $content
|
||||
. '</hx-partial>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the browser's URL to change and adds it to the history.
|
||||
*
|
||||
* Equivalent to using the htmx hx-push-url property. When the user
|
||||
* navigates back, htmx re-requests the URL from the server and swaps it
|
||||
* into <body> (or the [hx-history-elt] element if present).
|
||||
*
|
||||
* @param string $url Relative path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function pushUrl(string $url): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Push-Url: ' . static::route($url));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the browser's URL to change without adding it to the history.
|
||||
*
|
||||
* Equivalent to using the htmx hx-replace-url property.
|
||||
*
|
||||
* @param string $url Relative path.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function replaceUrl(string $url): void
|
||||
{
|
||||
if (static::isHtmx()) {
|
||||
header('HX-Replace-Url: ' . static::route($url));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the HTMX swap target.
|
||||
*
|
||||
* @param string $selector
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function retarget(string $selector): void
|
||||
{
|
||||
header('HX-Retarget: ' . $selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the HTMX swap rule.
|
||||
*
|
||||
* The value is a swap style optionally followed by swap options,
|
||||
* e.g. "outerHTML transition:true" or "delete".
|
||||
*
|
||||
* @param string $rule
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function reswap(string $rule = 'innerHTML'): void
|
||||
{
|
||||
header('HX-Reswap: ' . static::validateSwap($rule));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a HTMX swap value ("style [key:value]...").
|
||||
*
|
||||
* @param string $value
|
||||
*
|
||||
* @return string The normalized value.
|
||||
*
|
||||
* @throws \InvalidArgumentException On an unknown style or option.
|
||||
*/
|
||||
public static function validateSwap(string $value): string
|
||||
{
|
||||
$parts = preg_split(
|
||||
'/\s+/',
|
||||
trim($value),
|
||||
-1,
|
||||
PREG_SPLIT_NO_EMPTY
|
||||
);
|
||||
|
||||
if (empty($parts)) {
|
||||
throw new \InvalidArgumentException('Empty swap value.');
|
||||
}
|
||||
|
||||
if (!in_array($parts[0], static::SWAP_STYLES, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid swap style: ' . $parts[0]
|
||||
);
|
||||
}
|
||||
|
||||
foreach (array_slice($parts, 1) as $option) {
|
||||
$key = explode(':', $option)[0];
|
||||
if (!in_array($key, static::SWAP_OPTIONS, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Invalid swap option: ' . $option
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an HTMX event on the frontend upon response.
|
||||
*
|
||||
* @param Neuron|string $event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function trigger(Neuron|string $event): void
|
||||
{
|
||||
if (is_string($event)) {
|
||||
header('HX-Trigger: ' . $event);
|
||||
} else {
|
||||
header('HX-Trigger: ' . json_encode($event));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Libs\Sanitizer;
|
||||
|
||||
use ArrayAccess;
|
||||
use Countable;
|
||||
use Iterator;
|
||||
|
||||
/**
|
||||
* Library for HTML sanitization of arrays.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class SanitizerArrayProxy implements ArrayAccess, Iterator, Countable
|
||||
{
|
||||
public function __construct(
|
||||
protected array &$data
|
||||
) {
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
return isset($this->data[$offset]);
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
return SanitizerProxy::apply($this->data[$offset] ?? null);
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
$this->data[$offset] = $value;
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
unset($this->data[$offset]);
|
||||
}
|
||||
|
||||
public function current(): mixed
|
||||
{
|
||||
return SanitizerProxy::apply(current($this->data));
|
||||
}
|
||||
|
||||
public function key(): mixed
|
||||
{
|
||||
return key($this->data);
|
||||
}
|
||||
|
||||
public function next(): void
|
||||
{
|
||||
next($this->data);
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
reset($this->data);
|
||||
}
|
||||
|
||||
public function valid(): bool
|
||||
{
|
||||
$key = key($this->data);
|
||||
return $key !== null && isset($this->data[$key]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Libs\Sanitizer;
|
||||
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* HTML sanitization library.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class SanitizerProxy
|
||||
{
|
||||
public function __construct(
|
||||
protected mixed &$data
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to sanitize if is a string, ignore if is a enum, number boolean or null
|
||||
* or in other case, create a proxy.
|
||||
*
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public static function apply(mixed $data = null): mixed
|
||||
{
|
||||
if ($data instanceof UnitEnum) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
return match (gettype($data)) {
|
||||
'string' => htmlspecialchars($data),
|
||||
'integer', 'double', 'boolean', 'NULL' => $data,
|
||||
'array' => new SanitizerArrayProxy($data),
|
||||
default => new static($data)
|
||||
};
|
||||
}
|
||||
|
||||
public function __set(string $name, mixed $value): void
|
||||
{
|
||||
$this->data->{$name} = $value;
|
||||
}
|
||||
|
||||
public function __unset(string $name): void
|
||||
{
|
||||
unset($this->data->{$name});
|
||||
}
|
||||
|
||||
public function __get(string $key): mixed
|
||||
{
|
||||
return $this->apply($this->data->{$key});
|
||||
}
|
||||
|
||||
public function __isset(string $key): bool
|
||||
{
|
||||
return isset($this->data->{$key});
|
||||
}
|
||||
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
return $this->apply($this->data->{$name}(...$arguments));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "http",
|
||||
"version": "0.1.0",
|
||||
"description": "Utilidades HTTP (CurlRequest, Aria2, Proxy, CompreFace)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"curlRequest": { "files": ["src/Libs/CurlRequest.php"] },
|
||||
"aria2": { "files": ["src/Libs/Aria2.php"] },
|
||||
"proxy": { "files": ["src/Libs/Proxy.php"] },
|
||||
"compreFace": {
|
||||
"files": ["src/Libs/CompreFace.php"],
|
||||
"require": { "http:curlRequest": ">=0.1" }
|
||||
}
|
||||
},
|
||||
"default": ["curlRequest", "aria2", "proxy", "compreFace"]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
use CurlHandle;
|
||||
|
||||
/**
|
||||
* Aria2
|
||||
*
|
||||
* Library to control aria2c from PHP
|
||||
* using its jsonrpc API.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class Aria2
|
||||
{
|
||||
protected CurlHandle $ch;
|
||||
protected ?string $token;
|
||||
protected bool $batch = false;
|
||||
protected array $batch_list = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $server
|
||||
* Path to aria2's jsonrpc server.
|
||||
* @param string|null $token
|
||||
* (optional) Access token for aria2c API.
|
||||
*/
|
||||
public function __construct(string $server = 'http://127.0.0.1:6800/jsonrpc', ?string $token = null)
|
||||
{
|
||||
$this->ch = curl_init($server);
|
||||
$this->token = $token;
|
||||
curl_setopt_array($this->ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
curl_close($this->ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows starting a batch call.
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function batch(): static
|
||||
{
|
||||
$this->batch = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether it is in a batch call or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function inBatch(): bool
|
||||
{
|
||||
return $this->batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a batch request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function commit(): mixed
|
||||
{
|
||||
$data = json_encode($this->batch_list);
|
||||
$this->batch = false;
|
||||
$this->batch_list = [];
|
||||
$result = $this->send($data);
|
||||
return json_decode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to aria2c via jsonrpc.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string|bool
|
||||
*/
|
||||
protected function send(string $data): string|bool
|
||||
{
|
||||
curl_setopt($this->ch, CURLOPT_POSTFIELDS, $data);
|
||||
return curl_exec($this->ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic to simplify work :).
|
||||
*
|
||||
* PHP methods are automatically converted into aria2 methods.
|
||||
*
|
||||
* aria2.addUri => Aria2::addUri
|
||||
* aria2.addTorrent => Aria2::addTorrent
|
||||
* aria2.getPeers => Aria2::getPeers
|
||||
* aria2.addMetalink => Aria2::addMetalink
|
||||
* aria2.remove => Aria2::remove
|
||||
* aria2.pause => Aria2::pause
|
||||
* aria2.forcePause => Aria2::forcePause
|
||||
* aria2.pauseAll => Aria2::pauseAll
|
||||
* aria2.forcePauseAll => Aria2::forcePauseAll
|
||||
* aria2.unpause => Aria2::unpause
|
||||
* aria2.unpauseAll => Aria2::unpauseAll
|
||||
* aria2.forceRemove => Aria2::forceRemove
|
||||
* aria2.changePosition => Aria2::changePosition
|
||||
* aria2.tellStatus => Aria2::tellStatus
|
||||
* aria2.getUris => Aria2::getUris
|
||||
* aria2.getFiles => Aria2::getFiles
|
||||
* aria2.getServers => Aria2::getServers
|
||||
* aria2.tellActive => Aria2::tellActive
|
||||
* aria2.tellWaiting => Aria2::tellWaiting
|
||||
* aria2.tellStopped => Aria2::tellStopped
|
||||
* aria2.getOption => Aria2::getOption
|
||||
* aria2.changeUri => Aria2::changeUri
|
||||
* aria2.changeOption => Aria2::changeOption
|
||||
* aria2.getGlobalOption => Aria2::getGlobalOption
|
||||
* aria2.changeGlobalOption => Aria2::changeGlobalOption
|
||||
* aria2.purgeDownloadResult => Aria2::purgeDownloadResult
|
||||
* aria2.removeDownloadResult => Aria2::removeDownloadResult
|
||||
* aria2.getVersion => Aria2::getVersion
|
||||
* aria2.getSessionInfo => Aria2::getSessionInfo
|
||||
* aria2.shutdown => Aria2::shutdown
|
||||
* aria2.forceShutdown => Aria2::forceShutdown
|
||||
* aria2.getGlobalStat => Aria2::getGlobalStat
|
||||
* aria2.saveSession => Aria2::saveSession
|
||||
* system.multicall => Aria2::system_multicall
|
||||
* system.listMethods => Aria2::system_listMethods
|
||||
* system.listNotifications => Aria2::system_listNotifications
|
||||
*
|
||||
* @param array<int,mixed> $arg
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call(string $name, array $arg): mixed
|
||||
{
|
||||
if (!is_null($this->token)) {
|
||||
array_unshift($arg, $this->token);
|
||||
}
|
||||
|
||||
// Allow system methods (the last 3 on the list)
|
||||
if (strpos($name, '_') === false) { // if it doesn't contain underscores, it's an aria2 method command.
|
||||
$name = 'aria2.' . $name;
|
||||
} else {
|
||||
$name = str_replace('_', '.', $name);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => '1',
|
||||
'method' => $name,
|
||||
'params' => $arg,
|
||||
];
|
||||
|
||||
// Apply batch call if we are in one
|
||||
if ($this->batch) {
|
||||
$this->batch_list[] = $data;
|
||||
return $this;
|
||||
}
|
||||
|
||||
$data = json_encode($data);
|
||||
$response = $this->send($data);
|
||||
if ($response === false) {
|
||||
trigger_error(curl_error($this->ch));
|
||||
}
|
||||
|
||||
return json_decode($response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* CompreFase - DuckBrain
|
||||
*
|
||||
* Library for CompreFace (https://github.com/exadel-inc/CompreFace/).
|
||||
* Can use the constants COMPREFACE_API and COMPREFACE_KEY.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class CompreFace
|
||||
{
|
||||
private string $apiUrl;
|
||||
private CurlRequest $request;
|
||||
|
||||
/**
|
||||
* __construct
|
||||
*
|
||||
* @param string $apiKey
|
||||
* @param string $apiUrl
|
||||
*/
|
||||
public function __construct(string $apiKey = '', string $apiUrl = '')
|
||||
{
|
||||
if (!defined('COMPREFACE_KEY') && empty($apiKey)) {
|
||||
throw new \Exception('CompreFace API Key is not defined.');
|
||||
}
|
||||
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = COMPREFACE_KEY;
|
||||
}
|
||||
|
||||
if (!empty($apiUrl)) {
|
||||
$this->apiUrl = $apiUrl;
|
||||
} elseif (defined('COMPREFACE_API')) {
|
||||
$this->apiUrl = COMPREFACE_API;
|
||||
} else {
|
||||
$this->apiUrl = 'http://localhost:8000/api/v1/';
|
||||
}
|
||||
|
||||
$this->request = new CurlRequest();
|
||||
$this->request->headers['x-api-key'] = $apiKey;
|
||||
$this->request->headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the request's JSON and returns it as Neuron.
|
||||
*
|
||||
* @param mixed $default
|
||||
* Default value to return if the result is not JSON.
|
||||
*/
|
||||
private function parseJSON(mixed $default = [])
|
||||
{
|
||||
$json = json_decode($this->request->body());
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of subjects registered in CompreFace.
|
||||
*/
|
||||
public function subjects()
|
||||
{
|
||||
$this->request->get($this->apiUrl . 'recognition/subjects');
|
||||
|
||||
$response = $this->parseJSON(['subjects' => []]);
|
||||
|
||||
if (isset($response->subjects)) {
|
||||
return $response->subjects;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a subject.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function addSubject(string $name)
|
||||
{
|
||||
$this->request->post(
|
||||
$this->apiUrl . 'recognition/subjects',
|
||||
json_encode(['subject' => $name])
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a subject.
|
||||
*
|
||||
* @param string $name Current name
|
||||
* @param string $newName New name
|
||||
*/
|
||||
public function renameSubject(string $name, string $newName)
|
||||
{
|
||||
$this->request->put(
|
||||
$this->apiUrl . 'recognition/subjects/' . rawurlencode($name),
|
||||
json_encode(['subject' => $newName])
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a subject.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function deleteSubject(string $name)
|
||||
{
|
||||
$this->request->delete(
|
||||
$this->apiUrl . 'recognition/subjects/' . rawurlencode($name)
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all subjects from CompreFace.
|
||||
*/
|
||||
public function deleteAllSubjects()
|
||||
{
|
||||
$this->request->delete(
|
||||
$this->apiUrl . 'recognition/subjects'
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subject's images.
|
||||
*
|
||||
* @param string $subjectName
|
||||
*/
|
||||
public function subjectImages(string $subjectName)
|
||||
{
|
||||
$this->request->get(
|
||||
$this->apiUrl . 'recognition/faces',
|
||||
['size' => 100, 'subject' => $subjectName]
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an image to the subject.
|
||||
*
|
||||
* @param string $subjectName
|
||||
* @param string $base64_image
|
||||
*/
|
||||
public function addSubjectImage(string $subjectName, string $base64_image)
|
||||
{
|
||||
$this->request->post(
|
||||
$this->apiUrl . 'recognition/faces?subject=' . rawurlencode($subjectName),
|
||||
json_encode(['file' => $base64_image])
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image from the subject.
|
||||
*
|
||||
* @param string $imageId
|
||||
*/
|
||||
public function deteleSubjectImage(string $imageId)
|
||||
{
|
||||
// CompreFace also allows deleting multiple or deleting all,
|
||||
// but for now I won't add it to this library, as it is not necessary.
|
||||
$this->request->delete(
|
||||
$this->apiUrl . 'recognition/faces/' . rawurlencode($imageId)
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Facial recognition of the sent image.
|
||||
*
|
||||
* @param string $base64_image
|
||||
*/
|
||||
public function recognize(string $base64_image)
|
||||
{
|
||||
$this->request->post(
|
||||
$this->apiUrl . 'recognition/recognize',
|
||||
json_encode(['file' => $base64_image])
|
||||
);
|
||||
|
||||
return $this->parseJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the subject exists in CompreFace.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function subjectExist(string $name)
|
||||
{
|
||||
$subject = $this->subjects();
|
||||
return in_array($name, $subject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* CurlRequest - DuckBrain
|
||||
*
|
||||
* Facade for CURL.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class CurlRequest
|
||||
{
|
||||
public array $headers = [];
|
||||
public array $options = [
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLINFO_HEADER_OUT => true,
|
||||
];
|
||||
public int $statusCode = 0;
|
||||
protected string $responseHeaders = '';
|
||||
protected string $responseBody = '';
|
||||
|
||||
/**
|
||||
* Sends the request.
|
||||
*
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
protected function send(array $options): void
|
||||
{
|
||||
$handler = curl_init();
|
||||
|
||||
$headers = [];
|
||||
foreach ($this->headers as $index => $value) {
|
||||
$headers[] = "$index: $value";
|
||||
}
|
||||
|
||||
$options[CURLOPT_HTTPHEADER] = $headers;
|
||||
|
||||
foreach ($this->options as $index => $option) {
|
||||
if (!isset($options[$index])) {
|
||||
$options[$index] = $option;
|
||||
}
|
||||
}
|
||||
|
||||
curl_setopt_array($handler, $options);
|
||||
|
||||
$this->responseBody = curl_exec($handler);
|
||||
$this->responseHeaders = curl_getinfo($handler, CURLINFO_HEADER_OUT);
|
||||
$this->statusCode = curl_getinfo($handler, CURLINFO_HTTP_CODE);
|
||||
|
||||
// throw new Exception('Error al realizar la solicitud. '. curl_error($handler));
|
||||
|
||||
curl_close($handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request using the GET method.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $args
|
||||
*
|
||||
* @return CurlRequest
|
||||
*/
|
||||
public function get(string $url, array $args = []): CurlRequest
|
||||
{
|
||||
$this->send([
|
||||
CURLOPT_URL => $url . '?' . http_build_query($args),
|
||||
CURLOPT_POST => false,
|
||||
CURLOPT_PUT => false,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request using the POST method.
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $fields
|
||||
*
|
||||
* @return CurlRequest
|
||||
*/
|
||||
public function post(string $url, string $fields = ''): CurlRequest
|
||||
{
|
||||
$this->send([
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request using the PUT method.
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $fields
|
||||
*
|
||||
* @return CurlRequest
|
||||
*/
|
||||
public function put(string $url, string $fields = ''): CurlRequest
|
||||
{
|
||||
$this->send([
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request using the PATCH method.
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $fields
|
||||
*
|
||||
* @return CurlRequest
|
||||
*/
|
||||
public function patch(string $url, string $fields = ''): CurlRequest
|
||||
{
|
||||
$this->send([
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request using the DELETE method.
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $fields
|
||||
*
|
||||
* @return CurlRequest
|
||||
*/
|
||||
public function delete(string $url, string $fields = ''): CurlRequest
|
||||
{
|
||||
$this->send([
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the response headers.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function headers(): string
|
||||
{
|
||||
return $this->responseHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the response body.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function body(): string
|
||||
{
|
||||
return $this->responseBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* PHP Proxy - DuckBrain
|
||||
*
|
||||
* Proxy is a library to create a PHP proxy.
|
||||
* It allows controlling the headers that will be returned to the client,
|
||||
* in addition to placing filters on the resulting HTML code.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class Proxy
|
||||
{
|
||||
public static $proxied_headers = [
|
||||
'Set-Cookie',
|
||||
'Content-Type',
|
||||
'Cookie',
|
||||
'Location',
|
||||
];
|
||||
public static $proxy_base_url_canonical = '/';
|
||||
public static $destination_host = SOURCE_URL;
|
||||
protected static $filters = [];
|
||||
|
||||
/**
|
||||
* Adds a callable (AKA filter) that will receive the content
|
||||
* and should return a string.
|
||||
*
|
||||
* @param callable $cb
|
||||
* Function that will modify the content.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add_filter(callable $cb): void
|
||||
{
|
||||
static::$filters[] = $cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the filters configured with add_filter.
|
||||
*
|
||||
* @param string $content
|
||||
* Content on which the filters will be applied.
|
||||
*
|
||||
* @return mixed
|
||||
* It would be correct to return a string, but
|
||||
* since the filters might not return correctly,
|
||||
* to avoid a design error, we specify that it returns mixed.
|
||||
*/
|
||||
protected static function apply_filters(string $content): mixed
|
||||
{
|
||||
foreach (static::$filters as $filter) {
|
||||
$content = call_user_func($filter, $content);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the proxy process.
|
||||
*
|
||||
* @param string|null $proxy_request_uri
|
||||
* The address to which the proxy will be applied.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(?string $proxy_request_uri = null): void
|
||||
{
|
||||
// Get the URI if not defined
|
||||
if (is_null($proxy_request_uri)) {
|
||||
$proxy_request_uri = substr($_SERVER['REQUEST_URI'], strlen(static::$proxy_base_url_canonical));
|
||||
} elseif (substr($proxy_request_uri, 0, 1) == '/') {
|
||||
$proxy_request_uri = substr($proxy_request_uri, 1);
|
||||
}
|
||||
|
||||
// Generate the final URL
|
||||
$proxy_request_url = static::$destination_host . $proxy_request_uri;
|
||||
|
||||
// cURL initialization
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $proxy_request_url);
|
||||
curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HEADER, 1);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Expect:'));
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
|
||||
|
||||
// Capture client cookies
|
||||
$hdrs = [];
|
||||
if (isset($_SERVER['HTTP_COOKIE'])) {
|
||||
$hdrs[] = "Cookie: " . $_SERVER['HTTP_COOKIE'];
|
||||
}
|
||||
|
||||
// Capture client browser (User-Agent)
|
||||
if (isset($_SERVER['HTTP_USER_AGENT'])) {
|
||||
$hdrs[] = "User-Agent: " . $_SERVER['HTTP_USER_AGENT'];
|
||||
}
|
||||
|
||||
// Add captured headers
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $hdrs);
|
||||
|
||||
// Capture all client headers
|
||||
$hdrs = [];
|
||||
foreach (\getallheaders() as $name => $value) {
|
||||
$hdrs[] = "$name: $value";
|
||||
}
|
||||
|
||||
// Add captured headers to the request
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $hdrs);
|
||||
|
||||
// Add POST parameters
|
||||
if (sizeof($_POST) > 0) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($_POST));
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Parse the response
|
||||
list($headers, $body) = explode("\r\n\r\n", $res, 2);
|
||||
$headers = str_replace(static::$destination_host, '/', $headers);
|
||||
|
||||
$headers = explode("\r\n", $headers);
|
||||
$hs = [];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (false !== strpos($header, ': ')) {
|
||||
list($h, $v) = explode(': ', $header);
|
||||
$hs[$h][] = $v;
|
||||
} else {
|
||||
$header1 = $header;
|
||||
}
|
||||
}
|
||||
|
||||
// Define headers in the client
|
||||
list($proto, $code, $text) = explode(' ', $header1);
|
||||
header($_SERVER['SERVER_PROTOCOL'] . ' ' . $code . ' ' . $text);
|
||||
|
||||
foreach (static::$proxied_headers as $hname) {
|
||||
if (isset($hs[$hname])) {
|
||||
foreach ($hs[$hname] as $v) {
|
||||
if ($hname === 'Set-Cookie') {
|
||||
header($hname . ": " . $v, false);
|
||||
} else {
|
||||
header($hname . ": " . $v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
die(static::apply_filters($body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "id",
|
||||
"version": "0.1.0",
|
||||
"description": "Identificadores ULID (Ulid)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"ulid": { "files": ["src/Libs/Ulid.php"] }
|
||||
},
|
||||
"default": ["ulid"]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* ULID (Universally Unique Lexicographically Sortable Identifier) implementation.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class Ulid
|
||||
{
|
||||
public const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
public const ENCODING_LENGTH = 32;
|
||||
public const RANDOM_LENGTH = 16;
|
||||
public const TIME_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private static $lastGenTime = 0;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private static $lastRandChars = [];
|
||||
|
||||
/**
|
||||
* Generates a unique ULID.
|
||||
*
|
||||
* Borrowed from robinvdvleuten/ulid
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function generate(): string
|
||||
{
|
||||
$timestamp = (int) (microtime(true) * 1000);
|
||||
|
||||
$duplicateTime = $timestamp === static::$lastGenTime;
|
||||
static::$lastGenTime = $timestamp;
|
||||
|
||||
$timeChars = '';
|
||||
$randChars = '';
|
||||
|
||||
for ($i = static::TIME_LENGTH - 1; $i >= 0; $i--) {
|
||||
$mod = $timestamp % static::ENCODING_LENGTH;
|
||||
$timeChars = static::ENCODING_CHARS[$mod] . $timeChars;
|
||||
$timestamp = ($timestamp - $mod) / static::ENCODING_LENGTH;
|
||||
}
|
||||
|
||||
if (!$duplicateTime) {
|
||||
for ($i = 0; $i < static::RANDOM_LENGTH; $i++) {
|
||||
static::$lastRandChars[$i] = random_int(0, 31);
|
||||
}
|
||||
} else {
|
||||
for ($i = static::RANDOM_LENGTH - 1; $i >= 0 && static::$lastRandChars[$i] === 31; $i--) {
|
||||
static::$lastRandChars[$i] = 0;
|
||||
}
|
||||
|
||||
static::$lastRandChars[$i]++;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < static::RANDOM_LENGTH; $i++) {
|
||||
$randChars .= static::ENCODING_CHARS[static::$lastRandChars[$i]];
|
||||
}
|
||||
|
||||
return $timeChars . $randChars;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates a ULID.
|
||||
*
|
||||
* @param string $ulid
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function validate(string $ulid): bool
|
||||
{
|
||||
return (bool) preg_match('/^[0-9A-HJKMNPQRSTVWXYZ]{26}$/', strtoupper($ulid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "test",
|
||||
"version": "0.1.0",
|
||||
"description": "Kit de pruebas PHPUnit para Duckbrain",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"harness": {
|
||||
"files": ["phpunit.xml", "tests"],
|
||||
"require": { "commands:cli": ">=0.1" }
|
||||
}
|
||||
},
|
||||
"default": ["harness"],
|
||||
"post-install": [
|
||||
"composer require --dev phpunit/phpunit:^11.0 fakerphp/faker",
|
||||
"composer dump-autoload"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
requireCoverageMetadata="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
</source>
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Libs\Neuron;
|
||||
use Libs\Router;
|
||||
use Tests\Support\HttpStubs;
|
||||
use Tests\Support\Response;
|
||||
|
||||
/**
|
||||
* Base para tests de endpoint: despacha peticiones al router in-process
|
||||
* (sin servidor HTTP) y expone helpers de aserción.
|
||||
*
|
||||
* No incluye helpers de autenticación: Duckbrain no define login por defecto,
|
||||
* así que simular una sesión es responsabilidad de cada proyecto. Extiende esta
|
||||
* clase (o crea tu propia subclase) y añade tu helper, p. ej. `loggedInAs()`.
|
||||
*
|
||||
* Nota: `#[RunTestsInSeparateProcesses]` NO se hereda en PHPUnit 11, así que
|
||||
* cada clase concreta de endpoint debe declarar el atributo para aislar el
|
||||
* estado estático (Synapsis, Router::$params, superglobales, http_response_code).
|
||||
*/
|
||||
abstract class EndpointTestCase extends TestCase
|
||||
{
|
||||
protected function dispatch(
|
||||
string $method,
|
||||
string $path,
|
||||
array $data = [],
|
||||
array $headers = []
|
||||
): Response {
|
||||
$method = strtoupper($method);
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
$_SERVER['REQUEST_URI'] = $path;
|
||||
$_SERVER['HTTP_ACCEPT'] = $headers['Accept'] ?? 'text/html';
|
||||
|
||||
$_GET = [];
|
||||
$_POST = [];
|
||||
|
||||
$query = parse_url($path, PHP_URL_QUERY);
|
||||
if (is_string($query)) {
|
||||
parse_str($query, $_GET);
|
||||
}
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||
$_POST = $data;
|
||||
}
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
Router::$params = new Neuron();
|
||||
HttpStubs::reset();
|
||||
http_response_code(200);
|
||||
|
||||
ob_start();
|
||||
try {
|
||||
Router::apply(parse_url($path, PHP_URL_PATH) ?: $path);
|
||||
} finally {
|
||||
$body = (string) ob_get_clean();
|
||||
}
|
||||
|
||||
return new Response(
|
||||
http_response_code(),
|
||||
$body,
|
||||
HttpStubs::$headers,
|
||||
HttpStubs::$cookies
|
||||
);
|
||||
}
|
||||
|
||||
protected function get(string $path, array $headers = []): Response
|
||||
{
|
||||
return $this->dispatch('GET', $path, [], $headers);
|
||||
}
|
||||
|
||||
protected function post(string $path, array $data = [], array $headers = []): Response
|
||||
{
|
||||
return $this->dispatch('POST', $path, $data, $headers);
|
||||
}
|
||||
|
||||
protected function patch(string $path, array $data = [], array $headers = []): Response
|
||||
{
|
||||
return $this->dispatch('PATCH', $path, $data, $headers);
|
||||
}
|
||||
|
||||
protected function delete(string $path, array $data = [], array $headers = []): Response
|
||||
{
|
||||
return $this->dispatch('DELETE', $path, $data, $headers);
|
||||
}
|
||||
|
||||
protected function assertStatus(int $expected, Response $response): void
|
||||
{
|
||||
$this->assertSame(
|
||||
$expected,
|
||||
$response->status,
|
||||
"Expected status {$expected}, got {$response->status}. Body: {$response->body}"
|
||||
);
|
||||
}
|
||||
|
||||
protected function assertRedirect(string $expectedPath, Response $response): void
|
||||
{
|
||||
$target = $response->redirectTarget();
|
||||
|
||||
$this->assertNotNull($target, 'Expected a redirect header (HX-Redirect or Location).');
|
||||
$this->assertStringEndsWith($expectedPath, $target);
|
||||
}
|
||||
|
||||
protected function assertHtmxTrigger(string $event, Response $response): void
|
||||
{
|
||||
$this->assertSame($event, $response->trigger());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Factories;
|
||||
|
||||
use Faker\Factory as FakerFactory;
|
||||
use Faker\Generator;
|
||||
|
||||
/**
|
||||
* Factory - DuckBrain testing
|
||||
*
|
||||
* Clase base para crear fábricas de datos de prueba. Cada subclase
|
||||
* define los atributos por defecto de un modelo y construye su
|
||||
* instancia.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
abstract class Factory
|
||||
{
|
||||
protected static ?Generator $fake = null;
|
||||
|
||||
/**
|
||||
* Devuelve una instancia de Faker (singleton por fábrica).
|
||||
*/
|
||||
protected static function faker(): Generator
|
||||
{
|
||||
return static::$fake ??= FakerFactory::create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusiona los atributos por defecto con los indicados.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function attributes(array $attributes = []): array
|
||||
{
|
||||
return array_merge(static::definition(), $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atributos por defecto de la fábrica.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
abstract protected static function definition(): array;
|
||||
|
||||
/**
|
||||
* Crea una instancia del modelo con los atributos dados.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
abstract public static function create(array $attributes = []): object;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
/**
|
||||
* Colector estático de headers y cookies emitidos durante los tests.
|
||||
*
|
||||
* En el SAPI CLI, `header()` y `setcookie()` son no-op y `headers_list()`
|
||||
* queda vacío; las funciones de `tests/Support/http_stubs.php` registran
|
||||
* aquí lo que la aplicación intenta emitir.
|
||||
*/
|
||||
class HttpStubs
|
||||
{
|
||||
public static array $headers = [];
|
||||
public static array $cookies = [];
|
||||
|
||||
public static function header(string $value, bool $replace = true, int $responseCode = 0): void
|
||||
{
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $value, $matches)) {
|
||||
http_response_code((int) $matches[1]);
|
||||
}
|
||||
|
||||
if ($responseCode > 0) {
|
||||
http_response_code($responseCode);
|
||||
}
|
||||
|
||||
static::$headers[] = $value;
|
||||
}
|
||||
|
||||
public static function setcookie(
|
||||
string $name,
|
||||
string $value = '',
|
||||
int $expires = 0,
|
||||
string $path = '',
|
||||
string $domain = '',
|
||||
bool $secure = false,
|
||||
bool $httponly = false
|
||||
): void {
|
||||
static::$cookies[$name] = $value;
|
||||
}
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
static::$headers = [];
|
||||
static::$cookies = [];
|
||||
}
|
||||
|
||||
public static function headerValue(string $prefix): ?string
|
||||
{
|
||||
foreach (static::$headers as $header) {
|
||||
if (stripos($header, $prefix) === 0) {
|
||||
return substr($header, strlen($prefix));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function hasHeader(string $prefix): bool
|
||||
{
|
||||
return static::headerValue($prefix) !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
/**
|
||||
* Respuesta capturada de una petición despachada in-process.
|
||||
*/
|
||||
class Response
|
||||
{
|
||||
public function __construct(
|
||||
public int $status,
|
||||
public string $body,
|
||||
public array $headers = [],
|
||||
public array $cookies = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function header(string $name): ?string
|
||||
{
|
||||
foreach ($this->headers as $header) {
|
||||
if (stripos($header, $name . ':') === 0) {
|
||||
return trim(substr($header, strlen($name) + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasHeader(string $name): bool
|
||||
{
|
||||
return $this->header($name) !== null;
|
||||
}
|
||||
|
||||
public function redirectTarget(): ?string
|
||||
{
|
||||
return $this->header('HX-Redirect') ?? $this->header('Location');
|
||||
}
|
||||
|
||||
public function trigger(): ?string
|
||||
{
|
||||
return $this->header('HX-Trigger');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Stubs por namespace de `header()` y `setcookie()`.
|
||||
*
|
||||
* Como el SAPI CLI no emite headers, estas funciones interceptan las llamadas
|
||||
* no cualificadas de los namespaces de la aplicación y las registran en
|
||||
* {@see \Tests\Support\HttpStubs}.
|
||||
*
|
||||
* La plantilla cubre únicamente el namespace del núcleo (`Libs`), que es el
|
||||
* único que emite cabeceras en Duckbrain. Si tu proyecto emite headers o
|
||||
* cookies desde otro namespace (`Models`, `Handlers`, `Controllers\...`, etc.),
|
||||
* copia el bloque de `Libs` cambiando el nombre del namespace. Las llamadas
|
||||
* desde namespaces no registrados aquí se ignorarán en los tests.
|
||||
*/
|
||||
|
||||
namespace Libs {
|
||||
function header(string $header, bool $replace = true, int $response_code = 0): void
|
||||
{
|
||||
\Tests\Support\HttpStubs::header($header, $replace, $response_code);
|
||||
}
|
||||
|
||||
function setcookie(string $name, string $value = '', int $expires = 0, string $path = '', string $domain = '', bool $secure = false, bool $httponly = false): void
|
||||
{
|
||||
\Tests\Support\HttpStubs::setcookie($name, $value, $expires, $path, $domain, $secure, $httponly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Commands\Migration;
|
||||
use Override;
|
||||
use PHPUnit\Framework\TestCase as PHPunitTestCase;
|
||||
|
||||
abstract class TestCase extends PHPunitTestCase
|
||||
{
|
||||
private static Migration $db_migration;
|
||||
|
||||
#[Override]
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
// Garantiza que ningún test corra contra la base de datos real.
|
||||
self::assertSame('sqlite', DB_TYPE, 'Tests must run on SQLite.');
|
||||
self::assertSame(':memory:', DB_NAME, 'Tests must run on an in-memory database.');
|
||||
|
||||
static::$db_migration = new Migration(verbose: false);
|
||||
static::$db_migration->ensureDatabaseExists();
|
||||
// fresh() limpia el historial de migraciones y vuelve a aplicarlas: en
|
||||
// modo aislado, el proceso padre también ejecuta este hook y deja el
|
||||
// archivo de control poblado, por lo que migrate() se saltaría el
|
||||
// esquema sobre la base :memory: vacía del hijo.
|
||||
static::$db_migration->fresh();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
// Ejecutar el rollback de las migraciones
|
||||
static::$db_migration->rollback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Libs\Database;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Prueba de humo: verifica que el entorno de pruebas quedó bien instalado.
|
||||
*/
|
||||
class BootstrapTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function coreClassesAreAutoloadable(): void
|
||||
{
|
||||
$this->assertTrue(class_exists(Database::class));
|
||||
$this->assertTrue(class_exists(\Libs\Loader::class));
|
||||
$this->assertTrue(class_exists(\Libs\Router::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function databaseConstantsAreDefined(): void
|
||||
{
|
||||
$this->assertNotEmpty(DB_TYPE);
|
||||
$this->assertNotEmpty(DB_NAME);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function testDatabaseIsReachable(): void
|
||||
{
|
||||
$db = Database::getInstance(
|
||||
type: DB_TYPE,
|
||||
host: DB_HOST,
|
||||
name: DB_NAME,
|
||||
user: DB_USER,
|
||||
pass: DB_PASS,
|
||||
);
|
||||
|
||||
$this->assertSame(1, (int) $db->query('SELECT 1')->fetchColumn());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
// Bootstrap para tests - carga el autoload de Composer
|
||||
|
||||
use Libs\Loader;
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// ============================================
|
||||
// IMPORTANT: Definir constantes ANTES de cargar config.php
|
||||
// para sobreescribir la configuración para definir una
|
||||
// base de datos para sólo los test
|
||||
// ============================================
|
||||
define('DB_TYPE', 'sqlite');
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', ':memory:');
|
||||
define('DB_USER', '');
|
||||
define('DB_PASS', '');
|
||||
|
||||
// Incluir config.php con el autoloader
|
||||
@require_once __DIR__ . '/../autoload.php';
|
||||
|
||||
// Router autoload
|
||||
Loader::load(ROOT_CORE . '/Routers/');
|
||||
|
||||
// Other autoloads
|
||||
Loader::load(ROOT_CORE . '/Autoloads/');
|
||||
|
||||
// Stubs de header()/setcookie() por namespace
|
||||
require_once __DIR__ . '/Support/http_stubs.php';
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "text",
|
||||
"version": "0.1.0",
|
||||
"description": "Filtros y reemplazos de texto (Filter, Replacement)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"filter": { "files": ["src/Libs/Filter.php"] },
|
||||
"replacement": { "files": ["src/Libs/Replacement.php"] }
|
||||
},
|
||||
"default": ["filter", "replacement"]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* Filter - DuckBrain
|
||||
*
|
||||
* Library for text filters.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @licence MIT
|
||||
*/
|
||||
class Filter
|
||||
{
|
||||
protected static array $filters = [];
|
||||
|
||||
/**
|
||||
* Adds a filter.
|
||||
*
|
||||
* @param callable $filterCallback
|
||||
* Function that performs the filter, receives the content and returns it filtered.
|
||||
* @param string $context
|
||||
* Context of the filter.
|
||||
* @param int $priority
|
||||
* Priority of the filter, by default 10. Lower values are executed first.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add(callable $filterCallback, string $context = 'content', int $priority = 10): void
|
||||
{
|
||||
if (empty(static::$filters[$context])) {
|
||||
static::$filters[$context] = [];
|
||||
}
|
||||
|
||||
static::$filters[$context][] = [
|
||||
'priority' => $priority,
|
||||
'callback' => $filterCallback,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured filters.
|
||||
*
|
||||
* @param string $content
|
||||
* Content to which the filter will be applied.
|
||||
* @param string $context
|
||||
* Context of the filter.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function apply(string $content, string $context = 'content'): string
|
||||
{
|
||||
if (empty(static::$filters[$context])) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Sort filters in ascending order according to their priority.
|
||||
usort(static::$filters[$context], function ($a, $b) {
|
||||
return $a['priority'] - $b['priority'];
|
||||
});
|
||||
|
||||
foreach (static::$filters[$context] as $filter) {
|
||||
$content = call_user_func($filter['callback'], $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Libs;
|
||||
|
||||
/**
|
||||
* Replacement - DuckBrain
|
||||
*
|
||||
* Library for batch replacements using regex.
|
||||
*
|
||||
* @author KJ
|
||||
* @website https://kj2.me
|
||||
* @license MIT
|
||||
*/
|
||||
class Replacement
|
||||
{
|
||||
protected static array $replace = [
|
||||
'patern' => [],
|
||||
'replacement' => [],
|
||||
];
|
||||
protected static array $replaceCallback = [];
|
||||
|
||||
/**
|
||||
* Adds a replacement configuration.
|
||||
*
|
||||
* @param string $patern
|
||||
* Replacement pattern (Regular expression).
|
||||
*
|
||||
* @param string|callable $replacementOrCallback
|
||||
* Replacement string or callback function for replacement.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add(string $patern, string|callable $replacementOrCallback): void
|
||||
{
|
||||
if (is_callable($replacementOrCallback)) {
|
||||
static::$replaceCallback[$patern] = $replacementOrCallback;
|
||||
return;
|
||||
}
|
||||
|
||||
static::$replace['patern'][] = $patern;
|
||||
static::$replace['replacement'][] = $replacementOrCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a replacement configuration.
|
||||
*
|
||||
* @param string $patern
|
||||
* Replacement pattern (Regular expression).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function delete(string $patern): void
|
||||
{
|
||||
$index = array_search($patern, static::$replace['patern']);
|
||||
if ($index !== false) {
|
||||
unset(static::$replace['patern'][$index]);
|
||||
unset(static::$replace['replacement'][$index]);
|
||||
}
|
||||
unset(static::$replaceCallback[$patern]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured replacements.
|
||||
*
|
||||
* @param string $content
|
||||
* Content to which batch replacements will be applied.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function apply(string $content): string
|
||||
{
|
||||
$content = preg_replace(
|
||||
static::$replace['patern'],
|
||||
static::$replace['replacement'],
|
||||
$content
|
||||
);
|
||||
|
||||
$content = preg_replace_callback_array(
|
||||
static::$replaceCallback,
|
||||
$content
|
||||
);
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user