Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60e1eebb7 | ||
|
|
4014551aae | ||
|
|
6028abe51c | ||
|
|
c2931245bb | ||
|
|
5eb019726a | ||
|
|
74da6f9c64 | ||
|
|
15b66733b6 | ||
|
|
901968d0bd | ||
|
|
fc51133d92 | ||
|
|
2dc048ce02 | ||
|
|
06f6635f8b | ||
|
|
4085363572 | ||
|
|
f7e6b2c832 | ||
|
|
3773303e6b | ||
|
|
df976c7985 | ||
|
|
90e800ba1f | ||
|
|
40d853b2b0 | ||
|
|
a7f1a4dc60 | ||
|
|
d11fd1989d | ||
|
|
6925b814e6 | ||
|
|
eb5e6eeed4 | ||
|
|
0082cbe3f4 | ||
|
|
0b98b7a26b | ||
|
|
1127272330 | ||
|
|
6e7b572935 |
@@ -4,7 +4,7 @@
|
||||
CONFIG := packages/commands/src/Toolbox/Config.php
|
||||
VERSION := $(shell grep -oP "const VERSION = '\K[^']+" $(CONFIG))
|
||||
|
||||
.PHONY: help test build publish check clean
|
||||
.PHONY: help test build compat release publish check clean
|
||||
|
||||
help: ## Muestra esta ayuda
|
||||
@grep -hE '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | sed -E 's/^([a-z-]+):.*## (.+)$$/ make \1 \xe2\x80\x94 \2/'
|
||||
@@ -15,6 +15,12 @@ test: ## Corre la suite de pruebas (harness propio, sin phpunit)
|
||||
build: ## Construye dist/duckbrain.phar y su .sha256
|
||||
scripts/build-phar
|
||||
|
||||
compat: ## Barrido de compatibilidad contra el core real (requiere red)
|
||||
scripts/compat-sweep $(if $(ARGS),$(ARGS),)
|
||||
|
||||
release: ## Asistente interactivo para versionar un paquete (scripts/release)
|
||||
./scripts/release $(if $(ARGS),$(ARGS),)
|
||||
|
||||
publish: build ## Crea/mueve la release v$(VERSION) en Forgejo y sube los assets
|
||||
scripts/publish-phar
|
||||
|
||||
|
||||
+102
-10
@@ -41,7 +41,11 @@ repositorio.
|
||||
#+BEGIN_SRC text
|
||||
duckbrain-toolbox/
|
||||
bin/duckbrain instalador global (bootstrap)
|
||||
scripts/release escritor único de versión + tag
|
||||
src/autoload.php mapea Toolbox\ -> packages/commands/src/Toolbox/
|
||||
scripts/release version + tag + push (interactivo o directo)
|
||||
scripts/build-phar construye dist/duckbrain.phar autocontenido
|
||||
scripts/publish-phar publica el phar como release en Forgejo
|
||||
Makefile test / build / release / publish / check / clean
|
||||
packages/
|
||||
commands/ cli, migration, toolbox
|
||||
htmx/ htmlComponent, htmx
|
||||
@@ -61,7 +65,7 @@ por el componente que pides; los metadatos del paquete no se instalan.
|
||||
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
|
||||
git clone https://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
|
||||
@@ -73,6 +77,25 @@ duckbrain add crypto htmx # copias reales, listas para commitear
|
||||
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.
|
||||
|
||||
Alternativa sin clone del repo: descargar el *phar autocontenido* publicado como
|
||||
release y ejecutarlo directamente (los enlaces simbólicos no valen:
|
||||
=self-update= reemplaza el binario con =rename()= sobre su propia ruta):
|
||||
|
||||
#+BEGIN_SRC sh
|
||||
VERSION=$(curl -s https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases \
|
||||
| jq -r '.[].tag_name' | grep -E '^v[0-9]' | sort -V | tail -1)
|
||||
curl -fsSL "https://git.kj2.me/kj/duckbrain-toolbox/releases/download/$VERSION/duckbrain.phar" \
|
||||
-o ~/.local/bin/duckbrain
|
||||
chmod +x ~/.local/bin/duckbrain
|
||||
|
||||
duckbrain --version # ya es el instalador global; se actualiza solo:
|
||||
duckbrain self-update
|
||||
#+END_SRC
|
||||
|
||||
(Para probar el tuyo local sin release, usa =cp dist/duckbrain.phar ~/.local/bin/duckbrain=
|
||||
en lugar del =curl=; =make build= + =ln -s= solo sirve mientras no llames a
|
||||
=self-update=.)
|
||||
|
||||
* Comandos
|
||||
|
||||
** =duckbrain install [dir]=
|
||||
@@ -108,7 +131,7 @@ duckbrain update --dry-run # muestra el plan sin escribir
|
||||
|
||||
- =list=: core y componentes instalados con versión y modo.
|
||||
- =doctor=: matriz de compatibilidad, sin efectos secundarios.
|
||||
- =catalog=: paquetes y componentes disponibles.
|
||||
- =catalog=: paquetes y componentes disponibles, cada uno con su descripción.
|
||||
- =adopt=: registra copias hechas a mano en proyectos antiguos.
|
||||
|
||||
* Manifiesto =duckbrain.json=
|
||||
@@ -210,20 +233,89 @@ 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.
|
||||
|
||||
Los valores =tested= de los manifiestos no son decorativos: los escribe sólo
|
||||
quien corrió el *barrido de compatibilidad* (=make compat=), que monta un
|
||||
proyecto temporal por paquete contra el core real publicado y lo ejercita por
|
||||
HTTP (migraciones sobre sqlite, runner phpunit real; los clientes con servicio
|
||||
externo — =aria2=, =proxy=, =compreFace= — sólo a nivel de carga/instantiación,
|
||||
consignado así en =tests/compat/report.json=). Cualquier
|
||||
=Deprecated=/=Warning=/=Notice= durante el barrido cuenta como fallo.
|
||||
Un test de la suite (=group11=) exige que cada =tested= declarado tenga su =ok=
|
||||
en ese reporte y que no se estampen componentes deliberadamente sin datos
|
||||
(=commands:cli=/=commands:toolbox=, que son el instalador y no consumen el
|
||||
core). Al aparecer un tag nuevo del core, el ritual es: =make compat= → arreglar
|
||||
lo que falle → estampar → =make release=.
|
||||
|
||||
* 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.
|
||||
Hay *dos* sistemas de versión y no hay que confundirlos:
|
||||
|
||||
El release lo hace un único script:
|
||||
1. *Paquetes/componentes* — la versión vive en =packages/<paquete>/duckbrain.json= y
|
||||
se versiona de forma independiente con tags prefijados (=http-v0.1.0=,
|
||||
=crypto-v0.1.0=…). La única forma de cambiarla es el asistente:
|
||||
|
||||
#+BEGIN_SRC sh
|
||||
scripts/release <paquete> <patch|minor|major>
|
||||
make release # interactivo: elige
|
||||
# paquete e incremento,
|
||||
# muestra cambios
|
||||
# pendientes y confirma
|
||||
scripts/release <paquete> <patch|minor|major> [--yes] # modo directo
|
||||
#+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=.
|
||||
Bumpea el manifiesto, commitea, crea el tag y lo publica. Al liberar
|
||||
=commands= sincroniza también =Config::VERSION= en el mismo commit. 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. Las versiones retiradas se listan en
|
||||
=yanked.json=; las prereleases se omiten salvo =--pre=. Para detectar bumps
|
||||
olvidados: =git log <paquete>-v<última>..HEAD -- packages/<paquete>= (vacío = al día).
|
||||
|
||||
2. *Instalador/phar* — =Config::VERSION= (=packages/commands/src/Toolbox/Config.php=):
|
||||
es lo que imprime la CLI y la referencia con la que =self-update= compara las
|
||||
releases del servidor. No se edita a mano; la mantiene el release de
|
||||
=commands=. Se publica con =make publish= (ver *Makefile*).
|
||||
|
||||
* Makefile
|
||||
|
||||
=make help= lista los objetivos:
|
||||
|
||||
| Objetivo | Qué hace |
|
||||
|--------------+------------------------------------------------------------------------------------------------------------------------|
|
||||
| =make test= | Corre la suite (=php tests/run.php=). En verde antes de commitear. |
|
||||
| =make build= | Genera =dist/duckbrain.phar= + =dist/duckbrain.phar.sha256=. |
|
||||
| =make compat= | Barrido de compatibilidad contra el core real (=scripts/compat-sweep=); requiere red. =ARGS="--only pkg"= para un paquete. |
|
||||
| =make release= | Asistente interactivo de releases de paquetes (=ARGS="pkg minor --yes"= para modo directo). |
|
||||
| =make publish= | =build= + publica la release =v$(Config::VERSION)= con los assets. Requiere =FORGEJO_TOKEN=. |
|
||||
| =make check= | =self-update --check= contra el servidor: =actual/disponible=, exit 1 si hay nuevo. |
|
||||
| =make clean= | Borra =dist/=. |
|
||||
|
||||
El token para publicar es un token personal de la API de Forgejo
|
||||
(*Ajustes → Aplicaciones*, scope =write:repository=, con caducidad y revocable).
|
||||
Los PAT de Forgejo no pueden limitarse a un solo repositorio: para aislar el
|
||||
riesgo, crea un *usuario bot* con acceso Write solo en =duckbrain-toolbox= y usa
|
||||
su token. =dist/= está en gitignore: nunca se commitea.
|
||||
|
||||
* self-update (el phar)
|
||||
|
||||
#+BEGIN_SRC sh
|
||||
duckbrain self-update # se auto-reemplaza con la última release
|
||||
duckbrain self-update --check # actual vs disponible (exit 1 si hay nueva)
|
||||
duckbrain self-update --force # reemplaza aunque esté al día
|
||||
#+END_SRC
|
||||
|
||||
Lee la lista de releases
|
||||
(=https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases=, sobrescribible
|
||||
con =DUCKBRAIN_UPDATE_URL=) y elige el *mayor tag semver* con la misma clase que
|
||||
el core usa con sus tags; tags que no son semver y drafts se ignoran. No
|
||||
existe la ruta =/releases/latest/download/= en Forgejo, así que no se depende de
|
||||
ella: basta con subir =make publish= la versión real =vX.Y.Z=. La descarga se
|
||||
verifica contra el =.sha256= publicado y el reemplazo es atómico (temp + rename)
|
||||
sobre =Phar::running(false)=. Si =Config::VERSION >= remota= no se descarga nada.
|
||||
|
||||
Para tocar el instalador con el phar en mente: =build-phar= ya ejecuta con
|
||||
=php -d phar.readonly=0= (crear phars está bloqueado por defecto), el código que
|
||||
corre *dentro* del phar no debe usar =glob()= (no soporta el stream =phar://=;
|
||||
usa =DirectoryIterator= / =file_get_contents()=), y la raíz del toolbox se
|
||||
resuelve con =Config::toolboxDir()=, que ya antepone =phar://= cuando toca.
|
||||
|
||||
* Pruebas
|
||||
|
||||
|
||||
+95
-23
@@ -2,39 +2,111 @@
|
||||
<?php
|
||||
|
||||
// CLI del proyecto Duckbrain. Requiere el autoloader del core (proyecto ya
|
||||
// armado). Delega en el motor `Toolbox\` si el componente opcional
|
||||
// `commands:toolbox` está instalado; en caso contrario, sólo ofrece los
|
||||
// comandos propios del proyecto (p. ej. migraciones).
|
||||
require_once('./autoload.php');
|
||||
// armado). Despachador genérico: el primer argumento resuelve primero como
|
||||
// nombre de clase del namespace Commands del proyecto y, si no, como alias
|
||||
// declarado por una de esas clases (`public static function aliases():
|
||||
// array`). Un alias duplicado —o que robe el nombre de otra clase— aborta
|
||||
// con error explícito listando ambos dueños. Los comandos implementan
|
||||
// Interfaces\CommandInterface (run + describe obligatorios).
|
||||
|
||||
$command = $argv[1] ?? null;
|
||||
require_once './autoload.php';
|
||||
|
||||
$toolboxCommands = [
|
||||
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt',
|
||||
'help', '--help', '-h', '--version', '-V',
|
||||
];
|
||||
/**
|
||||
* @return array<string, string> clase por nombre de archivo (basename => FQCN)
|
||||
*/
|
||||
function project_commands(): array
|
||||
{
|
||||
$classes = [];
|
||||
foreach (glob(rtrim(ROOT_CORE, '/') . '/Commands/*.php') as $file) {
|
||||
$base = basename($file, '.php');
|
||||
$class = 'Commands\\' . $base;
|
||||
if (class_exists($class) && is_a($class, 'Interfaces\\CommandInterface', true)) {
|
||||
$classes[$base] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
if ($command === null || in_array($command, $toolboxCommands, true)) {
|
||||
if (!class_exists('Toolbox\\Cli')) {
|
||||
fwrite(STDERR, "El componente 'commands:toolbox' no está instalado.\n");
|
||||
fwrite(STDERR, "Añádelo con el instalador global: duckbrain add commands:toolbox\n");
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classes
|
||||
* @return array<string, string> alias => FQCN
|
||||
*/
|
||||
function alias_map(array $classes): array
|
||||
{
|
||||
$map = [];
|
||||
$byLower = [];
|
||||
foreach ($classes as $base => $class) {
|
||||
$byLower[strtolower($base)] = $class;
|
||||
}
|
||||
|
||||
foreach ($classes as $base => $class) {
|
||||
if (!method_exists($class, 'aliases')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($class::aliases() as $alias) {
|
||||
$owner = strtolower($base);
|
||||
$key = strtolower((string) $alias);
|
||||
|
||||
if ($key === $owner) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($map[$key]) && $map[$key] !== $class) {
|
||||
fwrite(STDERR, "Conflicto de alias '{$alias}': lo declaran {$map[$key]} y {$class}.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit((new Toolbox\Cli())->run($argv));
|
||||
if (isset($byLower[$key])) {
|
||||
fwrite(STDERR, "El alias '{$alias}' de {$class} colisiona con el comando {$byLower[$key]}.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 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");
|
||||
$map[$key] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
$cli = new $coreCommand();
|
||||
return $map;
|
||||
}
|
||||
|
||||
if ($cli instanceof Interfaces\CommandInterface) {
|
||||
call_user_func_array([$cli, 'run'], array_slice($argv, 2));
|
||||
$classes = project_commands();
|
||||
$map = alias_map($classes);
|
||||
$command = $argv[1] ?? null;
|
||||
|
||||
if ($command === null) {
|
||||
$rev = [];
|
||||
foreach ($map as $alias => $class) {
|
||||
$rev[$class][] = $alias;
|
||||
}
|
||||
|
||||
foreach ($classes as $base => $class) {
|
||||
$line = ' ' . strtolower($base);
|
||||
if (($rev[$class] ?? []) !== []) {
|
||||
$line .= ', ' . implode(', ', $rev[$class]);
|
||||
}
|
||||
fwrite(STDOUT, $line . ' — ' . $class::describe() . "\n");
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$direct = 'Commands\\' . ucfirst($command);
|
||||
$class = null;
|
||||
$slice = 2;
|
||||
|
||||
if (class_exists($direct) && is_a($direct, 'Interfaces\\CommandInterface', true)) {
|
||||
$class = $direct;
|
||||
} else {
|
||||
exit("Invalid command.\n");
|
||||
$class = $map[strtolower($command)] ?? null;
|
||||
$slice = $class === null ? $slice : 1;
|
||||
}
|
||||
|
||||
if ($class === null) {
|
||||
fwrite(STDERR, "Comando desconocido: {$command}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$cli = new $class();
|
||||
|
||||
call_user_func_array([$cli, 'run'], array_slice($argv, $slice));
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "commands",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.6",
|
||||
"description": "CLI y migraciones para Duckbrain",
|
||||
"components": {
|
||||
"cli": {
|
||||
"files": [
|
||||
"duckbrain",
|
||||
"src/Interfaces/CommandInterface.php"
|
||||
]
|
||||
],
|
||||
"description": "CLI del proyecto (binario duckbrain)",
|
||||
"core": "none"
|
||||
},
|
||||
"migration": {
|
||||
"files": [
|
||||
@@ -16,12 +18,20 @@
|
||||
],
|
||||
"require": {
|
||||
"commands:cli": "*"
|
||||
}
|
||||
},
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"description": "Migraciones de base de datos (mysql/pgsql/sqlite)"
|
||||
},
|
||||
"toolbox": {
|
||||
"files": [
|
||||
"src/Toolbox"
|
||||
]
|
||||
"src/Toolbox",
|
||||
"src/Commands/Toolbox.php"
|
||||
],
|
||||
"description": "Gestor de addons dentro del propio proyecto",
|
||||
"core": "none"
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
|
||||
@@ -10,6 +10,11 @@ use PDO;
|
||||
|
||||
class Migration implements CommandInterface
|
||||
{
|
||||
public static function describe(): string
|
||||
{
|
||||
return 'Migraciones de base de datos (create, migrate, rollback, fresh, clear)';
|
||||
}
|
||||
|
||||
private const MIGRATIONS_PATH = ROOT_DIR . '/migrations/';
|
||||
private string $migratonsDatabaseFile;
|
||||
private PDO $db;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Commands;
|
||||
|
||||
use Interfaces\CommandInterface;
|
||||
use Toolbox\Cli;
|
||||
|
||||
/**
|
||||
* Entry point del gestor de addons dentro del proyecto: el bin `duckbrain`
|
||||
* despacha aquí todo alias del motor (add, remove, update, list, doctor,
|
||||
* catalog, adopt, help y variantes de versión/usage). Vive en el namespace
|
||||
* Commands del proyecto para que el binario siga siendo un despachador
|
||||
* genérico: no conoce la lista de comandos de nadie más que de los propios.
|
||||
*/
|
||||
class Toolbox implements CommandInterface
|
||||
{
|
||||
public static function describe(): string
|
||||
{
|
||||
return 'Gestor de addons del proyecto (motor duckbrain-toolbox)';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function aliases(): array
|
||||
{
|
||||
return [
|
||||
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt', 'commands',
|
||||
'help', '--help', '-h', '--version', '-V',
|
||||
];
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$args = func_get_args();
|
||||
|
||||
exit((new Cli())->run(array_merge(['duckbrain'], array_map('strval', $args))));
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,10 @@ interface CommandInterface
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void;
|
||||
|
||||
/**
|
||||
* Descripción de una línea del comando, obligatoria: fuerza a documentar
|
||||
* cada comando del proyecto para que sea visible en la superficie.
|
||||
*/
|
||||
public static function describe(): string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Toolbox;
|
||||
use Toolbox\Commands\AddCommand;
|
||||
use Toolbox\Commands\AdoptCommand;
|
||||
use Toolbox\Commands\CatalogCommand;
|
||||
use Toolbox\Commands\CommandsCommand;
|
||||
use Toolbox\Commands\DoctorCommand;
|
||||
use Toolbox\Commands\InstallCommand;
|
||||
use Toolbox\Commands\ListCommand;
|
||||
@@ -24,6 +25,7 @@ final class Cli
|
||||
'doctor' => DoctorCommand::class,
|
||||
'catalog' => CatalogCommand::class,
|
||||
'adopt' => AdoptCommand::class,
|
||||
'commands' => CommandsCommand::class,
|
||||
'self-update' => SelfUpdateCommand::class,
|
||||
];
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ final class AdoptCommand extends Command
|
||||
'toolbox_commit' => null,
|
||||
'mode' => 'copy',
|
||||
'origin' => $hasLocal ? 'local' : 'adopted',
|
||||
'description' => $package->descriptionOf($component->name),
|
||||
'files' => $files,
|
||||
]);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ final class CatalogCommand extends Command
|
||||
$first ? $package->name . ' (' . $package->version . ')' : '',
|
||||
$name,
|
||||
$installed,
|
||||
$first ? $package->description : '',
|
||||
$package->descriptionOf($name) ?? '',
|
||||
];
|
||||
$first = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Toolbox\Commands;
|
||||
|
||||
use Toolbox\Lock;
|
||||
use Toolbox\Ownership;
|
||||
|
||||
/**
|
||||
* Superficie ejecutable del proyecto: comandos en src/Commands con sus
|
||||
* aliases, descripciones y origen. A diferencia del despachador del bin,
|
||||
* las colisiones de alias aquí se REPORTAN (marcadas con !) y no abortan.
|
||||
*/
|
||||
final class CommandsCommand extends Command
|
||||
{
|
||||
public static function describe(): string
|
||||
{
|
||||
return 'Lista los comandos disponibles del proyecto';
|
||||
}
|
||||
|
||||
public function run(array $args): int
|
||||
{
|
||||
$parsed = $this->parse($args);
|
||||
$root = $this->root($parsed['options']);
|
||||
|
||||
$autoload = $root . '/autoload.php';
|
||||
if (!defined('ROOT_CORE') && is_file($autoload)) {
|
||||
$previous = getcwd();
|
||||
chdir($root);
|
||||
try {
|
||||
include_once $autoload;
|
||||
} catch (\Throwable) {
|
||||
// el proyecto no arranca: se lista sin clases cargables
|
||||
}
|
||||
if ($previous !== false) {
|
||||
chdir($previous);
|
||||
}
|
||||
}
|
||||
|
||||
$coreDir = defined('ROOT_CORE') ? rtrim(ROOT_CORE, '/') : $root . '/src';
|
||||
$dir = $coreDir . '/Commands';
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
$this->console->out('El proyecto no tiene comandos en src/Commands.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lock = Lock::load($root);
|
||||
$ownership = Ownership::fromLock($lock);
|
||||
|
||||
$info = [];
|
||||
$aliasOwners = [];
|
||||
|
||||
foreach (glob($dir . '/*.php') as $file) {
|
||||
$base = basename($file, '.php');
|
||||
$class = 'Commands\\' . $base;
|
||||
|
||||
if (!class_exists($class, true) || !is_a($class, 'Interfaces\\CommandInterface', true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$aliases = method_exists($class, 'aliases') ? $class::aliases() : [];
|
||||
foreach ($aliases as $alias) {
|
||||
$aliasOwners[strtolower((string) $alias)][] = $class;
|
||||
}
|
||||
|
||||
$owner = $ownership->ownerOf('src/Commands/' . $base . '.php');
|
||||
$origin = 'proyecto';
|
||||
if ($owner === 'core') {
|
||||
$origin = 'core';
|
||||
} elseif ($owner !== null) {
|
||||
$origin = $owner . '@' . ($lock->component($owner)['version'] ?? '?');
|
||||
}
|
||||
|
||||
$info[$base] = [
|
||||
'class' => $class,
|
||||
'describe' => $class::describe(),
|
||||
'aliases' => $aliases,
|
||||
'origin' => $origin,
|
||||
];
|
||||
}
|
||||
|
||||
$collisions = [];
|
||||
foreach ($aliasOwners as $alias => $owners) {
|
||||
$uniq = array_unique($owners);
|
||||
if (count($uniq) > 1) {
|
||||
$collisions[$alias] = $uniq;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($info as $base => $row) {
|
||||
foreach ($row['aliases'] as $alias) {
|
||||
$aKey = strtolower((string) $alias);
|
||||
foreach (array_keys($info) as $other) {
|
||||
if (strtolower($other) !== $aKey || $other === $base) {
|
||||
continue;
|
||||
}
|
||||
$owners = array_values(array_unique([...($collisions[$aKey] ?? []), $row['class'], $info[$other]['class']]));
|
||||
$collisions[$aKey] = $owners;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [['COMANDO', 'ALIASES', 'DESCRIPCIÓN', 'ORIGEN']];
|
||||
foreach ($info as $base => $row) {
|
||||
$marks = [];
|
||||
foreach ($row['aliases'] as $alias) {
|
||||
$marks[] = (isset($collisions[strtolower((string) $alias)]) ? $alias . '!' : (string) $alias);
|
||||
}
|
||||
$marked = $marks === [] ? '—' : implode(', ', $marks);
|
||||
if ($marked !== '—' && isset($collisions[strtolower($base)])) {
|
||||
$marked .= ' !';
|
||||
}
|
||||
|
||||
$rows[] = [strtolower($base), $marked, $row['describe'], $row['origin']];
|
||||
}
|
||||
|
||||
$this->console->table($rows);
|
||||
|
||||
foreach ($collisions as $alias => $owners) {
|
||||
$this->console->warn("alias '{$alias}' declarado o robado por: " . implode(', ', $owners));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,17 @@ final class DoctorCommand extends Command
|
||||
|
||||
$requires = null;
|
||||
$tested = null;
|
||||
$independent = false;
|
||||
if ($component !== null && $package !== null && $catalog->has($package)) {
|
||||
$manifest = $catalog->get($package);
|
||||
if ($manifest->hasComponent($component)) {
|
||||
$requires = $manifest->coreRequirement($component);
|
||||
$tested = $manifest->testedCore($component);
|
||||
$independent = $manifest->coreIndependent($component);
|
||||
}
|
||||
}
|
||||
|
||||
$result = Compatibility::evaluateCore($coreVersion, $requires, $tested);
|
||||
$result = Compatibility::evaluateCore($coreVersion, $requires, $tested, $independent);
|
||||
|
||||
$rows[] = [
|
||||
$id,
|
||||
|
||||
@@ -19,15 +19,21 @@ final class ListCommand extends Command
|
||||
$toolbox = $this->toolboxDir($parsed['options']);
|
||||
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
|
||||
|
||||
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO']];
|
||||
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO', 'DESCRIPCIÓN']];
|
||||
|
||||
$core = $installer->lock()->core();
|
||||
if ($core !== null) {
|
||||
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy'];
|
||||
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy', ''];
|
||||
}
|
||||
|
||||
foreach ($installer->lock()->components() as $id => $entry) {
|
||||
$rows[] = [$entry['package'], $id, $entry['version'], $entry['mode']];
|
||||
$rows[] = [
|
||||
$entry['package'],
|
||||
$id,
|
||||
$entry['version'],
|
||||
$entry['mode'],
|
||||
$entry['description'] ?? '—',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($rows) === 1) {
|
||||
|
||||
@@ -12,6 +12,7 @@ final class Compatibility
|
||||
public const UNTESTED = 'untested';
|
||||
public const INSUFFICIENT = 'insufficient';
|
||||
public const UNKNOWN = 'unknown';
|
||||
public const INDEPENDENT = 'independent';
|
||||
|
||||
/**
|
||||
* @return array{status:string, message:string}
|
||||
@@ -19,8 +20,13 @@ final class Compatibility
|
||||
public static function evaluateCore(
|
||||
?string $coreVersion,
|
||||
?string $requires,
|
||||
?string $tested
|
||||
?string $tested,
|
||||
bool $independent = false
|
||||
): array {
|
||||
if ($independent) {
|
||||
return ['status' => self::INDEPENDENT, 'message' => 'independiente del core'];
|
||||
}
|
||||
|
||||
if ($coreVersion === null) {
|
||||
return ['status' => self::UNKNOWN, 'message' => 'versión del core desconocida (sin tags)'];
|
||||
}
|
||||
@@ -71,6 +77,7 @@ final class Compatibility
|
||||
self::OK => 'OK',
|
||||
self::UNTESTED => 'NO PROBADO',
|
||||
self::INSUFFICIENT => 'INSUFICIENTE',
|
||||
self::INDEPENDENT => 'INDEPENDIENTE',
|
||||
default => 'SIN DATOS',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ final class Component
|
||||
public readonly array $requires = [],
|
||||
public readonly ?string $coreRequires = null,
|
||||
public readonly ?string $coreTested = null,
|
||||
public readonly ?string $description = null,
|
||||
public readonly ?array $destinations = null,
|
||||
public readonly bool $override = false,
|
||||
public readonly bool $coreDeclared = false,
|
||||
public readonly ?bool $coreIndependent = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,14 +54,62 @@ final class Component
|
||||
}
|
||||
}
|
||||
|
||||
$description = null;
|
||||
if (isset($data['description'])) {
|
||||
if (!is_string($data['description'])) {
|
||||
throw new ToolboxException("El componente '{$name}' tiene un 'description' inválido");
|
||||
}
|
||||
|
||||
$description = $data['description'];
|
||||
}
|
||||
|
||||
$coreDeclared = array_key_exists('core', $data);
|
||||
$coreIndependent = null;
|
||||
$coreRequires = null;
|
||||
$coreTested = null;
|
||||
if ($coreDeclared) {
|
||||
[$coreRequires, $coreTested, $coreIndependent] = self::parseCore($data['core'], "el componente '{$name}'");
|
||||
}
|
||||
|
||||
return new self(
|
||||
name: $name,
|
||||
files: array_values($data['files']),
|
||||
requires: $requires,
|
||||
coreRequires: $data['core']['requires'] ?? null,
|
||||
coreTested: $data['core']['tested'] ?? null,
|
||||
coreRequires: $coreRequires,
|
||||
coreTested: $coreTested,
|
||||
description: $description,
|
||||
destinations: $destinations,
|
||||
override: (bool) ($data['override'] ?? false),
|
||||
coreDeclared: $coreDeclared,
|
||||
coreIndependent: $coreIndependent,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloque `core`: objeto de rango ({requires?, tested?}) o la cadena
|
||||
* "none" (independencia afirmativa, excluyente con el rango).
|
||||
*
|
||||
* @return array{0: ?string, 1: ?string, 2: ?bool}
|
||||
*/
|
||||
public static function parseCore(mixed $core, string $origin): array
|
||||
{
|
||||
if ($core === 'none') {
|
||||
return [null, null, true];
|
||||
}
|
||||
|
||||
if (!is_array($core)) {
|
||||
throw new ToolboxException("{$origin} tiene un 'core' inválido");
|
||||
}
|
||||
|
||||
$requires = $core['requires'] ?? null;
|
||||
$tested = $core['tested'] ?? null;
|
||||
|
||||
foreach (['requires' => $requires, 'tested' => $tested] as $key => $value) {
|
||||
if ($value !== null && !is_string($value)) {
|
||||
throw new ToolboxException("{$origin} tiene un 'core.{$key}' inválido");
|
||||
}
|
||||
}
|
||||
|
||||
return [$requires, $tested, null];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@ namespace Toolbox;
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
public const VERSION = '0.2.0';
|
||||
public const VERSION = '0.2.6';
|
||||
|
||||
public static function toolboxUrl(): string
|
||||
{
|
||||
return self::env('DUCKBRAIN_TOOLBOX_URL')
|
||||
?: 'git@git.kj2.me:kj/duckbrain-toolbox.git';
|
||||
?: 'https://git.kj2.me/kj/duckbrain-toolbox.git';
|
||||
}
|
||||
|
||||
public static function coreUrl(): string
|
||||
{
|
||||
return self::env('DUCKBRAIN_CORE_URL')
|
||||
?: 'git@git.kj2.me:kj/duckbrain.git';
|
||||
?: 'https://git.kj2.me/kj/duckbrain.git';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,12 @@ final class CoreProvider
|
||||
throw new ToolboxException('No se pudo clonar el core: ' . implode("\n", $out));
|
||||
}
|
||||
} else {
|
||||
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1');
|
||||
exec('git -C ' . escapeshellarg($dir) . ' remote set-url origin ' . escapeshellarg($url) . ' 2>&1');
|
||||
$out = [];
|
||||
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1', $out, $code);
|
||||
if ($code !== 0) {
|
||||
throw new ToolboxException('No se pudo actualizar el core: ' . implode("\n", $out));
|
||||
}
|
||||
}
|
||||
|
||||
$provider = new self($dir);
|
||||
|
||||
@@ -111,10 +111,11 @@ final class Installer
|
||||
$compat = Compatibility::evaluateCore(
|
||||
$this->coreVersion(),
|
||||
$manifest->coreRequirement($componentName),
|
||||
$manifest->testedCore($componentName)
|
||||
$manifest->testedCore($componentName),
|
||||
$manifest->coreIndependent($componentName)
|
||||
);
|
||||
|
||||
if ($compat['status'] !== Compatibility::OK) {
|
||||
if (!in_array($compat['status'], [Compatibility::OK, Compatibility::INDEPENDENT], true)) {
|
||||
$warnings[] = $id . ': ' . $compat['message'];
|
||||
$assumeYes = (bool) ($options['yes'] ?? false);
|
||||
if (!$assumeYes && !$this->console->confirm(" {$id}: {$compat['message']}. ¿Continuar?", false)) {
|
||||
@@ -156,6 +157,7 @@ final class Installer
|
||||
'version' => $version,
|
||||
'toolbox_commit' => $commit,
|
||||
'mode' => $mode,
|
||||
'description' => $manifest->descriptionOf($componentName),
|
||||
'files' => $result['files'],
|
||||
]);
|
||||
}
|
||||
@@ -191,7 +193,8 @@ final class Installer
|
||||
$manifest->postInstall,
|
||||
$this->console,
|
||||
(bool) ($options['yes'] ?? false),
|
||||
$options['postRunner'] ?? null
|
||||
$options['postRunner'] ?? null,
|
||||
$this->root,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ final class Lock
|
||||
'toolbox_commit' => $entry['toolbox_commit'] ?? null,
|
||||
'mode' => (string) ($entry['mode'] ?? 'copy'),
|
||||
'origin' => (string) ($entry['origin'] ?? 'toolbox'),
|
||||
'description' => $entry['description'] ?? null,
|
||||
'files' => is_array($entry['files'] ?? null) ? $entry['files'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ final class Manifest
|
||||
public readonly ?string $coreTested,
|
||||
public readonly ?string $php,
|
||||
public readonly array $postInstall,
|
||||
public readonly ?bool $coreIndependent = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,16 +84,23 @@ final class Manifest
|
||||
$postInstall[] = $command;
|
||||
}
|
||||
|
||||
$coreRequires = $coreTested = null;
|
||||
$coreIndependent = null;
|
||||
if (array_key_exists('core', $data)) {
|
||||
[$coreRequires, $coreTested, $coreIndependent] = Component::parseCore($data['core'], "el manifiesto '{$origin}'");
|
||||
}
|
||||
|
||||
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,
|
||||
coreRequires: $coreRequires,
|
||||
coreTested: $coreTested,
|
||||
php: is_string($data['php'] ?? null) ? $data['php'] : null,
|
||||
postInstall: $postInstall,
|
||||
coreIndependent: $coreIndependent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,21 +148,49 @@ final class Manifest
|
||||
return $this->default;
|
||||
}
|
||||
|
||||
public function coreRequirement(?string $component = null): ?string
|
||||
/**
|
||||
* Bloque de core efectivo: si el componente declara `core` (rango o
|
||||
* "none") ese bloque gana por completo; si no, hereda el del paquete.
|
||||
*
|
||||
* @return array{requires: ?string, tested: ?string, independent: ?bool}
|
||||
*/
|
||||
private function coreBlock(?string $component): array
|
||||
{
|
||||
if ($component !== null) {
|
||||
return $this->component($component)->coreRequires ?? $this->coreRequires;
|
||||
$c = $this->component($component);
|
||||
if ($c->coreDeclared) {
|
||||
return ['requires' => $c->coreRequires, 'tested' => $c->coreTested, 'independent' => $c->coreIndependent];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->coreRequires;
|
||||
return ['requires' => $this->coreRequires, 'tested' => $this->coreTested, 'independent' => $this->coreIndependent];
|
||||
}
|
||||
|
||||
public function coreRequirement(?string $component = null): ?string
|
||||
{
|
||||
return $this->coreBlock($component)['requires'];
|
||||
}
|
||||
|
||||
public function testedCore(?string $component = null): ?string
|
||||
{
|
||||
if ($component !== null) {
|
||||
return $this->component($component)->coreTested ?? $this->coreTested;
|
||||
return $this->coreBlock($component)['tested'];
|
||||
}
|
||||
|
||||
return $this->coreTested;
|
||||
public function coreIndependent(?string $component = null): bool
|
||||
{
|
||||
return $this->coreBlock($component)['independent'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descripcion efectiva: la propia del componente o, en su ausencia, la del
|
||||
* paquete (mismo patrón de herencia que los rangos de core).
|
||||
*/
|
||||
public function descriptionOf(?string $component = null): ?string
|
||||
{
|
||||
if ($component !== null) {
|
||||
return $this->component($component)->description ?? $this->description;
|
||||
}
|
||||
|
||||
return $this->description;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,27 @@ final class PostInstall
|
||||
/**
|
||||
* @param list<string> $commands
|
||||
* @param callable(string):int|null $runner
|
||||
* @param string|null $cwd Directorio de trabajo del proyecto: los
|
||||
* comandos se ejecutan alli, no donde arranco
|
||||
* el instalador.
|
||||
* @return list<string> comandos ejecutados
|
||||
*/
|
||||
public static function run(
|
||||
array $commands,
|
||||
Console $console,
|
||||
bool $assumeYes = false,
|
||||
?callable $runner = null
|
||||
?callable $runner = null,
|
||||
?string $cwd = null
|
||||
): array {
|
||||
$executed = [];
|
||||
$previous = null;
|
||||
|
||||
if ($cwd !== null && is_dir($cwd)) {
|
||||
$previous = getcwd();
|
||||
chdir($cwd);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($commands as $command) {
|
||||
$console->out(' post-install: ' . $command);
|
||||
|
||||
@@ -41,6 +52,11 @@ final class PostInstall
|
||||
|
||||
$executed[] = $command;
|
||||
}
|
||||
} finally {
|
||||
if ($previous !== null) {
|
||||
chdir($previous);
|
||||
}
|
||||
}
|
||||
|
||||
return $executed;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
{
|
||||
"name": "crypto",
|
||||
"version": "0.1.0",
|
||||
"description": "Criptografía y segundo factor (Crypto, Nonce, TwoFactorAuthenticator)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"version": "0.1.1",
|
||||
"description": "Criptograf\u00eda 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"] }
|
||||
"crypto": {
|
||||
"files": [
|
||||
"src/Libs/Crypto.php"
|
||||
],
|
||||
"description": "Cifrado sim\u00e9trico con salida hex/base64"
|
||||
},
|
||||
"default": ["crypto", "nonce", "twoFactor"]
|
||||
"nonce": {
|
||||
"files": [
|
||||
"src/Libs/Nonce.php"
|
||||
],
|
||||
"description": "Generaci\u00f3n y verificaci\u00f3n de tokens de un solo uso"
|
||||
},
|
||||
"twoFactor": {
|
||||
"files": [
|
||||
"src/Libs/TwoFactorAuthenticator.php"
|
||||
],
|
||||
"description": "TOTP compatible con Google Authenticator"
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
"crypto",
|
||||
"nonce",
|
||||
"twoFactor"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
{
|
||||
"name": "htmx",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Motor de vistas basado en componentes y HTMX",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"htmlComponent": {
|
||||
"files": ["src/Libs/HTMLComponent.php", "src/Libs/Sanitizer"]
|
||||
"files": [
|
||||
"src/Libs/HTMLComponent.php",
|
||||
"src/Libs/Sanitizer"
|
||||
],
|
||||
"description": "Motor de vistas por componentes"
|
||||
},
|
||||
"htmx": {
|
||||
"files": ["src/Libs/HTMX.php"],
|
||||
"require": { "htmx:htmlComponent": ">=0.1" }
|
||||
"files": [
|
||||
"src/Libs/HTMX.php"
|
||||
],
|
||||
"require": {
|
||||
"htmx:htmlComponent": ">=0.1"
|
||||
},
|
||||
"description": "Detecci\u00f3n y fragmentos de peticiones HTMX"
|
||||
}
|
||||
},
|
||||
"default": ["htmlComponent", "htmx"]
|
||||
"default": [
|
||||
"htmlComponent",
|
||||
"htmx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
{
|
||||
"name": "http",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Utilidades HTTP (CurlRequest, Aria2, Proxy, CompreFace)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"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"] },
|
||||
"curlRequest": {
|
||||
"files": [
|
||||
"src/Libs/CurlRequest.php"
|
||||
],
|
||||
"description": "Cliente HTTP sobre cURL con cabeceras y c\u00f3digo de estado"
|
||||
},
|
||||
"aria2": {
|
||||
"files": [
|
||||
"src/Libs/Aria2.php"
|
||||
],
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"description": "Cliente JSON-RPC de aria2 (cola de descargas)"
|
||||
},
|
||||
"proxy": {
|
||||
"files": [
|
||||
"src/Libs/Proxy.php"
|
||||
],
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"description": "Proxy HTML con filtros de respuesta"
|
||||
},
|
||||
"compreFace": {
|
||||
"files": ["src/Libs/CompreFace.php"],
|
||||
"require": { "http:curlRequest": ">=0.1" }
|
||||
"files": [
|
||||
"src/Libs/CompreFace.php"
|
||||
],
|
||||
"require": {
|
||||
"http:curlRequest": ">=0.1"
|
||||
},
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"description": "Cliente de la API CompreFace (reconocimiento facial)"
|
||||
}
|
||||
},
|
||||
"default": ["curlRequest", "aria2", "proxy", "compreFace"]
|
||||
"default": [
|
||||
"curlRequest",
|
||||
"aria2",
|
||||
"proxy",
|
||||
"compreFace"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,16 +40,6 @@ class Aria2
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
curl_close($this->ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows starting a batch call.
|
||||
*
|
||||
|
||||
@@ -52,10 +52,6 @@ class CurlRequest
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,9 +22,25 @@ class Proxy
|
||||
'Location',
|
||||
];
|
||||
public static $proxy_base_url_canonical = '/';
|
||||
public static $destination_host = SOURCE_URL;
|
||||
public static ?string $destination_host = null;
|
||||
protected static $filters = [];
|
||||
|
||||
/**
|
||||
* Host destino efectivo: el asignado explicitamente o la constante
|
||||
* SOURCE_URL del proyecto si existe. Se resuelve tarde para que la
|
||||
* simple carga de la clase no falle sin configuracion.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function destinationHost(): string
|
||||
{
|
||||
if (static::$destination_host !== null) {
|
||||
return static::$destination_host;
|
||||
}
|
||||
|
||||
return defined('SOURCE_URL') ? (string) SOURCE_URL : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a callable (AKA filter) that will receive the content
|
||||
* and should return a string.
|
||||
@@ -76,7 +92,7 @@ class Proxy
|
||||
}
|
||||
|
||||
// Generate the final URL
|
||||
$proxy_request_url = static::$destination_host . $proxy_request_uri;
|
||||
$proxy_request_url = static::destinationHost() . $proxy_request_uri;
|
||||
|
||||
// cURL initialization
|
||||
$ch = curl_init();
|
||||
@@ -121,11 +137,10 @@ class Proxy
|
||||
|
||||
// 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 = str_replace(static::destinationHost(), '/', $headers);
|
||||
|
||||
$headers = explode("\r\n", $headers);
|
||||
$hs = [];
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
{
|
||||
"name": "test",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Kit de pruebas PHPUnit para Duckbrain",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"harness": {
|
||||
"files": ["phpunit.xml", "tests"],
|
||||
"require": { "commands:cli": ">=0.1" }
|
||||
"files": [
|
||||
"phpunit.xml",
|
||||
"tests"
|
||||
],
|
||||
"require": {
|
||||
"commands:migration": ">=0.1"
|
||||
},
|
||||
"description": "Kit de pruebas PHPUnit para el proyecto"
|
||||
}
|
||||
},
|
||||
"default": ["harness"],
|
||||
"default": [
|
||||
"harness"
|
||||
],
|
||||
"post-install": [
|
||||
"composer require --dev phpunit/phpunit:^11.0 fakerphp/faker",
|
||||
"composer dump-autoload"
|
||||
|
||||
@@ -6,6 +6,18 @@ use Libs\Loader;
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Mapeo PSR-4 propio del arbol de pruebas (Tests\ -> tests/) sin exigir que el
|
||||
// proyecto declare autoload-dev en su composer.json.
|
||||
spl_autoload_register(function (string $class): void {
|
||||
if (!str_starts_with($class, 'Tests\\')) {
|
||||
return;
|
||||
}
|
||||
$file = __DIR__ . '/' . str_replace('\\', '/', substr($class, 6)) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// IMPORTANT: Definir constantes ANTES de cargar config.php
|
||||
// para sobreescribir la configuración para definir una
|
||||
@@ -20,6 +32,11 @@ define('DB_PASS', '');
|
||||
// Incluir config.php con el autoloader
|
||||
@require_once __DIR__ . '/../autoload.php';
|
||||
|
||||
// El ledger de migraciones (Commands\Migration) necesita el directorio.
|
||||
if (!is_dir(ROOT_DIR . '/migrations')) {
|
||||
mkdir(ROOT_DIR . '/migrations', 0777, true);
|
||||
}
|
||||
|
||||
// Router autoload
|
||||
Loader::load(ROOT_CORE . '/Routers/');
|
||||
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
{
|
||||
"name": "text",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Filtros y reemplazos de texto (Filter, Replacement)",
|
||||
"core": { "requires": ">=0.1", "tested": "0.1" },
|
||||
"core": {
|
||||
"requires": ">=0.1",
|
||||
"tested": "0.1"
|
||||
},
|
||||
"php": ">=8.1",
|
||||
"components": {
|
||||
"filter": { "files": ["src/Libs/Filter.php"] },
|
||||
"replacement": { "files": ["src/Libs/Replacement.php"] }
|
||||
"filter": {
|
||||
"files": [
|
||||
"src/Libs/Filter.php"
|
||||
],
|
||||
"description": "Filtros de contenido extensibles por contexto y prioridad"
|
||||
},
|
||||
"default": ["filter", "replacement"]
|
||||
"replacement": {
|
||||
"files": [
|
||||
"src/Libs/Replacement.php"
|
||||
],
|
||||
"description": "B\u00fasqueda y reemplazo de patrones sobre contenido"
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
"filter",
|
||||
"replacement"
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env bash
|
||||
# Barrido de compatibilidad componente <-> core real (change verify-core-compat).
|
||||
# Monta un proyecto temporal por paquete contra el core real publicado, lo
|
||||
# ejercita segun su nivel de evidencia (tests/compat/levels.json) con criterios
|
||||
# estrictos (cualquier Deprecated/Warning/Notice/Fatal cuenta FAIL) y escribe
|
||||
# tests/compat/report.json.
|
||||
#
|
||||
# Uso: scripts/compat-sweep [--only <paquete> ...]
|
||||
# Env: DUCKBRAIN_CORE_URL repositorio del core (default: Config::coreUrl())
|
||||
# DUCKBRAIN_CACHE_DIR caché donde CoreProvider mantiene el clon del core
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPAT="$ROOT/tests/compat"
|
||||
LEVELS="$COMPAT/levels.json"
|
||||
REPORT="$COMPAT/report.json"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
ONLY=()
|
||||
args=("$@")
|
||||
i=0
|
||||
while [ "$i" -lt "${#args[@]}" ]; do
|
||||
case "${args[i]}" in
|
||||
--only) i=$((i + 1)); ONLY+=("${args[i]}") ;;
|
||||
*) echo "Uso: scripts/compat-sweep [--only <paquete> ...]" >&2; exit 2 ;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# --- resolver el core real (clon + checkout del mayor tag semver) --------------
|
||||
CORE_INFO="$(php -d error_reporting=-1 -r '
|
||||
require "'"$ROOT"'/src/autoload.php";
|
||||
try {
|
||||
$p = Toolbox\CoreProvider::fromUrl(
|
||||
Toolbox\Config::coreUrl(), Toolbox\Config::cacheDir(), new Toolbox\Console()
|
||||
);
|
||||
$v = $p->version();
|
||||
if ($v === null) {
|
||||
fwrite(STDERR, "El core no tiene tags semver publicados; nada que verificar.\n");
|
||||
exit(1);
|
||||
}
|
||||
echo $p->dir(), "\n", $v, "\n", (string) $p->commit(), "\n";
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "No se pudo acceder al core real: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}' 2>"$WORK/core.err")" || {
|
||||
echo "== ERROR: barrido abortado ==" >&2
|
||||
cat "$WORK/core.err" >&2
|
||||
exit 1
|
||||
}
|
||||
CORE_DIR="$(printf '%s\n' "$CORE_INFO" | sed -n 1p)"
|
||||
CORE_VER="$(printf '%s\n' "$CORE_INFO" | sed -n 2p)"
|
||||
CORE_SHA="$(printf '%s\n' "$CORE_INFO" | sed -n 3p)"
|
||||
echo "== core real: v$CORE_VER ($CORE_SHA) en $CORE_DIR =="
|
||||
echo
|
||||
|
||||
LEVELS_PATH="$LEVELS" php -r '
|
||||
$levels = json_decode((string) file_get_contents(getenv("LEVELS_PATH")), true);
|
||||
echo implode("\n", array_keys($levels)), "\n";' > "$WORK/pkgs"
|
||||
|
||||
RESULTS="$WORK/results"
|
||||
: > "$RESULTS"
|
||||
FAILED=0
|
||||
|
||||
wanted() {
|
||||
[ "${#ONLY[@]}" -eq 0 ] && return 0
|
||||
local w
|
||||
for w in "${ONLY[@]}"; do [ "$w" = "$1" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
if [ -n "${SERVER_PID:-}" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
# --- por paquete ---------------------------------------------------------------
|
||||
while read -r PKG; do
|
||||
wanted "$PKG" || continue
|
||||
PROJ="$WORK/proj-$PKG"
|
||||
LOG="$WORK/$PKG.server.log"
|
||||
echo "── $PKG"
|
||||
|
||||
php -d error_reporting=-1 "$ROOT/bin/duckbrain" install "$PROJ" --core-dir="$CORE_DIR" --yes >/dev/null
|
||||
php -d error_reporting=-1 "$ROOT/bin/duckbrain" add "$PKG" --root="$PROJ" --yes >/dev/null \
|
||||
|| { echo "$PKG:add|fail" >> "$RESULTS"; echo " add fallo"; FAILED=1; continue; }
|
||||
|
||||
# Config del proyecto temporal: sqlite, clave para crypto y db local.
|
||||
PROJ_PATH="$PROJ" php -r '
|
||||
$proj = getenv("PROJ_PATH");
|
||||
$f = "$proj/config.php";
|
||||
$cfg = (string) file_get_contents($f);
|
||||
$cfg = str_replace("define(\x27DB_TYPE\x27, \x27mysql\x27);", "define(\x27DB_TYPE\x27, \x27sqlite\x27);", $cfg);
|
||||
$cfg = str_replace("define(\x27DB_NAME\x27, \x27\x27);", "define(\x27DB_NAME\x27, \x27$proj/app.sqlite\x27);", $cfg);
|
||||
if (!str_contains($cfg, "PRIVATE_KEY")) {
|
||||
$cfg .= "\ndefine(\x27PRIVATE_KEY\x27, \x27compat-sweep-clave-de-prueba-32!\x27);\n";
|
||||
}
|
||||
file_put_contents($f, $cfg);'
|
||||
|
||||
# Router de compatibilidad del fixture (si lo hay).
|
||||
if [ -f "$COMPAT/$PKG/router.php" ]; then
|
||||
mkdir -p "$PROJ/src/Routers"
|
||||
sed "s|@COMPAT@|$COMPAT/$PKG|g" "$COMPAT/$PKG/router.php" > "$PROJ/src/Routers/Compat.php"
|
||||
fi
|
||||
|
||||
# Servidor con diagnósticos al máximo y capturados.
|
||||
BASE=""
|
||||
: > "$LOG"
|
||||
for PORT in $(seq 18100 18120); do
|
||||
PHP_CLI_SERVER_WORKERS=4 php -d error_reporting=-1 -d display_errors=1 \
|
||||
-d log_errors=1 -d error_log="$LOG" -S "127.0.0.1:$PORT" -t "$PROJ" "$PROJ/index.php" \
|
||||
>/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
|
||||
sleep 0.15
|
||||
if curl -s -o /dev/null "http://127.0.0.1:$PORT/compat/ping"; then
|
||||
BASE="http://127.0.0.1:$PORT"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
stop_server
|
||||
done
|
||||
if [ -z "$BASE" ]; then
|
||||
echo " no hubo servidor en 18100-18120" >&2
|
||||
echo "$PKG:server|fail" >> "$RESULTS"; FAILED=1; continue
|
||||
fi
|
||||
|
||||
# Aserciones del fixture (si lo hay; los niveles "none" no tienen).
|
||||
if [ -f "$COMPAT/$PKG/assert.sh" ]; then
|
||||
BASE="$BASE" PROJ="$PROJ" PKG="$PKG" LOG="$LOG" RESULTS="$RESULTS" COMPAT="$COMPAT" \
|
||||
bash "$COMPAT/$PKG/assert.sh" || true
|
||||
fi
|
||||
stop_server
|
||||
|
||||
# Criterio estricto: cualquier diagnostico en el log del servidor es fallo.
|
||||
if grep -qE 'Deprecated|Warning:|Notice:|Fatal error|Parse error' "$LOG"; then
|
||||
echo " diagnostico PHP detectado en el servidor (ver $LOG):"
|
||||
grep -E 'Deprecated|Warning:|Notice:|Fatal error|Parse error' "$LOG" | sed 's/^/ /'
|
||||
FAILED=1
|
||||
fi
|
||||
done < "$WORK/pkgs"
|
||||
|
||||
# --- merge de resultados con levels.json y reporte ------------------------------
|
||||
LEVELS_PATH="$LEVELS" RESULTS_PATH="$RESULTS" REPORT_PATH="$REPORT" \
|
||||
CORE_VER="$CORE_VER" CORE_SHA="$CORE_SHA" php -r '
|
||||
$levels = json_decode((string) file_get_contents(getenv("LEVELS_PATH")), true);
|
||||
$results = [];
|
||||
foreach (file(getenv("RESULTS_PATH"), FILE_IGNORE_NEW_LINES) as $line) {
|
||||
[$id, $status] = array_pad(explode("|", $line, 2), 2, "");
|
||||
$results[$id] = $status;
|
||||
}
|
||||
// Corre parciales (--only): hereda del reporte previo los ok del mismo core.
|
||||
$prior = is_file(getenv("REPORT_PATH"))
|
||||
? (json_decode((string) file_get_contents(getenv("REPORT_PATH")), true) ?: [])
|
||||
: [];
|
||||
$stale = ($prior["core"]["version"] ?? null) !== getenv("CORE_VER");
|
||||
$out = [];
|
||||
$fail = 0;
|
||||
foreach ($levels as $pkg => $comps) {
|
||||
foreach ($comps as $comp => $level) {
|
||||
$reason = null;
|
||||
if (is_array($level)) { $reason = $level["reason"] ?? null; $level = $level["level"]; }
|
||||
$id = "$pkg:$comp";
|
||||
$status = $results[$id] ?? ($level === "none" ? "skipped" : "not_attempted");
|
||||
if ($status === "not_attempted" && !$stale && ($prior["components"][$id]["status"] ?? "") === "ok") {
|
||||
$status = "ok";
|
||||
}
|
||||
if (!in_array($status, ["ok", "skipped"], true)) { $fail++; }
|
||||
$out[$id] = array_filter([
|
||||
"status" => $status,
|
||||
"level" => $level,
|
||||
"reason" => $reason,
|
||||
], fn ($v) => $v !== null);
|
||||
}
|
||||
}
|
||||
$report = [
|
||||
"core" => ["version" => getenv("CORE_VER"), "commit" => getenv("CORE_SHA")],
|
||||
"generated" => gmdate("Y-m-d\TH:i:s\Z"),
|
||||
"components" => $out,
|
||||
];
|
||||
file_put_contents(getenv("REPORT_PATH"), json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
foreach ($out as $id => $r) {
|
||||
printf(" %-24s %-13s %s%s\n", $id, $r["status"], $r["level"], isset($r["reason"]) ? " (" . $r["reason"] . ")" : "");
|
||||
}
|
||||
exit($fail === 0 ? 0 : 1);'
|
||||
|
||||
STATUS=$?
|
||||
if [ "$STATUS" = 0 ]; then
|
||||
echo "OK: barrido de compatibilidad completo contra core v$CORE_VER"
|
||||
else
|
||||
echo "FAIL: hay componentes sin verificar o con fallos (ver $REPORT)" >&2
|
||||
fi
|
||||
exit $STATUS
|
||||
+142
-37
@@ -1,46 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Escritor único de versión + tag de un paquete del toolbox.
|
||||
# Uso: scripts/release <paquete> <patch|minor|major>
|
||||
# Unico escritor de version + tag + push de un paquete del toolbox.
|
||||
# Interactivo: scripts/release (o `make release`)
|
||||
# Directo: scripts/release <paquete> <patch|minor|major> [--yes]
|
||||
# Al liberar `commands` sincroniza Config::VERSION con la version del manifiesto.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "Uso: scripts/release <paquete> <patch|minor|major>" >&2
|
||||
exit 1
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG_PHP="$ROOT/packages/commands/src/Toolbox/Config.php"
|
||||
|
||||
die() { echo "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
echo "Uso: scripts/release [<paquete> <patch|minor|major>] [--yes]" >&2
|
||||
echo " sin argumentos: modo interactivo (o 'make release')" >&2
|
||||
}
|
||||
|
||||
packages() {
|
||||
local f
|
||||
for f in "$ROOT"/packages/*/duckbrain.json; do
|
||||
[ -f "$f" ] && basename "$(dirname "$f")"
|
||||
done
|
||||
}
|
||||
|
||||
manifest() { echo "$ROOT/packages/$1/duckbrain.json"; }
|
||||
|
||||
version_of() {
|
||||
php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$(manifest "$1")"
|
||||
}
|
||||
|
||||
bump() {
|
||||
php -r '
|
||||
$v = ltrim($argv[1], "vV");
|
||||
$p = array_map("intval", array_pad(explode(".", preg_replace("/[-+].*/", "", $v)), 3, 0));
|
||||
switch ($argv[2]) {
|
||||
case "major": $p[0]++; $p[1] = 0; $p[2] = 0; break;
|
||||
case "minor": $p[1]++; $p[2] = 0; break;
|
||||
default: $p[2]++;
|
||||
}
|
||||
echo implode(".", $p);' "$1" "$2"
|
||||
}
|
||||
|
||||
ANSWER=""
|
||||
|
||||
ask() {
|
||||
printf '%s' "$1" >&2
|
||||
read -r ANSWER || die "entrada agotada; pasa los argumentos o correlo en un TTY"
|
||||
}
|
||||
|
||||
ASSUME_YES=0
|
||||
POSITIONAL=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
*) POSITIONAL+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
confirm() {
|
||||
[ "$ASSUME_YES" = "1" ] && return 0
|
||||
ask "$1 [Y/n] "
|
||||
case "${ANSWER,,}" in ""|y|yes) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# --- eleccion de paquete -----------------------------------------------------
|
||||
if [ "${#POSITIONAL[@]}" -ge 1 ]; then
|
||||
PACKAGE="${POSITIONAL[0]}"
|
||||
else
|
||||
mapfile -t PKGS < <(packages)
|
||||
[ "${#PKGS[@]}" -gt 0 ] || die "No hay paquetes en $ROOT/packages/"
|
||||
echo "Paquetes del toolbox:"
|
||||
for i in "${!PKGS[@]}"; do
|
||||
printf ' %2d) %-10s v%s\n' "$((i + 1))" "${PKGS[i]}" "$(version_of "${PKGS[i]}")"
|
||||
done
|
||||
while :; do
|
||||
ask 'Elegir paquete (numero o nombre): '; R="$ANSWER"
|
||||
if [[ "$R" =~ ^[0-9]+$ ]] && [ "$R" -ge 1 ] && [ "$R" -le "${#PKGS[@]}" ]; then
|
||||
PACKAGE="${PKGS[R - 1]}"
|
||||
break
|
||||
fi
|
||||
printf '%s\n' "${PKGS[@]}" | grep -qxF "$R" && { PACKAGE="$R"; break; }
|
||||
echo " opcion invalida" >&2
|
||||
done
|
||||
fi
|
||||
|
||||
PACKAGE="$1"
|
||||
TYPE="$2"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="$ROOT/packages/$PACKAGE/duckbrain.json"
|
||||
MANIFEST="$(manifest "$PACKAGE")"
|
||||
[ -f "$MANIFEST" ] || die "No existe el paquete '$PACKAGE' ($MANIFEST)"
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "No existe el paquete '$PACKAGE' ($MANIFEST)" >&2
|
||||
exit 1
|
||||
# --- eleccion de incremento ---------------------------------------------------
|
||||
if [ "${#POSITIONAL[@]}" -ge 2 ]; then
|
||||
TYPE="${POSITIONAL[1]}"
|
||||
else
|
||||
while :; do
|
||||
printf 'Incremento para %s (actual v%s):\n 1) patch\n 2) minor\n 3) major\n' "$PACKAGE" "$(version_of "$PACKAGE")" >&2
|
||||
ask 'Elegir (numero o nombre): '; R="$ANSWER"
|
||||
case "$R" in
|
||||
1|patch) TYPE=patch; break ;;
|
||||
2|minor) TYPE=minor; break ;;
|
||||
3|major) TYPE=major; break ;;
|
||||
esac
|
||||
echo " opcion invalida" >&2
|
||||
done
|
||||
fi
|
||||
|
||||
case "$TYPE" in
|
||||
patch|minor|major) ;;
|
||||
*) echo "Incremento inválido: '$TYPE' (usa patch|minor|major)" >&2; exit 1 ;;
|
||||
*) die "Incremento invalido: '$TYPE' (usa patch|minor|major)" ;;
|
||||
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
|
||||
VERSION="$(version_of "$PACKAGE")"
|
||||
[ -n "$VERSION" ] || die "El manifiesto no declara 'version': $MANIFEST"
|
||||
NEW_VERSION="$(bump "$VERSION" "$TYPE")"
|
||||
TAG="$PACKAGE-v$NEW_VERSION"
|
||||
|
||||
CURRENT_CONFIG=""
|
||||
[ "$PACKAGE" = "commands" ] && CURRENT_CONFIG="$(grep -oP "const VERSION = '\K[^']+" "$CONFIG_PHP" || true)"
|
||||
|
||||
LAST_TAG="$(git -C "$ROOT" tag -l "$PACKAGE-v*" --sort=-v:refname | head -1 || true)"
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
CHANGES="$(git -C "$ROOT" log --oneline "$LAST_TAG"..HEAD -- "packages/$PACKAGE" || true)"
|
||||
else
|
||||
CHANGES="$(git -C "$ROOT" log --oneline HEAD -- "packages/$PACKAGE" || true)"
|
||||
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")"
|
||||
# --- resumen y confirmacion ---------------------------------------------------
|
||||
echo "Release: $PACKAGE v$VERSION -> v$NEW_VERSION (tag $TAG)"
|
||||
if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then
|
||||
echo " Config::VERSION: v$CURRENT_CONFIG -> v$NEW_VERSION (se sincroniza)"
|
||||
fi
|
||||
if [ -z "$CHANGES" ]; then
|
||||
echo "Cambios desde el ultimo release: ninguno"
|
||||
else
|
||||
echo "Cambios desde el ultimo release:"
|
||||
echo "$CHANGES" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
confirm "Confirmar release?" || die "Cancelado; sin cambios."
|
||||
|
||||
# --- aplicar -------------------------------------------------------------------
|
||||
php -r '
|
||||
$file = $argv[1];
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
@@ -48,14 +144,23 @@ $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
|
||||
if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then
|
||||
sed -i "s/const VERSION = '[^']*'/const VERSION = '$NEW_VERSION'/" "$CONFIG_PHP"
|
||||
fi
|
||||
|
||||
echo "release $PACKAGE v$NEW_VERSION ($TAG)"
|
||||
git -C "$ROOT" add -- "$MANIFEST"
|
||||
[ "$PACKAGE" = "commands" ] && git -C "$ROOT" add -- "$CONFIG_PHP"
|
||||
git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION"
|
||||
git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION"
|
||||
echo "OK: commit + tag $TAG creados."
|
||||
|
||||
if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then
|
||||
if confirm "Pushear a origin (commit + tag)?"; then
|
||||
git -C "$ROOT" push -q origin HEAD --follow-tags
|
||||
echo "OK: pusheado."
|
||||
else
|
||||
echo "Sin pushear. Cuando quieras: git push origin HEAD --follow-tags"
|
||||
fi
|
||||
else
|
||||
echo "No hay remote origin; el tag queda local."
|
||||
fi
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke HTTP del paquete cache (APCu sólo existe en SAPI web).
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"; return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check apcache /compat/cache/apcache 'APC_OK'
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete cache (APCu via SAPI web).
|
||||
|
||||
use Libs\APCache;
|
||||
use Libs\Router;
|
||||
|
||||
Router::get('/compat/cache/apcache', function () {
|
||||
$key = 'compat_' . bin2hex(random_bytes(4));
|
||||
$ok = APCache::save($key, 'valor-42', 30) !== false
|
||||
&& APCache::get($key) === 'valor-42';
|
||||
APCache::delete($key);
|
||||
$ok = $ok && APCache::get($key) === false;
|
||||
echo $ok ? 'APC_OK' : 'APC_FAIL';
|
||||
});
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke commands:migration sobre sqlite (flujo CLI real del proyecto).
|
||||
# Recibe BASE, PROJ, PKG, LOG, RESULTS.
|
||||
set -u
|
||||
|
||||
res() { echo "$PKG:$1|$2" >> "$RESULTS"; }
|
||||
|
||||
mkdir -p "$PROJ/migrations"
|
||||
|
||||
cat > "$PROJ/migrations/2026_01_01_000000_compat_demo.php" <<'PHP'
|
||||
<?php
|
||||
|
||||
use Libs\Migration;
|
||||
|
||||
return new class () extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$this->create('compat_table', [
|
||||
'id INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
$this->string('name'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->dropTable('compat_table');
|
||||
}
|
||||
};
|
||||
PHP
|
||||
|
||||
if ! (cd "$PROJ" && php duckbrain migration migrate >"$PROJ/../migrate.out" 2>&1); then
|
||||
echo " migrate fallo:" >&2
|
||||
sed 's/^/ /' "$PROJ/../migrate.out" >&2
|
||||
res migration fail
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PROJ_PATH="$PROJ" php -r '
|
||||
$proj = getenv("PROJ_PATH");
|
||||
$db = new PDO("sqlite:$proj/app.sqlite");
|
||||
$table = (bool) $db->query("SELECT name FROM sqlite_master WHERE type=" . chr(39) . "table" . chr(39) . " AND name=" . chr(39) . "compat_table" . chr(39))->fetchColumn();
|
||||
$ledger = glob("$proj/migrations/migrations-*.sqlite")[0] ?? null;
|
||||
$done = 0;
|
||||
if ($ledger !== null) {
|
||||
$l = new PDO("sqlite:" . $ledger);
|
||||
$done = (int) $l->query("SELECT COUNT(*) FROM migrations")->fetchColumn();
|
||||
}
|
||||
exit($table && $done === 1 ? 0 : 1);' \
|
||||
&& res migration ok \
|
||||
|| { echo " compat_table o registro de migracion ausentes" >&2; res migration fail; }
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke HTTP del paquete crypto. Recibe BASE, PKG, RESULTS.
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"; return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check crypto /compat/crypto/roundtrip 'CRYPTO_OK'
|
||||
check nonce /compat/crypto/nonce 'NONCE_OK'
|
||||
check twoFactor /compat/crypto/2fa 'TFA_OK'
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete crypto.
|
||||
|
||||
use Libs\Crypto;
|
||||
use Libs\Nonce;
|
||||
use Libs\Router;
|
||||
use Libs\TwoFactorAuthenticator;
|
||||
|
||||
Router::get('/compat/crypto/roundtrip', function () {
|
||||
$plain = 'secreto: 42 ✓';
|
||||
$hex = Crypto::encryptHex($plain);
|
||||
$b64 = Crypto::encrypt64($plain);
|
||||
$ok = Crypto::decryptHex($hex) === $plain
|
||||
&& Crypto::decrypt64($b64) === $plain
|
||||
&& Crypto::decrypt(Crypto::encrypt($plain)) === $plain;
|
||||
echo $ok ? 'CRYPTO_OK' : 'CRYPTO_MISMATCH';
|
||||
});
|
||||
|
||||
Router::get('/compat/crypto/nonce', function () {
|
||||
$n = Nonce::get('compat');
|
||||
echo (Nonce::check($n, 'compat') && !Nonce::check($n . 'x', 'compat')) ? 'NONCE_OK' : 'NONCE_FAIL';
|
||||
});
|
||||
|
||||
Router::get('/compat/crypto/2fa', function () {
|
||||
$tfa = new TwoFactorAuthenticator();
|
||||
$secret = $tfa->createSecret(16);
|
||||
$code = $tfa->getCode($secret);
|
||||
echo ($tfa->verifyCode($secret, $code, 0) && !$tfa->verifyCode($secret, '000000', 0)) ? 'TFA_OK' : 'TFA_FAIL';
|
||||
});
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke HTTP del paquete htmx. Recibe BASE, PKG, RESULTS.
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"; return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check htmlComponent /compat/htmx/render '<h1 id="compat-greet">A<b></h1>'
|
||||
check htmx /compat/htmx/static 'HTMX:false:true'
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete htmx: monta un componente propio sobre
|
||||
// el motor HTMLComponent/HTMX con el arranque real del core.
|
||||
|
||||
use Libs\HTMLComponent;
|
||||
use Libs\HTMX;
|
||||
use Libs\Router;
|
||||
|
||||
$views = ROOT_CORE . '/Views';
|
||||
if (!is_dir($views)) {
|
||||
mkdir($views, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($views . '/CompatGreet.php', <<<'VIEW'
|
||||
<h1 id="compat-greet"><?php echo $this->escapedProperty('name'); ?></h1>
|
||||
VIEW);
|
||||
|
||||
file_put_contents($views . '/CompatButton.php', <<<'VIEW'
|
||||
<button hx-post="/compat/htmx/echo" hx-target="#compat-greet" class="btn">ir</button>
|
||||
VIEW);
|
||||
|
||||
Router::get('/compat/htmx/render', function () {
|
||||
echo 'GREET:';
|
||||
HTMLComponent::render('CompatGreet', ['name' => 'A<b>']);
|
||||
});
|
||||
|
||||
Router::get('/compat/htmx/static', function () {
|
||||
$instance = new HTMX([], '<i hx-get="/x">ok</i>');
|
||||
header('X-Marker: ' . (HTMX::isHtmx() ? 'yes' : 'no'));
|
||||
echo 'HTMX:' . var_export(HTMX::isPartialRequest(), true)
|
||||
. ':' . var_export($instance->getContent() !== '', true);
|
||||
});
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke del paquete http. Recibe BASE, PKG, RESULTS.
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"; return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check curlRequest /compat/http/curl 'CURL_OK'
|
||||
check aria2 '/compat/http/load?class=Aria2' 'LOAD_OK:Libs\Aria2'
|
||||
check proxy '/compat/http/load?class=Proxy' 'LOAD_OK:Libs\Proxy'
|
||||
check compreFace '/compat/http/load?class=CompreFace' 'LOAD_OK:Libs\CompreFace'
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete http: curlRequest con flujo completo
|
||||
// (se llama a si mismo via el mismo servidor) y carga nivel 1 de los clientes
|
||||
// con servicio externo.
|
||||
|
||||
use Libs\Aria2;
|
||||
use Libs\CompreFace;
|
||||
use Libs\CurlRequest;
|
||||
use Libs\Proxy;
|
||||
use Libs\Router;
|
||||
|
||||
Router::get('/compat/http/echo', function () {
|
||||
echo 'ECHO:' . ($_GET['who'] ?? 'nadie');
|
||||
});
|
||||
|
||||
Router::get('/compat/http/curl', function () {
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$req = new CurlRequest();
|
||||
$req->get("http://$host/compat/http/echo?who=42");
|
||||
echo str_contains($req->body(), 'ECHO:42') ? 'CURL_OK' : 'CURL_FAIL:' . substr($req->body(), 0, 80);
|
||||
});
|
||||
|
||||
Router::get('/compat/http/load', function () {
|
||||
$class = 'Libs\\' . basename((string) ($_GET['class'] ?? ''));
|
||||
if (!class_exists($class)) {
|
||||
echo 'LOAD_MISSING';
|
||||
return;
|
||||
}
|
||||
$instance = match ($class) {
|
||||
'Libs\\Aria2' => new Aria2('http://127.0.0.1:6800/jsonrpc'),
|
||||
'Libs\\CompreFace' => new CompreFace('compat-key', 'http://127.0.0.1:18080'),
|
||||
default => new $class(),
|
||||
};
|
||||
echo 'LOAD_OK:' . get_class($instance);
|
||||
});
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke HTTP del paquete id. Recibe BASE, PKG, RESULTS.
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"; return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check ulid /compat/id/ulid 'ULID_OK'
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete id.
|
||||
|
||||
use Libs\Router;
|
||||
use Libs\Ulid;
|
||||
|
||||
Router::get('/compat/id/ulid', function () {
|
||||
$a = Ulid::generate();
|
||||
$b = Ulid::generate();
|
||||
$ok = strlen($a) === 26 && Ulid::validate($a) && !Ulid::validate(str_repeat('0', 25)) && $a !== $b;
|
||||
echo $ok ? 'ULID_OK' : 'ULID_FAIL';
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"text": {
|
||||
"filter": "full",
|
||||
"replacement": "full"
|
||||
},
|
||||
"crypto": {
|
||||
"crypto": "full",
|
||||
"nonce": "full",
|
||||
"twoFactor": "full"
|
||||
},
|
||||
"id": {
|
||||
"ulid": "full"
|
||||
},
|
||||
"cache": {
|
||||
"apcache": "full"
|
||||
},
|
||||
"htmx": {
|
||||
"htmlComponent": "full",
|
||||
"htmx": "full"
|
||||
},
|
||||
"http": {
|
||||
"curlRequest": "full",
|
||||
"aria2": "load",
|
||||
"proxy": "load",
|
||||
"compreFace": "load"
|
||||
},
|
||||
"commands": {
|
||||
"migration": "sqlite",
|
||||
"cli": { "level": "none", "reason": "es el CLI del instalador; no consume el core (evidencia: suite offline group7/8)" },
|
||||
"toolbox": { "level": "none", "reason": "motor del instalador; no consume el core (evidencia: suite offline group7/8)" }
|
||||
},
|
||||
"test": {
|
||||
"harness": "runner"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"core": {
|
||||
"version": "0.1.0",
|
||||
"commit": "3b6da45b019d0c232bb5b2f1dc158d2bf25c8e1b"
|
||||
},
|
||||
"generated": "2026-10-05T20:34:25Z",
|
||||
"components": {
|
||||
"text:filter": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"text:replacement": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"crypto:crypto": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"crypto:nonce": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"crypto:twoFactor": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"id:ulid": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"cache:apcache": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"htmx:htmlComponent": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"htmx:htmx": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"http:curlRequest": {
|
||||
"status": "ok",
|
||||
"level": "full"
|
||||
},
|
||||
"http:aria2": {
|
||||
"status": "ok",
|
||||
"level": "load"
|
||||
},
|
||||
"http:proxy": {
|
||||
"status": "ok",
|
||||
"level": "load"
|
||||
},
|
||||
"http:compreFace": {
|
||||
"status": "ok",
|
||||
"level": "load"
|
||||
},
|
||||
"commands:migration": {
|
||||
"status": "ok",
|
||||
"level": "sqlite"
|
||||
},
|
||||
"commands:cli": {
|
||||
"status": "skipped",
|
||||
"level": "none",
|
||||
"reason": "es el CLI del instalador; no consume el core (evidencia: suite offline group7/8)"
|
||||
},
|
||||
"commands:toolbox": {
|
||||
"status": "skipped",
|
||||
"level": "none",
|
||||
"reason": "motor del instalador; no consume el core (evidencia: suite offline group7/8)"
|
||||
},
|
||||
"test:harness": {
|
||||
"status": "ok",
|
||||
"level": "runner"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test:harness — runner real (phpunit via composer) en el proyecto temporal.
|
||||
# Recibe PROJ, PKG, RESULTS.
|
||||
set -u
|
||||
|
||||
res() { echo "$PKG:harness|$1" >> "$RESULTS"; }
|
||||
|
||||
cd "$PROJ" || { res fail; exit 0; }
|
||||
|
||||
if [ ! -f composer.json ]; then
|
||||
printf '{"name":"duckbrain/compat-probe","require":{}}\n' > composer.json
|
||||
fi
|
||||
|
||||
if ! COMPOSER_NO_INTERACTION=1 composer require --dev phpunit/phpunit:^11.0 fakerphp/faker \
|
||||
--no-interaction >/dev/null 2>"$PROJ/../composer.err"; then
|
||||
echo " composer require fallo:" >&2
|
||||
sed 's/^/ /' "$PROJ/../composer.err" >&2
|
||||
res fail
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! ./vendor/bin/phpunit >/dev/null 2>"$PROJ/../phpunit.err"; then
|
||||
echo " phpunit fallo:" >&2
|
||||
sed 's/^/ /' "$PROJ/../phpunit.err" >&2
|
||||
res fail
|
||||
exit 0
|
||||
fi
|
||||
|
||||
res ok
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke HTTP del paquete text. Recibe BASE, PROJ, PKG, LOG, RESULTS.
|
||||
set -u
|
||||
|
||||
check() {
|
||||
local comp="$1" path="$2" expect="$3" body
|
||||
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
return
|
||||
fi
|
||||
if printf '%s' "$body" | grep -qF "$expect"; then
|
||||
echo "$PKG:$comp|ok" >> "$RESULTS"
|
||||
else
|
||||
echo " respuesta inesperada en $path: $(printf '%s' "$body" | head -c 200)" >&2
|
||||
echo "$PKG:$comp|fail" >> "$RESULTS"
|
||||
fi
|
||||
}
|
||||
|
||||
check filter /compat/text/filter 'HOLA MUNDIN'
|
||||
check replacement /compat/text/replacement 'bar y food'
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
// Router de compatibilidad del paquete text (inyectado en src/Routers/Compat.php
|
||||
// por scripts/compat-sweep). Ejercita Filter y Replacement sobre el arranque
|
||||
// real del core (Loader -> Router::apply()).
|
||||
|
||||
use Libs\Filter;
|
||||
use Libs\Replacement;
|
||||
use Libs\Router;
|
||||
|
||||
Router::get('/compat/text/filter', function () {
|
||||
Filter::add(
|
||||
fn (string $content): string => str_replace('mundo', 'MUNDIN', $content),
|
||||
'compat',
|
||||
5
|
||||
);
|
||||
Filter::add(
|
||||
fn (string $content): string => strtoupper($content) . '|',
|
||||
'compat',
|
||||
20
|
||||
);
|
||||
echo 'FILTER_OK:' . Filter::apply('hola mundo', 'compat');
|
||||
});
|
||||
|
||||
Router::get('/compat/text/replacement', function () {
|
||||
Replacement::add('/\bfoo\b/', 'bar');
|
||||
echo 'REPL_OK:' . Replacement::apply('foo y food y foo');
|
||||
});
|
||||
@@ -162,3 +162,31 @@ Harness::it('build-phar genera un phar ejecutable con su sha256', function () {
|
||||
|
||||
rrmdir($dist);
|
||||
});
|
||||
|
||||
Harness::it('el phar resuelve su catálogo embebido y su raíz sin --toolbox', 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');
|
||||
|
||||
$phar = $dist . '/duckbrain.phar';
|
||||
|
||||
// Ejecutado desde $dist (cwd sin packages/), sin --toolbox: la raíz debe
|
||||
// resolverse al propio phar y el catálogo debe leerse bajo phar://.
|
||||
$lines = [];
|
||||
exec('cd ' . escapeshellarg($dist) . ' && php ' . escapeshellarg($phar) . ' catalog 2>&1', $lines, $ccode);
|
||||
eq(0, $ccode, 'catalog desde el phar termina bien');
|
||||
$text = implode("\n", $lines);
|
||||
ok(str_contains($text, 'commands'), 'descubre paquetes embebidos bajo phar://');
|
||||
ok(str_contains($text, 'curlRequest'), 'los componentes embebidos aparecen sin flags');
|
||||
|
||||
// Alta de un componente desde el catálogo embebido, sin --toolbox.
|
||||
$proj = $dist . '/proj';
|
||||
$lines2 = [];
|
||||
exec('cd ' . escapeshellarg($dist) . ' && php ' . escapeshellarg($phar) . ' add id --root=' . escapeshellarg($proj) . ' --yes 2>&1', $lines2, $acode);
|
||||
eq(0, $acode, 'add desde el phar sin --toolbox');
|
||||
ok(is_file($proj . '/src/Libs/Ulid.php'), 'el archivo del componente quedó instalado');
|
||||
|
||||
rrmdir($dist);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use Toolbox\Catalog;
|
||||
|
||||
Harness::suite('group11-compat');
|
||||
|
||||
$GLOBALS['g11_repo'] = dirname(__DIR__);
|
||||
|
||||
/**
|
||||
* Contratos:
|
||||
* - levels.json debe cubrir exactamente los componentes del catalogo.
|
||||
* - si tests/compat/report.json existe: todo 'tested' declarado debe estar
|
||||
* respaldado por un 'ok' del reporte, y el nivel 'none' de evidencia debe
|
||||
* coincidir con la declaracion 'core: none' del manifiesto (bidireccional).
|
||||
* Sin reporte (entorno offline sin haber corrido el barrido) solo se exige el
|
||||
* primer contrato.
|
||||
*/
|
||||
Harness::it('levels.json cubre exactamente los componentes del catalogo', function () {
|
||||
$repo = $GLOBALS['g11_repo'];
|
||||
$levels = json_decode((string) file_get_contents($repo . '/tests/compat/levels.json'), true);
|
||||
ok(is_array($levels), 'levels.json debe ser un objeto por paquete');
|
||||
|
||||
$catalog = Catalog::fromRoot($repo);
|
||||
$declared = [];
|
||||
foreach ($catalog->packages() as $manifest) {
|
||||
foreach (array_keys($manifest->components()) as $component) {
|
||||
$declared[] = $manifest->name . ':' . $component;
|
||||
}
|
||||
}
|
||||
|
||||
$planned = [];
|
||||
foreach ($levels as $package => $components) {
|
||||
foreach (array_keys($components) as $component) {
|
||||
$planned[] = $package . ':' . $component;
|
||||
}
|
||||
}
|
||||
|
||||
sort($declared);
|
||||
sort($planned);
|
||||
eq($declared, $planned, 'cada componente del catalogo tiene su nivel de evidencia planeado');
|
||||
});
|
||||
|
||||
Harness::it('los stamps tested estan respaldados por el reporte del barrido', function () {
|
||||
$repo = $GLOBALS['g11_repo'];
|
||||
$reportPath = $repo . '/tests/compat/report.json';
|
||||
|
||||
if (!is_file($reportPath)) {
|
||||
return; // offline sin barrido previo: no se inventa evidencia ni se exige.
|
||||
}
|
||||
|
||||
$report = json_decode((string) file_get_contents($reportPath), true);
|
||||
ok(is_array($report) && isset($report['components']), 'report.json valido');
|
||||
|
||||
$levels = json_decode((string) file_get_contents($repo . '/tests/compat/levels.json'), true);
|
||||
$catalog = Catalog::fromRoot($repo);
|
||||
|
||||
foreach ($catalog->packages() as $manifest) {
|
||||
foreach (array_keys($manifest->components()) as $component) {
|
||||
$id = $manifest->name . ':' . $component;
|
||||
$level = $levels[$manifest->name][$component] ?? null;
|
||||
$level = is_array($level) ? $level['level'] : $level;
|
||||
$status = $report['components'][$id]['status'] ?? 'missing';
|
||||
$tested = $manifest->testedCore($component);
|
||||
$independ = $manifest->coreIndependent($component);
|
||||
|
||||
if ($level === 'none') {
|
||||
ok($independ, "$id esta sin evidencia de barrido y debe declarar core none");
|
||||
}
|
||||
|
||||
if ($independ) {
|
||||
eq('none', $level, "$id se declara independiente y su nivel debe ser none");
|
||||
eq(null, $tested, "$id independiente no puede declarar tested");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tested !== null) {
|
||||
eq('ok', $status, "$id declara tested='$tested' pero su evidencia en el reporte es '$status'");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Toolbox\Config;
|
||||
|
||||
Harness::suite('group12');
|
||||
|
||||
Harness::it('los defaults de lectura son HTTPS anónimos y los env ganan', function () {
|
||||
putenv('DUCKBRAIN_TOOLBOX_URL');
|
||||
putenv('DUCKBRAIN_CORE_URL');
|
||||
|
||||
ok(str_starts_with(Config::toolboxUrl(), 'https://'), 'toolboxUrl por defecto en https');
|
||||
ok(str_starts_with(Config::coreUrl(), 'https://'), 'coreUrl por defecto en https');
|
||||
|
||||
putenv('DUCKBRAIN_CORE_URL=ssh://git@example.test/kj/duckbrain.git');
|
||||
eq('ssh://git@example.test/kj/duckbrain.git', Config::coreUrl(), 'el override gana sobre el default');
|
||||
|
||||
putenv('DUCKBRAIN_CORE_URL');
|
||||
});
|
||||
@@ -121,3 +121,61 @@ Harness::it('ciclo de dependencias se detecta', function () {
|
||||
|
||||
rrmdir($dir);
|
||||
});
|
||||
|
||||
Harness::it('la descripcion del componente prevalece y hereda del paquete', function () {
|
||||
$m = Manifest::fromArray([
|
||||
'name' => 'pkg',
|
||||
'version' => '1.0.0',
|
||||
'description' => 'Resumen del paquete',
|
||||
'components' => [
|
||||
'a' => ['files' => ['src/A.php'], 'description' => 'Componente A'],
|
||||
'b' => ['files' => ['src/B.php']],
|
||||
],
|
||||
]);
|
||||
|
||||
eq('Componente A', $m->descriptionOf('a'), 'descripción propia gana');
|
||||
eq('Resumen del paquete', $m->descriptionOf('b'), 'sin descripción propia hereda del paquete');
|
||||
eq('Resumen del paquete', $m->descriptionOf(), 'sin componente: la del paquete');
|
||||
|
||||
$m2 = Manifest::fromArray(['name' => 'x', 'version' => '1.0.0', 'components' => ['c' => ['files' => ['c.php']]]]);
|
||||
eq('', $m2->descriptionOf('c'), 'paquete sin texto produce cadena vacía (no null)');
|
||||
|
||||
throws(fn () => Manifest::fromArray([
|
||||
'name' => 'y',
|
||||
'version' => '1.0.0',
|
||||
'components' => ['c' => ['files' => ['c.php'], 'description' => 7]],
|
||||
]), 'description no-string es inválida');
|
||||
});
|
||||
|
||||
Harness::it('core admite "none" como bloque con herencia completa', function () {
|
||||
$m = Manifest::fromArray([
|
||||
'name' => 'pkg',
|
||||
'version' => '1.0.0',
|
||||
'core' => 'none',
|
||||
'components' => [
|
||||
'a' => ['files' => ['src/A.php']],
|
||||
'b' => ['files' => ['src/B.php'], 'core' => ['requires' => '>=0.2', 'tested' => '0.2']],
|
||||
],
|
||||
]);
|
||||
|
||||
ok($m->coreIndependent('a'), 'a hereda la independencia del paquete');
|
||||
ok(!$m->coreIndependent('b'), 'b sobreescribe el bloque con su rango');
|
||||
eq('>=0.2', $m->coreRequirement('b'));
|
||||
eq('0.2', $m->testedCore('b'));
|
||||
eq(null, $m->coreRequirement('a'), 'independiente no arrastra rangos');
|
||||
|
||||
$m2 = Manifest::fromArray([
|
||||
'name' => 'pkg2',
|
||||
'version' => '1.0.0',
|
||||
'core' => ['requires' => '>=0.1'],
|
||||
'components' => ['c' => ['files' => ['src/C.php'], 'core' => 'none']],
|
||||
]);
|
||||
ok($m2->coreIndependent('c'), 'componente declara none sobre rango de paquete');
|
||||
eq(null, $m2->coreRequirement('c'), 'el rango del paquete no se filtra al bloque declarado none');
|
||||
eq('>=0.1', $m2->coreRequirement());
|
||||
|
||||
throws(fn () => Manifest::fromArray([
|
||||
'name' => 'bad', 'version' => '1.0.0', 'core' => 'otro',
|
||||
'components' => ['x' => ['files' => ['x.php']]],
|
||||
]), 'core string distinto de none es inválido');
|
||||
});
|
||||
|
||||
@@ -100,3 +100,52 @@ 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);
|
||||
});
|
||||
|
||||
Harness::it('el lock persiste la descripción y tolera locks sin el campo', function () {
|
||||
$dir = tmpdir();
|
||||
$lock = Lock::empty();
|
||||
$lock->setComponent('db:sql', [
|
||||
'package' => 'db',
|
||||
'version' => '1.0.0',
|
||||
'mode' => 'copy',
|
||||
'description' => 'SQL',
|
||||
'files' => [],
|
||||
]);
|
||||
$lock->save($dir);
|
||||
|
||||
eq('SQL', Lock::load($dir)->component('db:sql')['description'], 'roundtrip de description');
|
||||
|
||||
$raw = json_decode((string) file_get_contents($dir . '/.duckbrain/lock.json'), true);
|
||||
unset($raw['components']['db:sql']['description']);
|
||||
file_put_contents($dir . '/.duckbrain/lock.json', json_encode($raw));
|
||||
|
||||
eq(null, Lock::load($dir)->component('db:sql')['description'], 'lock antiguo sin campo se lee tolerante');
|
||||
|
||||
rrmdir($dir);
|
||||
});
|
||||
|
||||
Harness::it('add captura la descripción efectiva del catálogo en el lock', function () {
|
||||
$root = tmpdir('proj');
|
||||
$tool = tmpdir('tool');
|
||||
write_file($tool . '/packages/mix/duckbrain.json', json_encode([
|
||||
'name' => 'mix',
|
||||
'version' => '1.0.0',
|
||||
'description' => 'Descripción del paquete',
|
||||
'components' => [
|
||||
'own' => ['files' => ['src/Libs/Own.php'], 'description' => 'Descripción propia'],
|
||||
'inh' => ['files' => ['src/Libs/Inh.php']],
|
||||
],
|
||||
'default' => ['own', 'inh'],
|
||||
]));
|
||||
write_file($tool . '/packages/mix/src/Libs/Own.php', "<?php\n");
|
||||
write_file($tool . '/packages/mix/src/Libs/Inh.php', "<?php\n");
|
||||
|
||||
(new Toolbox\Installer($root, $tool, new Toolbox\Console()))->add(['mix'], ['yes' => true]);
|
||||
|
||||
$lock = Lock::load($root);
|
||||
eq('Descripción propia', $lock->component('mix:own')['description'], 'propia gana en el lock');
|
||||
eq('Descripción del paquete', $lock->component('mix:inh')['description'], 'sin propia, hereda del paquete');
|
||||
|
||||
rrmdir($root);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
@@ -66,3 +66,67 @@ Harness::it('doctor no modifica lock ni archivos', function () {
|
||||
rrmdir($root);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
Harness::it('core independiente: sin aviso en add incluso sin core conocido', function () {
|
||||
$root = tmpdir('proj');
|
||||
$tool = tmpdir('tool');
|
||||
write_file($tool . '/packages/http/duckbrain.json', json_encode([
|
||||
'name' => 'http', 'version' => '1.0.0', 'description' => 'HTTP',
|
||||
'core' => 'none',
|
||||
'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]],
|
||||
]));
|
||||
write_file($tool . '/packages/http/src/Libs/Aria2.php', "<?php\n");
|
||||
write_file($root . '/src/Libs/Router.php', "<?php\n");
|
||||
|
||||
$lock = Lock::load($root);
|
||||
$lock->setCore(['version' => null, 'commit' => 'x', 'files' => []]);
|
||||
$lock->save($root);
|
||||
|
||||
$out = fopen('php://memory', 'rw');
|
||||
$inst = new Toolbox\Installer($root, $tool, new Console($out));
|
||||
$res = $inst->add(['http:aria2'], []);
|
||||
rewind($out);
|
||||
$text = (string) stream_get_contents($out);
|
||||
|
||||
eq([], $res['warnings'], 'independiente no produce aviso');
|
||||
eq([], $res['aborted'], 'nada se aborta');
|
||||
ok(str_contains(implode(',', $res['installed']), 'http:aria2'), 'instala normal');
|
||||
ok(!str_contains($text, 'sin datos'), 'sin ruido en consola');
|
||||
|
||||
// control: mismo fixture sin declaración sí avisa
|
||||
write_file($tool . '/packages/http/duckbrain.json', json_encode([
|
||||
'name' => 'http', 'version' => '1.0.1', 'description' => 'HTTP',
|
||||
'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]],
|
||||
]));
|
||||
$out2 = fopen('php://memory', 'rw');
|
||||
$res2 = (new Toolbox\Installer($root, $tool, new Console($out2)))->add(['http:aria2'], ['yes' => true]);
|
||||
ok($res2['warnings'] !== [], 'sin declaración sigue avisando');
|
||||
|
||||
rrmdir($root);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
Harness::it('doctor muestra independiente sin rangos y sin fallo', function () {
|
||||
$root = tmpdir('proj');
|
||||
$tool = tmpdir('tool');
|
||||
write_file($tool . '/packages/db/duckbrain.json', json_encode([
|
||||
'name' => 'db', 'version' => '1.0.0', 'description' => 'DB',
|
||||
'components' => ['sql' => ['files' => ['src/Libs/Sql.php'], 'core' => 'none']],
|
||||
]));
|
||||
write_file($tool . '/packages/db/src/Libs/Sql.php', "<?php\n");
|
||||
|
||||
$lock = Lock::load($root);
|
||||
$lock->setCore(['version' => '1.5.0', 'commit' => 'c', 'files' => []]);
|
||||
$lock->setComponent('db:sql', ['package' => 'db', 'version' => '1.0.0', 'mode' => 'copy', 'files' => []]);
|
||||
$lock->save($root);
|
||||
|
||||
$out = fopen('php://memory', 'rw');
|
||||
$code = (new DoctorCommand(new Console($out)))->run(['--root=' . $root, '--toolbox=' . $tool]);
|
||||
rewind($out);
|
||||
$text = (string) stream_get_contents($out);
|
||||
|
||||
eq(0, $code);
|
||||
ok(str_contains($text, 'INDEPENDIENTE'), 'estado independiente visible');
|
||||
rrmdir($root);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ Harness::it('release bumpea, commitea, tagea y publica', function () {
|
||||
seed_package($repo, '1.2.3');
|
||||
git_commit($repo, 'init');
|
||||
|
||||
exec('cd ' . escapeshellarg($repo) . ' && bash scripts/release http minor', $out, $code);
|
||||
exec('cd ' . escapeshellarg($repo) . ' && bash scripts/release http minor --yes', $out, $code);
|
||||
eq(0, $code, 'release debe terminar bien: ' . implode("\n", $out));
|
||||
|
||||
$pushed = exec('git -C ' . escapeshellarg($repo) . ' tag -l "http-v1.3.0"');
|
||||
|
||||
@@ -238,3 +238,43 @@ Harness::it('--force resuelve drift pero no salta avisos de compatibilidad', fun
|
||||
rrmdir($proj);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
Harness::it('el catálogo describe cada componente con su texto propio o heredado', function () {
|
||||
$toolbox = g7_toolbox();
|
||||
$manifest = json_decode((string) file_get_contents($toolbox . '/packages/http/duckbrain.json'), true);
|
||||
$manifest['components']['curlRequest']['description'] = 'Cliente HTTP sobre cURL';
|
||||
write_file($toolbox . '/packages/http/duckbrain.json', json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
|
||||
$proj = tmpdir('proj');
|
||||
$out = fopen('php://memory', 'rw');
|
||||
$code = (new Toolbox\Commands\CatalogCommand(new Console($out)))->run(
|
||||
['--root=' . $proj, '--toolbox=' . $toolbox]
|
||||
);
|
||||
rewind($out);
|
||||
$text = (string) stream_get_contents($out);
|
||||
|
||||
eq(0, $code, 'catalog termina bien');
|
||||
ok((bool) preg_match('/curlRequest.*Cliente HTTP sobre cURL/', $text), 'fila propia con su texto');
|
||||
ok((bool) preg_match('/aria2.*Utilidades HTTP/', $text), 'fila sin texto hereda el del paquete');
|
||||
ok((bool) preg_match('/compreFace.*Utilidades HTTP/', $text), 'vecino heredando igual');
|
||||
ok(!preg_match('/aria2.*cURL/', $text), 'los textos no se mezclan entre filas');
|
||||
|
||||
rrmdir($toolbox);
|
||||
rrmdir($proj);
|
||||
});
|
||||
|
||||
Harness::it('un comando del proyecto sin describe() no carga (interfaz estricta)', function () {
|
||||
$proj = tmpdir('proj');
|
||||
mkdir($proj . '/src/Commands', 0777, true);
|
||||
write_file($proj . '/autoload.php', "<?php\nspl_autoload_register(function (string \$c): void {\n \$f = \$c === 'Commands\\\\SinDoc' ? ROOT_CORE . '/Commands/SinDoc.php' : null;\n if (\$f !== null && is_file(\$f)) { require \$f; }\n});\ndefine('ROOT_CORE', __DIR__ . '/src');\ndefine('ROOT_DIR', __DIR__);\n");
|
||||
write_file($proj . '/src/Commands/SinDoc.php', "<?php\nnamespace Commands;\nclass SinDoc implements \\Interfaces\\CommandInterface { public function run(): void {} }\n");
|
||||
write_file($proj . '/src/Interfaces/CommandInterface.php', "<?php\nnamespace Interfaces;\ninterface CommandInterface { public function run(): void; public static function describe(): string; }\n");
|
||||
|
||||
$out = [];
|
||||
$code = 0;
|
||||
exec('cd ' . escapeshellarg($proj) . ' && php -r \'require "autoload.php"; class_exists("Commands\\\\SinDoc") ? print("loaded") : print("missing");\' 2>&1', $out, $code);
|
||||
|
||||
ok($code !== 0 || str_contains(implode('', $out), 'Fatal'), 'la carga de un comando sin describe produce error fatal');
|
||||
|
||||
rrmdir($proj);
|
||||
});
|
||||
|
||||
@@ -61,14 +61,149 @@ Harness::it('la actualización del motor surte efecto en la siguiente invocació
|
||||
|
||||
// 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);
|
||||
$config = preg_replace("/VERSION = '[^']*'/", "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($before, \Toolbox\Config::VERSION), '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);
|
||||
});
|
||||
|
||||
Harness::it('el bin despacha por clase, alias, usage y guard de colisiones', 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']);
|
||||
|
||||
write_file($proj . '/src/Commands/Eco.php', "<?php\n\nnamespace Commands;\n\nclass Eco implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'Comando eco de prueba'; }\n public function run(): void { echo 'ECO_OK' . PHP_EOL; }\n}\n");
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain eco 2>&1', $o, $c1);
|
||||
eq(0, $c1); ok(str_contains(implode('', $o), 'ECO_OK'), 'dispatch por nombre de clase');
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c2);
|
||||
eq(0, $c2); ok(str_contains(implode('', $o), 'commands:cli'), 'dispatch por alias del motor');
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain 2>&1', $o, $c3);
|
||||
eq(0, $c3); $usage = implode('', $o);
|
||||
ok(str_contains($usage, 'eco') && str_contains($usage, 'Comando eco de prueba'), 'usage lista clase y describe');
|
||||
ok(str_contains($usage, 'toolbox, add'), 'usage lista aliases del entry del motor');
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain noexiste 2>&1', $o, $c4);
|
||||
eq(1, $c4); ok(str_contains(implode('', $o), 'Comando desconocido'), 'argumento inexistente falla limpio');
|
||||
|
||||
// alias-vs-alias: 'add' también declarado por Eco
|
||||
write_file($proj . '/src/Commands/Eco.php', str_replace(
|
||||
" public function run(): void",
|
||||
" public static function aliases(): array { return ['add']; }\n public function run(): void",
|
||||
(string) file_get_contents($proj . '/src/Commands/Eco.php')
|
||||
));
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c5);
|
||||
eq(1, $c5); ok(str_contains(implode('', $o), 'Conflicto de alias'), 'guard alias duplicado con ambos dueños');
|
||||
|
||||
// alias-vs-clase ajena: 'eco' declarado por Toolbox está prohibido; usar un tercero
|
||||
write_file($proj . '/src/Commands/Ladro.php', "<?php\n\nnamespace Commands;\n\nclass Ladro implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'ladrador'; }\n public static function aliases(): array { return ['eco']; }\n public function run(): void {}\n}\n");
|
||||
write_file($proj . '/src/Commands/Eco.php', str_replace(" public static function aliases(): array { return ['add']; }\n", '', (string) file_get_contents($proj . '/src/Commands/Eco.php')));
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain 2>&1', $o, $c6);
|
||||
eq(1, $c6); ok(str_contains(implode('', $o), 'colisiona con el comando'), 'guard alias que roba nombre de clase');
|
||||
|
||||
rrmdir($proj);
|
||||
rrmdir($core);
|
||||
});
|
||||
|
||||
Harness::it('bin nuevo sin el motor: los aliases del motor son comando desconocido', 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', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain catalog 2>&1', $o, $c);
|
||||
eq(1, $c); ok(str_contains(implode('', $o), 'Comando desconocido'), 'sin Commands\\Toolbox no hay delegación');
|
||||
|
||||
rrmdir($proj);
|
||||
rrmdir($core);
|
||||
});
|
||||
|
||||
Harness::it('list muestra la descripción del lock por componente, con — si falta', function () {
|
||||
$root = tmpdir('proj');
|
||||
$tool = tmpdir('tool');
|
||||
write_file($tool . '/packages/mix/duckbrain.json', json_encode([
|
||||
'name' => 'mix',
|
||||
'version' => '1.0.0',
|
||||
'description' => 'Descripción del paquete',
|
||||
'components' => [
|
||||
'own' => ['files' => ['src/Libs/Own.php'], 'description' => 'Descripción propia'],
|
||||
'inh' => ['files' => ['src/Libs/Inh.php']],
|
||||
'old' => ['files' => ['src/Libs/Old.php']],
|
||||
],
|
||||
'default' => ['own', 'inh', 'old'],
|
||||
]));
|
||||
foreach (['Own', 'Inh', 'Old'] as $c) {
|
||||
write_file($tool . "/packages/mix/src/Libs/{$c}.php", "<?php\n");
|
||||
}
|
||||
|
||||
(new Toolbox\Installer($root, $tool, new Console()))->add(['mix'], ['yes' => true]);
|
||||
|
||||
// simular lock viejo: quitar la descripción de `old`
|
||||
$raw = json_decode((string) file_get_contents($root . '/.duckbrain/lock.json'), true);
|
||||
unset($raw['components']['mix:old']['description']);
|
||||
file_put_contents($root . '/.duckbrain/lock.json', json_encode($raw));
|
||||
|
||||
$out = fopen('php://memory', 'rw');
|
||||
$code = (new Toolbox\Commands\ListCommand(new Console($out)))->run(['--root=' . $root, '--toolbox=' . $tool]);
|
||||
rewind($out);
|
||||
$text = (string) stream_get_contents($out);
|
||||
|
||||
eq(0, $code);
|
||||
ok((bool) preg_match('/mix:own.*Descripción propia/', $text), 'fila propia con su descripción');
|
||||
ok((bool) preg_match('/mix:inh.*Descripción del paquete/', $text), 'fila heredada con la del paquete');
|
||||
ok((bool) preg_match('/mix:old.*—/', $text), 'lock viejo sin campo muestra —');
|
||||
|
||||
rrmdir($root);
|
||||
rrmdir($tool);
|
||||
});
|
||||
|
||||
Harness::it('commands lista la superficie con origen y reporta colisiones', 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', 'commands:toolbox', '--root=' . $proj, '--toolbox=' . $repo, '--yes']);
|
||||
|
||||
$eco = "<?php\n\nnamespace Commands;\n\nclass Eco implements \\Interfaces\\CommandInterface\n{\n public static function describe(): string { return 'eco local'; }\n public static function aliases(): array { return ['hola']; }\n public function run(): void {}\n}\n";
|
||||
write_file($proj . '/src/Commands/Eco.php', $eco);
|
||||
|
||||
// sin colisión: funciona desde el bin del proyecto
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain commands 2>&1', $o, $code);
|
||||
eq(0, $code, 'bin del proyecto sirve commands');
|
||||
$text = implode("\n", $o);
|
||||
ok(str_contains($text, 'eco') && str_contains($text, 'hola'), 'comando propio con su alias');
|
||||
ok(str_contains($text, 'proyecto'), 'origen proyecto');
|
||||
ok(str_contains($text, 'commands:migration@'), 'origen componente@versión desde el lock');
|
||||
|
||||
// con colisión (roba el nombre de clase migration): el motor REPORTA, el bin aborta
|
||||
write_file($proj . '/src/Commands/Eco.php', str_replace("['hola']", "['migration']", $eco));
|
||||
|
||||
$out = fopen('php://memory', 'rw');
|
||||
$code = (new Toolbox\Commands\CommandsCommand(new Console($out, $out)))->run(['--root=' . $proj]);
|
||||
rewind($out);
|
||||
$text = (string) stream_get_contents($out);
|
||||
|
||||
eq(0, $code, 'la vista no aborta ante colisiones');
|
||||
ok(str_contains($text, 'migration!'), 'fila marcada con !');
|
||||
ok(str_contains($text, "WARN: alias 'migration'"), 'warn con los dueños');
|
||||
|
||||
$o = []; exec('cd ' . escapeshellarg($proj) . ' && php duckbrain list 2>&1', $o, $c2);
|
||||
eq(1, $c2, 'el despachador sí aborta ante colisión (guard del bin)');
|
||||
|
||||
rrmdir($proj);
|
||||
rrmdir($core);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user