diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts index 2fd658a6e89..58912a9e9e2 100644 --- a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts +++ b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts @@ -6,12 +6,6 @@ import { dialogFactory } from "../../../../Component/Dialog"; import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar"; import { syncVersion } from "WoltLabSuite/Core/Api/Devtools/Projects/SyncVersion"; -interface PipData { - dependencies: string[]; - pluginName: string; - targets: string[]; -} - type PendingPip = [string, string]; interface AjaxResponse { @@ -36,14 +30,15 @@ class AcpUiDevtoolsProjectSync { private readonly buttonStatus = new Map(); private readonly buttonSyncAll: HTMLButtonElement = document.getElementById("devtoolsSyncAll") as HTMLButtonElement; private readonly container = document.getElementById("syncPipMatches")!; - private readonly pips: PipData[] = []; + readonly #fullSyncQueue: PendingPip[]; private readonly projectId: number; private queue: PendingPip[] = []; #allowUnsafe = false; #syncVersionAfterCompletion = false; - constructor(projectId: number) { + constructor(projectId: number, fullSyncQueue: PendingPip[]) { this.projectId = projectId; + this.#fullSyncQueue = fullSyncQueue; const restrictedSync = document.getElementById("syncShowOnlyMatches") as HTMLInputElement; restrictedSync.addEventListener("change", () => { @@ -59,20 +54,15 @@ class AcpUiDevtoolsProjectSync { // Browsers may restore the checked state on reload. updateUnsafePips(); - const existingPips: string[] = []; - const knownPips: string[] = []; - const tmpPips: PipData[] = []; this.container .querySelectorAll(".jsHasPipTargets:not(.jsSkipTargetDetection)") .forEach((pip: HTMLTableRowElement) => { const pluginName = pip.dataset.pluginName!; - const targets: string[] = []; this.container .querySelectorAll(`.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePip`) .forEach((button: HTMLButtonElement) => { const target = button.dataset.target!; - targets.push(target); button.addEventListener("click", (event) => { event.preventDefault(); @@ -93,71 +83,8 @@ class AcpUiDevtoolsProjectSync { ) as HTMLElement, ); }); - - // Unsafe PIPs must never be part of a full sync. - if (pip.dataset.isUnsafe === "true") { - return; - } - - const data: PipData = { - dependencies: JSON.parse(pip.dataset.syncDependencies!), - pluginName, - targets, - }; - - if (data.dependencies.length > 0) { - tmpPips.push(data); - } else { - this.pips.push(data); - knownPips.push(pluginName); - } - - existingPips.push(pluginName); }); - let resolvedDependency = false; - while (tmpPips.length > 0) { - resolvedDependency = false; - - tmpPips.forEach((item, index) => { - if (resolvedDependency) { - return; - } - - const openDependencies = item.dependencies.filter((dependency) => { - // Ignore any dependencies that are not present. - if (existingPips.indexOf(dependency) === -1) { - window.console.info(`The dependency "${dependency}" does not exist and has been ignored.`); - return false; - } - - return !knownPips.includes(dependency); - }); - - if (openDependencies.length === 0) { - knownPips.push(item.pluginName); - this.pips.push(item); - tmpPips.splice(index, 1); - - resolvedDependency = true; - } - }); - - if (!resolvedDependency) { - // We could not resolve any dependency, either because there is no more pip - // in `tmpPips` or we're facing a circular dependency. In case there are items - // left, we simply append them to the end and hope for the operation to - // complete anyway, despite unmatched dependencies. - tmpPips.forEach((pip) => { - window.console.warn("Unable to resolve dependencies for", pip); - - this.pips.push(pip); - }); - - break; - } - } - this.buttonSyncAll.addEventListener("click", () => this.syncAll()); } @@ -183,12 +110,7 @@ class AcpUiDevtoolsProjectSync { this.buttonSyncAll.classList.add("disabled"); this.#syncVersionAfterCompletion = true; - this.queue = []; - this.pips.forEach((pip) => { - pip.targets.forEach((target) => { - this.queue.push([pip.pluginName, target]); - }); - }); + this.queue = [...this.#fullSyncQueue]; this.syncNext(); } @@ -296,8 +218,8 @@ class AcpUiDevtoolsProjectSync { let acpUiDevtoolsProjectSync: AcpUiDevtoolsProjectSync; -export function init(projectId: number): void { +export function init(projectId: number, fullSyncQueue: PendingPip[]): void { if (!acpUiDevtoolsProjectSync) { - acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId); + acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId, fullSyncQueue); } } diff --git a/wcfsetup/install/files/acp/templates/devtoolsProjectSync.tpl b/wcfsetup/install/files/acp/templates/devtoolsProjectSync.tpl index bcb8159c416..4fb8fa822d4 100644 --- a/wcfsetup/install/files/acp/templates/devtoolsProjectSync.tpl +++ b/wcfsetup/install/files/acp/templates/devtoolsProjectSync.tpl @@ -69,9 +69,6 @@ data-is-important="{if $pip->isImportant()}true{else}false{/if}" {if $_targetCount} class="jsHasPipTargets" - {if !$_isUnsafe} - data-sync-dependencies="{$pip->getSyncDependencies(true)}" - {/if} {/if} > 0} rowspan="{$_targetCount}"{/if}> @@ -118,7 +115,7 @@ {jsphrase name='wcf.acp.devtools.sync.status.failure'} {jsphrase name='wcf.acp.devtools.sync.syncAll'} - AcpUiDevtoolsProjectSync.init({$object->projectID}); + AcpUiDevtoolsProjectSync.init({$object->projectID}, {unsafe:$fullSyncQueue|json}); }); diff --git a/wcfsetup/install/files/devtools.php b/wcfsetup/install/files/devtools.php new file mode 100644 index 00000000000..2e0baa7714a --- /dev/null +++ b/wcfsetup/install/files/devtools.php @@ -0,0 +1,33 @@ +#!/usr/bin/env php + + * @since 6.3 + */ + +if (\PHP_SAPI !== 'cli') { + \http_response_code(400); + + exit; +} + +if (\function_exists('posix_getuid') && \posix_getuid() === 0) { + \fwrite(\STDERR, "Refusing to execute as root.\n"); + + exit(1); +} + +\define('PACKAGE_ID', 1); + +// include config +require_once(__DIR__ . '/app.config.inc.php'); + +// initiate wcf core +require_once(WCF_DIR . 'lib/system/WCF.class.php'); + +exit(new \wcf\system\devtools\DevtoolsCLIWCF()->run(\array_slice($_SERVER['argv'], 1))); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.js index a63bd7c6c40..ae66b732414 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.js @@ -8,13 +8,14 @@ define(["require", "exports", "tslib", "../../../../Ajax", "WoltLabSuite/Core/La buttonStatus = new Map(); buttonSyncAll = document.getElementById("devtoolsSyncAll"); container = document.getElementById("syncPipMatches"); - pips = []; + #fullSyncQueue; projectId; queue = []; #allowUnsafe = false; #syncVersionAfterCompletion = false; - constructor(projectId) { + constructor(projectId, fullSyncQueue) { this.projectId = projectId; + this.#fullSyncQueue = fullSyncQueue; const restrictedSync = document.getElementById("syncShowOnlyMatches"); restrictedSync.addEventListener("change", () => { this.container.classList.toggle("jsShowOnlyMatches"); @@ -27,19 +28,14 @@ define(["require", "exports", "tslib", "../../../../Ajax", "WoltLabSuite/Core/La showUnsafePips.addEventListener("change", () => updateUnsafePips()); // Browsers may restore the checked state on reload. updateUnsafePips(); - const existingPips = []; - const knownPips = []; - const tmpPips = []; this.container .querySelectorAll(".jsHasPipTargets:not(.jsSkipTargetDetection)") .forEach((pip) => { const pluginName = pip.dataset.pluginName; - const targets = []; this.container .querySelectorAll(`.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePip`) .forEach((button) => { const target = button.dataset.target; - targets.push(target); button.addEventListener("click", (event) => { event.preventDefault(); if (this.queue.length > 0) { @@ -51,58 +47,7 @@ define(["require", "exports", "tslib", "../../../../Ajax", "WoltLabSuite/Core/La this.buttons.set(identifier, button); this.buttonStatus.set(identifier, this.container.querySelector(`.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePipResult[data-target="${target}"]`)); }); - // Unsafe PIPs must never be part of a full sync. - if (pip.dataset.isUnsafe === "true") { - return; - } - const data = { - dependencies: JSON.parse(pip.dataset.syncDependencies), - pluginName, - targets, - }; - if (data.dependencies.length > 0) { - tmpPips.push(data); - } - else { - this.pips.push(data); - knownPips.push(pluginName); - } - existingPips.push(pluginName); }); - let resolvedDependency = false; - while (tmpPips.length > 0) { - resolvedDependency = false; - tmpPips.forEach((item, index) => { - if (resolvedDependency) { - return; - } - const openDependencies = item.dependencies.filter((dependency) => { - // Ignore any dependencies that are not present. - if (existingPips.indexOf(dependency) === -1) { - window.console.info(`The dependency "${dependency}" does not exist and has been ignored.`); - return false; - } - return !knownPips.includes(dependency); - }); - if (openDependencies.length === 0) { - knownPips.push(item.pluginName); - this.pips.push(item); - tmpPips.splice(index, 1); - resolvedDependency = true; - } - }); - if (!resolvedDependency) { - // We could not resolve any dependency, either because there is no more pip - // in `tmpPips` or we're facing a circular dependency. In case there are items - // left, we simply append them to the end and hope for the operation to - // complete anyway, despite unmatched dependencies. - tmpPips.forEach((pip) => { - window.console.warn("Unable to resolve dependencies for", pip); - this.pips.push(pip); - }); - break; - } - } this.buttonSyncAll.addEventListener("click", () => this.syncAll()); } sync(pluginName, target) { @@ -123,12 +68,7 @@ define(["require", "exports", "tslib", "../../../../Ajax", "WoltLabSuite/Core/La } this.buttonSyncAll.classList.add("disabled"); this.#syncVersionAfterCompletion = true; - this.queue = []; - this.pips.forEach((pip) => { - pip.targets.forEach((target) => { - this.queue.push([pip.pluginName, target]); - }); - }); + this.queue = [...this.#fullSyncQueue]; this.syncNext(); } syncNext() { @@ -216,9 +156,9 @@ define(["require", "exports", "tslib", "../../../../Ajax", "WoltLabSuite/Core/La } } let acpUiDevtoolsProjectSync; - function init(projectId) { + function init(projectId, fullSyncQueue) { if (!acpUiDevtoolsProjectSync) { - acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId); + acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId, fullSyncQueue); } } }); diff --git a/wcfsetup/install/files/lib/acp/page/DevtoolsProjectSyncPage.class.php b/wcfsetup/install/files/lib/acp/page/DevtoolsProjectSyncPage.class.php index dc0e766950a..4f8b8f572c2 100644 --- a/wcfsetup/install/files/lib/acp/page/DevtoolsProjectSyncPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/DevtoolsProjectSyncPage.class.php @@ -54,6 +54,7 @@ public function assignVariables() 'objectID' => $this->object->projectID, 'object' => $this->object, 'project' => $this->object, + 'fullSyncQueue' => $this->object->getFullSyncQueue(), ]); } } diff --git a/wcfsetup/install/files/lib/command/devtools/project/SyncPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/command/devtools/project/SyncPackageInstallationPlugin.class.php new file mode 100644 index 00000000000..81913490178 --- /dev/null +++ b/wcfsetup/install/files/lib/command/devtools/project/SyncPackageInstallationPlugin.class.php @@ -0,0 +1,121 @@ + + * @since 6.3 + */ +final class SyncPackageInstallationPlugin +{ + public function __construct( + private readonly DevtoolsProject $project, + private readonly DevtoolsPip $devtoolsPip, + private readonly string $target, + ) {} + + public function __invoke(): bool + { + $packageInstallationPlugin = $this->devtoolsPip->getDecoratedObject(); + $pluginName = $packageInstallationPlugin->pluginName; + + $dispatcher = new DevtoolsPackageInstallationDispatcher($this->project); + $instructions = $this->devtoolsPip->getInstructions($this->project, $this->target); + if ($packageInstallationPlugin->className === DatabasePackageInstallationPlugin::class) { + $pip = new DatabasePackageInstallationPlugin($dispatcher, $instructions, $this->project); + } else { + /** @var IPackageInstallationPlugin $pip */ + $pip = new $packageInstallationPlugin->className($dispatcher, $instructions); + } + + $bootstrapPathname = \sprintf( + '%slib/bootstrap/%s.php', + \WCF_DIR, + $this->project->getPackage()->package, + ); + $hadBootstrapFile = \file_exists($bootstrapPathname); + + $invokeAgain = false; + try { + $pip->update(); + } catch (SplitNodeException $e) { + if (!\in_array($pluginName, ['database', 'script'], true)) { + throw new \RuntimeException("PIP '{$pluginName}' is not allowed to throw a 'SplitNodeException'."); + } + + $invokeAgain = true; + } + + SearchIndexManager::getInstance()->createSearchIndices(); + + VersionTracker::getInstance()->createStorageTables(); + + CacheHandler::getInstance()->flushAll(); + + if ($pip instanceof OptionPackageInstallationPlugin) { + OptionEditor::resetCache(); + } + + switch ($pluginName) { + case 'file': + ApiEndpointCacheBuilder::getInstance()->reset(); + StyleHandler::resetStylesheets(false); + + new SetLastUpdateTime()(); + + if (!$hadBootstrapFile) { + \clearstatcache(filename: $bootstrapPathname); + if (\file_exists($bootstrapPathname)) { + new RebuildBootstrapper()(); + } + } + break; + + case 'language': + case 'menuItem': + LanguageFactory::getInstance()->clearCache(); + LanguageFactory::getInstance()->deleteLanguageCache(); + break; + + case 'acpTemplate': + case 'template': + case 'templateListener': + // resets the compiled templates + LanguageFactory::getInstance()->deleteLanguageCache(); + break; + } + + EventHandler::getInstance()->fire( + new PackageInstallationPluginSynced($pluginName, $invokeAgain) + ); + + return $invokeAgain; + } +} diff --git a/wcfsetup/install/files/lib/command/devtools/project/SyncPackageVersion.class.php b/wcfsetup/install/files/lib/command/devtools/project/SyncPackageVersion.class.php new file mode 100644 index 00000000000..022e328b507 --- /dev/null +++ b/wcfsetup/install/files/lib/command/devtools/project/SyncPackageVersion.class.php @@ -0,0 +1,37 @@ + + * @since 6.3 + */ +final class SyncPackageVersion +{ + public function __construct( + private readonly Package $package, + private readonly string $projectVersion, + ) {} + + public function __invoke(): void + { + if (Package::compareVersion($this->package->packageVersion, $this->projectVersion, '>=')) { + return; + } + + new PackageEditor($this->package)->update([ + 'packageVersion' => $this->projectVersion, + ]); + + PackageCacheBuilder::getInstance()->reset(); + } +} diff --git a/wcfsetup/install/files/lib/data/devtools/project/DevtoolsProject.class.php b/wcfsetup/install/files/lib/data/devtools/project/DevtoolsProject.class.php index 79144ab9bc8..c13aaaa0c89 100644 --- a/wcfsetup/install/files/lib/data/devtools/project/DevtoolsProject.class.php +++ b/wcfsetup/install/files/lib/data/devtools/project/DevtoolsProject.class.php @@ -83,6 +83,77 @@ public function getPips() return $pips; } + /** + * Returns the PIP targets of a full sync in the order they must be synced, + * as pairs of the PIP name and the target. A PIP is synced after the PIPs + * it depends on, dependencies on PIPs without targets are ignored. Unsafe + * PIPs are never part of a full sync. + * + * @return list + * @since 6.3 + */ + public function getFullSyncQueue(): array + { + $resolved = []; + $pending = []; + foreach ($this->getPips() as $pip) { + if ($pip->isUnsafe()) { + continue; + } + + $targets = $pip->getTargets($this); + if ($targets === []) { + continue; + } + + $dependencies = $pip->getSyncDependencies(); + if ($dependencies === []) { + $resolved[$pip->pluginName] = $targets; + } else { + $pending[$pip->pluginName] = [$dependencies, $targets]; + } + } + + $available = \array_merge(\array_keys($resolved), \array_keys($pending)); + while ($pending !== []) { + $next = null; + foreach ($pending as $pluginName => [$dependencies]) { + $openDependencies = \array_filter( + $dependencies, + static fn (string $dependency) => \in_array($dependency, $available, true) + && !isset($resolved[$dependency]) + ); + + if ($openDependencies === []) { + $next = $pluginName; + break; + } + } + + if ($next === null) { + // Circular dependencies cannot be resolved, sync the remaining + // PIPs last and hope that they complete anyway. + foreach ($pending as $pluginName => [, $targets]) { + $resolved[$pluginName] = $targets; + } + + break; + } + + $resolved[$next] = $pending[$next][1]; + unset($pending[$next]); + } + + $queue = []; + foreach ($resolved as $pluginName => $targets) { + foreach ($targets as $target) { + $queue[] = [$pluginName, $target]; + } + } + + return $queue; + } + /** * Validates the repository and returns the first error message, or * an empty string on success. diff --git a/wcfsetup/install/files/lib/data/package/installation/plugin/PackageInstallationPluginAction.class.php b/wcfsetup/install/files/lib/data/package/installation/plugin/PackageInstallationPluginAction.class.php index be6517f736d..8edb4ce4c64 100644 --- a/wcfsetup/install/files/lib/data/package/installation/plugin/PackageInstallationPluginAction.class.php +++ b/wcfsetup/install/files/lib/data/package/installation/plugin/PackageInstallationPluginAction.class.php @@ -2,27 +2,12 @@ namespace wcf\data\package\installation\plugin; -use wcf\command\package\RebuildBootstrapper; -use wcf\command\package\SetLastUpdateTime; +use wcf\command\devtools\project\SyncPackageInstallationPlugin; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\devtools\project\DevtoolsProject; -use wcf\data\option\OptionEditor; -use wcf\event\package\PackageInstallationPluginSynced; -use wcf\system\cache\builder\ApiEndpointCacheBuilder; -use wcf\system\cache\CacheHandler; -use wcf\system\devtools\pip\DevtoolsPackageInstallationDispatcher; use wcf\system\devtools\pip\DevtoolsPip; -use wcf\system\event\EventHandler; use wcf\system\exception\PermissionDeniedException; use wcf\system\exception\UserInputException; -use wcf\system\language\LanguageFactory; -use wcf\system\package\plugin\DatabasePackageInstallationPlugin; -use wcf\system\package\plugin\IPackageInstallationPlugin; -use wcf\system\package\plugin\OptionPackageInstallationPlugin; -use wcf\system\package\SplitNodeException; -use wcf\system\search\SearchIndexManager; -use wcf\system\style\StyleHandler; -use wcf\system\version\VersionTracker; use wcf\system\WCF; /** @@ -109,84 +94,13 @@ public function validateInvoke() */ public function invoke() { - $dispatcher = new DevtoolsPackageInstallationDispatcher($this->project); - $className = $this->packageInstallationPlugin->className; - if ($className === DatabasePackageInstallationPlugin::class) { - $pip = new DatabasePackageInstallationPlugin( - $dispatcher, - $this->devtoolsPip->getInstructions($this->project, $this->parameters['target']), - $this->project, - ); - } else { - /** @var IPackageInstallationPlugin $pip */ - $pip = new $this->packageInstallationPlugin->className( - $dispatcher, - $this->devtoolsPip->getInstructions($this->project, $this->parameters['target']) - ); - } - - $bootstrapPathname = \sprintf( - '%slib/bootstrap/%s.php', - \WCF_DIR, - $this->project->getPackage()->package, - ); - $hadBootstrapFile = \file_exists($bootstrapPathname); - $start = \microtime(true); - $invokeAgain = false; - try { - $pip->update(); - } catch (SplitNodeException $e) { - if (!\in_array($this->parameters['pluginName'], ['database', 'script'], true)) { - throw new \RuntimeException("PIP '{$this->packageInstallationPlugin->pluginName}' is not allowed to throw a 'SplitNodeException'."); - } - - $invokeAgain = true; - } - - SearchIndexManager::getInstance()->createSearchIndices(); - - VersionTracker::getInstance()->createStorageTables(); - - CacheHandler::getInstance()->flushAll(); - - if ($pip instanceof OptionPackageInstallationPlugin) { - OptionEditor::resetCache(); - } - - switch ($this->packageInstallationPlugin->pluginName) { - case 'file': - ApiEndpointCacheBuilder::getInstance()->reset(); - StyleHandler::resetStylesheets(false); - - new SetLastUpdateTime()(); - - if (!$hadBootstrapFile) { - \clearstatcache(filename: $bootstrapPathname); - if (\file_exists($bootstrapPathname)) { - new RebuildBootstrapper()(); - } - } - break; - - case 'language': - case 'menuItem': - LanguageFactory::getInstance()->clearCache(); - LanguageFactory::getInstance()->deleteLanguageCache(); - break; - - case 'acpTemplate': - case 'template': - case 'templateListener': - // resets the compiled templates - LanguageFactory::getInstance()->deleteLanguageCache(); - break; - } - - EventHandler::getInstance()->fire( - new PackageInstallationPluginSynced($this->packageInstallationPlugin->pluginName, $invokeAgain) - ); + $invokeAgain = new SyncPackageInstallationPlugin( + $this->project, + $this->devtoolsPip, + $this->parameters['target'], + )(); return [ 'invokeAgain' => $invokeAgain, diff --git a/wcfsetup/install/files/lib/system/devtools/DevtoolsCLIWCF.class.php b/wcfsetup/install/files/lib/system/devtools/DevtoolsCLIWCF.class.php new file mode 100644 index 00000000000..496405cc63e --- /dev/null +++ b/wcfsetup/install/files/lib/system/devtools/DevtoolsCLIWCF.class.php @@ -0,0 +1,539 @@ + + * @since 6.3 + */ +final class DevtoolsCLIWCF extends CLIWCF +{ + private const EXIT_SUCCESS = 0; + + private const EXIT_FAILURE = 1; + + private const EXIT_USAGE = 2; + + /** + * Returned by `invoke` if the PIP has split its work and must be invoked + * again for the same target. + */ + private const EXIT_INVOKE_AGAIN = 3; + + /** + * Options accepted by each command, `invoke` is internal and therefore + * omitted from the usage. + */ + private const COMMANDS = [ + 'help' => [], + 'projects' => [], + 'targets' => ['project', 'pip'], + 'sync' => ['project', 'pip', 'target', 'all', 'allow-unsafe'], + 'invoke' => ['project', 'pip', 'target', 'allow-unsafe'], + ]; + + private const REQUIRED_OPTIONS = [ + 'help' => [], + 'projects' => [], + 'targets' => ['project'], + 'sync' => ['project'], + 'invoke' => ['project', 'pip', 'target'], + ]; + + private const FLAGS = ['all', 'allow-unsafe']; + + /** + * @phpstan-ignore constructor.missingParentCall + */ + public function __construct() + { + \set_exception_handler(self::handleUncaughtException(...)); + + if (!\defined('TMP_DIR')) { + \define('TMP_DIR', FileUtil::getTempFolder()); + } + + \define('ENABLE_BENCHMARK', 0); + + $this->initDB(); + $this->loadOptions(); + $this->initSession(); + $this->initLanguage(); + $this->initTPL(); + $this->initCoreObjects(); + $this->initApplications(); + + $this->runBootstrappers(); + + // the destructor registered in core.functions.php will only call the destructor of the parent class + \register_shutdown_function([self::class, 'destruct']); + } + + /** + * Runs the command and returns the exit code. + * + * @param list $arguments + */ + public function run(array $arguments): int + { + $command = \array_shift($arguments) ?? 'help'; + if (!isset(self::COMMANDS[$command])) { + $this->error("Unknown command '{$command}'."); + $this->printUsageHint(); + + return self::EXIT_USAGE; + } + + $options = $this->parseOptions($command, $arguments); + if ($options === null || !$this->validateOptions($command, $options)) { + $this->printUsageHint(); + + return self::EXIT_USAGE; + } + + if ($command === 'help') { + $this->printUsage(); + + return self::EXIT_SUCCESS; + } + + if (\ENABLE_DEVELOPER_TOOLS === 0) { + $this->error('The developer tools are disabled.'); + + return self::EXIT_FAILURE; + } + + return match ($command) { + 'projects' => $this->listProjects(), + 'targets' => $this->listTargets($options), + 'sync' => $this->sync($options), + 'invoke' => $this->invoke($options), + }; + } + + private function listProjects(): int + { + $projectList = new DevtoolsProjectList(); + $projectList->sqlOrderBy = 'name'; + $projectList->readObjects(); + + foreach ($projectList as $project) { + $this->write("{$project->projectID}\t{$project->name}\t{$project->path}"); + } + + return self::EXIT_SUCCESS; + } + + /** + * @param array $options + */ + private function listTargets(array $options): int + { + $project = $this->getProject($options['project']); + if ($project === null) { + return self::EXIT_FAILURE; + } + + if (isset($options['pip'])) { + $pip = $this->getPip($options['pip']); + if ($pip === null) { + return self::EXIT_FAILURE; + } + + $pips = [$pip]; + } else { + $pips = $project->getPips(); + } + + foreach ($pips as $pip) { + foreach ($pip->getTargets($project) as $target) { + $this->write("{$pip->pluginName}\t{$target}" . ($pip->isUnsafe() ? "\tunsafe" : '')); + } + } + + return self::EXIT_SUCCESS; + } + + /** + * @param array $options + */ + private function sync(array $options): int + { + $project = $this->getProject($options['project']); + if ($project === null) { + return self::EXIT_FAILURE; + } + + if (isset($options['all'])) { + $queue = $project->getFullSyncQueue(); + } else { + $pip = $this->getSyncablePip($options); + if ($pip === null) { + return self::EXIT_FAILURE; + } + + if (isset($options['target'])) { + if (!$this->isValidTarget($project, $pip, $options['target'])) { + return self::EXIT_FAILURE; + } + + $targets = [$options['target']]; + } else { + $targets = $pip->getTargets($project); + if ($targets === []) { + $this->error("The PIP '{$pip->pluginName}' has no targets in this project."); + + return self::EXIT_FAILURE; + } + } + + $queue = \array_map(static fn (string $target) => [$pip->pluginName, $target], $targets); + } + + foreach ($queue as [$pluginName, $target]) { + if (!$this->syncTarget($project, $pluginName, $target, isset($options['allow-unsafe']))) { + return self::EXIT_FAILURE; + } + } + + if (isset($options['all'])) { + new SyncPackageVersion( + $project->getPackage(), + $project->getPackageArchive()->getPackageInfo('version'), + )(); + } + + return self::EXIT_SUCCESS; + } + + /** + * Syncs a single target by invoking the PIP in child processes until it + * has completed its work. + */ + private function syncTarget(DevtoolsProject $project, string $pluginName, string $target, bool $allowUnsafe): bool + { + $command = [ + \PHP_BINARY, + \WCF_DIR . 'devtools.php', + 'invoke', + "--project={$project->projectID}", + "--pip={$pluginName}", + "--target={$target}", + ]; + if ($allowUnsafe) { + $command[] = '--allow-unsafe'; + } + + while (true) { + $start = \microtime(true); + + $process = \proc_open($command, [0 => \STDIN, 1 => \STDOUT, 2 => \STDERR], $pipes); + if ($process === false) { + $this->error("Unable to start the process to sync '{$pluginName}' ({$target})."); + + return false; + } + + $exitCode = \proc_close($process); + $timeElapsed = \sprintf('%.2fs', \microtime(true) - $start); + + if ($exitCode === self::EXIT_SUCCESS) { + $this->write("{$pluginName}\t{$target}\t{$timeElapsed}"); + + return true; + } elseif ($exitCode === self::EXIT_INVOKE_AGAIN) { + $this->write("{$pluginName}\t{$target}\t{$timeElapsed}\t(continuing)"); + } else { + $this->error("Syncing '{$pluginName}' ({$target}) failed with exit code {$exitCode}."); + + return false; + } + } + } + + /** + * Invokes the PIP once for a single target. + * + * @param array $options + */ + private function invoke(array $options): int + { + $project = $this->getProject($options['project']); + if ($project === null) { + return self::EXIT_FAILURE; + } + + $pip = $this->getSyncablePip($options); + if ($pip === null || !$this->isValidTarget($project, $pip, $options['target'])) { + return self::EXIT_FAILURE; + } + + $invokeAgain = new SyncPackageInstallationPlugin($project, $pip, $options['target'])(); + + return $invokeAgain ? self::EXIT_INVOKE_AGAIN : self::EXIT_SUCCESS; + } + + /** + * Resolves the project by its ID, its path, its name or the identifier of + * its package, in this order. A path must contain a `/` or be `.` or `..`, + * otherwise a directory in the working directory that happens to be named + * like a package would shadow the identifier. + */ + private function getProject(string $value): ?DevtoolsProject + { + $projectList = new DevtoolsProjectList(); + $projectList->readObjects(); + $projects = $projectList->getObjects(); + + $project = null; + if (\preg_match('~^[1-9][0-9]*$~', $value)) { + $project = $projects[(int)$value] ?? null; + } elseif (\str_contains($value, '/') || $value === '.' || $value === '..') { + $path = \realpath($value); + if ($path === false || !\is_dir($path)) { + $this->error("The directory '{$value}' does not exist."); + + return null; + } + + $path = FileUtil::addTrailingSlash($path); + foreach ($projects as $candidate) { + $candidatePath = \realpath($candidate->path); + if ($candidatePath !== false && FileUtil::addTrailingSlash($candidatePath) === $path) { + $project = $candidate; + break; + } + } + } else { + foreach ($projects as $candidate) { + if ($candidate->name === $value) { + $project = $candidate; + break; + } + } + + if ($project === null) { + foreach ($projects as $candidate) { + if ( + $candidate->validate(true) === '' + && $candidate->getPackageArchive()->getPackageInfo('name') === $value + ) { + $project = $candidate; + break; + } + } + } + } + + if ($project === null) { + $this->error("Unknown project '{$value}'."); + + return null; + } + + $error = $project->validate(); + if ($error !== '') { + // The messages are phrases that may contain markup. + $error = StringUtil::decodeHTML(\strip_tags($error)); + $this->error("The project '{$project->name}' is invalid: {$error}"); + + return null; + } + + return $project; + } + + private function getPip(string $pluginName): ?DevtoolsPip + { + $packageInstallationPlugin = new PackageInstallationPlugin($pluginName); + if ($packageInstallationPlugin->isNil()) { + $this->error("Unknown PIP '{$pluginName}'."); + + return null; + } + + return new DevtoolsPip($packageInstallationPlugin); + } + + /** + * @param array $options + */ + private function getSyncablePip(array $options): ?DevtoolsPip + { + $pip = $this->getPip($options['pip']); + if ($pip === null) { + return null; + } + + if ($pip->isUnsafe() && !isset($options['allow-unsafe'])) { + $this->error("The PIP '{$pip->pluginName}' is unsafe and requires --allow-unsafe."); + + return null; + } + + return $pip; + } + + private function isValidTarget(DevtoolsProject $project, DevtoolsPip $pip, string $target): bool + { + if (!\in_array($target, $pip->getTargets($project), true)) { + $this->error("Unknown target '{$target}' of the PIP '{$pip->pluginName}'."); + + return false; + } + + return true; + } + + /** + * Checks the required options and the combinations of options that the + * command does not support. + * + * @param array $options + */ + private function validateOptions(string $command, array $options): bool + { + foreach (self::REQUIRED_OPTIONS[$command] as $name) { + if (!isset($options[$name])) { + $this->error("The command '{$command}' requires the option --{$name}."); + + return false; + } + } + + if ($command !== 'sync') { + return true; + } + + if (isset($options['all'])) { + if (isset($options['pip']) || isset($options['target']) || isset($options['allow-unsafe'])) { + $this->error('--all cannot be combined with --pip, --target or --allow-unsafe.'); + + return false; + } + } elseif (!isset($options['pip'])) { + $this->error('Either --pip or --all is required.'); + + return false; + } + + return true; + } + + /** + * Parses `--name=value` options and `--flag` flags, returns `null` if the + * arguments are invalid for the command. + * + * @param list $arguments + * @return ?array + */ + private function parseOptions(string $command, array $arguments): ?array + { + $options = []; + foreach ($arguments as $argument) { + if (!\preg_match('~^--([a-z-]+)(?:=(.*))?$~s', $argument, $matches)) { + $this->error("Unexpected argument '{$argument}'."); + + return null; + } + + $name = $matches[1]; + if (!\in_array($name, self::COMMANDS[$command], true)) { + $this->error("The command '{$command}' does not support the option --{$name}."); + + return null; + } + + if (isset($options[$name])) { + $this->error("The option --{$name} was given more than once."); + + return null; + } + + $isFlag = \in_array($name, self::FLAGS, true); + if ($isFlag === isset($matches[2])) { + $this->error( + $isFlag + ? "The option --{$name} does not take a value." + : "The option --{$name} requires a value." + ); + + return null; + } + + $options[$name] = $isFlag ? true : $matches[2]; + } + + return $options; + } + + private function printUsage(): void + { + $this->write(<<<'EOT' + Usage: php devtools.php [options] + + Commands: + projects + List the projects as their ID, name and path. + + targets --project= [--pip=] + List the targets of all PIPs or of a single PIP. + + sync --project= --all + Sync all PIPs in the order of their dependencies, followed by + the package version. Unsafe PIPs are never synced. + + sync --project= --pip= [--target=] [--allow-unsafe] + Sync all targets or a single target of a PIP. Unsafe PIPs + require --allow-unsafe. + + is the ID, the path or the name of the project, or the + identifier of its package. A path must contain a '/' or be '.'. + + Exit codes: 0 on success, 1 on failure, 2 on invalid arguments. + EOT); + } + + private function printUsageHint(): void + { + $this->error("Run 'php devtools.php help' for usage."); + } + + private function write(string $message): void + { + \fwrite(\STDOUT, $message . "\n"); + } + + private function error(string $message): void + { + \fwrite(\STDERR, $message . "\n"); + } + + private static function handleUncaughtException(\Throwable $e): never + { + \fwrite(\STDERR, (string)$e . "\n"); + + exit(self::EXIT_FAILURE); + } +} diff --git a/wcfsetup/install/files/lib/system/devtools/pip/DevtoolsPip.class.php b/wcfsetup/install/files/lib/system/devtools/pip/DevtoolsPip.class.php index 09cd849d91b..2f8b7bb7201 100644 --- a/wcfsetup/install/files/lib/system/devtools/pip/DevtoolsPip.class.php +++ b/wcfsetup/install/files/lib/system/devtools/pip/DevtoolsPip.class.php @@ -131,13 +131,11 @@ public function supportsGui() } /** - * @return string|string[] + * @return list */ - public function getSyncDependencies(bool $toJson = true) + public function getSyncDependencies(): array { - $dependencies = \call_user_func([$this->getDecoratedObject()->className, 'getSyncDependencies']); - - return ($toJson) ? \json_encode($dependencies, \JSON_THROW_ON_ERROR) : $dependencies; + return \call_user_func([$this->getDecoratedObject()->className, 'getSyncDependencies']); } /** diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/devtools/projects/SyncVersion.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/devtools/projects/SyncVersion.class.php index f1bc2fae253..0e787c21825 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/devtools/projects/SyncVersion.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/devtools/projects/SyncVersion.class.php @@ -5,11 +5,9 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use wcf\command\devtools\project\SyncPackageVersion; use wcf\data\devtools\project\DevtoolsProject; -use wcf\data\package\Package; -use wcf\data\package\PackageEditor; use wcf\http\Helper; -use wcf\system\cache\builder\PackageCacheBuilder; use wcf\system\endpoint\IController; use wcf\system\endpoint\PostRequest; use wcf\system\exception\PermissionDeniedException; @@ -40,25 +38,14 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res throw new UserInputException('projectID'); } - $this->syncVersion($project, $package); + new SyncPackageVersion( + $package, + $project->getPackageArchive()->getPackageInfo('version'), + )(); return new JsonResponse([]); } - private function syncVersion(DevtoolsProject $project, Package $package): void - { - $projectVersion = $project->getPackageArchive()->getPackageInfo('version'); - if (Package::compareVersion($package->packageVersion, $projectVersion, '>=')) { - return; - } - - new PackageEditor($package)->update([ - 'packageVersion' => $projectVersion, - ]); - - PackageCacheBuilder::getInstance()->reset(); - } - private function assertProjectCanBeManaged(): void { if (\ENABLE_DEVELOPER_TOOLS === 0 || !WCF::getSession()->hasPermission('admin.configuration.package.canInstallPackage')) {