382 lines
12 KiB
PHP
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) : [];
|
|
}
|
|
}
|