Files
duckbrain-toolbox/packages/commands/src/Toolbox/Installer.php
T

382 lines
12 KiB
PHP

<?php
namespace Toolbox;
/**
* Motor de gestión compartido por el instalador global y el CLI del proyecto:
* alta, baja, actualización de core/componentes, listado y diagnóstico.
*/
final class Installer
{
private readonly Catalog $catalog;
private readonly Resolver $resolver;
private readonly PackageSource $source;
private readonly FileSync $sync;
private readonly Copier $copier;
private ProjectManifest $manifest;
private Lock $lock;
private readonly string $root;
private readonly string $rootCore;
public function __construct(
string $root,
private readonly string $toolboxDir,
private readonly Console $console,
?string $rootCore = null,
) {
$this->root = rtrim($root, '/');
$this->rootCore = $rootCore !== null ? rtrim($rootCore, '/') : $this->root . '/src';
$this->catalog = Catalog::fromRoot($this->toolboxDir);
$this->resolver = new Resolver($this->catalog);
$this->source = new DirectorySource(rtrim($this->toolboxDir, '/') . '/packages');
$this->sync = new FileSync();
$this->copier = new Copier($this->sync);
$this->manifest = ProjectManifest::load($this->root);
$this->lock = Lock::load($this->root);
}
public function catalog(): Catalog
{
return $this->catalog;
}
public function lock(): Lock
{
return $this->lock;
}
public function manifest(): ProjectManifest
{
return $this->manifest;
}
public function coreVersion(): ?string
{
return $this->lock->core()['version'] ?? null;
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function add(array $requests, array $options): array
{
return $this->installRequests($requests, $options, false);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function update(array $requests, array $options): array
{
return $this->installRequests($requests, $options, true);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
private function installRequests(array $requests, array $options, bool $isUpdate): array
{
$clean = [];
$constraints = [];
foreach ($requests as $request) {
[$id, $constraint] = array_pad(explode('@', $request, 2), 2, null);
$clean[] = $id;
if ($constraint !== null) {
$constraints[$id] = $constraint;
}
}
$resolved = $this->resolver->resolve($clean);
$warnings = [];
$installed = [];
$aborted = [];
$actions = [];
$post = [];
foreach ($resolved as $entry) {
$package = $entry['package'];
$componentName = $entry['component'];
$manifest = $this->catalog->get($package);
$component = $manifest->component($componentName);
$id = $package . ':' . $componentName;
[$version, $commit] = $this->resolveVersion($package);
$compat = Compatibility::evaluateCore(
$this->coreVersion(),
$manifest->coreRequirement($componentName),
$manifest->testedCore($componentName),
$manifest->coreIndependent($componentName)
);
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)) {
$aborted[] = $id;
continue;
}
}
$mode = ($options['link'] ?? false) ? 'link' : 'copy';
$expected = $this->lock->component($id)['files'] ?? [];
$files = FileExpander::expand($this->source, $package, $component->files);
$result = $this->copier->install(
$files,
$this->source->baseDir($package),
$this->root,
$this->rootCore,
$component->destinations,
$mode,
$options,
$expected
);
foreach ($result['actions'] as $rel => $action) {
$actions[$rel] = $action;
}
if ($result['aborted'] !== []) {
foreach ($result['aborted'] as $rel) {
$aborted[] = $rel;
}
continue;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->setComponent($id, [
'package' => $package,
'version' => $version,
'toolbox_commit' => $commit,
'mode' => $mode,
'description' => $manifest->descriptionOf($componentName),
'files' => $result['files'],
]);
}
$installed[] = $id;
$post[$package] = $manifest;
}
foreach ($constraints as $key => $constraint) {
if (str_contains($key, ':')) {
if (!$isUpdate) {
$this->manifest->setConstraint($key, $constraint);
}
continue;
}
foreach ($installed as $id) {
if (str_starts_with($id, $key . ':') && !$isUpdate) {
$this->manifest->setConstraint($id, $constraint);
}
}
}
Ownership::fromLock($this->lock)->assertNoCollisions();
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
foreach ($post as $manifest) {
if ($manifest->postInstall !== []) {
PostInstall::run(
$manifest->postInstall,
$this->console,
(bool) ($options['yes'] ?? false),
$options['postRunner'] ?? null,
$this->root,
);
}
}
}
return ['installed' => $installed, 'warnings' => $warnings, 'aborted' => $aborted, 'actions' => $actions];
}
/**
* @param list<string> $ids
* @param array<string, bool|string> $options
* @return array{removed:list<string>, aborted:list<string>}
*/
public function remove(array $ids, array $options): array
{
$removed = [];
$aborted = [];
foreach ($ids as $id) {
$entry = $this->lock->component($id);
if ($entry === null) {
$aborted[] = $id;
continue;
}
$componentAbort = false;
foreach ($entry['files'] as $relative => $hash) {
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
$componentAbort = true;
}
}
if ($componentAbort) {
continue;
}
$this->lock->removeComponent($id);
$this->manifest->removeConstraint($id);
$removed[] = $id;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
}
return ['removed' => $removed, 'aborted' => $aborted];
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function installCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, false);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function updateCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, true);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
private function writeCore(CoreProvider $provider, array $options, bool $removeStale): array
{
$protected = $this->componentOwnedPaths();
$expected = $this->lock->core()['files'] ?? [];
$files = $provider->files();
$installed = [];
$actions = [];
$aborted = [];
foreach ($files as $relative) {
if (isset($protected[$relative])) {
continue;
}
$absolute = $this->root . '/' . $relative;
if ($options['dryRun'] ?? false) {
$actions[$relative] = 'planned';
$installed[$relative] = 'sha256:' . hash('sha256', $provider->read($relative));
continue;
}
$action = $this->sync->write($absolute, $provider->read($relative), $expected[$relative] ?? null, $options);
$actions[$relative] = $action;
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
continue;
}
$installed[$relative] = Drift::hash($absolute);
}
if ($removeStale) {
foreach ($expected as $relative => $hash) {
if (isset($installed[$relative]) || isset($protected[$relative]) || in_array($relative, $files, true)) {
continue;
}
if ($options['dryRun'] ?? false) {
$actions[$relative] = 'remove-planned';
continue;
}
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
}
}
}
if ($aborted === [] && !($options['dryRun'] ?? false)) {
$this->lock->setCore([
'version' => $provider->version() ?? $this->coreVersion(),
'commit' => $provider->commit() ?? ($this->lock->core()['commit'] ?? null),
'files' => $installed,
]);
$this->lock->save($this->root);
}
return ['files' => $installed, 'actions' => $actions, 'aborted' => $aborted, 'version' => $provider->version()];
}
/** @return array<string, true> */
private function componentOwnedPaths(): array
{
$paths = [];
foreach ($this->lock->components() as $entry) {
foreach (array_keys($entry['files'] ?? []) as $relative) {
$paths[$relative] = true;
}
}
return $paths;
}
/**
* @return array{0:string, 1:?string}
*/
private function resolveVersion(string $package): array
{
if (is_dir($this->toolboxDir . '/.git')) {
$resolver = new TagResolver(new ToolboxRepo($this->toolboxDir, ''), $this->yanked($package));
$latest = $resolver->latest($package);
if ($latest !== null) {
return [$latest['version'], $latest['commit']];
}
}
return [$this->catalog->get($package)->version, null];
}
/**
* @return list<string>
*/
private function yanked(string $package): array
{
$file = $this->toolboxDir . '/yanked.json';
if (!is_file($file)) {
return [];
}
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) {
return [];
}
$list = $data[$package] ?? [];
return is_array($list) ? array_values($list) : [];
}
}