first commit.
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#+TITLE: Duckbrain toolbox
|
||||||
|
#+AUTHOR: KJ
|
||||||
|
#+OPTIONS: toc:2
|
||||||
|
|
||||||
|
Instalador y catálogo de paquetes de [[https://git.kj2.me/kj/duckbrain][Duckbrain]]. Trae el núcleo a un
|
||||||
|
proyecto y gestiona sus addons con un solo comando, manteniendo la filosofía del
|
||||||
|
núcleo: *sin Composer obligatorio*, código legible y *copias reales
|
||||||
|
desplegables* (hosting compartido, FTP, etc.).
|
||||||
|
|
||||||
|
* Qué resuelve
|
||||||
|
|
||||||
|
El núcleo Duckbrain mantiene su código simple y sin dependencias, y todo lo
|
||||||
|
accesorio vive en addons. Antes, instalar un addon era copiar archivos a mano
|
||||||
|
dentro de =src/=: cada proyecto quedaba con una versión distinta, las
|
||||||
|
correcciones no se propagaban y se acumulaba código muerto.
|
||||||
|
|
||||||
|
El toolbox convierte eso en:
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
duckbrain install mi-proyecto
|
||||||
|
duckbrain add crypto
|
||||||
|
duckbrain update
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
* Conceptos
|
||||||
|
|
||||||
|
| Concepto | Qué es |
|
||||||
|
|-------------+----------------------------------------------------------|
|
||||||
|
| Repositorio | Dónde vive el código (canal de distribución) |
|
||||||
|
| Paquete | Una familia de componentes (=http=, =crypto=, =text=…) |
|
||||||
|
| Componente | La unidad instalable (una clase o una familia) |
|
||||||
|
| Manifiesto | =duckbrain.json=: qué archivos aporta cada componente |
|
||||||
|
| Catálogo | Los paquetes disponibles en el toolbox |
|
||||||
|
| Lock | =.duckbrain/lock.json=: qué quedó instalado y con qué hash |
|
||||||
|
|
||||||
|
La unidad que se pide con =duckbrain add= es el *componente*, no el paquete ni el
|
||||||
|
repositorio.
|
||||||
|
|
||||||
|
* Estructura del repositorio
|
||||||
|
|
||||||
|
#+BEGIN_SRC text
|
||||||
|
duckbrain-toolbox/
|
||||||
|
bin/duckbrain instalador global (bootstrap)
|
||||||
|
scripts/release escritor único de versión + tag
|
||||||
|
packages/
|
||||||
|
commands/ cli, migration, toolbox
|
||||||
|
htmx/ htmlComponent, htmx
|
||||||
|
http/ curlRequest, aria2, proxy, compreFace
|
||||||
|
crypto/ crypto, nonce, twoFactor
|
||||||
|
text/ filter, replacement
|
||||||
|
cache/ apcache
|
||||||
|
id/ ulid
|
||||||
|
test/ harness
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Cada paquete tiene su =duckbrain.json=. Sólo se copian los archivos declarados
|
||||||
|
por el componente que pides; los metadatos del paquete no se instalan.
|
||||||
|
|
||||||
|
* Instalación
|
||||||
|
|
||||||
|
El instalador global arranca un proyecto desde cero y no necesita nada previo:
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
git clone git@git.kj2.me:kj/duckbrain-toolbox.git
|
||||||
|
ln -s "$PWD/duckbrain-toolbox/bin/duckbrain" ~/.local/bin/duckbrain
|
||||||
|
|
||||||
|
duckbrain install mi-proyecto # descarga el núcleo Duckbrain
|
||||||
|
cd mi-proyecto
|
||||||
|
duckbrain add crypto htmx # copias reales, listas para commitear
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*Uso opcional dentro del proyecto*: si quieres que el proyecto gestione sus
|
||||||
|
propios addons sin el instalador global, instala el componente =commands:toolbox=
|
||||||
|
(es un =add= más, no un requisito). A partir de ahí =./duckbrain= funciona solo.
|
||||||
|
|
||||||
|
* Comandos
|
||||||
|
|
||||||
|
** =duckbrain install [dir]=
|
||||||
|
|
||||||
|
Descarga el núcleo Duckbrain en =dir=. Es sólo del instalador global.
|
||||||
|
|
||||||
|
** =duckbrain add <componente|paquete>=
|
||||||
|
|
||||||
|
Instala componentes, resolviendo dependencias y copiando archivos.
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
duckbrain add crypto # la familia completa
|
||||||
|
duckbrain add http:aria2 # un solo componente
|
||||||
|
duckbrain add http@^0.1 # fijando restricción de versión
|
||||||
|
duckbrain add htmx --link # enlaza en lugar de copiar (desarrollo)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** =duckbrain remove <componente>=
|
||||||
|
|
||||||
|
Borra sólo los archivos de ese componente; si alguno fue editado localmente,
|
||||||
|
se bloquea y pide =--force=, =--backup= o =--keep-local=.
|
||||||
|
|
||||||
|
** =duckbrain update [core|<componente>] [--dry-run]=
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
duckbrain update core # sólo el núcleo
|
||||||
|
duckbrain update http # un paquete
|
||||||
|
duckbrain update # todo: catálogo -> core -> componentes
|
||||||
|
duckbrain update --dry-run # muestra el plan sin escribir
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** =duckbrain list= / =duckbrain doctor= / =duckbrain catalog= / =duckbrain adopt=
|
||||||
|
|
||||||
|
- =list=: core y componentes instalados con versión y modo.
|
||||||
|
- =doctor=: matriz de compatibilidad, sin efectos secundarios.
|
||||||
|
- =catalog=: paquetes y componentes disponibles.
|
||||||
|
- =adopt=: registra copias hechas a mano en proyectos antiguos.
|
||||||
|
|
||||||
|
* Manifiesto =duckbrain.json=
|
||||||
|
|
||||||
|
#+BEGIN_SRC json
|
||||||
|
{
|
||||||
|
"name": "http",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Utilidades HTTP",
|
||||||
|
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||||
|
"php": ">=8.1",
|
||||||
|
"components": {
|
||||||
|
"curlRequest": { "files": ["src/Libs/CurlRequest.php"] },
|
||||||
|
"aria2": { "files": ["src/Libs/Aria2.php"] },
|
||||||
|
"compreFace": {
|
||||||
|
"files": ["src/Libs/CompreFace.php"],
|
||||||
|
"require": { "http:curlRequest": ">=0.1" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": ["curlRequest", "aria2", "compreFace"]
|
||||||
|
}
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
- =files=: archivos (o directorios) que aporta el componente.
|
||||||
|
- =require=: dependencias entre componentes, con rangos semver.
|
||||||
|
- =core.requires= / =core.tested=: compatibilidad con el núcleo.
|
||||||
|
- =post-install=: comandos de configuración (se muestran y confirman).
|
||||||
|
|
||||||
|
* Destino de los archivos
|
||||||
|
|
||||||
|
- un archivo bajo =src/= → =ROOT_CORE/= (quitando el prefijo =src/=);
|
||||||
|
- cualquier otro archivo → =ROOT_DIR/=.
|
||||||
|
|
||||||
|
Así =crypto/src/Libs/Crypto.php= → =src/Libs/Crypto.php= y
|
||||||
|
=commands/duckbrain= → =duckbrain=.
|
||||||
|
|
||||||
|
* Estado del proyecto
|
||||||
|
|
||||||
|
- =.duckbrain/manifest.json=: lo que *quieres* (restricciones de versión).
|
||||||
|
- =.duckbrain/lock.json=: lo que *tienes* (core y componentes, con versión,
|
||||||
|
commit del toolbox y hash por archivo).
|
||||||
|
|
||||||
|
Los hashes permiten detectar ediciones locales. La actualización respeta lo que
|
||||||
|
editaste y te pregunta antes de pisarlo.
|
||||||
|
|
||||||
|
* Migrar un proyecto existente
|
||||||
|
|
||||||
|
Un proyecto que ya tenía Duckbrain y addons copiados a mano entra al toolbox sin
|
||||||
|
perder nada. Clave: =adopt= *no modifica archivos*; sólo registra en
|
||||||
|
=.duckbrain/lock.json= lo que encuentra. Los cambios de contenido los hace
|
||||||
|
=update=, y sólo cuando tú lo pides.
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
# 0. Red de seguridad
|
||||||
|
git add -A && git commit -m "antes de migrar a duckbrain-toolbox"
|
||||||
|
|
||||||
|
# 1. Registrar el core y las copias existentes (NO toca archivos)
|
||||||
|
duckbrain adopt
|
||||||
|
|
||||||
|
# 2. Traer lo canónico (y respaldar antes de pisar)
|
||||||
|
duckbrain update core --backup
|
||||||
|
duckbrain add http crypto text --backup
|
||||||
|
|
||||||
|
# 3. Estado y compatibilidad
|
||||||
|
duckbrain list
|
||||||
|
duckbrain doctor
|
||||||
|
|
||||||
|
# 4. Commitear la migración
|
||||||
|
git add -A && git commit -m "migrado a duckbrain-toolbox"
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Cómo trata =adopt= cada archivo:
|
||||||
|
|
||||||
|
| Caso del archivo | Qué hace =adopt= | ¿Actualiza? |
|
||||||
|
|------------------------------+--------------------------------------+-------------|
|
||||||
|
| Coincide con el catálogo | Lo registra como =adopted= | No |
|
||||||
|
| Existe pero difiere | Lo registra con =origin: local= | No |
|
||||||
|
| Del core (existe en el core) | Registra el hash actual en =lock.core= | No |
|
||||||
|
| Ajeno al core/addons | No lo toca | No |
|
||||||
|
|
||||||
|
Las copias divergentes quedan marcadas como *edición local*: un =update=
|
||||||
|
posterior te avisa y no las pisa salvo =--force= o =--backup=. Así puedes revisar
|
||||||
|
el =git diff= con calma (por ejemplo, el arreglo de =Replacement= en
|
||||||
|
=multipaste-vip=) y decidir cuándo reconciliar. Como un proyecto heredado no
|
||||||
|
tiene hashes previos, =--backup= respalda también la primera escritura.
|
||||||
|
|
||||||
|
* Compatibilidad
|
||||||
|
|
||||||
|
Cada componente declara hasta qué versión del núcleo fue probado:
|
||||||
|
|
||||||
|
#+BEGIN_SRC text
|
||||||
|
core < requires avisa "requiere core >=X"
|
||||||
|
requires ≤ core ≤ tested compatible
|
||||||
|
core > tested avisa "no probado"
|
||||||
|
sin datos avisa "sin datos"
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Nunca bloquea: se confirma con =--yes=. Al core se le sigue la pista por sus
|
||||||
|
tags semver (=vX.Y.Z=, empezando por =v0.1.0=); si no hay tags, la versión es
|
||||||
|
desconocida y se fija por commit.
|
||||||
|
|
||||||
|
* Versionado y releases
|
||||||
|
|
||||||
|
Cada paquete se versiona de forma independiente con tags prefijados
|
||||||
|
(=http-v0.1.0=, =crypto-v0.1.0=…). El tag es el descubrimiento; el *commit* es la
|
||||||
|
identidad, así que el lock pinea commits y no se rompe si un tag se mueve.
|
||||||
|
|
||||||
|
El release lo hace un único script:
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
scripts/release <paquete> <patch|minor|major>
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Bumpea la versión del manifiesto, commitea, crea el tag y publica. Las versiones
|
||||||
|
retiradas se listan en =yanked.json=; las prereleases se omiten salvo =--pre=.
|
||||||
|
|
||||||
|
* Pruebas
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
php tests/run.php
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
* Contacto
|
||||||
|
|
||||||
|
Puedes encontrarme en Telegram como [[https://telegram.me/keyjay][@keyjay]] o por correo:
|
||||||
|
webmaster@outcontrol.net
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/autoload.php';
|
||||||
|
|
||||||
|
use Toolbox\Cli;
|
||||||
|
|
||||||
|
exit((new Cli())->run($argv));
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Construye el instalador autocontenido `duckbrain.phar` y publica su sha256.
|
||||||
|
# Salida en `dist/` (o $DUCKBRAIN_DIST). Uso: scripts/build-phar
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT="${DUCKBRAIN_DIST:-$ROOT/dist}"
|
||||||
|
PHAR="$OUT/duckbrain.phar"
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
php -d phar.readonly=0 -r '
|
||||||
|
$root = $argv[1];
|
||||||
|
$pharPath = $argv[2];
|
||||||
|
@unlink($pharPath);
|
||||||
|
|
||||||
|
$phar = new Phar($pharPath);
|
||||||
|
$phar->startBuffering();
|
||||||
|
$phar->setStub("#!/usr/bin/env php\n<?php Phar::mapPhar(\"duckbrain.phar\"); require \"phar://duckbrain.phar/bin/duckbrain\"; __HALT_COMPILER();");
|
||||||
|
|
||||||
|
$skip = [".git/", "dist/", "tests/", "openspec/", ".opencode/", "node_modules/"];
|
||||||
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isDir()) { continue; }
|
||||||
|
$rel = substr($file->getPathname(), strlen($root) + 1);
|
||||||
|
foreach ($skip as $prefix) {
|
||||||
|
if (str_starts_with($rel, $prefix)) { continue 2; }
|
||||||
|
}
|
||||||
|
$phar->addFile($file->getPathname(), $rel);
|
||||||
|
}
|
||||||
|
$phar->stopBuffering();
|
||||||
|
echo "built {$pharPath}\n";
|
||||||
|
' "$ROOT" "$PHAR"
|
||||||
|
|
||||||
|
chmod +x "$PHAR"
|
||||||
|
sha256sum "$PHAR" | awk '{print $1}' > "$PHAR.sha256"
|
||||||
|
echo "sha256: $(cat "$PHAR.sha256")"
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Escritor único de versión + tag de un paquete del toolbox.
|
||||||
|
# Uso: scripts/release <paquete> <patch|minor|major>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
echo "Uso: scripts/release <paquete> <patch|minor|major>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PACKAGE="$1"
|
||||||
|
TYPE="$2"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MANIFEST="$ROOT/packages/$PACKAGE/duckbrain.json"
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "No existe el paquete '$PACKAGE' ($MANIFEST)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$TYPE" in
|
||||||
|
patch|minor|major) ;;
|
||||||
|
*) echo "Incremento inválido: '$TYPE' (usa patch|minor|major)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
VERSION="$(php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$MANIFEST")"
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "El manifiesto no declara 'version': $MANIFEST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_VERSION="$(php -r '
|
||||||
|
$v = ltrim($argv[1], "vV");
|
||||||
|
$type = $argv[2];
|
||||||
|
$p = explode(".", preg_replace("/[-+].*/", "", $v));
|
||||||
|
$p = array_pad($p, 3, 0);
|
||||||
|
$p = array_map("intval", $p);
|
||||||
|
if ($type === "major") { $p[0]++; $p[1] = 0; $p[2] = 0; }
|
||||||
|
elseif ($type === "minor") { $p[1]++; $p[2] = 0; }
|
||||||
|
else { $p[2]++; }
|
||||||
|
echo implode(".", $p);
|
||||||
|
' "$VERSION" "$TYPE")"
|
||||||
|
|
||||||
|
php -r '
|
||||||
|
$file = $argv[1];
|
||||||
|
$data = json_decode(file_get_contents($file), true);
|
||||||
|
$data["version"] = $argv[2];
|
||||||
|
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||||
|
' "$MANIFEST" "$NEW_VERSION"
|
||||||
|
|
||||||
|
TAG="$PACKAGE-v$NEW_VERSION"
|
||||||
|
|
||||||
|
git -C "$ROOT" add -- "$MANIFEST"
|
||||||
|
git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION"
|
||||||
|
git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION"
|
||||||
|
|
||||||
|
if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git -C "$ROOT" push -q origin HEAD --follow-tags
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "release $PACKAGE v$NEW_VERSION ($TAG)"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Autoloader del toolbox (instalador global). El motor vive en el paquete
|
||||||
|
// `commands`, que es su fuente única; el mismo namespace `Toolbox\` es resoluble
|
||||||
|
// por el autoloader del core cuando el componente se instala en un proyecto.
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$prefix = 'Toolbox\\';
|
||||||
|
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
|
||||||
|
$file = __DIR__ . '/../packages/commands/src/Toolbox/' . $relative . '.php';
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\SelfUpdater;
|
||||||
|
use Toolbox\ToolboxException;
|
||||||
|
|
||||||
|
Harness::suite('group10');
|
||||||
|
|
||||||
|
$GLOBALS['g10_repo'] = dirname(__DIR__);
|
||||||
|
|
||||||
|
Harness::it('self-update verifica sha256 y reemplaza atómicamente', function () {
|
||||||
|
$release = tmpdir('release');
|
||||||
|
$work = tmpdir('work');
|
||||||
|
|
||||||
|
$content = "#!/usr/bin/env php\n<?php // v2\n";
|
||||||
|
write_file($release . '/duckbrain.phar', $content);
|
||||||
|
write_file($release . '/duckbrain.phar.sha256', hash('sha256', $content));
|
||||||
|
|
||||||
|
$target = $work . '/duckbrain.phar';
|
||||||
|
write_file($target, "old");
|
||||||
|
|
||||||
|
(new SelfUpdater(new Toolbox\Console()))->update($release, $target, $work);
|
||||||
|
|
||||||
|
eq($content, file_get_contents($target), 'binario reemplazado por el nuevo');
|
||||||
|
ok(is_executable($target), 'queda ejecutable');
|
||||||
|
|
||||||
|
rrmdir($release);
|
||||||
|
rrmdir($work);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('self-update aborta ante sha256 inválido sin tocar el binario', function () {
|
||||||
|
$release = tmpdir('release');
|
||||||
|
$work = tmpdir('work');
|
||||||
|
|
||||||
|
write_file($release . '/duckbrain.phar', "contenido nuevo");
|
||||||
|
write_file($release . '/duckbrain.phar.sha256', 'deadbeef');
|
||||||
|
|
||||||
|
$target = $work . '/duckbrain.phar';
|
||||||
|
write_file($target, 'viejo');
|
||||||
|
|
||||||
|
throws(function () use ($release, $target, $work) {
|
||||||
|
(new SelfUpdater(new Toolbox\Console()))->update($release, $target, $work);
|
||||||
|
});
|
||||||
|
|
||||||
|
eq('viejo', file_get_contents($target), 'el binario actual queda intacto');
|
||||||
|
|
||||||
|
rrmdir($release);
|
||||||
|
rrmdir($work);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('build-phar genera un phar ejecutable con su sha256', function () {
|
||||||
|
$repo = $GLOBALS['g10_repo'];
|
||||||
|
$dist = tmpdir('dist');
|
||||||
|
|
||||||
|
exec('DUCKBRAIN_DIST=' . escapeshellarg($dist) . ' bash ' . escapeshellarg($repo . '/scripts/build-phar') . ' 2>&1', $out, $code);
|
||||||
|
eq(0, $code, 'build-phar termina bien: ' . implode("\n", $out));
|
||||||
|
|
||||||
|
$phar = $dist . '/duckbrain.phar';
|
||||||
|
ok(is_file($phar), 'phar generado');
|
||||||
|
ok(is_file($phar . '.sha256'), 'sha256 publicado');
|
||||||
|
eq(hash_file('sha256', $phar), trim((string) file_get_contents($phar . '.sha256')));
|
||||||
|
|
||||||
|
exec('php ' . escapeshellarg($phar) . ' --version 2>&1', $ver, $vcode);
|
||||||
|
eq(0, $vcode, 'el phar arranca');
|
||||||
|
ok(str_contains(implode("\n", $ver), 'duckbrain-toolbox'), 'el phar imprime su versión');
|
||||||
|
|
||||||
|
rrmdir($dist);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Catalog;
|
||||||
|
use Toolbox\Manifest;
|
||||||
|
use Toolbox\Resolver;
|
||||||
|
use Toolbox\Semver;
|
||||||
|
use Toolbox\ToolboxException;
|
||||||
|
|
||||||
|
Harness::suite('group2');
|
||||||
|
|
||||||
|
Harness::it('semver compara y satisface rangos', function () {
|
||||||
|
ok(Semver::satisfies('1.2.0', '>=1.0'));
|
||||||
|
ok(!Semver::satisfies('0.9.0', '>=1.0'));
|
||||||
|
ok(Semver::satisfies('1.2.3', '^1.2'));
|
||||||
|
ok(!Semver::satisfies('2.0.0', '^1.2'));
|
||||||
|
ok(Semver::satisfies('1.2.9', '~1.2.3'));
|
||||||
|
ok(!Semver::satisfies('1.3.0', '~1.2.3'));
|
||||||
|
ok(Semver::satisfies('1.0.0', '*'));
|
||||||
|
eq(0, Semver::compare('1.0.0', '1.0.0'));
|
||||||
|
ok(Semver::compare('1.0.1', '1.0.0') > 0);
|
||||||
|
ok(Semver::compare('1.0.0-beta', '1.0.0') < 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('manifiesto válido parsea identidad, componentes y default', function () {
|
||||||
|
$m = Manifest::fromArray([
|
||||||
|
'name' => 'http',
|
||||||
|
'version' => '1.2.0',
|
||||||
|
'description' => 'Utilidades HTTP',
|
||||||
|
'components' => [
|
||||||
|
'curlRequest' => ['files' => ['src/Libs/CurlRequest.php']],
|
||||||
|
'aria2' => ['files' => ['src/Libs/Aria2.php']],
|
||||||
|
],
|
||||||
|
'default' => ['curlRequest', 'aria2'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
eq('http', $m->name);
|
||||||
|
eq('1.2.0', $m->version);
|
||||||
|
eq(['curlRequest', 'aria2'], $m->expand(null));
|
||||||
|
eq(['aria2'], $m->expand('aria2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('manifiesto sin name es inválido', function () {
|
||||||
|
throws(fn () => Manifest::fromArray(['version' => '1.0.0', 'components' => ['a' => ['files' => ['x']]]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('manifiesto sin components es inválido', function () {
|
||||||
|
throws(fn () => Manifest::fromArray(['name' => 'x', 'version' => '1.0.0']));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('default que referencia componente inexistente es inválido', function () {
|
||||||
|
throws(fn () => Manifest::fromArray([
|
||||||
|
'name' => 'x', 'version' => '1.0.0',
|
||||||
|
'components' => ['a' => ['files' => ['x']]],
|
||||||
|
'default' => ['nope'],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('paquete sin default y sin componente explícito falla', function () {
|
||||||
|
$m = Manifest::fromArray([
|
||||||
|
'name' => 'x', 'version' => '1.0.0',
|
||||||
|
'components' => ['a' => ['files' => ['x']]],
|
||||||
|
]);
|
||||||
|
throws(fn () => $m->expand(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('resuelve dependencias en orden (dependencias primero)', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
write_file($dir . '/http/duckbrain.json', json_encode([
|
||||||
|
'name' => 'http', 'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
'curlRequest' => ['files' => ['src/Libs/CurlRequest.php']],
|
||||||
|
'compreFace' => [
|
||||||
|
'files' => ['src/Libs/CompreFace.php'],
|
||||||
|
'require' => ['http:curlRequest' => '>=1.0'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
$resolver = new Resolver(new Catalog($dir));
|
||||||
|
$result = $resolver->resolve(['http:compreFace']);
|
||||||
|
|
||||||
|
eq([
|
||||||
|
['package' => 'http', 'component' => 'curlRequest'],
|
||||||
|
['package' => 'http', 'component' => 'compreFace'],
|
||||||
|
], $result);
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('require no satisfecho detiene con error', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
write_file($dir . '/a/duckbrain.json', json_encode([
|
||||||
|
'name' => 'a', 'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
'main' => ['files' => ['src/Libs/A.php'], 'require' => ['b:lib' => '>=2.0']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
write_file($dir . '/b/duckbrain.json', json_encode([
|
||||||
|
'name' => 'b', 'version' => '1.0.0',
|
||||||
|
'components' => ['lib' => ['files' => ['src/Libs/B.php']]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$resolver = new Resolver(new Catalog($dir));
|
||||||
|
throws(fn () => $resolver->resolve(['a:main']));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('ciclo de dependencias se detecta', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
write_file($dir . '/a/duckbrain.json', json_encode([
|
||||||
|
'name' => 'a', 'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
'x' => ['files' => ['src/Libs/A.php'], 'require' => ['a:y' => '*']],
|
||||||
|
'y' => ['files' => ['src/Libs/B.php'], 'require' => ['a:x' => '*']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$resolver = new Resolver(new Catalog($dir));
|
||||||
|
throws(fn () => $resolver->resolve(['a:x']));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Drift;
|
||||||
|
use Toolbox\FileSync;
|
||||||
|
|
||||||
|
Harness::suite('group3-drift');
|
||||||
|
|
||||||
|
Harness::it('sin drift escribe/reescribe', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
$sync = new FileSync();
|
||||||
|
|
||||||
|
eq(FileSync::WRITTEN, $sync->write($file, 'uno', null));
|
||||||
|
eq('uno', file_get_contents($file));
|
||||||
|
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
eq(FileSync::OVERWRITTEN, $sync->write($file, 'dos', $hash));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('drift sin bandera aborta', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
write_file($file, 'original');
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
write_file($file, 'editado');
|
||||||
|
|
||||||
|
$sync = new FileSync();
|
||||||
|
eq(FileSync::ABORTED, $sync->write($file, 'nuevo', $hash));
|
||||||
|
eq('editado', file_get_contents($file));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--force sobreescribe el drift', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
write_file($file, 'original');
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
write_file($file, 'editado');
|
||||||
|
|
||||||
|
$sync = new FileSync();
|
||||||
|
eq(FileSync::OVERWRITTEN, $sync->write($file, 'nuevo', $hash, ['force' => true]));
|
||||||
|
eq('nuevo', file_get_contents($file));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--backup respalda y escribe', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
write_file($file, 'original');
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
write_file($file, 'editado');
|
||||||
|
|
||||||
|
$sync = new FileSync();
|
||||||
|
eq(FileSync::BACKED_UP, $sync->write($file, 'nuevo', $hash, ['backup' => true]));
|
||||||
|
eq('nuevo', file_get_contents($file));
|
||||||
|
eq('editado', file_get_contents($file . '.bak'));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--backup respalda en la primera escritura sin hash previo', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
write_file($file, 'heredado');
|
||||||
|
|
||||||
|
$sync = new FileSync();
|
||||||
|
eq(FileSync::BACKED_UP, $sync->write($file, 'canonico', null, ['backup' => true]));
|
||||||
|
eq('canonico', file_get_contents($file));
|
||||||
|
eq('heredado', file_get_contents($file . '.bak'), 'conserva la copia heredada');
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--keep-local conserva y no escribe', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/a.php';
|
||||||
|
write_file($file, 'original');
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
write_file($file, 'editado');
|
||||||
|
|
||||||
|
$sync = new FileSync();
|
||||||
|
eq(FileSync::SKIPPED, $sync->write($file, 'nuevo', $hash, ['keepLocal' => true]));
|
||||||
|
eq('editado', file_get_contents($file));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Destination;
|
||||||
|
use Toolbox\Drift;
|
||||||
|
use Toolbox\Lock;
|
||||||
|
use Toolbox\Ownership;
|
||||||
|
use Toolbox\ProjectManifest;
|
||||||
|
|
||||||
|
Harness::suite('group3');
|
||||||
|
|
||||||
|
Harness::it('manifest de deseo guarda y recupera restricciones', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$m = ProjectManifest::load($dir);
|
||||||
|
$m->setConstraint('http:aria2', '^1.2');
|
||||||
|
$m->coreConstraint = '>=1.1';
|
||||||
|
$m->save($dir);
|
||||||
|
|
||||||
|
$loaded = ProjectManifest::load($dir);
|
||||||
|
eq('^1.2', $loaded->constraint('http:aria2'));
|
||||||
|
eq('>=1.1', $loaded->coreConstraint);
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('lock guarda core y componentes con schema esperado', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/src/Libs/Aria2.php';
|
||||||
|
write_file($file, "<?php\n");
|
||||||
|
|
||||||
|
$lock = Lock::empty();
|
||||||
|
$lock->setCore(['version' => '1.5.0', 'commit' => 'abc', 'files' => ['src/Libs/Router.php' => 'sha256:x']]);
|
||||||
|
$lock->setComponent('http:aria2', [
|
||||||
|
'package' => 'http',
|
||||||
|
'version' => '1.2.0',
|
||||||
|
'toolbox_commit' => 'D',
|
||||||
|
'mode' => 'copy',
|
||||||
|
'files' => ['src/Libs/Aria2.php' => Drift::hash($file)],
|
||||||
|
]);
|
||||||
|
$lock->save($dir);
|
||||||
|
|
||||||
|
$loaded = Lock::load($dir);
|
||||||
|
eq('1.5.0', $loaded->core()['version']);
|
||||||
|
eq('http', $loaded->component('http:aria2')['package']);
|
||||||
|
eq('copy', $loaded->component('http:aria2')['mode']);
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('ownership detecta colisión de rutas', function () {
|
||||||
|
$lock = Lock::empty();
|
||||||
|
$lock->setCore(['files' => ['src/Libs/X.php' => 'sha256:a']]);
|
||||||
|
$lock->setComponent('a:b', ['package' => 'a', 'version' => '1', 'toolbox_commit' => null, 'mode' => 'copy', 'files' => ['src/Libs/X.php' => 'sha256:b']]);
|
||||||
|
|
||||||
|
$ownership = Ownership::fromLock($lock);
|
||||||
|
ok($ownership->collisions() !== [], 'debía detectar colisión');
|
||||||
|
throws(fn () => $ownership->assertNoCollisions());
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('ownership separa core y componentes sin colisión', function () {
|
||||||
|
$lock = Lock::empty();
|
||||||
|
$lock->setCore(['files' => ['src/Libs/Router.php' => 'sha256:a']]);
|
||||||
|
$lock->setComponent('a:b', ['package' => 'a', 'version' => '1', 'toolbox_commit' => null, 'mode' => 'copy', 'files' => ['src/Libs/B.php' => 'sha256:b']]);
|
||||||
|
|
||||||
|
$ownership = Ownership::fromLock($lock);
|
||||||
|
eq('core', $ownership->ownerOf('src/Libs/Router.php'));
|
||||||
|
eq('a:b', $ownership->ownerOf('src/Libs/B.php'));
|
||||||
|
|
||||||
|
$ownership->assertNoCollisions();
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('drift distingue match, drifted y missing', function () {
|
||||||
|
$dir = tmpdir();
|
||||||
|
$file = $dir . '/f.php';
|
||||||
|
write_file($file, 'original');
|
||||||
|
$hash = Drift::hash($file);
|
||||||
|
|
||||||
|
eq(Drift::MATCH, Drift::status($file, $hash));
|
||||||
|
|
||||||
|
write_file($file, 'editado');
|
||||||
|
eq(Drift::DRIFTED, Drift::status($file, $hash));
|
||||||
|
|
||||||
|
eq(Drift::MISSING, Drift::status($dir . '/nope.php', $hash));
|
||||||
|
|
||||||
|
rrmdir($dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('destino aplica la regla src y fuera de src', function () {
|
||||||
|
$rootDir = '/proj';
|
||||||
|
$rootCore = '/proj/src';
|
||||||
|
|
||||||
|
eq('src/Libs/Crypto.php', Destination::relative('src/Libs/Crypto.php', $rootDir, $rootCore));
|
||||||
|
eq('duckbrain', Destination::relative('duckbrain', $rootDir, $rootCore));
|
||||||
|
eq('/proj/src/Libs/Crypto.php', Destination::resolve('src/Libs/Crypto.php', $rootDir, $rootCore));
|
||||||
|
eq('/proj/duckbrain', Destination::resolve('duckbrain', $rootDir, $rootCore));
|
||||||
|
|
||||||
|
eq('custom/Libs/Crypto.php', Destination::relative('src/Libs/Crypto.php', $rootDir, '/proj/custom'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('destino explícito sobreescribe la regla', function () {
|
||||||
|
$dest = Destination::relative('src/Libs/Crypto.php', '/proj', '/proj/src', ['src/Libs/Crypto.php' => 'elsewhere/Crypto.php']);
|
||||||
|
eq('elsewhere/Crypto.php', $dest);
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Console;
|
||||||
|
use Toolbox\Copier;
|
||||||
|
use Toolbox\FileSync;
|
||||||
|
use Toolbox\PostInstall;
|
||||||
|
|
||||||
|
Harness::suite('group4');
|
||||||
|
|
||||||
|
function make_package_sources(string $dir): void
|
||||||
|
{
|
||||||
|
write_file($dir . '/duckbrain', "#!/usr/bin/env php\n<?php\n");
|
||||||
|
write_file($dir . '/src/Interfaces/CommandInterface.php', "<?php\n");
|
||||||
|
write_file($dir . '/src/Commands/Migration.php', "<?php\n");
|
||||||
|
write_file($dir . '/phpunit.xml', "<phpunit/>\n");
|
||||||
|
write_file($dir . '/tests/TestCase.php', "<?php\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Harness::it('copia respetando la regla src y fuera de src', function () {
|
||||||
|
$src = tmpdir('pkg');
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
make_package_sources($src);
|
||||||
|
|
||||||
|
$copier = new Copier();
|
||||||
|
$result = $copier->install(
|
||||||
|
['duckbrain', 'src/Interfaces/CommandInterface.php', 'src/Commands/Migration.php', 'phpunit.xml', 'tests/TestCase.php'],
|
||||||
|
$src,
|
||||||
|
$root,
|
||||||
|
$root . '/src',
|
||||||
|
null,
|
||||||
|
'copy'
|
||||||
|
);
|
||||||
|
|
||||||
|
ok(is_file($root . '/duckbrain'), 'binario CLI en ROOT_DIR');
|
||||||
|
ok(is_file($root . '/src/Interfaces/CommandInterface.php'), 'interfaz en ROOT_CORE');
|
||||||
|
ok(is_file($root . '/src/Commands/Migration.php'), 'comando en ROOT_CORE');
|
||||||
|
ok(is_file($root . '/phpunit.xml'), 'phpunit.xml en ROOT_DIR');
|
||||||
|
ok(is_file($root . '/tests/TestCase.php'), 'tests en ROOT_DIR');
|
||||||
|
ok(!is_dir($root . '/src/tests'), 'tests no va dentro de src');
|
||||||
|
eq(5, count($result['files']));
|
||||||
|
|
||||||
|
rrmdir($src);
|
||||||
|
rrmdir($root);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('destino explícito sobreescribe la regla en la copia', function () {
|
||||||
|
$src = tmpdir('pkg');
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
write_file($src . '/src/Libs/X.php', "<?php\n");
|
||||||
|
|
||||||
|
$copier = new Copier();
|
||||||
|
$copier->install(
|
||||||
|
['src/Libs/X.php'],
|
||||||
|
$src,
|
||||||
|
$root,
|
||||||
|
$root . '/src',
|
||||||
|
['src/Libs/X.php' => 'custom/X.php'],
|
||||||
|
'copy'
|
||||||
|
);
|
||||||
|
|
||||||
|
ok(is_file($root . '/custom/X.php'), 'destino explícito');
|
||||||
|
ok(!is_file($root . '/src/Libs/X.php'), 'no usa la regla por defecto');
|
||||||
|
|
||||||
|
rrmdir($src);
|
||||||
|
rrmdir($root);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('modo enlace crea symlink y refleja cambios', function () {
|
||||||
|
$src = tmpdir('pkg');
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
write_file($src . '/src/Libs/X.php', "uno");
|
||||||
|
|
||||||
|
$copier = new Copier();
|
||||||
|
$result = $copier->install(['src/Libs/X.php'], $src, $root, $root . '/src', null, 'link');
|
||||||
|
|
||||||
|
$target = $root . '/src/Libs/X.php';
|
||||||
|
ok(is_link($target), 'debe ser un symlink');
|
||||||
|
eq("uno", file_get_contents($target));
|
||||||
|
|
||||||
|
write_file($src . '/src/Libs/X.php', "dos");
|
||||||
|
eq("dos", file_get_contents($target), 'el enlace refleja cambios sin recopiar');
|
||||||
|
eq(FileSync::WRITTEN, $result['actions']['src/Libs/X.php']);
|
||||||
|
|
||||||
|
rrmdir($root);
|
||||||
|
rrmdir($src);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('post-install sin hooks no ejecuta nada', function () {
|
||||||
|
$console = new Console();
|
||||||
|
$executed = PostInstall::run([], $console, true, fn () => 0);
|
||||||
|
eq([], $executed);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('post-install ejecuta con confirmación (--yes) y runner', function () {
|
||||||
|
$console = new Console();
|
||||||
|
$ran = [];
|
||||||
|
$executed = PostInstall::run(
|
||||||
|
['composer require --dev phpunit/phpunit'],
|
||||||
|
$console,
|
||||||
|
true,
|
||||||
|
function (string $cmd) use (&$ran) { $ran[] = $cmd; return 0; }
|
||||||
|
);
|
||||||
|
|
||||||
|
eq(['composer require --dev phpunit/phpunit'], $executed);
|
||||||
|
eq(['composer require --dev phpunit/phpunit'], $ran);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Commands\DoctorCommand;
|
||||||
|
use Toolbox\Compatibility;
|
||||||
|
use Toolbox\Console;
|
||||||
|
use Toolbox\Lock;
|
||||||
|
|
||||||
|
Harness::suite('group5');
|
||||||
|
|
||||||
|
Harness::it('compatibilidad: core por encima de lo probado avisa', function () {
|
||||||
|
$r = Compatibility::evaluateCore('1.5.0', '>=1.1', '1.4');
|
||||||
|
eq(Compatibility::UNTESTED, $r['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('compatibilidad: dentro del rango es OK', function () {
|
||||||
|
$r = Compatibility::evaluateCore('1.5.0', '>=1.1', '1.5');
|
||||||
|
eq(Compatibility::OK, $r['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('compatibilidad: core insuficiente', function () {
|
||||||
|
$r = Compatibility::evaluateCore('1.5.0', '>=1.6', null);
|
||||||
|
eq(Compatibility::INSUFFICIENT, $r['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('compatibilidad: sin datos', function () {
|
||||||
|
$r = Compatibility::evaluateCore('1.5.0', null, null);
|
||||||
|
eq(Compatibility::UNKNOWN, $r['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('compatibilidad: versión de core desconocida no inventa 0.0.0', function () {
|
||||||
|
$r = Compatibility::evaluateCore(null, '>=1.0', '1.0');
|
||||||
|
eq(Compatibility::UNKNOWN, $r['status']);
|
||||||
|
ok(str_contains($r['message'], 'desconocida'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('compatibilidad PHP', function () {
|
||||||
|
eq(Compatibility::OK, Compatibility::evaluatePhp('>=8.1', '8.5.0')['status']);
|
||||||
|
eq(Compatibility::INSUFFICIENT, Compatibility::evaluatePhp('>=9.0', '8.5.0')['status']);
|
||||||
|
eq(Compatibility::UNKNOWN, Compatibility::evaluatePhp(null, '8.5.0')['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('doctor no modifica lock ni archivos', function () {
|
||||||
|
$root = tmpdir('proj');
|
||||||
|
$tool = tmpdir('tool');
|
||||||
|
|
||||||
|
$lock = Lock::empty();
|
||||||
|
$lock->setCore(['version' => '1.5.0', 'commit' => 'abc', 'files' => []]);
|
||||||
|
$lock->setComponent('http:aria2', ['package' => 'http', 'version' => '1.2.0', 'toolbox_commit' => 'D', 'mode' => 'copy', 'files' => []]);
|
||||||
|
$lock->save($root);
|
||||||
|
|
||||||
|
write_file($tool . '/packages/http/duckbrain.json', json_encode([
|
||||||
|
'name' => 'http', 'version' => '1.2.0',
|
||||||
|
'core' => ['requires' => '>=1.1', 'tested' => '1.4'],
|
||||||
|
'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$before = file_get_contents(Lock::path($root));
|
||||||
|
|
||||||
|
$cmd = new DoctorCommand(new Console());
|
||||||
|
$code = $cmd->run(['--root=' . $root, '--toolbox=' . $tool]);
|
||||||
|
eq(0, $code);
|
||||||
|
|
||||||
|
eq($before, file_get_contents(Lock::path($root)), 'el lock no debe cambiar');
|
||||||
|
ok(!is_file($root . '/.duckbrain/manifest.json'), 'doctor no crea manifest');
|
||||||
|
|
||||||
|
rrmdir($root);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
Harness::suite('group6-release');
|
||||||
|
|
||||||
|
Harness::it('release bumpea, commitea, tagea y publica', function () {
|
||||||
|
$repo = tmpdir('relrepo');
|
||||||
|
$origin = tmpdir('relorigin') . '/origin.git';
|
||||||
|
$script = dirname(__DIR__) . '/scripts/release';
|
||||||
|
|
||||||
|
git_init($repo);
|
||||||
|
exec('git init -q --bare ' . escapeshellarg($origin));
|
||||||
|
exec('git -C ' . escapeshellarg($repo) . ' remote add origin ' . escapeshellarg($origin));
|
||||||
|
|
||||||
|
write_file($repo . '/scripts/release', file_get_contents($script));
|
||||||
|
chmod($repo . '/scripts/release', 0755);
|
||||||
|
seed_package($repo, '1.2.3');
|
||||||
|
git_commit($repo, 'init');
|
||||||
|
|
||||||
|
exec('cd ' . escapeshellarg($repo) . ' && bash scripts/release http minor', $out, $code);
|
||||||
|
eq(0, $code, 'release debe terminar bien: ' . implode("\n", $out));
|
||||||
|
|
||||||
|
$pushed = exec('git -C ' . escapeshellarg($repo) . ' tag -l "http-v1.3.0"');
|
||||||
|
eq('http-v1.3.0', $pushed, 'tag creado');
|
||||||
|
|
||||||
|
$remote = exec('git -C ' . escapeshellarg($origin) . ' tag -l "http-v1.3.0"');
|
||||||
|
eq('http-v1.3.0', $remote, 'tag publicado en origin');
|
||||||
|
|
||||||
|
$manifest = json_decode((string) file_get_contents($repo . '/packages/http/duckbrain.json'), true);
|
||||||
|
eq('1.3.0', $manifest['version']);
|
||||||
|
|
||||||
|
rrmdir($repo);
|
||||||
|
rrmdir(dirname($origin));
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\TagResolver;
|
||||||
|
use Toolbox\ToolboxRepo;
|
||||||
|
|
||||||
|
Harness::suite('group6');
|
||||||
|
|
||||||
|
Harness::it('resuelve versiones por tags prefijados, ignora prereleases y yanked', function () {
|
||||||
|
$repoDir = tmpdir('toolrepo');
|
||||||
|
git_init($repoDir);
|
||||||
|
|
||||||
|
seed_package($repoDir, '1.0.0');
|
||||||
|
git_commit($repoDir, 'v1.0.0');
|
||||||
|
git_tag($repoDir, 'http-v1.0.0');
|
||||||
|
|
||||||
|
seed_package($repoDir, '1.2.0');
|
||||||
|
git_commit($repoDir, 'v1.2.0');
|
||||||
|
git_tag($repoDir, 'http-v1.2.0');
|
||||||
|
|
||||||
|
seed_package($repoDir, '1.3.0-rc1');
|
||||||
|
git_commit($repoDir, 'v1.3.0-rc1');
|
||||||
|
git_tag($repoDir, 'http-v1.3.0-rc1');
|
||||||
|
|
||||||
|
$repo = new ToolboxRepo($repoDir, '');
|
||||||
|
$resolver = new TagResolver($repo);
|
||||||
|
|
||||||
|
eq('1.2.0', $resolver->latest('http')['version'], 'prerelease omitida por defecto');
|
||||||
|
eq('1.3.0-rc1', $resolver->latest('http', null, true)['version'], '--pre incluye prerelease');
|
||||||
|
eq('1.2.0', $resolver->latest('http', '^1.0')['version']);
|
||||||
|
eq(null, $resolver->latest('http', '^0.1'), 'restricción sin coincidencia');
|
||||||
|
|
||||||
|
$yanked = new TagResolver($repo, ['1.2.0']);
|
||||||
|
eq('1.0.0', $yanked->latest('http')['version'], 'versión retirada se salta');
|
||||||
|
|
||||||
|
rrmdir($repoDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('tag inconsistente con el manifiesto se rechaza', function () {
|
||||||
|
$repoDir = tmpdir('toolrepo');
|
||||||
|
git_init($repoDir);
|
||||||
|
|
||||||
|
seed_package($repoDir, '1.0.0');
|
||||||
|
git_commit($repoDir, 'v1.0.0');
|
||||||
|
git_tag($repoDir, 'http-v1.0.0');
|
||||||
|
|
||||||
|
// Tag nuevo apuntando al mismo commit (manifiesto dice 1.0.0, no 9.9.9)
|
||||||
|
git_tag($repoDir, 'http-v9.9.9');
|
||||||
|
|
||||||
|
$resolver = new TagResolver(new ToolboxRepo($repoDir, ''));
|
||||||
|
eq('1.0.0', $resolver->latest('http')['version'], 'el tag inconsistente no debe usarse');
|
||||||
|
|
||||||
|
$versions = $resolver->versions('http');
|
||||||
|
$invalid = array_values(array_filter($versions, fn ($v) => !$v['valid']));
|
||||||
|
eq(1, count($invalid));
|
||||||
|
eq('9.9.9', $invalid[0]['version']);
|
||||||
|
|
||||||
|
rrmdir($repoDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('clone superficial se completa y resuelve commit histórico', function () {
|
||||||
|
$origin = tmpdir('origin');
|
||||||
|
git_init($origin);
|
||||||
|
|
||||||
|
seed_package($origin, '1.0.0');
|
||||||
|
git_commit($origin, 'v1.0.0');
|
||||||
|
git_tag($origin, 'http-v1.0.0');
|
||||||
|
|
||||||
|
seed_package($origin, '1.1.0');
|
||||||
|
git_commit($origin, 'v1.1.0');
|
||||||
|
git_tag($origin, 'http-v1.1.0');
|
||||||
|
|
||||||
|
$cache = tmpdir('cache') . '/toolbox';
|
||||||
|
exec('git clone -q --depth 1 ' . escapeshellarg('file://' . $origin) . ' ' . escapeshellarg($cache) . ' 2>/dev/null');
|
||||||
|
|
||||||
|
$repo = new ToolboxRepo($cache, 'file://' . $origin);
|
||||||
|
ok(is_file($cache . '/.git/shallow'), 'la caché de prueba es superficial');
|
||||||
|
|
||||||
|
$repo->ensureFull();
|
||||||
|
|
||||||
|
ok(!is_file($cache . '/.git/shallow'), 'debe dejar de ser superficial');
|
||||||
|
$historical = $repo->commitOfTag('http-v1.0.0');
|
||||||
|
ok($historical !== null, 'resuelve el tag histórico');
|
||||||
|
ok($repo->fileAt($historical, 'packages/http/duckbrain.json') !== null, 'lee el archivo en ese commit');
|
||||||
|
|
||||||
|
rrmdir($origin);
|
||||||
|
rrmdir(dirname($cache));
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Commands\AddCommand;
|
||||||
|
use Toolbox\Commands\InstallCommand;
|
||||||
|
use Toolbox\Commands\ListCommand;
|
||||||
|
use Toolbox\Commands\RemoveCommand;
|
||||||
|
use Toolbox\Commands\UpdateCommand;
|
||||||
|
use Toolbox\Console;
|
||||||
|
use Toolbox\Lock;
|
||||||
|
|
||||||
|
Harness::suite('group7');
|
||||||
|
|
||||||
|
function g7_toolbox(bool $compreFaceInDefault = true): string
|
||||||
|
{
|
||||||
|
$dir = tmpdir('tool');
|
||||||
|
write_file($dir . '/packages/http/duckbrain.json', json_encode([
|
||||||
|
'name' => 'http',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'description' => 'Utilidades HTTP',
|
||||||
|
'components' => [
|
||||||
|
'curlRequest' => ['files' => ['src/Libs/CurlRequest.php']],
|
||||||
|
'aria2' => ['files' => ['src/Libs/Aria2.php']],
|
||||||
|
'compreFace' => [
|
||||||
|
'files' => ['src/Libs/CompreFace.php'],
|
||||||
|
'require' => ['http:curlRequest' => '>=1.0'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => $compreFaceInDefault ? ['curlRequest', 'aria2', 'compreFace'] : ['aria2'],
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
write_file($dir . '/packages/http/src/Libs/CurlRequest.php', "<?php\n// curl\n");
|
||||||
|
write_file($dir . '/packages/http/src/Libs/Aria2.php', "<?php\n// aria2\n");
|
||||||
|
write_file($dir . '/packages/http/src/Libs/CompreFace.php', "<?php\n// compreface\n");
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function g7_core(string $version = '0.1.0', string $router = 'router-v1'): string
|
||||||
|
{
|
||||||
|
$dir = tmpdir('core');
|
||||||
|
write_file($dir . '/autoload.php', <<<'PHP'
|
||||||
|
<?php
|
||||||
|
define('ROOT_DIR', __DIR__);
|
||||||
|
define('ROOT_CORE', ROOT_DIR . '/src');
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$fp = str_replace('\\', '/', $class);
|
||||||
|
$file = ROOT_CORE . '/' . dirname($fp) . '/' . basename($fp) . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
PHP);
|
||||||
|
write_file($dir . '/config.php', "<?php\n");
|
||||||
|
write_file($dir . '/src/Libs/Router.php', "<?php\n// " . $router . "\n");
|
||||||
|
// Documentación del repo (rastreada, pero excluida del core distribuible).
|
||||||
|
write_file($dir . '/readme.org', "* core readme\n");
|
||||||
|
|
||||||
|
git_init($dir);
|
||||||
|
git_commit($dir, 'core ' . $router);
|
||||||
|
if ($version !== '') {
|
||||||
|
git_tag($dir, 'v' . $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function g7_console(&$stream): Console
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
|
||||||
|
return new Console($stream, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
Harness::it('install descarga el core en un directorio vacío', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
|
||||||
|
$cmd = new InstallCommand(new Console());
|
||||||
|
$code = $cmd->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
eq(0, $code);
|
||||||
|
ok(is_file($proj . '/autoload.php'));
|
||||||
|
ok(is_file($proj . '/src/Libs/Router.php'));
|
||||||
|
ok(!is_file($proj . '/readme.org'), 'no copia la documentación del core');
|
||||||
|
eq('0.1.0', Lock::load($proj)->core()['version'], 'versión resuelta por tag v0.1.0');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('add copia componente, registra lock y manifest', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
|
||||||
|
$cmd = new AddCommand(new Console());
|
||||||
|
$code = $cmd->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
eq(0, $code);
|
||||||
|
ok(is_file($proj . '/src/Libs/Aria2.php'), 'archivo copiado');
|
||||||
|
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
ok($lock->has('http:aria2'), 'lock registra el componente');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('add de paquete expande default; require trae dependencia', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox(false); // default sólo aria2
|
||||||
|
|
||||||
|
$cmd = new AddCommand(new Console());
|
||||||
|
$cmd->run(['http:compreFace', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/src/Libs/CompreFace.php'));
|
||||||
|
ok(is_file($proj . '/src/Libs/CurlRequest.php'), 'dependencia require instalada');
|
||||||
|
|
||||||
|
$proj2 = tmpdir('proj2');
|
||||||
|
$cmd->run(['http', '--root=' . $proj2, '--toolbox=' . $tool, '--yes']);
|
||||||
|
ok(is_file($proj2 . '/src/Libs/Aria2.php'));
|
||||||
|
ok(!is_file($proj2 . '/src/Libs/CompreFace.php'), 'default sólo trae lo declarado');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($proj2);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('remove sólo quita archivos del componente', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$core = g7_core();
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
(new AddCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
|
||||||
|
(new RemoveCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
|
||||||
|
ok(!is_file($proj . '/src/Libs/Aria2.php'), 'archivo del componente eliminado');
|
||||||
|
ok(is_file($proj . '/src/Libs/Router.php'), 'archivo del core intacto');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('update core sólo toca rutas del core', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
$core = g7_core('1.0.0', 'router-v1');
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
(new AddCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
|
||||||
|
$core2 = g7_core('2.0.0', 'router-v2');
|
||||||
|
(new UpdateCommand(new Console()))->run(['core', '--root=' . $proj, '--toolbox=' . $tool, '--core-dir=' . $core2, '--yes']);
|
||||||
|
|
||||||
|
ok(str_contains((string) file_get_contents($proj . '/src/Libs/Router.php'), 'router-v2'), 'core actualizado');
|
||||||
|
ok(is_file($proj . '/src/Libs/Aria2.php'), 'componente intacto');
|
||||||
|
eq('2.0.0', Lock::load($proj)->core()['version']);
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
rrmdir($core);
|
||||||
|
rrmdir($core2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('list muestra instalados con versión y modo', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
(new AddCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
|
||||||
|
$console = g7_console($stream);
|
||||||
|
(new ListCommand($console))->run(['--root=' . $proj, '--toolbox=' . $tool]);
|
||||||
|
rewind($stream);
|
||||||
|
$output = stream_get_contents($stream);
|
||||||
|
|
||||||
|
ok(str_contains($output, 'http:aria2'), 'id en la salida');
|
||||||
|
ok(str_contains($output, 'copy'), 'modo en la salida');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--dry-run no escribe archivos ni lock', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
|
||||||
|
(new AddCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes', '--dry-run']);
|
||||||
|
|
||||||
|
ok(!is_file($proj . '/src/Libs/Aria2.php'), 'no copia en dry-run');
|
||||||
|
ok(!is_file(Lock::path($proj)), 'no crea lock en dry-run');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('core sin tags: versión desconocida y commit pineado', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
$core = g7_core(''); // repo git sin tags
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $tool, '--core-dir=' . $core, '--yes']);
|
||||||
|
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
eq(null, $lock->core()['version'], 'sin tags la versión es desconocida');
|
||||||
|
ok($lock->core()['commit'] !== null, 'commit pineado sin depender de tags');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('--force resuelve drift pero no salta avisos de compatibilidad', function () {
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$tool = g7_toolbox();
|
||||||
|
|
||||||
|
// add once and edit locally
|
||||||
|
(new AddCommand(new Console()))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes']);
|
||||||
|
write_file($proj . '/src/Libs/Aria2.php', "<?php\n// editado\n");
|
||||||
|
|
||||||
|
// package declares tested core 0.9 -> warning against core 0.0.0 (unknown) is OK;
|
||||||
|
// set a tested value to force a warning
|
||||||
|
$json = json_decode((string) file_get_contents($tool . '/packages/http/duckbrain.json'), true);
|
||||||
|
$json['core'] = ['requires' => '>=1.0'];
|
||||||
|
write_file($tool . '/packages/http/duckbrain.json', json_encode($json));
|
||||||
|
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
$console = new Console($stream, $stream);
|
||||||
|
|
||||||
|
$code = (new AddCommand($console))->run(['http:aria2', '--root=' . $proj, '--toolbox=' . $tool, '--yes', '--force']);
|
||||||
|
eq(0, $code, 'force resuelve el drift');
|
||||||
|
|
||||||
|
rewind($stream);
|
||||||
|
$output = stream_get_contents($stream);
|
||||||
|
ok(str_contains($output, 'WARN'), 'sigue avisando de compatibilidad');
|
||||||
|
ok(str_contains((string) file_get_contents($proj . '/src/Libs/Aria2.php'), 'aria2'), 'sobreescribe con --force');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($tool);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Commands\AddCommand;
|
||||||
|
use Toolbox\Commands\InstallCommand;
|
||||||
|
use Toolbox\Console;
|
||||||
|
use Toolbox\Lock;
|
||||||
|
|
||||||
|
Harness::suite('group8');
|
||||||
|
|
||||||
|
$GLOBALS['g8_repo'] = dirname(__DIR__);
|
||||||
|
|
||||||
|
Harness::it('el binario global usa el motor empaquetado en commands', function () {
|
||||||
|
$repo = $GLOBALS['g8_repo'];
|
||||||
|
exec('php ' . escapeshellarg($repo . '/bin/duckbrain') . ' --version 2>&1', $out, $code);
|
||||||
|
eq(0, $code);
|
||||||
|
ok(str_contains(implode("\n", $out), 'duckbrain-toolbox'), 'el binario global arranca');
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('commands:toolbox es un componente opcional que se copia y hashea', function () {
|
||||||
|
$repo = $GLOBALS['g8_repo'];
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
|
||||||
|
(new AddCommand(new Console()))->run(['commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/src/Toolbox/Installer.php'), 'motor copiado a ROOT_CORE/Toolbox');
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
ok($lock->has('commands:toolbox'), 'lock registra el componente opcional');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('el CLI del proyecto gestiona addons sin el instalador global', function () {
|
||||||
|
$repo = $GLOBALS['g8_repo'];
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
|
||||||
|
// Core + CLI + motor (todo vía el instalador global, como bootstrap).
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $repo, '--core-dir=' . $core, '--yes']);
|
||||||
|
(new AddCommand(new Console()))->run(['commands:cli', 'commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/duckbrain'), 'binario del proyecto instalado');
|
||||||
|
|
||||||
|
$cmd = 'cd ' . escapeshellarg($proj) . ' && php duckbrain --version 2>&1';
|
||||||
|
exec($cmd, $out, $code);
|
||||||
|
eq(0, $code, 'el binario del proyecto arranca: ' . implode("\n", $out));
|
||||||
|
ok(str_contains(implode("\n", $out), 'duckbrain-toolbox'), 'usa el motor del proyecto');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('la actualización del motor surte efecto en la siguiente invocación', function () {
|
||||||
|
$repo = $GLOBALS['g8_repo'];
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
|
||||||
|
(new InstallCommand(new Console()))->run([$proj, '--toolbox=' . $repo, '--core-dir=' . $core, '--yes']);
|
||||||
|
(new AddCommand(new Console()))->run(['commands:cli', 'commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||||
|
|
||||||
|
$before = trim((string) shell_exec('cd ' . escapeshellarg($proj) . ' && php duckbrain --version 2>&1'));
|
||||||
|
|
||||||
|
// Simula una actualización del motor reescribiendo su archivo en disco.
|
||||||
|
$config = (string) file_get_contents($proj . '/src/Toolbox/Config.php');
|
||||||
|
$config = str_replace("VERSION = '0.1.0'", "VERSION = '0.9.9'", $config);
|
||||||
|
write_file($proj . '/src/Toolbox/Config.php', $config);
|
||||||
|
|
||||||
|
$after = trim((string) shell_exec('cd ' . escapeshellarg($proj) . ' && php duckbrain --version 2>&1'));
|
||||||
|
|
||||||
|
ok(str_contains($before, '0.1.0'), 'antes usa la versión vieja');
|
||||||
|
ok(str_contains($after, '0.9.9'), 'después usa la versión nueva sin recompilar');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Toolbox\Catalog;
|
||||||
|
use Toolbox\Commands\AdoptCommand;
|
||||||
|
use Toolbox\Console;
|
||||||
|
use Toolbox\Installer;
|
||||||
|
use Toolbox\Lock;
|
||||||
|
|
||||||
|
Harness::suite('group9');
|
||||||
|
|
||||||
|
$GLOBALS['g9_repo'] = dirname(__DIR__);
|
||||||
|
|
||||||
|
Harness::it('el catálogo lista todos los paquetes absorbidos', function () {
|
||||||
|
$catalog = Catalog::fromRoot($GLOBALS['g9_repo']);
|
||||||
|
$names = array_keys($catalog->packages());
|
||||||
|
|
||||||
|
foreach (['http', 'crypto', 'text', 'cache', 'id', 'htmx', 'commands', 'test'] as $pkg) {
|
||||||
|
ok(in_array($pkg, $names, true), "paquete '{$pkg}' presente en el catálogo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('http instala su familia y resuelve compreFace -> curlRequest', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$inst = new Installer($proj, $repo, new Console(), $proj . '/src');
|
||||||
|
|
||||||
|
$result = $inst->add(['http'], ['yes' => true]);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/src/Libs/CurlRequest.php'));
|
||||||
|
ok(is_file($proj . '/src/Libs/CompreFace.php'));
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
ok($lock->has('http:compreFace') && $lock->has('http:curlRequest'), 'resuelve la dependencia');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('htmx instala htmlComponent con su Sanitizer via dependencia', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$inst = new Installer($proj, $repo, new Console(), $proj . '/src');
|
||||||
|
|
||||||
|
$inst->add(['htmx:htmx'], ['yes' => true]);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/src/Libs/HTMX.php'));
|
||||||
|
ok(is_file($proj . '/src/Libs/HTMLComponent.php'), 'dependencia htmlComponent');
|
||||||
|
ok(is_file($proj . '/src/Libs/Sanitizer/SanitizerProxy.php'), 'directorio Sanitizer expandido');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('test trae commands:cli y ejecuta post-install', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$inst = new Installer($proj, $repo, new Console(), $proj . '/src');
|
||||||
|
|
||||||
|
$ran = [];
|
||||||
|
$result = $inst->add(['test'], ['yes' => true, 'postRunner' => function (string $c) use (&$ran) { $ran[] = $c; }]);
|
||||||
|
|
||||||
|
ok(is_file($proj . '/phpunit.xml'), 'phpunit.xml en ROOT_DIR');
|
||||||
|
ok(is_file($proj . '/tests/TestCase.php'), 'directorio tests expandido');
|
||||||
|
ok(Lock::load($proj)->has('commands:cli'), 'dependencia commands:cli instalada');
|
||||||
|
eq(2, count($ran), 'se ejecutaron los dos comandos de post-install');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('no queda el Migration.php duplicado; sólo el de commands', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
ok(is_file($repo . '/packages/commands/src/Libs/Migration.php'), 'existe el de commands');
|
||||||
|
foreach (['http', 'crypto', 'text', 'cache', 'id'] as $pkg) {
|
||||||
|
ok(!is_file($repo . "/packages/{$pkg}/src/Libs/Migration.php"), "extras no filtró Migration.php a {$pkg}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('adopt registra copias idénticas y divergentes sin modificar archivos', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
$projDiv = tmpdir('projdiv');
|
||||||
|
|
||||||
|
$aria2 = (string) file_get_contents($repo . '/packages/http/src/Libs/Aria2.php');
|
||||||
|
|
||||||
|
write_file($proj . '/src/Libs/Aria2.php', $aria2);
|
||||||
|
write_file($projDiv . '/src/Libs/Aria2.php', $aria2 . "\n// edicion local\n");
|
||||||
|
$before = (string) file_get_contents($projDiv . '/src/Libs/Aria2.php');
|
||||||
|
|
||||||
|
(new AdoptCommand(new Console()))->run(['--root=' . $proj, '--toolbox=' . $repo, '--core-dir=' . $core]);
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
ok($lock->has('http:aria2'), 'adopta la copia idéntica');
|
||||||
|
eq('adopted', $lock->component('http:aria2')['origin']);
|
||||||
|
|
||||||
|
(new AdoptCommand(new Console()))->run(['--root=' . $projDiv, '--toolbox=' . $repo, '--core-dir=' . $core]);
|
||||||
|
$lockD = Lock::load($projDiv);
|
||||||
|
ok($lockD->has('http:aria2'), 'adopta la copia divergente');
|
||||||
|
eq('local', $lockD->component('http:aria2')['origin'], 'la marca como edición local');
|
||||||
|
eq($before, file_get_contents($projDiv . '/src/Libs/Aria2.php'), 'no modifica el archivo');
|
||||||
|
ok(\Toolbox\Drift::isDrifted($projDiv . '/src/Libs/Aria2.php', $lockD->component('http:aria2')['files']['src/Libs/Aria2.php']), 'el drift queda detectado');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($projDiv);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
|
|
||||||
|
Harness::it('adopt registra el core existente con sus hashes', function () {
|
||||||
|
$repo = $GLOBALS['g9_repo'];
|
||||||
|
$core = g7_core('0.1.0');
|
||||||
|
$proj = tmpdir('proj');
|
||||||
|
|
||||||
|
// Simula un proyecto heredado con el core copiado a mano.
|
||||||
|
foreach (['autoload.php', 'config.php', 'src/Libs/Router.php'] as $file) {
|
||||||
|
write_file($proj . '/' . $file, (string) file_get_contents($core . '/' . $file));
|
||||||
|
}
|
||||||
|
|
||||||
|
(new AdoptCommand(new Console()))->run(['--root=' . $proj, '--toolbox=' . $repo, '--core-dir=' . $core]);
|
||||||
|
|
||||||
|
$lock = Lock::load($proj);
|
||||||
|
ok($lock->core() !== null, 'core registrado en el lock');
|
||||||
|
ok(isset($lock->core()['files']['src/Libs/Router.php']), 'archivo del core con hash');
|
||||||
|
ok(!isset($lock->core()['files']['src/Libs/Aria2.php']), 'no adopta archivos ajenos');
|
||||||
|
|
||||||
|
rrmdir($proj);
|
||||||
|
rrmdir($core);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/autoload.php';
|
||||||
|
|
||||||
|
final class Harness
|
||||||
|
{
|
||||||
|
public static int $passed = 0;
|
||||||
|
public static int $failed = 0;
|
||||||
|
/** @var list<string> */
|
||||||
|
public static array $failures = [];
|
||||||
|
public static string $suite = '';
|
||||||
|
|
||||||
|
public static function suite(string $name): void
|
||||||
|
{
|
||||||
|
self::$suite = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function it(string $description, callable $fn): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
self::$passed++;
|
||||||
|
fwrite(STDOUT, " \033[32mok\033[0m " . $description . PHP_EOL);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$failed++;
|
||||||
|
self::$failures[] = self::$suite . ' :: ' . $description . ' -> ' . $e->getMessage();
|
||||||
|
fwrite(STDOUT, " \033[31mFAIL\033[0m " . $description . ' -> ' . $e->getMessage() . PHP_EOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function summary(): int
|
||||||
|
{
|
||||||
|
fwrite(STDOUT, PHP_EOL);
|
||||||
|
fwrite(STDOUT, sprintf("passed: %d, failed: %d\n", self::$passed, self::$failed));
|
||||||
|
if (self::$failed > 0) {
|
||||||
|
fwrite(STDOUT, "Failures:\n");
|
||||||
|
foreach (self::$failures as $failure) {
|
||||||
|
fwrite(STDOUT, ' - ' . $failure . PHP_EOL);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(bool $condition, string $message = 'assertion failed'): void
|
||||||
|
{
|
||||||
|
if (!$condition) {
|
||||||
|
throw new \RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eq(mixed $expected, mixed $actual, string $message = ''): void
|
||||||
|
{
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
throw new \RuntimeException(sprintf(
|
||||||
|
'%s expected %s, got %s',
|
||||||
|
$message !== '' ? $message . ':' : 'values differ,',
|
||||||
|
var_export($expected, true),
|
||||||
|
var_export($actual, true)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function throws(callable $fn, string $message = 'expected exception'): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpdir(string $prefix = 'duckbrain-test'): string
|
||||||
|
{
|
||||||
|
$dir = sys_get_temp_dir() . '/' . $prefix . '-' . bin2hex(random_bytes(6));
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rrmdir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exec('rm -rf ' . escapeshellarg($dir) . ' 2>/dev/null');
|
||||||
|
}
|
||||||
|
|
||||||
|
function write_file(string $path, string $contents): void
|
||||||
|
{
|
||||||
|
if (!is_dir(dirname($path))) {
|
||||||
|
mkdir(dirname($path), 0777, true);
|
||||||
|
}
|
||||||
|
file_put_contents($path, $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
function git_init(string $dir): void
|
||||||
|
{
|
||||||
|
exec('git init -q ' . escapeshellarg($dir));
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' config user.email test@test');
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' config user.name test');
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' config commit.gpgsign false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function git_commit(string $dir, string $msg): void
|
||||||
|
{
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' add -A');
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' commit -qm ' . escapeshellarg($msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function git_tag(string $dir, string $tag): void
|
||||||
|
{
|
||||||
|
exec('git -C ' . escapeshellarg($dir) . ' tag ' . escapeshellarg($tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed_package(string $repo, string $version): void
|
||||||
|
{
|
||||||
|
write_file($repo . '/packages/http/duckbrain.json', json_encode([
|
||||||
|
'name' => 'http',
|
||||||
|
'version' => $version,
|
||||||
|
'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]],
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/harness.php';
|
||||||
|
|
||||||
|
foreach (glob(__DIR__ . '/*_test.php') ?: [] as $testFile) {
|
||||||
|
require_once $testFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(Harness::summary());
|
||||||
Reference in New Issue
Block a user