diff --git a/composer.json b/composer.json index 362677dad..4ecf9b615 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,8 @@ "twig/twig": "^3.14", "wikimedia/less.php": "^5.0", "wikimedia/minify": "~2.2", - "winter/laravel-config-writer": "^1.0.1" + "winter/laravel-config-writer": "^1.0.1", + "winter/packager": "^0.4.3" }, "require-dev": { "phpunit/phpunit": "^9.5.8", @@ -99,5 +100,10 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "config": { + "allow-plugins": { + "php-http/discovery": false + } + } } diff --git a/src/Console/Command.php b/src/Console/Command.php index d092ae5f2..a9d1ff35b 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -1,19 +1,39 @@ -replaces)) { $this->setAliases($this->replaces); } + + $this->extendableConstruct(); + } + + /** + * Override the laravel run function to allow us to run callbacks on the command prior to excution. + * Run the console command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + public function run(InputInterface $input, OutputInterface $output): int + { + $this->output = $this->laravel->make( + OutputStyle::class, + ['input' => $input, 'output' => $output] + ); + + $this->components = $this->laravel->make(Factory::class, ['output' => $this->output]); + + /** + * @event command.beforeRun + * Called before the command is run; useful for intercepting the output of the command or auditing the commands run + * + * Example usage: + * + * Command::extend(function (Command $command) { + * $command->bindEvent('command.beforeRun', function () use ($command) { + * MyTaskManager::instance()->setOutput($command->getOutput()); + * }); + * }); + * + */ + $this->fireEvent('command.beforeRun', [$this]); + + $renderer = Termwind::getRenderer(); + renderUsing($this->output->getOutput()); + + try { + // Calling the grandparent run() method, see: https://www.php.net/manual/en/language.oop5.inheritance.php#100005 + return SymfonyCommand::run( + $this->input = $input, + $this->output + ); + } finally { + $this->untrap(); + // Restore the original termwind renderer + renderUsing($renderer); + } } /** @@ -60,4 +130,69 @@ public function error($string, $verbosity = null) { $this->components->error($string, $this->parseVerbosity($verbosity)); } + + /** + * Magic allowing for extendable properties + * + * @param $name + * @return mixed|null + */ + public function __get($name) + { + return $this->extendableGet($name); + } + + /** + * Magic allowing for extendable properties + * + * @param $name + * @param $value + * @return void + */ + public function __set($name, $value) + { + $this->extendableSet($name, $value); + } + + /** + * Magic allowing for dynamic extension + * + * @param $name + * @param $params + * @return mixed + */ + public function __call($name, $params) + { + if ($name === 'extend') { + if (empty($params[0]) || !is_callable($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + if ($params[0] instanceof \Closure) { + return $params[0]->call($this, $params[1] ?? $this); + } + return \Closure::fromCallable($params[0])->call($this, $params[1] ?? $this); + } + + return $this->extendableCall($name, $params); + } + + /** + * Magic allowing for dynamic static extension + * + * @param $name + * @param $params + * @return mixed|void + */ + public static function __callStatic($name, $params) + { + if ($name === 'extend') { + if (empty($params[0])) { + throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.'); + } + self::extendableExtendCallback($params[0], $params[1] ?? false, $params[2] ?? null); + return; + } + + return parent::__callStatic($name, $params); + } } diff --git a/src/Foundation/Extension/WinterExtension.php b/src/Foundation/Extension/WinterExtension.php new file mode 100644 index 000000000..7eb338cdd --- /dev/null +++ b/src/Foundation/Extension/WinterExtension.php @@ -0,0 +1,16 @@ +setWorkDir(realpath(base_path())); + + return static::$composer; + } + + public static function __callStatic(string $name, array $args = []): mixed + { + if (!isset(static::$composer)) { + static::make(); + } + + return static::$composer->{$name}(...$args); + } + + /** + * Pin the provided package to the provided version range. If no version range is provided then + * Composer will use whatever it would use if require $package was run. + */ + public static function pin(string $package, ?string $version = null): void + { + $requiredPackage = $package; + if (!is_null($version)) { + $requiredPackage .= ":$version"; + } + static::require($requiredPackage, noUpdate: true, noScripts: true); + } + + /** + * Get the Winter extensions present in the current project + * @return array $packages List of packages ['type' => ['path' => $details]] + */ + public static function getWinterPackages(): array + { + $installed = static::make()->getInstalledFile()->packages; + $packages = []; + foreach ($installed as $name => $details) { + $type = null; + if ($name === 'winter/storm') { + $type = 'core'; + } + + $type = $type ?? match ($details['type']) { + 'winter-plugin', 'october-plugin' => 'plugins', + 'winter-module', 'october-module' => 'modules', + 'winter-theme', 'october-theme' => 'themes', + default => null + }; + + if (!$type) { + continue; + } + + $details['path'] = realpath( + static::make()->getComposerVendorDir() + . DIRECTORY_SEPARATOR + . $details['install-path'] + ); + + $packages[$type][$details['path']] = $details; + } + + return $packages; + } + + /** + * Get the available updates for the project + * @TODO: Check if we need to cache this + * @return array [$package => ['from' => string, 'to' => string, 'ref' => string, 'available' => array]] + */ + public static function getAvailableUpdates(): array + { + $upgrades = static::update(dryRun: true, withAllDependencies: true)->getUpgraded(); + // Get an array of package names that are winter packages + $packages = array_values( + array_map( + fn ($package) => $package['name'], + array_merge(...array_values(static::getWinterPackages())) + ) + ); + + $winterPackages = array_filter($upgrades, function ($key) use ($packages) { + return in_array($key, $packages); + }, ARRAY_FILTER_USE_KEY); + + foreach ($winterPackages as $name => $details) { + $winterPackages[$name] = [ + 'from' => $details[0], + 'to' => $details[1], + ]; + + $info = static::show( + package: $name, + mode: ShowMode::AVAILABLE, + latest: true, + returnArray: true + ); + + $winterPackages[$name] = [ + 'from' => $details[0], + 'to' => $details[1], + 'ref' => $info['dist']['reference'] ?? null, + 'available' => static::filterProductionVersions($info['versions'], [$details[0]]), + ]; + } + + return $winterPackages; + } + + /** + * Gets the latest supported version constraints for the provided package that Composer + * would use under the current conditions + * @TODO: Evaluate for removal if it doesn't get used for the UI + */ + public static function getLatestSupportedVersion(string $package): string + { + $message = static::require(package: $package, dryRun: true); + $output = explode(PHP_EOL, $message); + preg_match('/Using version (.*?) /', $output[count($output) - 1], $matches); + + return $matches[1] ?? throw new CommandException('Unable to determine required version'); + } + + /** + * Check if there is an update available for the provided package + */ + public static function updateAvailable(string $package): bool + { + return isset(static::getAvailableUpdates()[$package]); + } + + /** + * Get the package info for the provided WinterExtension + */ + public static function getPackageInfoByExtension(WinterExtension $extension): array + { + return static::getPackageInfoByPath($extension->getPath()); + } + + /** + * Get the package name for the provided WinterExtension + */ + public static function getPackageNameByExtension(WinterExtension $extension): ?string + { + return static::getPackageInfoByPath($extension->getPath())['name']; + } + + /** + * Get the package info from the provided path + */ + public static function getPackageInfoByPath(string $path): array + { + return array_merge(...array_values(static::getWinterPackages()))[$path] ?? []; + } + + /** + * Get list of Winter packages that are present on the system with their current version + * @return array [$package => ['version' => string, 'ref' => string]] + */ + public static function getWinterPackagesWithVersion(): array + { + $packages = []; + foreach (array_merge(...array_values(static::getWinterPackages())) as $package) { + $packages[$package['name']] = [ + 'version' => $package['version'] ?? null, + 'ref' => $package['dist']['reference'] ?? null + ]; + } + + return $packages; + } + + /** + * Removes all dev versions not present in the keep paramater + */ + protected static function filterProductionVersions(array $versions, array $keep = []): array + { + foreach ($versions as $index => $version) { + if ((!str_starts_with($version, 'v') || str_ends_with($version, '-dev')) && !in_array($version, $keep)) { + unset($versions[$index]); + } + } + + usort($versions, fn (string $a, string $b): int => version_compare($b, $a)); + + return $versions; + } +} diff --git a/src/Support/ModuleServiceProvider.php b/src/Support/ModuleServiceProvider.php index 96d63ced4..c3cc76c60 100644 --- a/src/Support/ModuleServiceProvider.php +++ b/src/Support/ModuleServiceProvider.php @@ -1,17 +1,26 @@ loadRoutesFrom($routesFile); } + + // Bind the service provider to the application container + $this->app->instance($this::class, $this); } /** @@ -90,4 +102,24 @@ protected function loadConfigFrom($path, $namespace) $config = $this->app['config']; $config->package($namespace, $path); } + + public function getVersion(): string + { + return $this->composerPackage['versions'][0] ?? 'dev-unknown'; + } + + public function getPath(): string + { + return $this->path ?? $this->path = dirname((new ReflectionClass(get_called_class()))->getFileName()); + } + + public function getIdentifier(): string + { + return $this->identifier ?? $this->identifier = (new ReflectionClass(get_called_class()))->getNamespaceName(); + } + + public function __toString(): string + { + return $this->getIdentifier(); + } } diff --git a/src/Support/Traits/HasComposerPackage.php b/src/Support/Traits/HasComposerPackage.php new file mode 100644 index 000000000..d95cc14a2 --- /dev/null +++ b/src/Support/Traits/HasComposerPackage.php @@ -0,0 +1,40 @@ + '', + * ] + */ + protected ?array $composerPackage = null; + + /** + * Get the composer package details + */ + protected function getComposerPackage(): ?array + { + return $this->composerPackage ?? $this->composerPackage = Composer::getPackageInfoByExtension($this); + } + + /** + * Get the composer package name + */ + public function getComposerPackageName(): ?string + { + return $this->getComposerPackage()['name'] ?? null; + } + + /** + * Get the composer package version + */ + public function getComposerPackageVersion(): ?string + { + return $this->getComposerPackage()['version'] ?? null; + } +}