From b7799f9fff7d2a942787e139a984a5fd10a6e32d Mon Sep 17 00:00:00 2001 From: kj Date: Mon, 5 Oct 2026 15:08:39 -0300 Subject: [PATCH] first commit. --- README.org | 237 +++++ bin/duckbrain | 8 + packages/.gitkeep | 0 packages/cache/duckbrain.json | 11 + packages/cache/src/Libs/APCache.php | 60 ++ packages/commands/duckbrain | 40 + packages/commands/duckbrain.json | 31 + packages/commands/src/Commands/Migration.php | 259 +++++ .../src/Interfaces/CommandInterface.php | 13 + packages/commands/src/Libs/Migration.php | 986 ++++++++++++++++++ packages/commands/src/Toolbox/Catalog.php | 53 + packages/commands/src/Toolbox/Cli.php | 84 ++ .../src/Toolbox/Commands/AddCommand.php | 47 + .../src/Toolbox/Commands/AdoptCommand.php | 152 +++ .../src/Toolbox/Commands/CatalogCommand.php | 44 + .../commands/src/Toolbox/Commands/Command.php | 107 ++ .../src/Toolbox/Commands/CommandInterface.php | 13 + .../src/Toolbox/Commands/DoctorCommand.php | 62 ++ .../src/Toolbox/Commands/InstallCommand.php | 43 + .../src/Toolbox/Commands/ListCommand.php | 42 + .../src/Toolbox/Commands/RemoveCommand.php | 44 + .../Toolbox/Commands/SelfUpdateCommand.php | 37 + .../src/Toolbox/Commands/UpdateCommand.php | 62 ++ .../commands/src/Toolbox/Compatibility.php | 77 ++ packages/commands/src/Toolbox/Component.php | 64 ++ packages/commands/src/Toolbox/Config.php | 62 ++ packages/commands/src/Toolbox/Console.php | 79 ++ packages/commands/src/Toolbox/Copier.php | 101 ++ .../commands/src/Toolbox/CoreProvider.php | 187 ++++ packages/commands/src/Toolbox/Destination.php | 43 + .../commands/src/Toolbox/DirectorySource.php | 25 + packages/commands/src/Toolbox/Drift.php | 36 + .../commands/src/Toolbox/FileExpander.php | 43 + packages/commands/src/Toolbox/FileSync.php | 92 ++ packages/commands/src/Toolbox/Installer.php | 367 +++++++ packages/commands/src/Toolbox/Lock.php | 112 ++ packages/commands/src/Toolbox/Manifest.php | 160 +++ packages/commands/src/Toolbox/Ownership.php | 86 ++ .../commands/src/Toolbox/PackageSource.php | 10 + packages/commands/src/Toolbox/PostInstall.php | 47 + .../commands/src/Toolbox/ProjectManifest.php | 95 ++ packages/commands/src/Toolbox/Resolver.php | 99 ++ packages/commands/src/Toolbox/SelfUpdater.php | 51 + packages/commands/src/Toolbox/Semver.php | 148 +++ packages/commands/src/Toolbox/TagResolver.php | 106 ++ .../commands/src/Toolbox/ToolboxException.php | 7 + packages/commands/src/Toolbox/ToolboxRepo.php | 107 ++ packages/crypto/duckbrain.json | 13 + packages/crypto/src/Libs/Crypto.php | 112 ++ packages/crypto/src/Libs/Nonce.php | 53 + .../src/Libs/TwoFactorAuthenticator.php | 256 +++++ packages/htmx/duckbrain.json | 17 + packages/htmx/src/Libs/HTMLComponent.php | 470 +++++++++ packages/htmx/src/Libs/HTMX.php | 447 ++++++++ .../Libs/Sanitizer/SanitizerArrayProxy.php | 73 ++ .../src/Libs/Sanitizer/SanitizerProxy.php | 67 ++ packages/http/duckbrain.json | 17 + packages/http/src/Libs/Aria2.php | 179 ++++ packages/http/src/Libs/CompreFace.php | 211 ++++ packages/http/src/Libs/CurlRequest.php | 174 ++++ packages/http/src/Libs/Proxy.php | 160 +++ packages/id/duckbrain.json | 11 + packages/id/src/Libs/Ulid.php | 83 ++ packages/test/duckbrain.json | 18 + packages/test/phpunit.xml | 27 + packages/test/tests/EndpointTestCase.php | 113 ++ packages/test/tests/Factories/Factory.php | 55 + packages/test/tests/Feature/.gitkeep | 0 packages/test/tests/Support/HttpStubs.php | 63 ++ packages/test/tests/Support/Response.php | 43 + packages/test/tests/Support/http_stubs.php | 27 + packages/test/tests/TestCase.php | 35 + packages/test/tests/Unit/BootstrapTest.php | 42 + packages/test/tests/bootstrap.php | 30 + packages/text/duckbrain.json | 12 + packages/text/src/Libs/Filter.php | 69 ++ packages/text/src/Libs/Replacement.php | 85 ++ scripts/build-phar | 37 + scripts/release | 61 ++ src/autoload.php | 18 + tests/group10_release_test.php | 67 ++ tests/group2_manifest_test.php | 123 +++ tests/group3_drift_test.php | 90 ++ tests/group3_state_test.php | 102 ++ tests/group4_copy_test.php | 106 ++ tests/group5_compat_test.php | 68 ++ tests/group6_release_test.php | 33 + tests/group6_tags_test.php | 87 ++ tests/group7_commands_test.php | 240 +++++ tests/group8_project_cli_test.php | 74 ++ tests/group9_absorb_test.php | 123 +++ tests/harness.php | 127 +++ tests/run.php | 9 + 93 files changed, 8966 insertions(+) create mode 100644 README.org create mode 100755 bin/duckbrain create mode 100644 packages/.gitkeep create mode 100644 packages/cache/duckbrain.json create mode 100755 packages/cache/src/Libs/APCache.php create mode 100644 packages/commands/duckbrain create mode 100644 packages/commands/duckbrain.json create mode 100644 packages/commands/src/Commands/Migration.php create mode 100644 packages/commands/src/Interfaces/CommandInterface.php create mode 100644 packages/commands/src/Libs/Migration.php create mode 100644 packages/commands/src/Toolbox/Catalog.php create mode 100644 packages/commands/src/Toolbox/Cli.php create mode 100644 packages/commands/src/Toolbox/Commands/AddCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/AdoptCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/CatalogCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/Command.php create mode 100644 packages/commands/src/Toolbox/Commands/CommandInterface.php create mode 100644 packages/commands/src/Toolbox/Commands/DoctorCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/InstallCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/ListCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/RemoveCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php create mode 100644 packages/commands/src/Toolbox/Commands/UpdateCommand.php create mode 100644 packages/commands/src/Toolbox/Compatibility.php create mode 100644 packages/commands/src/Toolbox/Component.php create mode 100644 packages/commands/src/Toolbox/Config.php create mode 100644 packages/commands/src/Toolbox/Console.php create mode 100644 packages/commands/src/Toolbox/Copier.php create mode 100644 packages/commands/src/Toolbox/CoreProvider.php create mode 100644 packages/commands/src/Toolbox/Destination.php create mode 100644 packages/commands/src/Toolbox/DirectorySource.php create mode 100644 packages/commands/src/Toolbox/Drift.php create mode 100644 packages/commands/src/Toolbox/FileExpander.php create mode 100644 packages/commands/src/Toolbox/FileSync.php create mode 100644 packages/commands/src/Toolbox/Installer.php create mode 100644 packages/commands/src/Toolbox/Lock.php create mode 100644 packages/commands/src/Toolbox/Manifest.php create mode 100644 packages/commands/src/Toolbox/Ownership.php create mode 100644 packages/commands/src/Toolbox/PackageSource.php create mode 100644 packages/commands/src/Toolbox/PostInstall.php create mode 100644 packages/commands/src/Toolbox/ProjectManifest.php create mode 100644 packages/commands/src/Toolbox/Resolver.php create mode 100644 packages/commands/src/Toolbox/SelfUpdater.php create mode 100644 packages/commands/src/Toolbox/Semver.php create mode 100644 packages/commands/src/Toolbox/TagResolver.php create mode 100644 packages/commands/src/Toolbox/ToolboxException.php create mode 100644 packages/commands/src/Toolbox/ToolboxRepo.php create mode 100644 packages/crypto/duckbrain.json create mode 100644 packages/crypto/src/Libs/Crypto.php create mode 100755 packages/crypto/src/Libs/Nonce.php create mode 100644 packages/crypto/src/Libs/TwoFactorAuthenticator.php create mode 100644 packages/htmx/duckbrain.json create mode 100644 packages/htmx/src/Libs/HTMLComponent.php create mode 100644 packages/htmx/src/Libs/HTMX.php create mode 100644 packages/htmx/src/Libs/Sanitizer/SanitizerArrayProxy.php create mode 100644 packages/htmx/src/Libs/Sanitizer/SanitizerProxy.php create mode 100644 packages/http/duckbrain.json create mode 100644 packages/http/src/Libs/Aria2.php create mode 100644 packages/http/src/Libs/CompreFace.php create mode 100644 packages/http/src/Libs/CurlRequest.php create mode 100644 packages/http/src/Libs/Proxy.php create mode 100644 packages/id/duckbrain.json create mode 100644 packages/id/src/Libs/Ulid.php create mode 100644 packages/test/duckbrain.json create mode 100644 packages/test/phpunit.xml create mode 100644 packages/test/tests/EndpointTestCase.php create mode 100644 packages/test/tests/Factories/Factory.php create mode 100644 packages/test/tests/Feature/.gitkeep create mode 100644 packages/test/tests/Support/HttpStubs.php create mode 100644 packages/test/tests/Support/Response.php create mode 100644 packages/test/tests/Support/http_stubs.php create mode 100644 packages/test/tests/TestCase.php create mode 100644 packages/test/tests/Unit/BootstrapTest.php create mode 100644 packages/test/tests/bootstrap.php create mode 100644 packages/text/duckbrain.json create mode 100644 packages/text/src/Libs/Filter.php create mode 100644 packages/text/src/Libs/Replacement.php create mode 100755 scripts/build-phar create mode 100755 scripts/release create mode 100644 src/autoload.php create mode 100644 tests/group10_release_test.php create mode 100644 tests/group2_manifest_test.php create mode 100644 tests/group3_drift_test.php create mode 100644 tests/group3_state_test.php create mode 100644 tests/group4_copy_test.php create mode 100644 tests/group5_compat_test.php create mode 100644 tests/group6_release_test.php create mode 100644 tests/group6_tags_test.php create mode 100644 tests/group7_commands_test.php create mode 100644 tests/group8_project_cli_test.php create mode 100644 tests/group9_absorb_test.php create mode 100644 tests/harness.php create mode 100644 tests/run.php diff --git a/README.org b/README.org new file mode 100644 index 0000000..91c80d7 --- /dev/null +++ b/README.org @@ -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 = + +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 = + +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|] [--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 +#+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 diff --git a/bin/duckbrain b/bin/duckbrain new file mode 100755 index 0000000..164b496 --- /dev/null +++ b/bin/duckbrain @@ -0,0 +1,8 @@ +#!/usr/bin/env php +run($argv)); diff --git a/packages/.gitkeep b/packages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/cache/duckbrain.json b/packages/cache/duckbrain.json new file mode 100644 index 0000000..c724ae6 --- /dev/null +++ b/packages/cache/duckbrain.json @@ -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"] +} diff --git a/packages/cache/src/Libs/APCache.php b/packages/cache/src/Libs/APCache.php new file mode 100755 index 0000000..6ee1170 --- /dev/null +++ b/packages/cache/src/Libs/APCache.php @@ -0,0 +1,60 @@ +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"); +} diff --git a/packages/commands/duckbrain.json b/packages/commands/duckbrain.json new file mode 100644 index 0000000..6ead397 --- /dev/null +++ b/packages/commands/duckbrain.json @@ -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" + ] +} diff --git a/packages/commands/src/Commands/Migration.php b/packages/commands/src/Commands/Migration.php new file mode 100644 index 0000000..41ea20f --- /dev/null +++ b/packages/commands/src/Commands/Migration.php @@ -0,0 +1,259 @@ +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' +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); + } + } +} diff --git a/packages/commands/src/Interfaces/CommandInterface.php b/packages/commands/src/Interfaces/CommandInterface.php new file mode 100644 index 0000000..60ad7bd --- /dev/null +++ b/packages/commands/src/Interfaces/CommandInterface.php @@ -0,0 +1,13 @@ +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 $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 $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 $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 $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 $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; + } +} diff --git a/packages/commands/src/Toolbox/Catalog.php b/packages/commands/src/Toolbox/Catalog.php new file mode 100644 index 0000000..6e5bbbf --- /dev/null +++ b/packages/commands/src/Toolbox/Catalog.php @@ -0,0 +1,53 @@ +|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 */ + 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]; + } +} diff --git a/packages/commands/src/Toolbox/Cli.php b/packages/commands/src/Toolbox/Cli.php new file mode 100644 index 0000000..25504d0 --- /dev/null +++ b/packages/commands/src/Toolbox/Cli.php @@ -0,0 +1,84 @@ + */ + 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 $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 [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)); + } + } +} diff --git a/packages/commands/src/Toolbox/Commands/AddCommand.php b/packages/commands/src/Toolbox/Commands/AddCommand.php new file mode 100644 index 0000000..036b00e --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/AddCommand.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/AdoptCommand.php b/packages/commands/src/Toolbox/Commands/AdoptCommand.php new file mode 100644 index 0000000..eee076d --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/AdoptCommand.php @@ -0,0 +1,152 @@ +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 $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); + } +} diff --git a/packages/commands/src/Toolbox/Commands/CatalogCommand.php b/packages/commands/src/Toolbox/Commands/CatalogCommand.php new file mode 100644 index 0000000..f1c8b4a --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/CatalogCommand.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/Command.php b/packages/commands/src/Toolbox/Commands/Command.php new file mode 100644 index 0000000..ec039c0 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/Command.php @@ -0,0 +1,107 @@ + $args + * @return array{options: array, args: list} + */ + 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 $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 $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 $options + * @return array + */ + 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'), + ]; + } +} diff --git a/packages/commands/src/Toolbox/Commands/CommandInterface.php b/packages/commands/src/Toolbox/Commands/CommandInterface.php new file mode 100644 index 0000000..14ac0d8 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/CommandInterface.php @@ -0,0 +1,13 @@ + $args + */ + public function run(array $args): int; + + public static function describe(): string; +} diff --git a/packages/commands/src/Toolbox/Commands/DoctorCommand.php b/packages/commands/src/Toolbox/Commands/DoctorCommand.php new file mode 100644 index 0000000..8a7f2cb --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/DoctorCommand.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/InstallCommand.php b/packages/commands/src/Toolbox/Commands/InstallCommand.php new file mode 100644 index 0000000..d4306a6 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/InstallCommand.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/ListCommand.php b/packages/commands/src/Toolbox/Commands/ListCommand.php new file mode 100644 index 0000000..d6b4779 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/ListCommand.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/RemoveCommand.php b/packages/commands/src/Toolbox/Commands/RemoveCommand.php new file mode 100644 index 0000000..5dff153 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/RemoveCommand.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php b/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php new file mode 100644 index 0000000..5ac1068 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/SelfUpdateCommand.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Commands/UpdateCommand.php b/packages/commands/src/Toolbox/Commands/UpdateCommand.php new file mode 100644 index 0000000..49b5109 --- /dev/null +++ b/packages/commands/src/Toolbox/Commands/UpdateCommand.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/packages/commands/src/Toolbox/Compatibility.php b/packages/commands/src/Toolbox/Compatibility.php new file mode 100644 index 0000000..d5c5167 --- /dev/null +++ b/packages/commands/src/Toolbox/Compatibility.php @@ -0,0 +1,77 @@ + 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', + }; + } +} diff --git a/packages/commands/src/Toolbox/Component.php b/packages/commands/src/Toolbox/Component.php new file mode 100644 index 0000000..39bd404 --- /dev/null +++ b/packages/commands/src/Toolbox/Component.php @@ -0,0 +1,64 @@ + $files + * @param array $requires + * @param array|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), + ); + } +} diff --git a/packages/commands/src/Toolbox/Config.php b/packages/commands/src/Toolbox/Config.php new file mode 100644 index 0000000..bbf229b --- /dev/null +++ b/packages/commands/src/Toolbox/Config.php @@ -0,0 +1,62 @@ +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> $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))); + } + } +} diff --git a/packages/commands/src/Toolbox/Copier.php b/packages/commands/src/Toolbox/Copier.php new file mode 100644 index 0000000..1e2d995 --- /dev/null +++ b/packages/commands/src/Toolbox/Copier.php @@ -0,0 +1,101 @@ + $files rutas relativas al paquete + * @param array $expectedFiles rel -> hash previo (lock) + * @param array|null $destinations + * @param array{force?:bool, backup?:bool, keepLocal?:bool, dryRun?:bool} $options + * @return array{files: array, actions: array, aborted: list} + */ + 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; + } +} diff --git a/packages/commands/src/Toolbox/CoreProvider.php b/packages/commands/src/Toolbox/CoreProvider.php new file mode 100644 index 0000000..4a011a0 --- /dev/null +++ b/packages/commands/src/Toolbox/CoreProvider.php @@ -0,0 +1,187 @@ +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 + */ + 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 + */ + 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 + */ + 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); + } +} diff --git a/packages/commands/src/Toolbox/Destination.php b/packages/commands/src/Toolbox/Destination.php new file mode 100644 index 0000000..6f1532d --- /dev/null +++ b/packages/commands/src/Toolbox/Destination.php @@ -0,0 +1,43 @@ + 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; + } +} diff --git a/packages/commands/src/Toolbox/DirectorySource.php b/packages/commands/src/Toolbox/DirectorySource.php new file mode 100644 index 0000000..88ed3a7 --- /dev/null +++ b/packages/commands/src/Toolbox/DirectorySource.php @@ -0,0 +1,25 @@ +/`. + */ +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; + } +} diff --git a/packages/commands/src/Toolbox/Drift.php b/packages/commands/src/Toolbox/Drift.php new file mode 100644 index 0000000..65e8183 --- /dev/null +++ b/packages/commands/src/Toolbox/Drift.php @@ -0,0 +1,36 @@ + $files + * @return list + */ + 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)); + } +} diff --git a/packages/commands/src/Toolbox/FileSync.php b/packages/commands/src/Toolbox/FileSync.php new file mode 100644 index 0000000..d5af06a --- /dev/null +++ b/packages/commands/src/Toolbox/FileSync.php @@ -0,0 +1,92 @@ +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 $requests + * @param array $options + * @return array{installed:list, warnings:list, aborted:list, actions:array} + */ + public function add(array $requests, array $options): array + { + return $this->installRequests($requests, $options, false); + } + + /** + * @param list $requests + * @param array $options + * @return array{installed:list, warnings:list, aborted:list, actions:array} + */ + public function update(array $requests, array $options): array + { + return $this->installRequests($requests, $options, true); + } + + /** + * @param list $requests + * @param array $options + * @return array{installed:list, warnings:list, aborted:list, actions:array} + */ + 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 $ids + * @param array $options + * @return array{removed:list, aborted:list} + */ + 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 $options + * @return array{files:array, actions:array, aborted:list, version:?string} + */ + public function installCore(CoreProvider $provider, array $options): array + { + return $this->writeCore($provider, $options, false); + } + + /** + * @param array $options + * @return array{files:array, actions:array, aborted:list, version:?string} + */ + public function updateCore(CoreProvider $provider, array $options): array + { + return $this->writeCore($provider, $options, true); + } + + /** + * @param array $options + * @return array{files:array, actions:array, aborted:list, 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 */ + 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 + */ + 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) : []; + } +} diff --git a/packages/commands/src/Toolbox/Lock.php b/packages/commands/src/Toolbox/Lock.php new file mode 100644 index 0000000..04d2808 --- /dev/null +++ b/packages/commands/src/Toolbox/Lock.php @@ -0,0 +1,112 @@ +}|null $core + * @param array}> $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}|null */ + public function core(): ?array + { + return $this->core; + } + + /** @return array}> */ + 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} $core */ + public function setCore(array $core): void + { + $this->core = $core; + } + + /** @param array{package:string, version:string, toolbox_commit:?string, mode:string, files:array} $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" + ); + } +} diff --git a/packages/commands/src/Toolbox/Manifest.php b/packages/commands/src/Toolbox/Manifest.php new file mode 100644 index 0000000..ad4ec34 --- /dev/null +++ b/packages/commands/src/Toolbox/Manifest.php @@ -0,0 +1,160 @@ + $components + * @param list $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 */ + 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 + */ + 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; + } +} diff --git a/packages/commands/src/Toolbox/Ownership.php b/packages/commands/src/Toolbox/Ownership.php new file mode 100644 index 0000000..e077d58 --- /dev/null +++ b/packages/commands/src/Toolbox/Ownership.php @@ -0,0 +1,86 @@ + path => owner id ('core' o 'pkg:comp') */ + private array $owners = []; + + /** @var array> 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 */ + public function pathsOf(string $owner): array + { + return array_keys(array_filter($this->owners, fn ($o) => $o === $owner)); + } + + /** + * @return array> + */ + 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)); + } +} diff --git a/packages/commands/src/Toolbox/PackageSource.php b/packages/commands/src/Toolbox/PackageSource.php new file mode 100644 index 0000000..27ce355 --- /dev/null +++ b/packages/commands/src/Toolbox/PackageSource.php @@ -0,0 +1,10 @@ + $commands + * @param callable(string):int|null $runner + * @return list 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; + } +} diff --git a/packages/commands/src/Toolbox/ProjectManifest.php b/packages/commands/src/Toolbox/ProjectManifest.php new file mode 100644 index 0000000..92d39c4 --- /dev/null +++ b/packages/commands/src/Toolbox/ProjectManifest.php @@ -0,0 +1,95 @@ + $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 */ + 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" + ); + } +} diff --git a/packages/commands/src/Toolbox/Resolver.php b/packages/commands/src/Toolbox/Resolver.php new file mode 100644 index 0000000..65b526e --- /dev/null +++ b/packages/commands/src/Toolbox/Resolver.php @@ -0,0 +1,99 @@ + */ + private array $visiting = []; + + /** @var array */ + private array $resolved = []; + + public function __construct(private readonly Catalog $catalog) + { + } + + /** + * @param list $requests + * @return list + */ + 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]; + } +} diff --git a/packages/commands/src/Toolbox/SelfUpdater.php b/packages/commands/src/Toolbox/SelfUpdater.php new file mode 100644 index 0000000..32f2c8e --- /dev/null +++ b/packages/commands/src/Toolbox/SelfUpdater.php @@ -0,0 +1,51 @@ +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 . ')'); + } +} diff --git a/packages/commands/src/Toolbox/Semver.php b/packages/commands/src/Toolbox/Semver.php new file mode 100644 index 0000000..6ce764c --- /dev/null +++ b/packages/commands/src/Toolbox/Semver.php @@ -0,0 +1,148 @@ + (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; + } +} diff --git a/packages/commands/src/Toolbox/TagResolver.php b/packages/commands/src/Toolbox/TagResolver.php new file mode 100644 index 0000000..a3faec3 --- /dev/null +++ b/packages/commands/src/Toolbox/TagResolver.php @@ -0,0 +1,106 @@ + $yanked versiones retiradas + */ + public function __construct( + private readonly ToolboxRepo $repo, + private readonly array $yanked = [], + ) { + } + + /** + * @return list + */ + 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, '-'); + } +} diff --git a/packages/commands/src/Toolbox/ToolboxException.php b/packages/commands/src/Toolbox/ToolboxException.php new file mode 100644 index 0000000..784c9be --- /dev/null +++ b/packages/commands/src/Toolbox/ToolboxException.php @@ -0,0 +1,7 @@ +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 + */ + 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 $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), '']; + } +} diff --git a/packages/crypto/duckbrain.json b/packages/crypto/duckbrain.json new file mode 100644 index 0000000..f4ce9d3 --- /dev/null +++ b/packages/crypto/duckbrain.json @@ -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"] +} diff --git a/packages/crypto/src/Libs/Crypto.php b/packages/crypto/src/Libs/Crypto.php new file mode 100644 index 0000000..3a178fb --- /dev/null +++ b/packages/crypto/src/Libs/Crypto.php @@ -0,0 +1,112 @@ + (time() - 1800); + } +} diff --git a/packages/crypto/src/Libs/TwoFactorAuthenticator.php b/packages/crypto/src/Libs/TwoFactorAuthenticator.php new file mode 100644 index 0000000..14cfed4 --- /dev/null +++ b/packages/crypto/src/Libs/TwoFactorAuthenticator.php @@ -0,0 +1,256 @@ +_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; + } +} diff --git a/packages/htmx/duckbrain.json b/packages/htmx/duckbrain.json new file mode 100644 index 0000000..df73f15 --- /dev/null +++ b/packages/htmx/duckbrain.json @@ -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"] +} diff --git a/packages/htmx/src/Libs/HTMLComponent.php b/packages/htmx/src/Libs/HTMLComponent.php new file mode 100644 index 0000000..d63cf18 --- /dev/null +++ b/packages/htmx/src/Libs/HTMLComponent.php @@ -0,0 +1,470 @@ +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 `` or a + * closing tag `` 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, 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]); + } +} diff --git a/packages/htmx/src/Libs/HTMX.php b/packages/htmx/src/Libs/HTMX.php new file mode 100644 index 0000000..84cecf8 --- /dev/null +++ b/packages/htmx/src/Libs/HTMX.php @@ -0,0 +1,447 @@ +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]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\t \r\n]+)?/siU', + '/([\t \r\n]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\t \r\n]+)?/siU', + '/([\t \r\n]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\t \r\n]+)?/siU', + '/([\t \r\n]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\t \r\n]+)?/siU', + '/([\t \r\n]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\t \r\n]+)?/siU', + '/([\t \r\n]+)?([\t \r\n]+)?(.+)?([\t \r\n]+)?([\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 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 '' + . $content + . ''; + } + + /** + * 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 (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)); + } + } +} diff --git a/packages/htmx/src/Libs/Sanitizer/SanitizerArrayProxy.php b/packages/htmx/src/Libs/Sanitizer/SanitizerArrayProxy.php new file mode 100644 index 0000000..f27d739 --- /dev/null +++ b/packages/htmx/src/Libs/Sanitizer/SanitizerArrayProxy.php @@ -0,0 +1,73 @@ +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); + } +} diff --git a/packages/htmx/src/Libs/Sanitizer/SanitizerProxy.php b/packages/htmx/src/Libs/Sanitizer/SanitizerProxy.php new file mode 100644 index 0000000..1e09b98 --- /dev/null +++ b/packages/htmx/src/Libs/Sanitizer/SanitizerProxy.php @@ -0,0 +1,67 @@ + 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)); + } +} diff --git a/packages/http/duckbrain.json b/packages/http/duckbrain.json new file mode 100644 index 0000000..455eb0f --- /dev/null +++ b/packages/http/duckbrain.json @@ -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"] +} diff --git a/packages/http/src/Libs/Aria2.php b/packages/http/src/Libs/Aria2.php new file mode 100644 index 0000000..caac697 --- /dev/null +++ b/packages/http/src/Libs/Aria2.php @@ -0,0 +1,179 @@ +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 $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); + } +} diff --git a/packages/http/src/Libs/CompreFace.php b/packages/http/src/Libs/CompreFace.php new file mode 100644 index 0000000..6ba51db --- /dev/null +++ b/packages/http/src/Libs/CompreFace.php @@ -0,0 +1,211 @@ +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); + } +} diff --git a/packages/http/src/Libs/CurlRequest.php b/packages/http/src/Libs/CurlRequest.php new file mode 100644 index 0000000..7a2ee46 --- /dev/null +++ b/packages/http/src/Libs/CurlRequest.php @@ -0,0 +1,174 @@ + 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; + } +} diff --git a/packages/http/src/Libs/Proxy.php b/packages/http/src/Libs/Proxy.php new file mode 100644 index 0000000..1a69fe8 --- /dev/null +++ b/packages/http/src/Libs/Proxy.php @@ -0,0 +1,160 @@ + $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)); + } +} diff --git a/packages/id/duckbrain.json b/packages/id/duckbrain.json new file mode 100644 index 0000000..e4e9aad --- /dev/null +++ b/packages/id/duckbrain.json @@ -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"] +} diff --git a/packages/id/src/Libs/Ulid.php b/packages/id/src/Libs/Ulid.php new file mode 100644 index 0000000..2e1c772 --- /dev/null +++ b/packages/id/src/Libs/Ulid.php @@ -0,0 +1,83 @@ += 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)); + } +} diff --git a/packages/test/duckbrain.json b/packages/test/duckbrain.json new file mode 100644 index 0000000..0bdef51 --- /dev/null +++ b/packages/test/duckbrain.json @@ -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" + ] +} diff --git a/packages/test/phpunit.xml b/packages/test/phpunit.xml new file mode 100644 index 0000000..1619786 --- /dev/null +++ b/packages/test/phpunit.xml @@ -0,0 +1,27 @@ + + + + + tests/Unit + + + tests/Feature + + + + + src + + + + + diff --git a/packages/test/tests/EndpointTestCase.php b/packages/test/tests/EndpointTestCase.php new file mode 100644 index 0000000..b4155fb --- /dev/null +++ b/packages/test/tests/EndpointTestCase.php @@ -0,0 +1,113 @@ + $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()); + } +} diff --git a/packages/test/tests/Factories/Factory.php b/packages/test/tests/Factories/Factory.php new file mode 100644 index 0000000..d169166 --- /dev/null +++ b/packages/test/tests/Factories/Factory.php @@ -0,0 +1,55 @@ + $attributes + * @return array + */ + protected static function attributes(array $attributes = []): array + { + return array_merge(static::definition(), $attributes); + } + + /** + * Atributos por defecto de la fábrica. + * + * @return array + */ + abstract protected static function definition(): array; + + /** + * Crea una instancia del modelo con los atributos dados. + * + * @param array $attributes + */ + abstract public static function create(array $attributes = []): object; +} diff --git a/packages/test/tests/Feature/.gitkeep b/packages/test/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/test/tests/Support/HttpStubs.php b/packages/test/tests/Support/HttpStubs.php new file mode 100644 index 0000000..fe8a534 --- /dev/null +++ b/packages/test/tests/Support/HttpStubs.php @@ -0,0 +1,63 @@ + 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; + } +} diff --git a/packages/test/tests/Support/Response.php b/packages/test/tests/Support/Response.php new file mode 100644 index 0000000..ecdec26 --- /dev/null +++ b/packages/test/tests/Support/Response.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/packages/test/tests/Support/http_stubs.php b/packages/test/tests/Support/http_stubs.php new file mode 100644 index 0000000..8668cb9 --- /dev/null +++ b/packages/test/tests/Support/http_stubs.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/packages/test/tests/Unit/BootstrapTest.php b/packages/test/tests/Unit/BootstrapTest.php new file mode 100644 index 0000000..24186b7 --- /dev/null +++ b/packages/test/tests/Unit/BootstrapTest.php @@ -0,0 +1,42 @@ +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()); + } +} diff --git a/packages/test/tests/bootstrap.php b/packages/test/tests/bootstrap.php new file mode 100644 index 0000000..1b59515 --- /dev/null +++ b/packages/test/tests/bootstrap.php @@ -0,0 +1,30 @@ +=0.1", "tested": "0.1" }, + "php": ">=8.1", + "components": { + "filter": { "files": ["src/Libs/Filter.php"] }, + "replacement": { "files": ["src/Libs/Replacement.php"] } + }, + "default": ["filter", "replacement"] +} diff --git a/packages/text/src/Libs/Filter.php b/packages/text/src/Libs/Filter.php new file mode 100644 index 0000000..1d59bab --- /dev/null +++ b/packages/text/src/Libs/Filter.php @@ -0,0 +1,69 @@ + $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; + } +} diff --git a/packages/text/src/Libs/Replacement.php b/packages/text/src/Libs/Replacement.php new file mode 100644 index 0000000..87169f3 --- /dev/null +++ b/packages/text/src/Libs/Replacement.php @@ -0,0 +1,85 @@ + [], + '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; + } +} diff --git a/scripts/build-phar b/scripts/build-phar new file mode 100755 index 0000000..8d6aa50 --- /dev/null +++ b/scripts/build-phar @@ -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\nisDir()) { 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")" diff --git a/scripts/release b/scripts/release new file mode 100755 index 0000000..d2d65aa --- /dev/null +++ b/scripts/release @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Escritor único de versión + tag de un paquete del toolbox. +# Uso: scripts/release +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "Uso: scripts/release " >&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)" diff --git a/src/autoload.php b/src/autoload.php new file mode 100644 index 0000000..a71fb91 --- /dev/null +++ b/src/autoload.php @@ -0,0 +1,18 @@ +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); +}); diff --git a/tests/group2_manifest_test.php b/tests/group2_manifest_test.php new file mode 100644 index 0000000..82d4b74 --- /dev/null +++ b/tests/group2_manifest_test.php @@ -0,0 +1,123 @@ +=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); +}); diff --git a/tests/group3_drift_test.php b/tests/group3_drift_test.php new file mode 100644 index 0000000..99495af --- /dev/null +++ b/tests/group3_drift_test.php @@ -0,0 +1,90 @@ +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); +}); diff --git a/tests/group3_state_test.php b/tests/group3_state_test.php new file mode 100644 index 0000000..55ae4c5 --- /dev/null +++ b/tests/group3_state_test.php @@ -0,0 +1,102 @@ +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, "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); +}); diff --git a/tests/group4_copy_test.php b/tests/group4_copy_test.php new file mode 100644 index 0000000..b73871f --- /dev/null +++ b/tests/group4_copy_test.php @@ -0,0 +1,106 @@ +\n"); + write_file($dir . '/tests/TestCase.php', "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', "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); +}); diff --git a/tests/group5_compat_test.php b/tests/group5_compat_test.php new file mode 100644 index 0000000..f231b75 --- /dev/null +++ b/tests/group5_compat_test.php @@ -0,0 +1,68 @@ +=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); +}); diff --git a/tests/group6_release_test.php b/tests/group6_release_test.php new file mode 100644 index 0000000..37c1b13 --- /dev/null +++ b/tests/group6_release_test.php @@ -0,0 +1,33 @@ +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)); +}); diff --git a/tests/group7_commands_test.php b/tests/group7_commands_test.php new file mode 100644 index 0000000..548ba30 --- /dev/null +++ b/tests/group7_commands_test.php @@ -0,0 +1,240 @@ + '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', "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', " 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); +}); diff --git a/tests/group8_project_cli_test.php b/tests/group8_project_cli_test.php new file mode 100644 index 0000000..96ffbb6 --- /dev/null +++ b/tests/group8_project_cli_test.php @@ -0,0 +1,74 @@ +&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); +}); diff --git a/tests/group9_absorb_test.php b/tests/group9_absorb_test.php new file mode 100644 index 0000000..690867c --- /dev/null +++ b/tests/group9_absorb_test.php @@ -0,0 +1,123 @@ +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); +}); diff --git a/tests/harness.php b/tests/harness.php new file mode 100644 index 0000000..7acf418 --- /dev/null +++ b/tests/harness.php @@ -0,0 +1,127 @@ + */ + 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)); +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..8a0db3f --- /dev/null +++ b/tests/run.php @@ -0,0 +1,9 @@ +