From 379fdb2bae8fb27528e11b1e11fe0061e378e75b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 10:57:54 -0400 Subject: [PATCH 01/44] Docblock. --- src/StarterKits/Installer.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index fd0500e966..8e5ef0d14c 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -117,6 +117,12 @@ public function withoutDependencies($withoutDependencies = false) return $this; } + /** + * Set interactive mode. + * + * @param bool $isInteractive + * @return $this + */ public function isInteractive($isInteractive = false) { Prompt::interactive($isInteractive); From 24b2e12322eaaf404ffa9056a7de3e8c24f274bc Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 10:58:00 -0400 Subject: [PATCH 02/44] =?UTF-8?q?Don=E2=80=99t=20need=20this.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/StarterKits/Installer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 8e5ef0d14c..dbfcaa3035 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -6,7 +6,6 @@ use Facades\Statamic\Console\Processes\TtyDetector; use Facades\Statamic\StarterKits\Hook; use Illuminate\Console\Command; -use Illuminate\Console\View\Components\Line; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Http; use Laravel\Prompts\Prompt; From 91d46145746c91defc60af9b968a4887ae1470ce Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 11:01:01 -0400 Subject: [PATCH 03/44] Extract these calls to `install()`. --- src/StarterKits/Installer.php | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index dbfcaa3035..d6292f952b 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -185,6 +185,8 @@ public function install() ->ensureExportPathsExist() ->ensureCompatibleDependencies() ->installFiles() + ->copyStarterKitConfig() + ->copyStarterKitHooks() ->installDependencies() ->makeSuperUser() ->runPostInstallHook() @@ -372,11 +374,6 @@ protected function installFiles() $this->copyFile($fromPath, $toPath); }); - if ($this->withConfig) { - $this->copyStarterKitConfig(); - $this->copyStarterKitHooks(); - } - return $this; } @@ -385,6 +382,7 @@ protected function installFiles() * * @param mixed $fromPath * @param mixed $toPath + * @return $this */ protected function copyFile($fromPath, $toPath) { @@ -393,15 +391,19 @@ protected function copyFile($fromPath, $toPath) $this->console->line("Installing file [{$displayPath}]"); $this->files->copy($fromPath, $this->preparePath($toPath)); + + return $this; } /** * Copy starter kit config without versions, to encourage dependency management using composer. + * + * @return $this */ protected function copyStarterKitConfig() { if (! $this->withConfig) { - return; + return $this; } if ($this->withoutDependencies) { @@ -425,15 +427,19 @@ protected function copyStarterKitConfig() } $this->files->put(base_path('starter-kit.yaml'), YAML::dump($config->all())); + + return $this; } /** * Copy starter kit hook scripts. + * + * @return $this */ protected function copyStarterKitHooks() { if (! $this->withConfig) { - return; + return $this; } $hooks = ['StarterKitPostInstall.php']; @@ -441,6 +447,8 @@ protected function copyStarterKitHooks() collect($hooks) ->filter(fn ($hook) => $this->files->exists($this->starterKitPath($hook))) ->each(fn ($hook) => $this->copyFile($this->starterKitPath($hook), base_path($hook))); + + return $this; } /** From 135fd8a55f29d6ce0513ed63b9463724d038ca91 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 16:23:09 -0400 Subject: [PATCH 04/44] Fix case. --- src/StarterKits/Installer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index d6292f952b..6724e99c75 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -48,7 +48,7 @@ public function __construct(string $package, $console = null, ?LicenseManager $l $this->licenseManager = $licenseManager; - $this->console = $console ?? new Nullconsole; + $this->console = $console ?? new NullConsole; $this->files = app(Filesystem::class); } From c03e5ca856356d2d1654a7d52a997e5713441452 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 19:02:56 -0400 Subject: [PATCH 05/44] Extract common filesystem helpers out to trait. --- .../Concerns/InteractsWithFilesystem.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/StarterKits/Concerns/InteractsWithFilesystem.php diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php new file mode 100644 index 0000000000..a7581ad1cd --- /dev/null +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -0,0 +1,48 @@ +line("Installing file [{$displayPath}]"); + + app(Filesystem::class)->copy($fromPath, $this->preparePath($toPath)); + + return $this; + } + + /** + * Prepare path directory. + * + * @param string $path + * @return string + */ + protected function preparePath($path) + { + $files = app(Filesystem::class); + + $directory = $files->isDirectory($path) + ? $path + : preg_replace('/(.*)\/[^\/]*/', '$1', Path::tidy($path)); + + if (! $files->exists($directory)) { + $files->makeDirectory($directory, 0755, true); + } + + return Path::tidy($path); + } +} From 251f0003a6c6b8367968bd20a9d79ea3b72f0625 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 19:05:17 -0400 Subject: [PATCH 06/44] Extract installation logic out to `ModuleInstaller`. --- src/StarterKits/Installer.php | 329 +++------------------------- src/StarterKits/ModuleInstaller.php | 322 +++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 300 deletions(-) create mode 100644 src/StarterKits/ModuleInstaller.php diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 6724e99c75..4031cb6aa3 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -13,7 +13,6 @@ use Statamic\Console\Please\Application as PleaseApplication; use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Facades\Blink; -use Statamic\Facades\Path; use Statamic\Facades\YAML; use Statamic\StarterKits\Exceptions\StarterKitException; use Statamic\Support\Str; @@ -23,18 +22,22 @@ final class Installer { - protected $package; + use Concerns\InteractsWithFilesystem; + + public $package; + public $withConfig; + public $withoutDependencies; + public $force; + public $console; + public $usingSubProcess; + protected $branch; protected $licenseManager; protected $files; protected $fromLocalRepo; - protected $withConfig; - protected $withoutDependencies; protected $withUser; - protected $usingSubProcess; - protected $force; - protected $console; protected $url; + protected $modules; protected $disableCleanup; /** @@ -182,14 +185,12 @@ public function install() ->prepareRepository() ->requireStarterKit() ->ensureConfig() - ->ensureExportPathsExist() - ->ensureCompatibleDependencies() - ->installFiles() - ->copyStarterKitConfig() - ->copyStarterKitHooks() - ->installDependencies() + ->instantiateModules() + ->installModules() + ->copyStarterKitConfig() // TODO handle modules + ->copyStarterKitHooks() // TODO handle modules ->makeSuperUser() - ->runPostInstallHook() + ->runPostInstallHooks() // TODO handle modules ->reticulateSplines() ->removeStarterKit() ->removeRepository() @@ -318,79 +319,23 @@ protected function ensureConfig() return $this; } - /** - * Ensure export paths exist. - * - * @return $this - * - * @throws StarterKitException - */ - protected function ensureExportPathsExist() - { - $this - ->exportPaths() - ->reject(function ($path) { - return $this->files->exists($this->starterKitPath($path)); - }) - ->each(function ($path) { - throw new StarterKitException("Starter kit path [{$path}] does not exist."); - }); - - return $this; - } - - /** - * Ensure compatible dependencies by performing a dry-run. - * - * @return $this - */ - protected function ensureCompatibleDependencies() + protected function instantiateModules() { - if ($this->withoutDependencies || $this->force) { - return $this; - } - - if ($packages = $this->installableDependencies('dependencies')) { - $this->ensureCanRequireDependencies($packages); - } - - if ($packages = $this->installableDependencies('dependencies_dev')) { - $this->ensureCanRequireDependencies($packages, true); - } - - return $this; - } + $topLevelConfigModule = $this->config()->except('modules'); - /** - * Install starter kit files. - * - * @return $this - */ - protected function installFiles() - { - $this->console->info('Installing files...'); + $optionalModules = $this->config('modules'); - $this->installableFiles()->each(function ($toPath, $fromPath) { - $this->copyFile($fromPath, $toPath); - }); + $this->modules = collect([$topLevelConfigModule]) + ->merge($optionalModules) + ->map(fn ($config) => new ModuleInstaller($config, $this)) + ->each(fn ($module) => $module->validate()); return $this; } - /** - * Copy starter kit file. - * - * @param mixed $fromPath - * @param mixed $toPath - * @return $this - */ - protected function copyFile($fromPath, $toPath) + protected function installModules() { - $displayPath = str_replace(Path::tidy(base_path().'/'), '', $toPath); - - $this->console->line("Installing file [{$displayPath}]"); - - $this->files->copy($fromPath, $this->preparePath($toPath)); + $this->modules->each(fn ($module) => $module->install()); return $this; } @@ -407,12 +352,12 @@ protected function copyStarterKitConfig() } if ($this->withoutDependencies) { - return $this->copyFile($this->starterKitPath('starter-kit.yaml'), base_path('starter-kit.yaml')); + return $this->installFile($this->starterKitPath('starter-kit.yaml'), base_path('starter-kit.yaml'), $this->console); } $this->console->line('Installing file [starter-kit.yaml]'); - $config = collect(YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml')))); + $config = $this->config(); $dependencies = collect() ->merge($config->get('dependencies')) @@ -446,79 +391,11 @@ protected function copyStarterKitHooks() collect($hooks) ->filter(fn ($hook) => $this->files->exists($this->starterKitPath($hook))) - ->each(fn ($hook) => $this->copyFile($this->starterKitPath($hook), base_path($hook))); - - return $this; - } - - /** - * Install starter kit dependencies. - * - * @return $this - */ - protected function installDependencies() - { - if ($this->withoutDependencies) { - return $this; - } - - if ($packages = $this->installableDependencies('dependencies')) { - $this->requireDependencies($packages); - } - - if ($packages = $this->installableDependencies('dependencies_dev')) { - $this->requireDependencies($packages, true); - } + ->each(fn ($hook) => $this->installFile($this->starterKitPath($hook), base_path($hook), $this->console)); return $this; } - /** - * Ensure dependencies are installable by performing a dry-run. - * - * @param array $packages - * @param bool $dev - */ - protected function ensureCanRequireDependencies($packages, $dev = false) - { - $requireMethod = $dev ? 'requireMultipleDev' : 'requireMultiple'; - - try { - Composer::withoutQueue()->throwOnFailure()->{$requireMethod}($packages, '--dry-run'); - } catch (ProcessException $exception) { - $this->rollbackWithError('Cannot install due to dependency conflict.', $exception->getMessage()); - } - } - - /** - * Install starter kit dependency permanently into app. - * - * @param array $packages - * @param bool $dev - */ - protected function requireDependencies($packages, $dev = false) - { - if ($dev) { - $this->console->info('Installing development dependencies...'); - } else { - $this->console->info('Installing dependencies...'); - } - - $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); - - if ($dev) { - $args[] = '--dev'; - } - - try { - Composer::withoutQueue()->throwOnFailure()->runAndOperateOnOutput($args, function ($output) { - return $this->outputFromSymfonyProcess($output); - }); - } catch (ProcessException $exception) { - $this->console->error('Error installing dependencies.'); - } - } - /** * Make super user. * @@ -544,7 +421,7 @@ public function makeSuperUser() * * @throws StarterKitException */ - public function runPostInstallHook($throwExceptions = false) + public function runPostInstallHooks($throwExceptions = false) { $postInstallHook = Hook::find($this->starterKitPath('StarterKitPostInstall.php')); @@ -728,7 +605,7 @@ protected function restoreComposerJson() * * @throws StarterKitException */ - protected function rollbackWithError($error, $output = null) + public function rollbackWithError($error, $output = null) { $this ->removeStarterKit() @@ -772,124 +649,6 @@ protected function starterKitPath($path = null) return collect([base_path("vendor/{$this->package}"), $path])->filter()->implode('/'); } - /** - * Clean up symfony process output and output to cli. - * - * TODO: Move to trait and reuse in MakeAddon? - * - * @return string - */ - private function outputFromSymfonyProcess(string $output) - { - // Remove terminal color codes. - $output = preg_replace('/\\e\[[0-9]+m/', '', $output); - - // Remove new lines. - $output = preg_replace('/[\r\n]+$/', '', $output); - - // If not a blank line, output to terminal. - if (! empty(trim($output))) { - $this->console->line($output); - } - - return $output; - } - - /** - * Get `export_paths` paths from config. - * - * @return \Illuminate\Support\Collection - */ - protected function exportPaths() - { - $config = YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml'))); - - return collect($config['export_paths'] ?? []); - } - - /** - * Get `export_as` paths (to be renamed on install) from config. - * - * @return \Illuminate\Support\Collection - */ - protected function exportAsPaths() - { - $config = YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml'))); - - return collect($config['export_as'] ?? []); - } - - /** - * Get installable files. - * - * @return \Illuminate\Support\Collection - */ - protected function installableFiles() - { - $installableFromExportPaths = $this - ->exportPaths() - ->flatMap(function ($path) { - return $this->expandConfigExportPaths($path); - }); - - $installableFromExportAsPaths = $this - ->exportAsPaths() - ->flip() - ->flatMap(function ($to, $from) { - return $this->expandConfigExportPaths($to, $from); - }); - - return collect() - ->merge($installableFromExportPaths) - ->merge($installableFromExportAsPaths); - } - - /** - * Expand config export path to `[$from => $to]` array format, normalizing directories to files. - * - * @param string $to - * @param string $from - * @return \Illuminate\Support\Collection - */ - protected function expandConfigExportPaths($to, $from = null) - { - $to = Path::tidy($this->starterKitPath($to)); - $from = Path::tidy($from ? $this->starterKitPath($from) : $to); - - $paths = collect([$from => $to]); - - if ($this->files->isDirectory($from)) { - $paths = collect($this->files->allFiles($from)) - ->map->getPathname() - ->mapWithKeys(function ($path) use ($from, $to) { - return [$path => str_replace($from, $to, $path)]; - }); - } - - return $paths->mapWithKeys(function ($to, $from) { - return [Path::tidy($from) => Path::tidy(str_replace("/vendor/{$this->package}", '', $to))]; - }); - } - - /** - * Prepare path directory. - * - * @param string $path - * @return string - */ - protected function preparePath($path) - { - $directory = $this->files->isDirectory($path) - ? $path - : preg_replace('/(.*)\/[^\/]*/', '$1', Path::tidy($path)); - - if (! $this->files->exists($directory)) { - $this->files->makeDirectory($directory, 0755, true); - } - - return Path::tidy($path); - } - /** * Get starter kit config. * @@ -905,34 +664,4 @@ protected function config($key = null) return $config; } - - /** - * Get installable dependencies from appropriate require key in composer.json. - * - * @param string $configKey - * @return array - */ - protected function installableDependencies($configKey) - { - return collect($this->config($configKey))->filter(function ($version, $package) { - return Str::contains($package, '/'); - })->all(); - } - - /** - * Normalize packages array to require args, with version handling if `package => version` array structure is passed. - * - * @return array - */ - private function normalizePackagesArrayToRequireArgs(array $packages) - { - return collect($packages) - ->map(function ($value, $key) { - return Str::contains($key, '/') - ? "{$key}:{$value}" - : "{$value}"; - }) - ->values() - ->all(); - } } diff --git a/src/StarterKits/ModuleInstaller.php b/src/StarterKits/ModuleInstaller.php new file mode 100644 index 0000000000..4df109bd3f --- /dev/null +++ b/src/StarterKits/ModuleInstaller.php @@ -0,0 +1,322 @@ +files = app(Filesystem::class); + } + + /** + * Get module config. + * + * @param string|null $key + * @return \Illuminate\Support\Collection + */ + protected function config($key = null) + { + if ($key) { + return $this->config->get($key); + } + + return $this->config; + } + + /** + * Get starter kit vendor path. + * + * @return string + */ + protected function starterKitPath($path = null) + { + return collect([base_path("vendor/{$this->installer->package}"), $path])->filter()->implode('/'); + } + + /** + * Validate starter kit module. + * + * @throws StarterKitException + */ + public function validate() + { + $this + ->ensureExportPathsExist() + ->ensureCompatibleDependencies(); + } + + /** + * Install starter kit module. + * + * @throws StarterKitException + */ + public function install() + { + $this + ->installFiles() + ->installDependencies(); + } + + /** + * Install starter kit module files. + * + * @return $this + */ + protected function installFiles() + { + $this->installer->console->info('Installing files...'); + + $this->installableFiles()->each(function ($toPath, $fromPath) { + $this->installFile($fromPath, $toPath, $this->installer->console); + }); + + return $this; + } + + /** + * Install starter kit module dependencies. + * + * @return $this + */ + protected function installDependencies() + { + if ($this->installer->withoutDependencies) { + return $this; + } + + if ($packages = $this->installableDependencies('dependencies')) { + $this->requireDependencies($packages); + } + + if ($packages = $this->installableDependencies('dependencies_dev')) { + $this->requireDependencies($packages, true); + } + + return $this; + } + + /** + * Get installable files. + * + * @return \Illuminate\Support\Collection + */ + protected function installableFiles() + { + $installableFromExportPaths = $this + ->exportPaths() + ->flatMap(fn ($path) => $this->expandConfigExportPaths($path)); + + $installableFromExportAsPaths = $this + ->exportAsPaths() + ->flip() + ->flatMap(fn ($to, $from) => $this->expandConfigExportPaths($to, $from)); + + return collect() + ->merge($installableFromExportPaths) + ->merge($installableFromExportAsPaths); + } + + /** + * Get `export_paths` paths from config. + * + * @return \Illuminate\Support\Collection + */ + protected function exportPaths() + { + return collect($this->config('export_paths') ?? []); + } + + /** + * Get `export_as` paths (to be renamed on install) from config. + * + * @return \Illuminate\Support\Collection + */ + protected function exportAsPaths() + { + return collect($this->config('export_as') ?? []); + } + + /** + * Expand config export path to `[$from => $to]` array format, normalizing directories to files. + * + * @param string $to + * @param string $from + * @return \Illuminate\Support\Collection + */ + protected function expandConfigExportPaths($to, $from = null) + { + $to = Path::tidy($this->starterKitPath($to)); + $from = Path::tidy($from ? $this->starterKitPath($from) : $to); + + $paths = collect([$from => $to]); + + if ($this->files->isDirectory($from)) { + $paths = collect($this->files->allFiles($from)) + ->map + ->getPathname() + ->mapWithKeys(fn ($path) => [ + $path => str_replace($from, $to, $path), + ]); + } + + return $paths->mapWithKeys(fn ($to, $from) => [ + Path::tidy($from) => Path::tidy(str_replace("/vendor/{$this->installer->package}", '', $to)), + ]); + } + + /** + * Install dependency permanently into app. + * + * @param array $packages + * @param bool $dev + */ + protected function requireDependencies($packages, $dev = false) + { + if ($dev) { + $this->installer->console->info('Installing development dependencies...'); + } else { + $this->installer->console->info('Installing dependencies...'); + } + + $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); + + if ($dev) { + $args[] = '--dev'; + } + + try { + Composer::withoutQueue()->throwOnFailure()->runAndOperateOnOutput($args, function ($output) { + return $this->outputFromSymfonyProcess($output); + }); + } catch (ProcessException $exception) { + $this->installer->console->error('Error installing dependencies.'); + } + } + + /** + * Clean up symfony process output and output to cli. + * + * @return string + */ + protected function outputFromSymfonyProcess(string $output) + { + // Remove terminal color codes. + $output = preg_replace('/\\e\[[0-9]+m/', '', $output); + + // Remove new lines. + $output = preg_replace('/[\r\n]+$/', '', $output); + + // If not a blank line, output to terminal. + if (! empty(trim($output))) { + $this->installer->console->line($output); + } + + return $output; + } + + /** + * Get installable dependencies from appropriate require key in composer.json. + * + * @param string $configKey + * @return array + */ + protected function installableDependencies($configKey) + { + return collect($this->config($configKey)) + ->filter(fn ($version, $package) => Str::contains($package, '/')) + ->all(); + } + + /** + * Ensure export paths exist. + * + * @return $this + * + * @throws StarterKitException + */ + protected function ensureExportPathsExist() + { + $this + ->exportPaths() + ->reject(fn ($path) => $this->files->exists($this->starterKitPath($path))) + ->each(function ($path) { + throw new StarterKitException("Starter kit path [{$path}] does not exist."); + }); + + return $this; + } + + /** + * Ensure compatible dependencies by performing a dry-run. + * + * @return $this + */ + protected function ensureCompatibleDependencies() + { + if ($this->installer->withoutDependencies || $this->installer->force) { + return $this; + } + + if ($packages = $this->installableDependencies('dependencies')) { + $this->ensureCanRequireDependencies($packages); + } + + if ($packages = $this->installableDependencies('dependencies_dev')) { + $this->ensureCanRequireDependencies($packages, true); + } + + return $this; + } + + /** + * Ensure dependencies are installable by performing a dry-run. + * + * @param array $packages + * @param bool $dev + */ + protected function ensureCanRequireDependencies($packages, $dev = false) + { + $requireMethod = $dev ? 'requireMultipleDev' : 'requireMultiple'; + + try { + Composer::withoutQueue()->throwOnFailure()->{$requireMethod}($packages, '--dry-run'); + } catch (ProcessException $exception) { + $this->installer->rollbackWithError('Cannot install due to dependency conflict.', $exception->getMessage()); + } + } + + /** + * Normalize packages array to require args, with version handling if `package => version` array structure is passed. + * + * @return array + */ + protected function normalizePackagesArrayToRequireArgs(array $packages) + { + return collect($packages) + ->map(function ($value, $key) { + return Str::contains($key, '/') + ? "{$key}:{$value}" + : "{$value}"; + }) + ->values() + ->all(); + } +} From a8440aa7593bc3d43703285a0275f981ab219f2c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 19:05:24 -0400 Subject: [PATCH 07/44] Update method usage. --- src/Console/Commands/StarterKitRunPostInstall.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Commands/StarterKitRunPostInstall.php b/src/Console/Commands/StarterKitRunPostInstall.php index 0db823cb58..42b0c901f2 100644 --- a/src/Console/Commands/StarterKitRunPostInstall.php +++ b/src/Console/Commands/StarterKitRunPostInstall.php @@ -45,7 +45,7 @@ public function handle() $installer = StarterKitInstaller::package($package, $this); try { - $installer->runPostInstallHook(true)->removeStarterKit(); + $installer->runPostInstallHooks(true)->removeStarterKit(); } catch (StarterKitException $exception) { $this->components->error($exception->getMessage()); From d231d5d6184e93a5fe5b96999fca0a2da0cffa79 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 1 Aug 2024 19:23:05 -0400 Subject: [PATCH 08/44] Love a good noun. --- src/StarterKits/Installer.php | 2 +- src/StarterKits/{ModuleInstaller.php => Module.php} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/StarterKits/{ModuleInstaller.php => Module.php} (99%) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 4031cb6aa3..abd5c40594 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -327,7 +327,7 @@ protected function instantiateModules() $this->modules = collect([$topLevelConfigModule]) ->merge($optionalModules) - ->map(fn ($config) => new ModuleInstaller($config, $this)) + ->map(fn ($config) => new Module($config, $this)) ->each(fn ($module) => $module->validate()); return $this; diff --git a/src/StarterKits/ModuleInstaller.php b/src/StarterKits/Module.php similarity index 99% rename from src/StarterKits/ModuleInstaller.php rename to src/StarterKits/Module.php index 4df109bd3f..39cecb4da9 100644 --- a/src/StarterKits/ModuleInstaller.php +++ b/src/StarterKits/Module.php @@ -10,7 +10,7 @@ use Statamic\StarterKits\Exceptions\StarterKitException; use Statamic\Support\Str; -final class ModuleInstaller +final class Module { use Concerns\InteractsWithFilesystem; From 9cfe1b5cff9489917da62aacbef4303cd4af32a6 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 12:16:27 -0400 Subject: [PATCH 09/44] Be modern. --- src/Console/Commands/StarterKitExport.php | 10 +- src/Console/Commands/StarterKitInstall.php | 9 +- .../Concerns/InteractsWithFilesystem.php | 12 +- src/StarterKits/Exporter.php | 76 ++------- src/StarterKits/Hook.php | 2 +- src/StarterKits/Installer.php | 155 +++++------------- src/StarterKits/LicenseManager.php | 54 ++---- src/StarterKits/Module.php | 110 ++++--------- 8 files changed, 124 insertions(+), 304 deletions(-) diff --git a/src/Console/Commands/StarterKitExport.php b/src/Console/Commands/StarterKitExport.php index 3760878701..556f27ae1b 100644 --- a/src/Console/Commands/StarterKitExport.php +++ b/src/Console/Commands/StarterKitExport.php @@ -56,7 +56,7 @@ public function handle() /** * Ask to stub out starter kit config. */ - protected function askToStubStarterKitConfig() + protected function askToStubStarterKitConfig(): void { $stubPath = __DIR__.'/stubs/starter-kits/starter-kit.yaml.stub'; $newPath = base_path($config = 'starter-kit.yaml'); @@ -75,10 +75,8 @@ protected function askToStubStarterKitConfig() /** * Get absolute path. - * - * @return string */ - protected function getAbsolutePath() + protected function getAbsolutePath(): string { $path = $this->argument('path'); @@ -89,10 +87,8 @@ protected function getAbsolutePath() /** * Ask to create export path. - * - * @param string $path */ - protected function askToCreateExportPath($path) + protected function askToCreateExportPath(string $path): void { if ($this->input->isInteractive()) { if (! confirm("Path [{$path}] does not exist. Would you like to create it now?", true)) { diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 6264ef1a55..00f70a467a 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -93,10 +93,8 @@ public function handle() /** * Get composer package (and optional branch). - * - * @return string */ - protected function getPackageAndBranch() + protected function getPackageAndBranch(): array { $package = $this->argument('package') ?: text('Package'); @@ -125,7 +123,10 @@ protected function shouldClear() return false; } - private function oldCliToolInstallationDetected() + /** + * Detect older Statamic CLI installation. + */ + private function oldCliToolInstallationDetected(): bool { return (! $this->input->isInteractive()) // CLI tool never runs interactively. && (! $this->option('cli-install')) // Updated CLI tool passes this option. diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php index a7581ad1cd..0cb180ffb2 100644 --- a/src/StarterKits/Concerns/InteractsWithFilesystem.php +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -2,6 +2,7 @@ namespace Statamic\StarterKits\Concerns; +use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Statamic\Facades\Path; @@ -9,12 +10,8 @@ trait InteractsWithFilesystem { /** * Install starter kit file. - * - * @param mixed $fromPath - * @param mixed $toPath - * @return $this */ - protected function installFile($fromPath, $toPath, $console) + protected function installFile(string $fromPath, string $toPath, Command $console): self { $displayPath = str_replace(Path::tidy(base_path().'/'), '', $toPath); @@ -27,11 +24,8 @@ protected function installFile($fromPath, $toPath, $console) /** * Prepare path directory. - * - * @param string $path - * @return string */ - protected function preparePath($path) + protected function preparePath(string $path): string { $files = app(Filesystem::class); diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 63995587eb..6f90c2d1d8 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -3,6 +3,7 @@ namespace Statamic\StarterKits; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use Statamic\Facades\YAML; use Statamic\StarterKits\Exceptions\StarterKitException; use Statamic\Support\Str; @@ -24,11 +25,9 @@ public function __construct() /** * Export starter kit. * - * @param string $absolutePath - * * @throws StarterKitException */ - public function export($absolutePath) + public function export(string $absolutePath): void { $this->exportPath = $absolutePath; @@ -49,10 +48,8 @@ public function export($absolutePath) /** * Export files and folders. - * - * @return $this */ - protected function exportFiles() + protected function exportFiles(): self { $this ->exportPaths() @@ -78,11 +75,9 @@ protected function exportFiles() /** * Ensure export path exists. * - * @param string $path - * * @throws StarterKitException */ - protected function ensureExportPathExists($path) + protected function ensureExportPathExists(string $path) { if (! $this->files->exists(base_path($path))) { throw new StarterKitException("Export path [{$path}] does not exist."); @@ -91,11 +86,8 @@ protected function ensureExportPathExists($path) /** * Copy path to new export path location. - * - * @param string $fromPath - * @param string $toPath */ - protected function copyPath($fromPath, $toPath = null) + protected function copyPath(string $fromPath, ?string $toPath = null): void { $toPath = $toPath ? "{$this->exportPath}/{$toPath}" @@ -112,11 +104,8 @@ protected function copyPath($fromPath, $toPath = null) /** * Prepare path directory. - * - * @param string $fromPath - * @param string $toPath */ - protected function preparePath($fromPath, $toPath) + protected function preparePath(string $fromPath, string $toPath): void { $directory = $this->files->isDirectory($fromPath) ? $toPath @@ -129,10 +118,8 @@ protected function preparePath($fromPath, $toPath) /** * Get starter kit config. - * - * @return \Illuminate\Support\Collection */ - protected function config() + protected function config(): Collection { return collect(YAML::parse($this->files->get(base_path('starter-kit.yaml')))); } @@ -140,11 +127,9 @@ protected function config() /** * Get starter kit `export_paths` paths from config. * - * @return \Illuminate\Support\Collection - * * @throws StarterKitException */ - protected function exportPaths() + protected function exportPaths(): Collection { $paths = collect($this->config()->get('export_paths')); @@ -160,11 +145,9 @@ protected function exportPaths() /** * Get starter kit 'export_as' paths (to be renamed on export) from config. * - * @return \Illuminate\Support\Collection - * * @throws StarterKitException */ - protected function exportAsPaths() + protected function exportAsPaths(): Collection { $paths = collect($this->config()->get('export_as')); @@ -177,10 +160,8 @@ protected function exportAsPaths() /** * Export starter kit config. - * - * @return $this */ - protected function exportConfig() + protected function exportConfig(): self { $config = $this->config(); @@ -193,10 +174,8 @@ protected function exportConfig() /** * Export starter kit hooks. - * - * @return $this */ - protected function exportHooks() + protected function exportHooks(): self { $hooks = ['StarterKitPostInstall.php']; @@ -209,11 +188,8 @@ protected function exportHooks() /** * Export dependencies from composer.json. - * - * @param \Illuminate\Support\Collection $config - * @return \Illuminate\Support\Collection */ - protected function exportDependenciesFromComposerJson($config) + protected function exportDependenciesFromComposerJson(Collection $config): Collection { $exportableDependencies = $this->getExportableDependenciesFromConfig($config); @@ -234,11 +210,8 @@ protected function exportDependenciesFromComposerJson($config) /** * Get exportable dependencies without versions from config. - * - * @param \Illuminate\Support\Collection $config - * @return \Illuminate\Support\Collection */ - protected function getExportableDependenciesFromConfig($config) + protected function getExportableDependenciesFromConfig(Collection $config): Collection { if ($this->hasDependenciesWithoutVersions($config)) { return collect($config->get('dependencies') ?? []); @@ -252,11 +225,8 @@ protected function getExportableDependenciesFromConfig($config) /** * Check if config has dependencies without versions. - * - * @param \Illuminate\Support\Collection $config - * @return bool */ - protected function hasDependenciesWithoutVersions($config) + protected function hasDependenciesWithoutVersions(Collection $config): bool { if (! $config->has('dependencies')) { return false; @@ -267,12 +237,8 @@ protected function hasDependenciesWithoutVersions($config) /** * Export dependencies from composer.json using specific require key. - * - * @param string $requireKey - * @param \Illuminate\Support\Collection $exportableDependencies - * @return \Illuminate\Support\Collection */ - protected function exportDependenciesFromComposerRequire($requireKey, $exportableDependencies) + protected function exportDependenciesFromComposerRequire(string $requireKey, Collection $exportableDependencies): mixed { $composerJson = json_decode($this->files->get(base_path('composer.json')), true); @@ -288,10 +254,8 @@ protected function exportDependenciesFromComposerRequire($requireKey, $exportabl /** * Export composer.json. - * - * @return $this */ - protected function exportComposerJson() + protected function exportComposerJson(): self { $composerJson = $this->prepareComposerJsonFromStub()->all(); @@ -305,10 +269,8 @@ protected function exportComposerJson() /** * Prepare composer.json from stub. - * - * @return \Illuminate\Support\Collection */ - protected function prepareComposerJsonFromStub() + protected function prepareComposerJsonFromStub(): Collection { $stub = $this->getComposerJsonStub(); @@ -326,10 +288,8 @@ protected function prepareComposerJsonFromStub() /** * Get composer.json stub. - * - * @return string */ - protected function getComposerJsonStub() + protected function getComposerJsonStub(): string { $stubPath = __DIR__.'/../Console/Commands/stubs/starter-kits/composer.json.stub'; diff --git a/src/StarterKits/Hook.php b/src/StarterKits/Hook.php index f96b57797e..ddaea58ce1 100644 --- a/src/StarterKits/Hook.php +++ b/src/StarterKits/Hook.php @@ -4,7 +4,7 @@ class Hook { - public function find($path) + public function find(string $path): mixed { if (app('files')->exists($path)) { require_once $path; diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index abd5c40594..bf356b2df2 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -42,10 +42,8 @@ final class Installer /** * Instantiate starter kit installer. - * - * @param mixed $console */ - public function __construct(string $package, $console = null, ?LicenseManager $licenseManager = null) + public function __construct(string $package, ?Command $console = null, ?LicenseManager $licenseManager = null) { $this->package = $package; @@ -58,22 +56,16 @@ public function __construct(string $package, $console = null, ?LicenseManager $l /** * Instantiate starter kit installer. - * - * @param mixed $console - * @return static */ - public static function package(string $package, ?Command $console = null, ?LicenseManager $licenseManager = null) + public static function package(string $package, ?Command $console = null, ?LicenseManager $licenseManager = null): self { return new self($package, $console, $licenseManager); } /** * Install from specific branch. - * - * @param string|null $branch - * @return $this */ - public function branch($branch = null) + public function branch(?string $branch = null): self { $this->branch = $branch; @@ -82,11 +74,8 @@ public function branch($branch = null) /** * Install from local repo configured in composer config.json. - * - * @param bool $fromLocalRepo - * @return $this */ - public function fromLocalRepo($fromLocalRepo = false) + public function fromLocalRepo(bool $fromLocalRepo = false): self { $this->fromLocalRepo = $fromLocalRepo; @@ -95,11 +84,8 @@ public function fromLocalRepo($fromLocalRepo = false) /** * Install with starter-kit config for local development purposes. - * - * @param bool $withConfig - * @return $this */ - public function withConfig($withConfig = false) + public function withConfig(bool $withConfig = false): self { $this->withConfig = $withConfig; @@ -108,11 +94,8 @@ public function withConfig($withConfig = false) /** * Install without dependencies. - * - * @param bool $withoutDependencies - * @return $this */ - public function withoutDependencies($withoutDependencies = false) + public function withoutDependencies(bool $withoutDependencies = false): self { $this->withoutDependencies = $withoutDependencies; @@ -120,12 +103,9 @@ public function withoutDependencies($withoutDependencies = false) } /** - * Set interactive mode. - * - * @param bool $isInteractive - * @return $this + * Set interactive mode on Laravel Prompts. */ - public function isInteractive($isInteractive = false) + public function isInteractive(bool $isInteractive = false): self { Prompt::interactive($isInteractive); @@ -134,11 +114,8 @@ public function isInteractive($isInteractive = false) /** * Install with super user. - * - * @param bool $withUser - * @return $this */ - public function withUser($withUser = false) + public function withUser(bool $withUser = false): self { $this->withUser = $withUser; @@ -147,11 +124,8 @@ public function withUser($withUser = false) /** * Install using sub-process. - * - * @param bool $usingSubProcess - * @return $this */ - public function usingSubProcess($usingSubProcess = false) + public function usingSubProcess(bool $usingSubProcess = false): self { $this->usingSubProcess = $usingSubProcess; @@ -160,11 +134,8 @@ public function usingSubProcess($usingSubProcess = false) /** * Force install and allow dependency errors. - * - * @param bool $force - * @return $this */ - public function force($force = false) + public function force(bool $force = false): self { $this->force = $force; @@ -176,7 +147,7 @@ public function force($force = false) * * @throws StarterKitException */ - public function install() + public function install(): void { $this ->validateLicense() @@ -201,9 +172,9 @@ public function install() /** * Check with license manager to determine whether or not to continue with installation. * - * @return $this + * @throws StarterKitException */ - protected function validateLicense() + protected function validateLicense(): self { if (! $this->licenseManager->isValid()) { throw new StarterKitException; @@ -214,10 +185,8 @@ protected function validateLicense() /** * Backup composer.json file. - * - * @return $this */ - protected function backupComposerJson() + protected function backupComposerJson(): self { $this->files->copy(base_path('composer.json'), base_path('composer.json.bak')); @@ -226,10 +195,8 @@ protected function backupComposerJson() /** * Detect repository url. - * - * @return $this */ - protected function detectRepositoryUrl() + protected function detectRepositoryUrl(): self { if ($this->fromLocalRepo) { return $this; @@ -252,10 +219,8 @@ protected function detectRepositoryUrl() /** * Prepare repository. - * - * @return $this */ - protected function prepareRepository() + protected function prepareRepository(): self { if ($this->fromLocalRepo || ! $this->url) { return $this; @@ -280,10 +245,8 @@ protected function prepareRepository() /** * Require starter kit dependency. - * - * @return $this */ - protected function requireStarterKit() + protected function requireStarterKit(): self { spin( function () { @@ -306,11 +269,9 @@ function () { /** * Ensure starter kit has config. * - * @return $this - * * @throws StarterKitException */ - protected function ensureConfig() + protected function ensureConfig(): self { if (! $this->files->exists($this->starterKitPath('starter-kit.yaml'))) { throw new StarterKitException('Starter kit config [starter-kit.yaml] does not exist.'); @@ -319,7 +280,10 @@ protected function ensureConfig() return $this; } - protected function instantiateModules() + /** + * Instantiate and validate modules. + */ + protected function instantiateModules(): self { $topLevelConfigModule = $this->config()->except('modules'); @@ -333,7 +297,10 @@ protected function instantiateModules() return $this; } - protected function installModules() + /** + * Install all the modules. + */ + protected function installModules(): self { $this->modules->each(fn ($module) => $module->install()); @@ -342,10 +309,8 @@ protected function installModules() /** * Copy starter kit config without versions, to encourage dependency management using composer. - * - * @return $this */ - protected function copyStarterKitConfig() + protected function copyStarterKitConfig(): self { if (! $this->withConfig) { return $this; @@ -378,10 +343,8 @@ protected function copyStarterKitConfig() /** * Copy starter kit hook scripts. - * - * @return $this */ - protected function copyStarterKitHooks() + protected function copyStarterKitHooks(): self { if (! $this->withConfig) { return $this; @@ -398,10 +361,8 @@ protected function copyStarterKitHooks() /** * Make super user. - * - * @return $this */ - public function makeSuperUser() + public function makeSuperUser(): self { if (! $this->withUser) { return $this; @@ -417,11 +378,9 @@ public function makeSuperUser() /** * Run post-install hook, if one exists in the starter kit. * - * @return $this - * * @throws StarterKitException */ - public function runPostInstallHooks($throwExceptions = false) + public function runPostInstallHooks(bool $throwExceptions = false): self { $postInstallHook = Hook::find($this->starterKitPath('StarterKitPostInstall.php')); @@ -448,10 +407,8 @@ public function runPostInstallHooks($throwExceptions = false) /** * Cache post install instructions for parent process (ie. statamic/cli installer). - * - * @return $this */ - protected function cachePostInstallInstructions() + protected function cachePostInstallInstructions(): self { $path = $this->preparePath(storage_path('statamic/tmp/cli/post-install-instructions.txt')); @@ -470,10 +427,8 @@ protected function cachePostInstallInstructions() /** * Register starter kit installed command for post install hook. - * - * @param string $commandClass */ - protected function registerInstalledCommand($commandClass) + protected function registerInstalledCommand(string $commandClass): void { $app = $this->console->getApplication(); @@ -489,11 +444,9 @@ protected function registerInstalledCommand($commandClass) } /** - * Reticulate splines. - * - * @return $this + * Reticulate splines, to prevent multiple Bézier curves from conjoining at the Maxis point of the starter kit install. */ - protected function reticulateSplines() + protected function reticulateSplines(): self { spin( function () { @@ -509,10 +462,8 @@ function () { /** * Remove starter kit dependency. - * - * @return $this */ - public function removeStarterKit() + public function removeStarterKit(): self { if ($this->disableCleanup) { return $this; @@ -532,10 +483,8 @@ function () { /** * Remove composer.json backup. - * - * @return $this */ - protected function removeComposerJsonBackup() + protected function removeComposerJsonBackup(): self { $this->files->delete(base_path('composer.json.bak')); @@ -544,10 +493,8 @@ protected function removeComposerJsonBackup() /** * Complete starter kit install, expiring license key and/or incrementing install count. - * - * @return $this */ - protected function completeInstall() + protected function completeInstall(): self { $this->licenseManager->completeInstall(); @@ -556,10 +503,8 @@ protected function completeInstall() /** * Remove repository. - * - * @return $this */ - protected function removeRepository() + protected function removeRepository(): self { if ($this->fromLocalRepo || ! $this->url) { return $this; @@ -587,10 +532,8 @@ protected function removeRepository() /** * Restore composer.json file. - * - * @return $this */ - protected function restoreComposerJson() + protected function restoreComposerJson(): self { $this->files->copy(base_path('composer.json.bak'), base_path('composer.json')); @@ -600,12 +543,9 @@ protected function restoreComposerJson() /** * Rollback with error. * - * @param string $error - * @param string|null $output - * * @throws StarterKitException */ - public function rollbackWithError($error, $output = null) + public function rollbackWithError(string $error, ?string $output = null): void { $this ->removeStarterKit() @@ -621,11 +561,8 @@ public function rollbackWithError($error, $output = null) /** * Remove the `require [--dev] [--dry-run] [--prefer-source]...` stuff from the end of composer error output. - * - * @param string $output - * @return string */ - protected function tidyComposerErrorOutput($output) + protected function tidyComposerErrorOutput(string $output): string { if (Str::contains($output, 'github.com') && Str::contains($output, ['access', 'permission', 'credential', 'authenticate'])) { return collect([ @@ -641,20 +578,16 @@ protected function tidyComposerErrorOutput($output) /** * Get starter kit vendor path. - * - * @return string */ - protected function starterKitPath($path = null) + protected function starterKitPath(?string $path = null): string { return collect([base_path("vendor/{$this->package}"), $path])->filter()->implode('/'); } /** * Get starter kit config. - * - * @return mixed */ - protected function config($key = null) + protected function config(?string $key = null): mixed { $config = collect(YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml')))); diff --git a/src/StarterKits/LicenseManager.php b/src/StarterKits/LicenseManager.php index cf360fe9c9..f5d090af1b 100644 --- a/src/StarterKits/LicenseManager.php +++ b/src/StarterKits/LicenseManager.php @@ -2,6 +2,7 @@ namespace Statamic\StarterKits; +use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Statamic\Console\NullConsole; @@ -17,11 +18,8 @@ final class LicenseManager /** * Instantiate starter kit license manager. - * - * @param string|null $licenseKey - * @param mixed $console */ - public function __construct(string $package, $licenseKey = null, $console = null) + public function __construct(string $package, ?string $licenseKey = null, ?Command $console = null) { $this->package = $package; $this->licenseKey = $licenseKey ?? config('statamic.system.license_key'); @@ -30,22 +28,16 @@ public function __construct(string $package, $licenseKey = null, $console = null /** * Instantiate starter kit license manager. - * - * @param string|null $licenceKey - * @param mixed $console - * @return static */ - public static function validate(string $package, $licenceKey = null, $console = null) + public static function validate(string $package, ?string $licenceKey = null, ?Command $console = null): self { return (new self($package, $licenceKey, $console))->performValidation(); } /** * Check if user is able to install starter kit, whether free or paid. - * - * @return bool */ - public function isValid() + public function isValid(): bool { return $this->valid; } @@ -53,7 +45,7 @@ public function isValid() /** * Expire license key and increment install count. */ - public function completeInstall() + public function completeInstall(): void { Http::post(self::OUTPOST_ENDPOINT.'installed', [ 'license' => $this->licenseKey, @@ -64,10 +56,8 @@ public function completeInstall() /** * Perform validation. - * - * @return $this */ - private function performValidation() + private function performValidation(): self { if (! $this->outpostGetStarterKitDetails()) { return $this->error('Cannot connect to [statamic.com] to validate license!'); @@ -102,10 +92,8 @@ private function performValidation() /** * Get starter kit details from outpost. - * - * @return $this */ - private function outpostGetStarterKitDetails() + private function outpostGetStarterKitDetails(): self { $response = Http::get(self::OUTPOST_ENDPOINT.$this->package); @@ -120,10 +108,8 @@ private function outpostGetStarterKitDetails() /** * Check if starter kit is a free starter kit. - * - * @return bool */ - private function isFreeStarterKit() + private function isFreeStarterKit(): bool { if ($this->details === false) { return true; @@ -134,10 +120,8 @@ private function isFreeStarterKit() /** * Check if outpost validates kit license. - * - * @return bool */ - private function outpostValidatesLicense() + private function outpostValidatesLicense(): bool { if (! $this->licenseKey) { return false; @@ -158,10 +142,8 @@ private function outpostValidatesLicense() /** * Clear license key. - * - * @return $this */ - private function clearLicenseKey() + private function clearLicenseKey(): self { $this->licenseKey = null; @@ -170,10 +152,8 @@ private function clearLicenseKey() /** * Set validated status to true. - * - * @return $this */ - private function setValid() + private function setValid(): self { $this->valid = true; @@ -182,10 +162,8 @@ private function setValid() /** * Output info message. - * - * @return $this */ - private function info(string $message) + private function info(string $message): self { $this->console->info($message); @@ -194,10 +172,8 @@ private function info(string $message) /** * Output error message. - * - * @return $this */ - private function error(string $message) + private function error(string $message): self { $this->console->error($message); @@ -206,10 +182,8 @@ private function error(string $message) /** * Output comment line. - * - * @return $this */ - private function comment(string $message) + private function comment(string $message): self { $this->console->comment($message); diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index 39cecb4da9..34090cb65d 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -17,46 +17,19 @@ final class Module protected $files; /** - * Instantiate starter kit module installer. - * - * @return \Illuminate\Support\Collection + * Instantiate starter kit module. */ public function __construct(protected Collection $config, protected Installer $installer) { $this->files = app(Filesystem::class); } - /** - * Get module config. - * - * @param string|null $key - * @return \Illuminate\Support\Collection - */ - protected function config($key = null) - { - if ($key) { - return $this->config->get($key); - } - - return $this->config; - } - - /** - * Get starter kit vendor path. - * - * @return string - */ - protected function starterKitPath($path = null) - { - return collect([base_path("vendor/{$this->installer->package}"), $path])->filter()->implode('/'); - } - /** * Validate starter kit module. * * @throws StarterKitException */ - public function validate() + public function validate(): void { $this ->ensureExportPathsExist() @@ -68,7 +41,7 @@ public function validate() * * @throws StarterKitException */ - public function install() + public function install(): void { $this ->installFiles() @@ -77,10 +50,8 @@ public function install() /** * Install starter kit module files. - * - * @return $this */ - protected function installFiles() + protected function installFiles(): self { $this->installer->console->info('Installing files...'); @@ -93,10 +64,8 @@ protected function installFiles() /** * Install starter kit module dependencies. - * - * @return $this */ - protected function installDependencies() + protected function installDependencies(): self { if ($this->installer->withoutDependencies) { return $this; @@ -115,10 +84,8 @@ protected function installDependencies() /** * Get installable files. - * - * @return \Illuminate\Support\Collection */ - protected function installableFiles() + protected function installableFiles(): Collection { $installableFromExportPaths = $this ->exportPaths() @@ -136,32 +103,24 @@ protected function installableFiles() /** * Get `export_paths` paths from config. - * - * @return \Illuminate\Support\Collection */ - protected function exportPaths() + protected function exportPaths(): Collection { return collect($this->config('export_paths') ?? []); } /** * Get `export_as` paths (to be renamed on install) from config. - * - * @return \Illuminate\Support\Collection */ - protected function exportAsPaths() + protected function exportAsPaths(): Collection { return collect($this->config('export_as') ?? []); } /** * Expand config export path to `[$from => $to]` array format, normalizing directories to files. - * - * @param string $to - * @param string $from - * @return \Illuminate\Support\Collection */ - protected function expandConfigExportPaths($to, $from = null) + protected function expandConfigExportPaths(string $to, ?string $from = null): Collection { $to = Path::tidy($this->starterKitPath($to)); $from = Path::tidy($from ? $this->starterKitPath($from) : $to); @@ -184,11 +143,8 @@ protected function expandConfigExportPaths($to, $from = null) /** * Install dependency permanently into app. - * - * @param array $packages - * @param bool $dev */ - protected function requireDependencies($packages, $dev = false) + protected function requireDependencies(array $packages, bool $dev = false): void { if ($dev) { $this->installer->console->info('Installing development dependencies...'); @@ -213,10 +169,8 @@ protected function requireDependencies($packages, $dev = false) /** * Clean up symfony process output and output to cli. - * - * @return string */ - protected function outputFromSymfonyProcess(string $output) + protected function outputFromSymfonyProcess(string $output): string { // Remove terminal color codes. $output = preg_replace('/\\e\[[0-9]+m/', '', $output); @@ -234,11 +188,8 @@ protected function outputFromSymfonyProcess(string $output) /** * Get installable dependencies from appropriate require key in composer.json. - * - * @param string $configKey - * @return array */ - protected function installableDependencies($configKey) + protected function installableDependencies(string $configKey): array { return collect($this->config($configKey)) ->filter(fn ($version, $package) => Str::contains($package, '/')) @@ -248,11 +199,9 @@ protected function installableDependencies($configKey) /** * Ensure export paths exist. * - * @return $this - * * @throws StarterKitException */ - protected function ensureExportPathsExist() + protected function ensureExportPathsExist(): self { $this ->exportPaths() @@ -266,10 +215,8 @@ protected function ensureExportPathsExist() /** * Ensure compatible dependencies by performing a dry-run. - * - * @return $this */ - protected function ensureCompatibleDependencies() + protected function ensureCompatibleDependencies(): self { if ($this->installer->withoutDependencies || $this->installer->force) { return $this; @@ -288,11 +235,8 @@ protected function ensureCompatibleDependencies() /** * Ensure dependencies are installable by performing a dry-run. - * - * @param array $packages - * @param bool $dev */ - protected function ensureCanRequireDependencies($packages, $dev = false) + protected function ensureCanRequireDependencies(array $packages, bool $dev = false): void { $requireMethod = $dev ? 'requireMultipleDev' : 'requireMultiple'; @@ -305,10 +249,8 @@ protected function ensureCanRequireDependencies($packages, $dev = false) /** * Normalize packages array to require args, with version handling if `package => version` array structure is passed. - * - * @return array */ - protected function normalizePackagesArrayToRequireArgs(array $packages) + protected function normalizePackagesArrayToRequireArgs(array $packages): array { return collect($packages) ->map(function ($value, $key) { @@ -319,4 +261,24 @@ protected function normalizePackagesArrayToRequireArgs(array $packages) ->values() ->all(); } + + /** + * Get starter kit vendor path. + */ + protected function starterKitPath(?string $path = null): string + { + return collect([base_path("vendor/{$this->installer->package}"), $path])->filter()->implode('/'); + } + + /** + * Get module config. + */ + protected function config(?string $key = null): mixed + { + if ($key) { + return $this->config->get($key); + } + + return $this->config; + } } From 2b02d935e841ef0aee6999d7baeab6ecad7f431b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 13:05:08 -0400 Subject: [PATCH 10/44] Clarify what this method is doing. --- src/Console/Commands/StarterKitInstall.php | 2 +- src/StarterKits/Installer.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 00f70a467a..637085cd8c 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -66,7 +66,7 @@ public function handle() ->withConfig($this->option('with-config')) ->withoutDependencies($this->option('without-dependencies')) ->isInteractive($isInteractive = $this->input->isInteractive()) - ->withUser($cleared && $isInteractive && ! $this->option('cli-install')) + ->withUserPrompt($cleared && $isInteractive && ! $this->option('cli-install')) ->usingSubProcess($this->option('cli-install')) ->force($this->option('force')); diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index bf356b2df2..995e962542 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -35,7 +35,7 @@ final class Installer protected $licenseManager; protected $files; protected $fromLocalRepo; - protected $withUser; + protected $withUserPrompt; protected $url; protected $modules; protected $disableCleanup; @@ -113,11 +113,11 @@ public function isInteractive(bool $isInteractive = false): self } /** - * Install with super user. + * Install with super user prompt. */ - public function withUser(bool $withUser = false): self + public function withUserPrompt(bool $withUserPrompt = false): self { - $this->withUser = $withUser; + $this->withUserPrompt = $withUserPrompt; return $this; } @@ -364,7 +364,7 @@ protected function copyStarterKitHooks(): self */ public function makeSuperUser(): self { - if (! $this->withUser) { + if (! $this->withUserPrompt) { return $this; } From 71585b7a89786d52ce729dfa51840effff7f698e Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 13:06:17 -0400 Subject: [PATCH 11/44] Solve this problem where it happens, not deeper in child class. --- src/Console/Commands/StarterKitInstall.php | 24 +++++++++++++++------- src/StarterKits/Installer.php | 11 ---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 637085cd8c..f9a8783ad6 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -3,6 +3,7 @@ namespace Statamic\Console\Commands; use Illuminate\Console\Command; +use Laravel\Prompts\Prompt; use Statamic\Console\RunsInPlease; use Statamic\Console\ValidatesInput; use Statamic\Rules\ComposerPackage; @@ -56,8 +57,8 @@ public function handle() return; } - if ($cleared = $this->shouldClear()) { - $this->call('statamic:site:clear', ['--no-interaction' => true]); + if ($cleared = $this->shouldClearSite()) { + $this->clearSite(); } $installer = StarterKitInstaller::package($package, $this, $licenseManager) @@ -65,8 +66,7 @@ public function handle() ->fromLocalRepo($this->option('local')) ->withConfig($this->option('with-config')) ->withoutDependencies($this->option('without-dependencies')) - ->isInteractive($isInteractive = $this->input->isInteractive()) - ->withUserPrompt($cleared && $isInteractive && ! $this->option('cli-install')) + ->withUserPrompt($cleared && $this->input->isInteractive() && ! $this->option('cli-install')) ->usingSubProcess($this->option('cli-install')) ->force($this->option('force')); @@ -109,10 +109,8 @@ protected function getPackageAndBranch(): array /** * Check if should clear site first. - * - * @return bool */ - protected function shouldClear() + protected function shouldClearSite(): bool { if ($this->option('clear-site')) { return true; @@ -123,6 +121,18 @@ protected function shouldClear() return false; } + /** + * Clear site, and re-set prompt interactivity for future prompts. + * + * See: https://github.com/statamic/cli/issues/62 + */ + protected function clearSite(): void + { + $this->call('statamic:site:clear', ['--no-interaction' => true]); + + Prompt::interactive($this->input->isInteractive()); + } + /** * Detect older Statamic CLI installation. */ diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 995e962542..3c30e68fb0 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -8,7 +8,6 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Http; -use Laravel\Prompts\Prompt; use Statamic\Console\NullConsole; use Statamic\Console\Please\Application as PleaseApplication; use Statamic\Console\Processes\Exceptions\ProcessException; @@ -102,16 +101,6 @@ public function withoutDependencies(bool $withoutDependencies = false): self return $this; } - /** - * Set interactive mode on Laravel Prompts. - */ - public function isInteractive(bool $isInteractive = false): self - { - Prompt::interactive($isInteractive); - - return $this; - } - /** * Install with super user prompt. */ From 316b11d2c4ca896037bcd9e168be68e5fe7c5ee3 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 14:10:34 -0400 Subject: [PATCH 12/44] Restore properties and use fluent getters/setters. --- src/Console/Commands/StarterKitInstall.php | 2 +- .../Commands/StarterKitRunPostInstall.php | 2 +- src/StarterKits/Installer.php | 83 ++++++++++--------- src/StarterKits/Module.php | 24 +++--- 4 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index f9a8783ad6..355dd9a6af 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -61,7 +61,7 @@ public function handle() $this->clearSite(); } - $installer = StarterKitInstaller::package($package, $this, $licenseManager) + $installer = (new StarterKitInstaller($package, $this, $licenseManager)) ->branch($branch) ->fromLocalRepo($this->option('local')) ->withConfig($this->option('with-config')) diff --git a/src/Console/Commands/StarterKitRunPostInstall.php b/src/Console/Commands/StarterKitRunPostInstall.php index 42b0c901f2..d71f344013 100644 --- a/src/Console/Commands/StarterKitRunPostInstall.php +++ b/src/Console/Commands/StarterKitRunPostInstall.php @@ -42,7 +42,7 @@ public function handle() return 1; } - $installer = StarterKitInstaller::package($package, $this); + $installer = new StarterKitInstaller($package, $this); try { $installer->runPostInstallHooks(true)->removeStarterKit(); diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 3c30e68fb0..775b0c7c9a 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -13,28 +13,29 @@ use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Facades\Blink; use Statamic\Facades\YAML; +use Statamic\StarterKits\Concerns\InteractsWithFilesystem; use Statamic\StarterKits\Exceptions\StarterKitException; use Statamic\Support\Str; +use Statamic\Support\Traits\FluentlyGetsAndSets; use function Laravel\Prompts\confirm; use function Laravel\Prompts\spin; final class Installer { - use Concerns\InteractsWithFilesystem; - - public $package; - public $withConfig; - public $withoutDependencies; - public $force; - public $console; - public $usingSubProcess; + use FluentlyGetsAndSets, InteractsWithFilesystem; + protected $package; protected $branch; protected $licenseManager; protected $files; protected $fromLocalRepo; + protected $withConfig; + protected $withoutDependencies; protected $withUserPrompt; + protected $usingSubProcess; + protected $force; + protected $console; protected $url; protected $modules; protected $disableCleanup; @@ -54,81 +55,85 @@ public function __construct(string $package, ?Command $console = null, ?LicenseM } /** - * Instantiate starter kit installer. + * Get or set whether to install from specific branch. */ - public static function package(string $package, ?Command $console = null, ?LicenseManager $licenseManager = null): self + public function branch(?string $branch = null): self|bool { - return new self($package, $console, $licenseManager); + return $this->fluentlyGetOrSet('branch')->args(func_get_args()); + + return $this; } /** - * Install from specific branch. + * Get or set whether to install from local repo configured in composer config.json. */ - public function branch(?string $branch = null): self + public function fromLocalRepo(bool $fromLocalRepo = false): self|bool { - $this->branch = $branch; + return $this->fluentlyGetOrSet('fromLocalRepo')->args(func_get_args()); return $this; } /** - * Install from local repo configured in composer config.json. + * Get or set whether to install with starter-kit config for local development purposes. */ - public function fromLocalRepo(bool $fromLocalRepo = false): self + public function withConfig(bool $withConfig = false): self|bool { - $this->fromLocalRepo = $fromLocalRepo; + return $this->fluentlyGetOrSet('withConfig')->args(func_get_args()); return $this; } /** - * Install with starter-kit config for local development purposes. + * Get or set whether to install without dependencies. */ - public function withConfig(bool $withConfig = false): self + public function withoutDependencies(?bool $withoutDependencies = false): self|bool { - $this->withConfig = $withConfig; - - return $this; + return $this->fluentlyGetOrSet('withoutDependencies')->args(func_get_args()); } /** - * Install without dependencies. + * Get or set whether to install with super user prompt. */ - public function withoutDependencies(bool $withoutDependencies = false): self + public function withUserPrompt(bool $withUserPrompt = false): self|bool { - $this->withoutDependencies = $withoutDependencies; + return $this->fluentlyGetOrSet('withUserPrompt')->args(func_get_args()); return $this; } /** - * Install with super user prompt. + * Get or set whether to install using sub-process. */ - public function withUserPrompt(bool $withUserPrompt = false): self + public function usingSubProcess(bool $usingSubProcess = false): self|bool { - $this->withUserPrompt = $withUserPrompt; + return $this->fluentlyGetOrSet('usingSubProcess')->args(func_get_args()); return $this; } /** - * Install using sub-process. + * Get or set whether to force install and allow dependency errors. */ - public function usingSubProcess(bool $usingSubProcess = false): self + public function force(bool $force = false): self|bool { - $this->usingSubProcess = $usingSubProcess; - - return $this; + return $this->fluentlyGetOrSet('force')->args(func_get_args()); } /** - * Force install and allow dependency errors. + * Get starter kit package. */ - public function force(bool $force = false): self + public function package(): string { - $this->force = $force; + return $this->package; + } - return $this; + /** + * Get console command instance. + */ + public function console(): Command|NullConsole + { + return $this->console; } /** @@ -306,7 +311,7 @@ protected function copyStarterKitConfig(): self } if ($this->withoutDependencies) { - return $this->installFile($this->starterKitPath('starter-kit.yaml'), base_path('starter-kit.yaml'), $this->console); + return $this->installFile($this->starterKitPath('starter-kit.yaml'), base_path('starter-kit.yaml'), $this->console()); } $this->console->line('Installing file [starter-kit.yaml]'); @@ -343,7 +348,7 @@ protected function copyStarterKitHooks(): self collect($hooks) ->filter(fn ($hook) => $this->files->exists($this->starterKitPath($hook))) - ->each(fn ($hook) => $this->installFile($this->starterKitPath($hook), base_path($hook), $this->console)); + ->each(fn ($hook) => $this->installFile($this->starterKitPath($hook), base_path($hook), $this->console())); return $this; } diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index 34090cb65d..7816e0626a 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -53,10 +53,10 @@ public function install(): void */ protected function installFiles(): self { - $this->installer->console->info('Installing files...'); + $this->installer->console()->info('Installing files...'); $this->installableFiles()->each(function ($toPath, $fromPath) { - $this->installFile($fromPath, $toPath, $this->installer->console); + $this->installFile($fromPath, $toPath, $this->installer->console()); }); return $this; @@ -67,7 +67,7 @@ protected function installFiles(): self */ protected function installDependencies(): self { - if ($this->installer->withoutDependencies) { + if ($this->installer->withoutDependencies()) { return $this; } @@ -136,8 +136,10 @@ protected function expandConfigExportPaths(string $to, ?string $from = null): Co ]); } + $package = $this->installer->package(); + return $paths->mapWithKeys(fn ($to, $from) => [ - Path::tidy($from) => Path::tidy(str_replace("/vendor/{$this->installer->package}", '', $to)), + Path::tidy($from) => Path::tidy(str_replace("/vendor/{$package}", '', $to)), ]); } @@ -147,9 +149,9 @@ protected function expandConfigExportPaths(string $to, ?string $from = null): Co protected function requireDependencies(array $packages, bool $dev = false): void { if ($dev) { - $this->installer->console->info('Installing development dependencies...'); + $this->installer->console()->info('Installing development dependencies...'); } else { - $this->installer->console->info('Installing dependencies...'); + $this->installer->console()->info('Installing dependencies...'); } $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); @@ -163,7 +165,7 @@ protected function requireDependencies(array $packages, bool $dev = false): void return $this->outputFromSymfonyProcess($output); }); } catch (ProcessException $exception) { - $this->installer->console->error('Error installing dependencies.'); + $this->installer->console()->error('Error installing dependencies.'); } } @@ -180,7 +182,7 @@ protected function outputFromSymfonyProcess(string $output): string // If not a blank line, output to terminal. if (! empty(trim($output))) { - $this->installer->console->line($output); + $this->installer->console()->line($output); } return $output; @@ -218,7 +220,7 @@ protected function ensureExportPathsExist(): self */ protected function ensureCompatibleDependencies(): self { - if ($this->installer->withoutDependencies || $this->installer->force) { + if ($this->installer->withoutDependencies() || $this->installer->force()) { return $this; } @@ -267,7 +269,9 @@ protected function normalizePackagesArrayToRequireArgs(array $packages): array */ protected function starterKitPath(?string $path = null): string { - return collect([base_path("vendor/{$this->installer->package}"), $path])->filter()->implode('/'); + $package = $this->installer->package(); + + return collect([base_path("vendor/{$package}"), $path])->filter()->implode('/'); } /** From cb8d068b2640c06a09e3e5a98812b65e82ed9275 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 16:46:44 -0400 Subject: [PATCH 13/44] Clean up console output. --- src/StarterKits/Installer.php | 2 ++ src/StarterKits/Module.php | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 775b0c7c9a..39399dac5b 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -296,6 +296,8 @@ protected function instantiateModules(): self */ protected function installModules(): self { + $this->console->info('Installing starter kit...'); + $this->modules->each(fn ($module) => $module->install()); return $this; diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index 7816e0626a..25a8a06a7f 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -53,8 +53,6 @@ public function install(): void */ protected function installFiles(): self { - $this->installer->console()->info('Installing files...'); - $this->installableFiles()->each(function ($toPath, $fromPath) { $this->installFile($fromPath, $toPath, $this->installer->console()); }); @@ -148,12 +146,6 @@ protected function expandConfigExportPaths(string $to, ?string $from = null): Co */ protected function requireDependencies(array $packages, bool $dev = false): void { - if ($dev) { - $this->installer->console()->info('Installing development dependencies...'); - } else { - $this->installer->console()->info('Installing dependencies...'); - } - $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); if ($dev) { From 00ed4bfa6c70a20f3ac8fb493bd6911fb7b6eba0 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 2 Aug 2024 16:47:16 -0400 Subject: [PATCH 14/44] Module prompts. --- src/StarterKits/Installer.php | 63 +++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 39399dac5b..7cb5154e2b 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -15,10 +15,12 @@ use Statamic\Facades\YAML; use Statamic\StarterKits\Concerns\InteractsWithFilesystem; use Statamic\StarterKits\Exceptions\StarterKitException; +use Statamic\Support\Arr; use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\select; use function Laravel\Prompts\spin; final class Installer @@ -275,22 +277,71 @@ protected function ensureConfig(): self } /** - * Instantiate and validate modules. + * Instantiate and validate modules that are to be installed. */ protected function instantiateModules(): self { - $topLevelConfigModule = $this->config()->except('modules'); + $topLevelConfig = $this->config()->except('modules')->all(); - $optionalModules = $this->config('modules'); + $nestedConfigs = $this->config('modules'); - $this->modules = collect([$topLevelConfigModule]) - ->merge($optionalModules) - ->map(fn ($config) => new Module($config, $this)) + $this->modules = collect(['top_level' => $topLevelConfig]) + ->merge($nestedConfigs) + ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) + ->filter() ->each(fn ($module) => $module->validate()); return $this; } + /** + * Instantiate individual module. + */ + protected function instantiateModule(array $config, string $key): Module|bool + { + $shouldPrompt = true; + + if ($key === 'top_level') { + $shouldPrompt = false; + } + + if (Arr::has($config, 'options')) { + return $this->instantiateOptionsModule($config, $key); + } + + if (Arr::get($config, 'prompt') === false) { + $shouldPrompt = false; + } + + if ($shouldPrompt && ! confirm(Arr::get($config, 'prompt', "Would you like to install the [{$key}] module?"), false)) { + return false; + } + + return new Module(collect($config), $this); + } + + /** + * Instantiate individual module. + */ + protected function instantiateOptionsModule(array $config, string $key): Module|bool + { + $options = collect($config['options']) + ->map(fn ($option, $key) => Arr::get($option, 'display', ucfirst($key))) + ->prepend(Arr::get($config, 'skip_display', 'No'), $skipModule = 'skip_module') + ->all(); + + $choice = select( + label: Arr::get($config, 'prompt', "Would you like to install one of the following [{$key}] modules?"), + options: $options, + ); + + if ($choice === $skipModule) { + return false; + } + + return new Module(collect($config['options'][$choice]), $this); + } + /** * Install all the modules. */ From f07ffbd1bd559dbcff052ed1d62e1ac26d235585 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Sat, 3 Aug 2024 17:44:55 -0400 Subject: [PATCH 15/44] Option `label` instead of `display`. --- src/StarterKits/Installer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 7cb5154e2b..352dac71e0 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -326,8 +326,8 @@ protected function instantiateModule(array $config, string $key): Module|bool protected function instantiateOptionsModule(array $config, string $key): Module|bool { $options = collect($config['options']) - ->map(fn ($option, $key) => Arr::get($option, 'display', ucfirst($key))) - ->prepend(Arr::get($config, 'skip_display', 'No'), $skipModule = 'skip_module') + ->map(fn ($option, $key) => Arr::get($option, 'label', ucfirst($key))) + ->prepend(Arr::get($config, 'skip_option', 'No'), $skipModule = 'skip_module') ->all(); $choice = select( From cdc746ca23b6a069755f360fce6ed9c8f84bd68d Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Sat, 3 Aug 2024 21:06:14 -0400 Subject: [PATCH 16/44] Handle when running non-interactively. --- src/Console/Commands/StarterKitInstall.php | 1 + src/StarterKits/Installer.php | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 355dd9a6af..7a3f8f2910 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -67,6 +67,7 @@ public function handle() ->withConfig($this->option('with-config')) ->withoutDependencies($this->option('without-dependencies')) ->withUserPrompt($cleared && $this->input->isInteractive() && ! $this->option('cli-install')) + ->isInteractive($this->input->isInteractive()) ->usingSubProcess($this->option('cli-install')) ->force($this->option('force')); diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 352dac71e0..db4f20fc86 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -35,6 +35,7 @@ final class Installer protected $withConfig; protected $withoutDependencies; protected $withUserPrompt; + protected $isInteractive; protected $usingSubProcess; protected $force; protected $console; @@ -104,6 +105,14 @@ public function withUserPrompt(bool $withUserPrompt = false): self|bool return $this; } + /** + * Get or set whether command is being run interactively. + */ + public function isInteractive($isInteractive = false): self|bool|null + { + return $this->fluentlyGetOrSet('isInteractive')->args(func_get_args()); + } + /** * Get or set whether to install using sub-process. */ @@ -313,7 +322,9 @@ protected function instantiateModule(array $config, string $key): Module|bool $shouldPrompt = false; } - if ($shouldPrompt && ! confirm(Arr::get($config, 'prompt', "Would you like to install the [{$key}] module?"), false)) { + if ($shouldPrompt && $this->isInteractive && ! confirm(Arr::get($config, 'prompt', "Would you like to install the [{$key}] module?"), false)) { + return false; + } elseif ($shouldPrompt && ! $this->isInteractive) { return false; } From 9d2ad4e0f505488812f79a09580bc314f3134dfc Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Sat, 3 Aug 2024 21:07:13 -0400 Subject: [PATCH 17/44] Fix type exceptions for tests. --- .../Concerns/InteractsWithFilesystem.php | 3 ++- src/StarterKits/Installer.php | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php index 0cb180ffb2..b468d1a0be 100644 --- a/src/StarterKits/Concerns/InteractsWithFilesystem.php +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use Statamic\Console\NullConsole; use Statamic\Facades\Path; trait InteractsWithFilesystem @@ -11,7 +12,7 @@ trait InteractsWithFilesystem /** * Install starter kit file. */ - protected function installFile(string $fromPath, string $toPath, Command $console): self + protected function installFile(string $fromPath, string $toPath, Command|NullConsole $console): self { $displayPath = str_replace(Path::tidy(base_path().'/'), '', $toPath); diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index db4f20fc86..2aba434879 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -60,7 +60,7 @@ public function __construct(string $package, ?Command $console = null, ?LicenseM /** * Get or set whether to install from specific branch. */ - public function branch(?string $branch = null): self|bool + public function branch(?string $branch = null): self|bool|null { return $this->fluentlyGetOrSet('branch')->args(func_get_args()); @@ -70,7 +70,7 @@ public function branch(?string $branch = null): self|bool /** * Get or set whether to install from local repo configured in composer config.json. */ - public function fromLocalRepo(bool $fromLocalRepo = false): self|bool + public function fromLocalRepo(bool $fromLocalRepo = false): self|bool|null { return $this->fluentlyGetOrSet('fromLocalRepo')->args(func_get_args()); @@ -80,7 +80,7 @@ public function fromLocalRepo(bool $fromLocalRepo = false): self|bool /** * Get or set whether to install with starter-kit config for local development purposes. */ - public function withConfig(bool $withConfig = false): self|bool + public function withConfig(bool $withConfig = false): self|bool|null { return $this->fluentlyGetOrSet('withConfig')->args(func_get_args()); @@ -90,7 +90,7 @@ public function withConfig(bool $withConfig = false): self|bool /** * Get or set whether to install without dependencies. */ - public function withoutDependencies(?bool $withoutDependencies = false): self|bool + public function withoutDependencies(?bool $withoutDependencies = false): self|bool|null { return $this->fluentlyGetOrSet('withoutDependencies')->args(func_get_args()); } @@ -98,7 +98,7 @@ public function withoutDependencies(?bool $withoutDependencies = false): self|bo /** * Get or set whether to install with super user prompt. */ - public function withUserPrompt(bool $withUserPrompt = false): self|bool + public function withUserPrompt(bool $withUserPrompt = false): self|bool|null { return $this->fluentlyGetOrSet('withUserPrompt')->args(func_get_args()); @@ -116,7 +116,7 @@ public function isInteractive($isInteractive = false): self|bool|null /** * Get or set whether to install using sub-process. */ - public function usingSubProcess(bool $usingSubProcess = false): self|bool + public function usingSubProcess(bool $usingSubProcess = false): self|bool|null { return $this->fluentlyGetOrSet('usingSubProcess')->args(func_get_args()); @@ -126,7 +126,7 @@ public function usingSubProcess(bool $usingSubProcess = false): self|bool /** * Get or set whether to force install and allow dependency errors. */ - public function force(bool $force = false): self|bool + public function force(bool $force = false): self|bool|null { return $this->fluentlyGetOrSet('force')->args(func_get_args()); } From fe3ad25702bd9efc0f2add1b49ce1093ef460d6a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 10:47:31 -0400 Subject: [PATCH 18/44] Misc tests. --- tests/StarterKits/InstallTest.php | 241 +++++++++++++++++- .../cool-runnings/resources/css/bobsled.css | 1 + .../cool-runnings/resources/css/jamaica.css | 1 + .../cool-runnings/resources/css/seo.css | 1 + .../cool-runnings/resources/js/react.js | 1 + .../cool-runnings/resources/js/vue.js | 1 + 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/bobsled.css create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/jamaica.css create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/seo.css create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 5b259068de..f55ba9c161 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -7,12 +7,16 @@ use Facades\Statamic\StarterKits\Hook; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Http; +use Laravel\Prompts\Key; +use Laravel\Prompts\Prompt as LaravelPrompt; use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StarterKitInstall as InstallCommand; use Statamic\Facades\Blink; use Statamic\Facades\Config; use Statamic\Facades\YAML; +use Statamic\StarterKits\Installer; +use Statamic\StarterKits\LicenseManager; use Statamic\Support\Str; use Tests\Fakes\Composer\FakeComposer; use Tests\TestCase; @@ -38,6 +42,8 @@ public function setUp(): void public function tearDown(): void { + Prompt::resetFallbacks(); + $this->restoreSite(); parent::tearDown(); @@ -706,6 +712,194 @@ public function it_installs_branch_with_slash_without_failing_package_validation $this->assertFileExists(base_path('copied.md')); } + #[Test] + public function it_installs_no_modules_by_default_when_running_non_interactively() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'resources/css/seo.css', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + ], + 'bobsled' => [ + 'export_paths' => [ + 'resources/css/bobsled.css', + ], + 'dependencies' => [ + 'bobsled/speed-calculator' => '^1.0.0', + ], + ], + 'jamaica' => [ + 'export_as' => [ + 'resources/css/theme.css' => 'resources/css/jamaica.css', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + + $this->installCoolRunnings(); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + } + + #[Test] + public function it_installs_modules_with_prompt_false_config_by_default_when_running_non_interactively() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'modules' => [ + 'seo' => [ + 'prompt' => false, // Setting prompt to false skips confirmation, so this module should still get installed non-interactively + 'export_paths' => [ + 'resources/css/seo.css', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + ], + 'bobsled' => [ + 'export_paths' => [ + 'resources/css/bobsled.css', + ], + 'dependencies' => [ + 'bobsled/speed-calculator' => '^1.0.0', + ], + ], + 'jamaica' => [ + 'prompt' => false, // Setting prompt to false skips confirmation, so this module should still get installed non-interactively + 'export_as' => [ + 'resources/css/theme.css' => 'resources/css/jamaica.css', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + + $this->installCoolRunnings(); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileExists(base_path('resources/css/theme.css')); + $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + } + + #[Test] + public function it_installs_a_module_when_user_confirms_interactively_via_prompt() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'resources/css/seo.css', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + ], + 'bobsled' => [ + 'export_paths' => [ + 'resources/css/bobsled.css', + ], + 'dependencies' => [ + 'bobsled/speed-calculator' => '^1.0.0', + ], + ], + 'jamaica' => [ + 'export_as' => [ + 'resources/css/theme.css' => 'resources/css/jamaica.css', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + + $this->installCoolRunningsWithModulePrompts([ + 'y', Key::ENTER, // install first seo module + Key::ENTER, // skip second bobsled module + 'y', Key::ENTER, // install third jamaica module + ]); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(base_path('resources/css/seo.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertFileExists(base_path('resources/css/theme.css')); + $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + } + + #[Test] + public function it_installs_modules_without_dependencies() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'dependencies' => [ + 'bobsled/speed-calculator' => '^1.0.0', + ], + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'resources/css/seo.css', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + + $this->installCoolRunningsWithModulePrompts(['y', Key::ENTER], withoutDependencies: true); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(base_path('resources/css/seo.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + } + private function kitRepoPath($path = null) { return collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/'); @@ -737,13 +931,9 @@ private function preparePath($path) return $path; } - private function installCoolRunnings($options = [], $customFake = null) + private function installCoolRunnings($options = [], $customHttpFake = null) { - Http::fake($customFake ?? [ - 'outpost.*' => Http::response(['data' => ['price' => null]], 200), - 'repo.packagist.org/*' => Http::response('', 200), - '*' => Http::response('', 404), - ]); + $this->httpFake($customHttpFake); $this->artisan('statamic:starter-kit:install', array_merge([ 'package' => 'statamic/cool-runnings', @@ -751,6 +941,32 @@ private function installCoolRunnings($options = [], $customFake = null) ], $options)); } + private function installCoolRunningsWithModulePrompts($modulePrompts = [], $withoutDependencies = false) + { + $this->httpFake(); + + Prompt::fake($modulePrompts); + + $licenseManager = LicenseManager::validate($package = 'statamic/cool-runnings'); + + // New up `Installer` class directly, because `Prompt::fake()` isn't compatible with `$this->artisan()` test helper. + // See: https://github.com/laravel/prompts/issues/158 + (new Installer($package, null, $licenseManager)) + ->withoutDependencies($withoutDependencies) + ->isInteractive(true) + ->withUserPrompt(false) + ->install(); + } + + private function httpFake($customFake = null) + { + Http::fake($customFake ?? [ + 'outpost.*' => Http::response(['data' => ['price' => null]], 200), + 'repo.packagist.org/*' => Http::response('', 200), + '*' => Http::response('', 404), + ]); + } + private function assertFileHasContent($expected, $path) { $this->assertFileExists($path); @@ -799,3 +1015,16 @@ public function handle() Blink::put('starter-kit-command-run', true); } } + +class Prompt extends LaravelPrompt +{ + public static function resetFallbacks() + { + static::$shouldFallback = false; + } + + public function value(): mixed + { + // + } +} diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/css/bobsled.css b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/bobsled.css new file mode 100644 index 0000000000..1d4733449d --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/bobsled.css @@ -0,0 +1 @@ +/* bobsled! */ diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/css/jamaica.css b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/jamaica.css new file mode 100644 index 0000000000..f505f1e200 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/jamaica.css @@ -0,0 +1 @@ +/* jamaica! */ diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/css/seo.css b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/seo.css new file mode 100644 index 0000000000..8a2e82aa3b --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/seo.css @@ -0,0 +1 @@ +/* seo! */ diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js new file mode 100644 index 0000000000..7e1ba06e9e --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js @@ -0,0 +1 @@ +// react stuff! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js new file mode 100644 index 0000000000..400cebebfa --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js @@ -0,0 +1 @@ +// vue stuff! From fd772dc56ea892ff13e04363770321dfc19f7e91 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 11:02:28 -0400 Subject: [PATCH 19/44] Windows. --- tests/StarterKits/InstallTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index f55ba9c161..20f2feae2b 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -815,6 +815,8 @@ public function it_installs_modules_with_prompt_false_config_by_default_when_run #[Test] public function it_installs_a_module_when_user_confirms_interactively_via_prompt() { + $this->markTestSkippedInWindows(); + $this->setConfig([ 'export_paths' => [ 'copied.md', @@ -868,6 +870,8 @@ public function it_installs_a_module_when_user_confirms_interactively_via_prompt #[Test] public function it_installs_modules_without_dependencies() { + $this->markTestSkippedInWindows(); + $this->setConfig([ 'export_paths' => [ 'copied.md', From 2315fea6f3477a3e0037a8730fffc068773bcffe Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 20:57:02 -0400 Subject: [PATCH 20/44] Misc cleanup. --- src/StarterKits/Installer.php | 2 +- src/StarterKits/Module.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 2aba434879..d5f9b1e982 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -332,7 +332,7 @@ protected function instantiateModule(array $config, string $key): Module|bool } /** - * Instantiate individual module. + * Instantiate options module. */ protected function instantiateOptionsModule(array $config, string $key): Module|bool { diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index 25a8a06a7f..f328ca849e 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -32,7 +32,7 @@ public function __construct(protected Collection $config, protected Installer $i public function validate(): void { $this - ->ensureExportPathsExist() + ->ensureInstallableFilesExist() ->ensureCompatibleDependencies(); } @@ -191,11 +191,11 @@ protected function installableDependencies(string $configKey): array } /** - * Ensure export paths exist. + * Ensure installable files exist. * * @throws StarterKitException */ - protected function ensureExportPathsExist(): self + protected function ensureInstallableFilesExist(): self { $this ->exportPaths() From 6eb6b7933dc6d0e5f860510f888c39561d16b6f2 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 22:00:07 -0400 Subject: [PATCH 21/44] Fixtures --- .../__fixtures__/cool-runnings/resources/js/jquery.js | 1 + .../__fixtures__/cool-runnings/resources/js/mootools.js | 1 + .../__fixtures__/cool-runnings/resources/js/react.js | 2 +- .../__fixtures__/cool-runnings/resources/js/svelte.js | 1 + .../StarterKits/__fixtures__/cool-runnings/resources/js/vue.js | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/jquery.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/mootools.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/svelte.js diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/jquery.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/jquery.js new file mode 100644 index 0000000000..635c87191a --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/jquery.js @@ -0,0 +1 @@ +// jquery! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/mootools.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/mootools.js new file mode 100644 index 0000000000..6af6dbe541 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/mootools.js @@ -0,0 +1 @@ +// mootools! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js index 7e1ba06e9e..58beb3ed0e 100644 --- a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js @@ -1 +1 @@ -// react stuff! +// react! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/svelte.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/svelte.js new file mode 100644 index 0000000000..058d233da0 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/svelte.js @@ -0,0 +1 @@ +// svelte! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js index 400cebebfa..3b9ba3fb4b 100644 --- a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js @@ -1 +1 @@ -// vue stuff! +// vue! From 80508737c3571edd9f769019e5b7d3b2140596e9 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 22:27:17 -0400 Subject: [PATCH 22/44] =?UTF-8?q?Add=20`=E2=80=94without-user`=20option=20?= =?UTF-8?q?to=20skip=20user=20prompt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Console/Commands/StarterKitInstall.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index 7a3f8f2910..d7db695ef2 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -29,6 +29,7 @@ class StarterKitInstall extends Command { --local : Install from local repo configured in composer config.json } { --with-config : Copy starter-kit.yaml config for local development } { --without-dependencies : Install without dependencies } + { --without-user : Install without creating user } { --force : Force install and allow dependency errors } { --cli-install : Installing from CLI Tool } { --clear-site : Clear site before installing }'; @@ -66,7 +67,7 @@ public function handle() ->fromLocalRepo($this->option('local')) ->withConfig($this->option('with-config')) ->withoutDependencies($this->option('without-dependencies')) - ->withUserPrompt($cleared && $this->input->isInteractive() && ! $this->option('cli-install')) + ->withUserPrompt($cleared && $this->input->isInteractive() && ! $this->option('without-user') && ! $this->option('cli-install')) ->isInteractive($this->input->isInteractive()) ->usingSubProcess($this->option('cli-install')) ->force($this->option('force')); From 4ba78361d81920f734952b27f26dc34cc847733b Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 6 Aug 2024 23:00:46 -0400 Subject: [PATCH 23/44] Refactor to use built-in interactive command assertions helpers. --- tests/StarterKits/InstallTest.php | 192 +++++++++++++++++++++++------- 1 file changed, 150 insertions(+), 42 deletions(-) diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 20f2feae2b..8378a9fdfb 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -7,8 +7,6 @@ use Facades\Statamic\StarterKits\Hook; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Http; -use Laravel\Prompts\Key; -use Laravel\Prompts\Prompt as LaravelPrompt; use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StarterKitInstall as InstallCommand; @@ -16,7 +14,6 @@ use Statamic\Facades\Config; use Statamic\Facades\YAML; use Statamic\StarterKits\Installer; -use Statamic\StarterKits\LicenseManager; use Statamic\Support\Str; use Tests\Fakes\Composer\FakeComposer; use Tests\TestCase; @@ -42,8 +39,6 @@ public function setUp(): void public function tearDown(): void { - Prompt::resetFallbacks(); - $this->restoreSite(); parent::tearDown(); @@ -377,6 +372,21 @@ public function it_clears_site_when_option_is_passed() $this->assertFileDoesNotExist(base_path('content/collections/blog')); } + #[Test] + public function it_clears_site_when_interactively_confirmed() + { + $this->files->put($this->preparePath(base_path('content/collections/pages/contact.md')), 'Contact'); + $this->files->put($this->preparePath(base_path('content/collections/blog/article.md')), 'Article'); + + $this + ->installCoolRunningsInteractively(['--without-user' => true]) + ->expectsConfirmation('Clear site first?', 'yes'); + + $this->assertFileExists(base_path('content/collections/pages/home.md')); + $this->assertFileDoesNotExist(base_path('content/collections/pages/contact.md')); + $this->assertFileDoesNotExist(base_path('content/collections/blog')); + } + #[Test] public function it_installs_dependencies() { @@ -813,10 +823,8 @@ public function it_installs_modules_with_prompt_false_config_by_default_when_run } #[Test] - public function it_installs_a_module_when_user_confirms_interactively_via_prompt() + public function it_installs_only_the_modules_confirmed_interactively_via_prompt() { - $this->markTestSkippedInWindows(); - $this->setConfig([ 'export_paths' => [ 'copied.md', @@ -843,6 +851,42 @@ public function it_installs_a_module_when_user_confirms_interactively_via_prompt 'resources/css/theme.css' => 'resources/css/jamaica.css', ], ], + 'js' => [ + 'options' => [ + 'react' => [ + 'export_paths' => [ + 'resources/js/react.js', + ], + ], + 'vue' => [ + 'export_paths' => [ + 'resources/js/vue.js', + ], + 'dependencies' => [ + 'bobsled/vue-components' => '^1.5', + ], + ], + 'svelte' => [ + 'export_paths' => [ + 'resources/js/svelte.js', + ], + ], + ], + ], + 'oldschool_js' => [ + 'options' => [ + 'jquery' => [ + 'export_paths' => [ + 'resources/js/jquery.js', + ], + ], + 'mootools' => [ + 'export_paths' => [ + 'resources/js/jquery.js', + ], + ], + ], + ], ], ]); @@ -850,28 +894,103 @@ public function it_installs_a_module_when_user_confirms_interactively_via_prompt $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileDoesNotExist(base_path('resources/js/vue.js')); + $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); + $this->assertFileDoesNotExist(base_path('resources/js/jquery.js')); + $this->assertFileDoesNotExist(base_path('resources/js/mootools.js')); $this->assertComposerJsonDoesntHave('statamic/seo-pro'); $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + $this->assertComposerJsonDoesntHave('bobsled/vue-components'); - $this->installCoolRunningsWithModulePrompts([ - 'y', Key::ENTER, // install first seo module - Key::ENTER, // skip second bobsled module - 'y', Key::ENTER, // install third jamaica module - ]); + $this + ->installCoolRunningsModules() + ->expectsConfirmation('Would you like to install the [seo] module?', 'yes') + ->expectsConfirmation('Would you like to install the [bobsled] module?', 'no') + ->expectsConfirmation('Would you like to install the [jamaica] module?', 'yes') + ->expectsQuestion('Would you like to install one of the following [js] modules?', 'vue') + ->expectsQuestion('Would you like to install one of the following [oldschool_js] modules?', 'skip_module'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); $this->assertFileExists(base_path('resources/css/theme.css')); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileExists(base_path('resources/js/vue.js')); + $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); + $this->assertFileDoesNotExist(base_path('resources/js/jquery.js')); + $this->assertFileDoesNotExist(base_path('resources/js/mootools.js')); $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + $this->assertComposerJsonHasPackageVersion('require', 'bobsled/vue-components', '^1.5'); } #[Test] - public function it_installs_modules_without_dependencies() + public function it_display_custom_module_prompts() { - $this->markTestSkippedInWindows(); + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'prompt' => 'Want some extra SEO magic?', + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + ], + 'js' => [ + 'prompt' => 'Want one of these fancy JS options?', + 'options' => [ + 'react' => [ + 'label' => 'React JS', + 'export_paths' => [ + 'resources/js/react.js', + ], + ], + 'vue' => [ + 'label' => 'Vue JS', + 'export_paths' => [ + 'resources/js/vue.js', + ], + ], + 'svelte' => [ + 'export_paths' => [ + 'resources/js/svelte.js', + ], + ], + ], + ], + ], + ]); + + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileDoesNotExist(base_path('resources/js/vue.js')); + $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); + + $this + ->installCoolRunningsModules() + ->expectsConfirmation('Want some extra SEO magic?', 'yes') + ->expectsQuestion('Want one of these fancy JS options?', 'svelte'); + + // TODO: Also assert custom option labels using `expectsChoice()`, + // but there is currently a bug with the third `$answers` param, + // so maybe we can revisit this after. For example... + // + // ->expectsChoice('Want one of these fancy JS options?', 'svelte', [ + // 'skip_module' => 'No', + // 'react' => 'React JS', + // 'vue' => 'Vue JS', + // 'svelte' => 'Svelte', + // ]); + + $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileDoesNotExist(base_path('resources/js/vue.js')); + $this->assertFileExists(base_path('resources/js/svelte.js')); + } + #[Test] + public function it_installs_modules_without_dependencies() + { $this->setConfig([ 'export_paths' => [ 'copied.md', @@ -896,7 +1015,9 @@ public function it_installs_modules_without_dependencies() $this->assertComposerJsonDoesntHave('statamic/seo-pro'); $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); - $this->installCoolRunningsWithModulePrompts(['y', Key::ENTER], withoutDependencies: true); + $this + ->installCoolRunningsModules(['--without-dependencies' => true]) + ->expectsConfirmation('Would you like to install the [seo] module?', 'yes'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); @@ -939,27 +1060,27 @@ private function installCoolRunnings($options = [], $customHttpFake = null) { $this->httpFake($customHttpFake); - $this->artisan('statamic:starter-kit:install', array_merge([ + return $this->artisan('statamic:starter-kit:install', array_merge([ 'package' => 'statamic/cool-runnings', '--no-interaction' => true, ], $options)); } - private function installCoolRunningsWithModulePrompts($modulePrompts = [], $withoutDependencies = false) + private function installCoolRunningsInteractively($options = [], $customHttpFake = null) { - $this->httpFake(); - - Prompt::fake($modulePrompts); + $this->httpFake($customHttpFake); - $licenseManager = LicenseManager::validate($package = 'statamic/cool-runnings'); + return $this->artisan('statamic:starter-kit:install', array_merge([ + 'package' => 'statamic/cool-runnings', + ], $options)); + } - // New up `Installer` class directly, because `Prompt::fake()` isn't compatible with `$this->artisan()` test helper. - // See: https://github.com/laravel/prompts/issues/158 - (new Installer($package, null, $licenseManager)) - ->withoutDependencies($withoutDependencies) - ->isInteractive(true) - ->withUserPrompt(false) - ->install(); + private function installCoolRunningsModules($options = [], $customHttpFake = null) + { + return $this->installCoolRunningsInteractively(array_merge($options, [ + '--clear-site' => true, // skip clear site prompt + '--without-user' => true, // skip create user prompt + ]), $customHttpFake); } private function httpFake($customFake = null) @@ -1019,16 +1140,3 @@ public function handle() Blink::put('starter-kit-command-run', true); } } - -class Prompt extends LaravelPrompt -{ - public static function resetFallbacks() - { - static::$shouldFallback = false; - } - - public function value(): mixed - { - // - } -} From 3651692b016f68bdef62870d875a23738af1a0a0 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 17:38:25 -0400 Subject: [PATCH 24/44] Split `Module` logic into `InstallableModule` and `ExportableModule`. --- src/StarterKits/ExportableModule.php | 135 +++++++++++++ src/StarterKits/InstallableModule.php | 274 ++++++++++++++++++++++++++ src/StarterKits/Module.php | 243 +++-------------------- 3 files changed, 438 insertions(+), 214 deletions(-) create mode 100644 src/StarterKits/ExportableModule.php create mode 100644 src/StarterKits/InstallableModule.php diff --git a/src/StarterKits/ExportableModule.php b/src/StarterKits/ExportableModule.php new file mode 100644 index 0000000000..1b43406538 --- /dev/null +++ b/src/StarterKits/ExportableModule.php @@ -0,0 +1,135 @@ +ensureModuleConfigNotEmpty() + ->ensureNotExportingComposerJson() + ->ensureExportablePathsExist(); + } + + /** + * Export starter kit module. + * + * @throws Exception|StarterKitException + */ + public function export(string $starterKitPath): void + { + $this + ->exportPaths() + ->each(fn ($path) => $this->exportPath( + from: $path, + starterKitPath: $starterKitPath, + )); + + $this + ->exportAsPaths() + ->each(fn ($to, $from) => $this->exportPath( + from: $from, + to: $to, + starterKitPath: $starterKitPath, + )); + } + + public function versionDependencies(): self + { + $exportableDependencies = $this->getExportableDependencies(); + + if ($dependencies = $this->exportDependenciesFromComposerRequire('require', $exportableDependencies)) { + $this->config->put('dependencies', $dependencies->all()); + } + + if ($devDependencies = $this->exportDependenciesFromComposerRequire('require-dev', $exportableDependencies)) { + $this->config->put('dependencies_dev', $devDependencies->all()); + } + + return $this; + } + + /** + * Get exportable dependencies without versions from module config. + */ + protected function getExportableDependencies(): Collection + { + $config = $this->config(); + + return collect() + ->merge($config->get('dependencies') ?? []) + ->merge($config->get('dependencies_dev') ?? []) + ->map(function ($value, $key) { + return Str::contains($key, '/') + ? $key + : $value; + }); + } + + /** + * Export dependencies from composer.json using specific require key. + */ + protected function exportDependenciesFromComposerRequire(string $requireKey, Collection $exportableDependencies): mixed + { + $composerJson = json_decode($this->files->get(base_path('composer.json')), true); + + $dependencies = collect($composerJson[$requireKey] ?? []) + ->filter(function ($version, $dependency) use ($exportableDependencies) { + return $exportableDependencies->contains($dependency); + }); + + return $dependencies->isNotEmpty() + ? $dependencies + : false; + } + + /** + * Ensure composer.json is not one of the export paths. + * + * @throws StarterKitException + */ + protected function ensureNotExportingComposerJson(): self + { + // Here we'll ensure both `export_as` values and keys are included, + // because we want to make sure `composer.json` is referenced on either end. + $flattenedExportPaths = $this + ->exportPaths() + ->merge($this->exportAsPaths()) + ->merge($this->exportAsPaths()->keys()); + + if ($flattenedExportPaths->contains('composer.json')) { + throw new StarterKitException('Cannot export [composer.json]. Please use `dependencies` array!'); + } + + return $this; + } + + /** + * Ensure export paths exist. + * + * @throws StarterKitException + */ + protected function ensureExportablePathsExist(): self + { + $this + ->exportPaths() + ->merge($this->exportAsPaths()->keys()) + ->reject(fn ($path) => $this->files->exists(base_path($path))) + ->each(function ($path) { + throw new StarterKitException("Cannot export [{$path}], because it does not exist in your app!"); + }); + + return $this; + } +} diff --git a/src/StarterKits/InstallableModule.php b/src/StarterKits/InstallableModule.php new file mode 100644 index 0000000000..88ef05ccd7 --- /dev/null +++ b/src/StarterKits/InstallableModule.php @@ -0,0 +1,274 @@ +installer = $installer; + + return $this; + } + + /** + * Validate starter kit module is installable. + * + * @throws Exception|StarterKitException + */ + public function validate(): void + { + $this + ->requireParentInstaller() + ->ensureModuleConfigNotEmpty() + ->ensureInstallableFilesExist() + ->ensureCompatibleDependencies(); + } + + /** + * Install starter kit module. + * + * @throws Exception|StarterKitException + */ + public function install(): void + { + $this + ->requireParentInstaller() + ->installFiles() + ->installDependencies(); + } + + /** + * Require parent installer instance. + * + * @throws Exception + */ + protected function requireParentInstaller(): self + { + if (! $this->installer) { + throw new Exception('Parent installer required for this operation!'); + } + + return $this; + } + + /** + * Install starter kit module files. + */ + protected function installFiles(): self + { + $this->installableFiles()->each(function ($toPath, $fromPath) { + $this->installFile($fromPath, $toPath, $this->installer->console()); + }); + + return $this; + } + + /** + * Install starter kit module dependencies. + */ + protected function installDependencies(): self + { + if ($this->installer->withoutDependencies()) { + return $this; + } + + if ($packages = $this->installableDependencies('dependencies')) { + $this->requireDependencies($packages); + } + + if ($packages = $this->installableDependencies('dependencies_dev')) { + $this->requireDependencies($packages, true); + } + + return $this; + } + + /** + * Get installable files. + */ + protected function installableFiles(): Collection + { + $installableFromExportPaths = $this + ->exportPaths() + ->flatMap(fn ($path) => $this->expandExportDirectoriesToFiles($path)); + + $installableFromExportAsPaths = $this + ->exportAsPaths() + ->flip() + ->flatMap(fn ($to, $from) => $this->expandExportDirectoriesToFiles($to, $from)); + + return collect() + ->merge($installableFromExportPaths) + ->merge($installableFromExportAsPaths); + } + + /** + * Expand export path to `[$from => $to]` array format, normalizing directories to files. + * + * This is necessary when installing starter kit into existing directories, so that we don't stomp whole directories. + */ + protected function expandExportDirectoriesToFiles(string $to, ?string $from = null): Collection + { + $to = Path::tidy($this->starterKitPath($to)); + $from = Path::tidy($from ? $this->starterKitPath($from) : $to); + + $paths = collect([$from => $to]); + + if ($this->files->isDirectory($from)) { + $paths = collect($this->files->allFiles($from)) + ->map + ->getPathname() + ->mapWithKeys(fn ($path) => [ + $path => str_replace($from, $to, $path), + ]); + } + + $package = $this->installer->package(); + + return $paths->mapWithKeys(fn ($to, $from) => [ + Path::tidy($from) => Path::tidy(str_replace("/vendor/{$package}", '', $to)), + ]); + } + + /** + * Install dependency permanently into app. + */ + protected function requireDependencies(array $packages, bool $dev = false): void + { + $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); + + if ($dev) { + $args[] = '--dev'; + } + + try { + Composer::withoutQueue()->throwOnFailure()->runAndOperateOnOutput($args, function ($output) { + return $this->outputFromSymfonyProcess($output); + }); + } catch (ProcessException $exception) { + $this->installer->console()->error('Error installing dependencies.'); + } + } + + /** + * Get installable dependencies from appropriate require key in composer.json. + */ + protected function installableDependencies(string $configKey): array + { + return collect($this->config($configKey)) + ->filter(fn ($version, $package) => Str::contains($package, '/')) + ->all(); + } + + /** + * Ensure installable files exist. + * + * @throws StarterKitException + */ + protected function ensureInstallableFilesExist(): self + { + $this + ->exportPaths() + ->merge($this->exportAsPaths()) + ->reject(fn ($path) => $this->files->exists($this->starterKitPath($path))) + ->each(function ($path) { + throw new StarterKitException("Starter kit path [{$path}] does not exist."); + }); + + return $this; + } + + /** + * Ensure compatible dependencies by performing a dry-run. + */ + protected function ensureCompatibleDependencies(): self + { + if ($this->installer->withoutDependencies() || $this->installer->force()) { + return $this; + } + + if ($packages = $this->installableDependencies('dependencies')) { + $this->ensureCanRequireDependencies($packages); + } + + if ($packages = $this->installableDependencies('dependencies_dev')) { + $this->ensureCanRequireDependencies($packages, true); + } + + return $this; + } + + /** + * Ensure dependencies are installable by performing a dry-run. + */ + protected function ensureCanRequireDependencies(array $packages, bool $dev = false): void + { + $requireMethod = $dev ? 'requireMultipleDev' : 'requireMultiple'; + + try { + Composer::withoutQueue()->throwOnFailure()->{$requireMethod}($packages, '--dry-run'); + } catch (ProcessException $exception) { + $this->installer->rollbackWithError('Cannot install due to dependency conflict.', $exception->getMessage()); + } + } + + /** + * Get starter kit vendor path. + */ + protected function starterKitPath(?string $path = null): string + { + $package = $this->installer->package(); + + return collect([base_path("vendor/{$package}"), $path])->filter()->implode('/'); + } + + /** + * Normalize packages array to require args, with version handling if `package => version` array structure is passed. + */ + protected function normalizePackagesArrayToRequireArgs(array $packages): array + { + return collect($packages) + ->map(function ($value, $key) { + return Str::contains($key, '/') + ? "{$key}:{$value}" + : "{$value}"; + }) + ->values() + ->all(); + } + + /** + * Clean up symfony process output and output to cli. + */ + protected function outputFromSymfonyProcess(string $output): string + { + // Remove terminal color codes. + $output = preg_replace('/\\e\[[0-9]+m/', '', $output); + + // Remove new lines. + $output = preg_replace('/[\r\n]+$/', '', $output); + + // If not a blank line, output to terminal. + if (! empty(trim($output))) { + $this->installer->console()->line($output); + } + + return $output; + } +} diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index f328ca849e..eb0726b391 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -2,105 +2,60 @@ namespace Statamic\StarterKits; -use Facades\Statamic\Console\Processes\Composer; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; -use Statamic\Console\Processes\Exceptions\ProcessException; -use Statamic\Facades\Path; use Statamic\StarterKits\Exceptions\StarterKitException; -use Statamic\Support\Str; -final class Module +abstract class Module { use Concerns\InteractsWithFilesystem; protected $files; + protected $config; + protected $key; /** * Instantiate starter kit module. */ - public function __construct(protected Collection $config, protected Installer $installer) + public function __construct(array|Collection $config, string $key) { $this->files = app(Filesystem::class); - } - /** - * Validate starter kit module. - * - * @throws StarterKitException - */ - public function validate(): void - { - $this - ->ensureInstallableFilesExist() - ->ensureCompatibleDependencies(); - } + $this->config = collect($config); - /** - * Install starter kit module. - * - * @throws StarterKitException - */ - public function install(): void - { - $this - ->installFiles() - ->installDependencies(); + $this->key = $key; } /** - * Install starter kit module files. + * Get module key. */ - protected function installFiles(): self + public function key(): string { - $this->installableFiles()->each(function ($toPath, $fromPath) { - $this->installFile($fromPath, $toPath, $this->installer->console()); - }); - - return $this; + return $this->key; } /** - * Install starter kit module dependencies. + * Check if this is a top level module. */ - protected function installDependencies(): self + public function isTopLevelModule(): bool { - if ($this->installer->withoutDependencies()) { - return $this; - } - - if ($packages = $this->installableDependencies('dependencies')) { - $this->requireDependencies($packages); - } - - if ($packages = $this->installableDependencies('dependencies_dev')) { - $this->requireDependencies($packages, true); - } - - return $this; + return $this->key === 'top_level'; } /** - * Get installable files. + * Get module config. */ - protected function installableFiles(): Collection + public function config(?string $key = null): mixed { - $installableFromExportPaths = $this - ->exportPaths() - ->flatMap(fn ($path) => $this->expandConfigExportPaths($path)); - - $installableFromExportAsPaths = $this - ->exportAsPaths() - ->flip() - ->flatMap(fn ($to, $from) => $this->expandConfigExportPaths($to, $from)); + if ($key) { + return $this->config->get($key); + } - return collect() - ->merge($installableFromExportPaths) - ->merge($installableFromExportAsPaths); + return $this->config; } /** - * Get `export_paths` paths from config. + * Get `export_paths` paths as collection from config. */ protected function exportPaths(): Collection { @@ -108,7 +63,7 @@ protected function exportPaths(): Collection } /** - * Get `export_as` paths (to be renamed on install) from config. + * Get `export_as` paths (to be renamed on install) as collection from config. */ protected function exportAsPaths(): Collection { @@ -116,165 +71,25 @@ protected function exportAsPaths(): Collection } /** - * Expand config export path to `[$from => $to]` array format, normalizing directories to files. - */ - protected function expandConfigExportPaths(string $to, ?string $from = null): Collection - { - $to = Path::tidy($this->starterKitPath($to)); - $from = Path::tidy($from ? $this->starterKitPath($from) : $to); - - $paths = collect([$from => $to]); - - if ($this->files->isDirectory($from)) { - $paths = collect($this->files->allFiles($from)) - ->map - ->getPathname() - ->mapWithKeys(fn ($path) => [ - $path => str_replace($from, $to, $path), - ]); - } - - $package = $this->installer->package(); - - return $paths->mapWithKeys(fn ($to, $from) => [ - Path::tidy($from) => Path::tidy(str_replace("/vendor/{$package}", '', $to)), - ]); - } - - /** - * Install dependency permanently into app. - */ - protected function requireDependencies(array $packages, bool $dev = false): void - { - $args = array_merge(['require'], $this->normalizePackagesArrayToRequireArgs($packages)); - - if ($dev) { - $args[] = '--dev'; - } - - try { - Composer::withoutQueue()->throwOnFailure()->runAndOperateOnOutput($args, function ($output) { - return $this->outputFromSymfonyProcess($output); - }); - } catch (ProcessException $exception) { - $this->installer->console()->error('Error installing dependencies.'); - } - } - - /** - * Clean up symfony process output and output to cli. - */ - protected function outputFromSymfonyProcess(string $output): string - { - // Remove terminal color codes. - $output = preg_replace('/\\e\[[0-9]+m/', '', $output); - - // Remove new lines. - $output = preg_replace('/[\r\n]+$/', '', $output); - - // If not a blank line, output to terminal. - if (! empty(trim($output))) { - $this->installer->console()->line($output); - } - - return $output; - } - - /** - * Get installable dependencies from appropriate require key in composer.json. - */ - protected function installableDependencies(string $configKey): array - { - return collect($this->config($configKey)) - ->filter(fn ($version, $package) => Str::contains($package, '/')) - ->all(); - } - - /** - * Ensure installable files exist. + * Ensure nested module config is not empty. * * @throws StarterKitException */ - protected function ensureInstallableFilesExist(): self + protected function ensureModuleConfigNotEmpty(): self { - $this - ->exportPaths() - ->reject(fn ($path) => $this->files->exists($this->starterKitPath($path))) - ->each(function ($path) { - throw new StarterKitException("Starter kit path [{$path}] does not exist."); - }); - - return $this; - } - - /** - * Ensure compatible dependencies by performing a dry-run. - */ - protected function ensureCompatibleDependencies(): self - { - if ($this->installer->withoutDependencies() || $this->installer->force()) { + if ($this->isTopLevelModule()) { return $this; } - if ($packages = $this->installableDependencies('dependencies')) { - $this->ensureCanRequireDependencies($packages); - } + $hasConfig = $this->config()->has('export_paths') + || $this->config()->has('export_as') + || $this->config()->has('dependencies') + || $this->config()->has('dependencies_dev'); - if ($packages = $this->installableDependencies('dependencies_dev')) { - $this->ensureCanRequireDependencies($packages, true); + if (! $hasConfig) { + throw new StarterKitException('Starter-kit module is missing `export_paths` or `dependencies`!'); } return $this; } - - /** - * Ensure dependencies are installable by performing a dry-run. - */ - protected function ensureCanRequireDependencies(array $packages, bool $dev = false): void - { - $requireMethod = $dev ? 'requireMultipleDev' : 'requireMultiple'; - - try { - Composer::withoutQueue()->throwOnFailure()->{$requireMethod}($packages, '--dry-run'); - } catch (ProcessException $exception) { - $this->installer->rollbackWithError('Cannot install due to dependency conflict.', $exception->getMessage()); - } - } - - /** - * Normalize packages array to require args, with version handling if `package => version` array structure is passed. - */ - protected function normalizePackagesArrayToRequireArgs(array $packages): array - { - return collect($packages) - ->map(function ($value, $key) { - return Str::contains($key, '/') - ? "{$key}:{$value}" - : "{$value}"; - }) - ->values() - ->all(); - } - - /** - * Get starter kit vendor path. - */ - protected function starterKitPath(?string $path = null): string - { - $package = $this->installer->package(); - - return collect([base_path("vendor/{$package}"), $path])->filter()->implode('/'); - } - - /** - * Get module config. - */ - protected function config(?string $key = null): mixed - { - if ($key) { - return $this->config->get($key); - } - - return $this->config; - } } From 83097d6344fb510d5ff9b383f5fbab03b7dd1413 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 17:38:44 -0400 Subject: [PATCH 25/44] Update `Installer` to use new `InstallableModule`. --- src/StarterKits/Installer.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index d5f9b1e982..ccc7269b92 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -163,10 +163,10 @@ public function install(): void ->ensureConfig() ->instantiateModules() ->installModules() - ->copyStarterKitConfig() // TODO handle modules - ->copyStarterKitHooks() // TODO handle modules + ->copyStarterKitConfig() + ->copyStarterKitHooks() ->makeSuperUser() - ->runPostInstallHooks() // TODO handle modules + ->runPostInstallHooks() ->reticulateSplines() ->removeStarterKit() ->removeRepository() @@ -290,7 +290,7 @@ protected function ensureConfig(): self */ protected function instantiateModules(): self { - $topLevelConfig = $this->config()->except('modules')->all(); + $topLevelConfig = $this->config()->all(); $nestedConfigs = $this->config('modules'); @@ -306,7 +306,7 @@ protected function instantiateModules(): self /** * Instantiate individual module. */ - protected function instantiateModule(array $config, string $key): Module|bool + protected function instantiateModule(array $config, string $key): InstallableModule|bool { $shouldPrompt = true; @@ -328,13 +328,13 @@ protected function instantiateModule(array $config, string $key): Module|bool return false; } - return new Module(collect($config), $this); + return (new InstallableModule($config, $key))->installer($this); } /** * Instantiate options module. */ - protected function instantiateOptionsModule(array $config, string $key): Module|bool + protected function instantiateOptionsModule(array $config, string $key): InstallableModule|bool { $options = collect($config['options']) ->map(fn ($option, $key) => Arr::get($option, 'label', ucfirst($key))) @@ -350,7 +350,7 @@ protected function instantiateOptionsModule(array $config, string $key): Module| return false; } - return new Module(collect($config['options'][$choice]), $this); + return (new InstallableModule($config['options'][$choice], $key))->installer($this); } /** From 37e7106bb0df82008b7c6a11c250f050be3168a8 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 17:39:03 -0400 Subject: [PATCH 26/44] Flesh out `InstallTest` a bit more. --- tests/StarterKits/InstallTest.php | 88 ++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 8378a9fdfb..8f533fa889 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -8,12 +8,12 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Http; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StarterKitInstall as InstallCommand; use Statamic\Facades\Blink; use Statamic\Facades\Config; use Statamic\Facades\YAML; -use Statamic\StarterKits\Installer; use Statamic\Support\Str; use Tests\Fakes\Composer\FakeComposer; use Tests\TestCase; @@ -1025,6 +1025,92 @@ public function it_installs_modules_without_dependencies() $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); } + #[Test] + public function it_requires_valid_module_config() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'prompt' => false, + // no installable config! + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + + $this + ->installCoolRunnings() + ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') + ->assertFailed(); + + $this->assertFileDoesNotExist(base_path('copied.md')); + } + + #[Test] + public function it_doesnt_require_anything_installable_in_top_level_if_user_wants_to_organize_using_modules_only() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'prompt' => false, + 'export_paths' => [ + 'copied.md', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + + $this + ->installCoolRunnings() + ->assertSuccessful(); + + $this->assertFileExists(base_path('copied.md')); + } + + #[Test] + #[DataProvider('validModuleConfigs')] + public function it_passes_validation_if_module_export_paths_or_dependencies_are_properly_configured($config) + { + $this->setConfig([ + 'modules' => [ + 'seo' => array_merge(['prompt' => false], $config), + ], + ]); + + $this + ->installCoolRunnings() + ->assertSuccessful(); + } + + public static function validModuleConfigs() + { + return [ + 'export paths' => [[ + 'export_paths' => [ + 'copied.md', + ], + ]], + 'export as paths' => [[ + 'export_as' => [ + 'copied.md' => 'resources/js/vue.js', + ], + ]], + 'dependencies' => [[ + 'dependencies' => [ + 'statamic/seo-pro' => '^1.0', + ], + ]], + 'dev dependencies' => [[ + 'dependencies_dev' => [ + 'statamic/seo-pro' => '^1.0', + ], + ]], + ]; + } + private function kitRepoPath($path = null) { return collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/'); From 624c0ac3e6958c090572d6f272155079c17b04c5 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 17:39:56 -0400 Subject: [PATCH 27/44] Refactor exporter to handle modules. --- src/Console/Commands/StarterKitExport.php | 6 +- .../Concerns/InteractsWithFilesystem.php | 20 ++ src/StarterKits/Exporter.php | 234 ++++++--------- tests/StarterKits/ExportTest.php | 273 ++++++++++++++++-- 4 files changed, 370 insertions(+), 163 deletions(-) diff --git a/src/Console/Commands/StarterKitExport.php b/src/Console/Commands/StarterKitExport.php index 556f27ae1b..2c2cb31114 100644 --- a/src/Console/Commands/StarterKitExport.php +++ b/src/Console/Commands/StarterKitExport.php @@ -2,12 +2,12 @@ namespace Statamic\Console\Commands; -use Facades\Statamic\StarterKits\Exporter as StarterKitExporter; use Illuminate\Console\Command; use Statamic\Console\RunsInPlease; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\StarterKits\Exceptions\StarterKitException; +use Statamic\StarterKits\Exporter as StarterKitExporter; use function Laravel\Prompts\confirm; @@ -42,8 +42,10 @@ public function handle() $this->askToCreateExportPath($path); } + $exporter = new StarterKitExporter($path); + try { - StarterKitExporter::export($path); + $exporter->export(); } catch (StarterKitException $exception) { $this->components->error($exception->getMessage()); diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php index b468d1a0be..25dac3bf98 100644 --- a/src/StarterKits/Concerns/InteractsWithFilesystem.php +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -23,6 +23,26 @@ protected function installFile(string $fromPath, string $toPath, Command|NullCon return $this; } + /** + * Export starter kit path. + */ + protected function exportPath(string $starterKitPath, string $from, ?string $to = null): void + { + $to = $to + ? "{$starterKitPath}/{$to}" + : "{$starterKitPath}/{$from}"; + + $from = base_path($from); + + $this->preparePath($to); + + $files = app(Filesystem::class); + + $files->isDirectory($from) + ? $files->copyDirectory($from, $to) + : $files->copy($from, $to); + } + /** * Prepare path directory. */ diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 6f90c2d1d8..831555e5a7 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -5,20 +5,27 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; use Statamic\Facades\YAML; +use Statamic\StarterKits\Concerns\InteractsWithFilesystem; use Statamic\StarterKits\Exceptions\StarterKitException; +use Statamic\Support\Arr; use Statamic\Support\Str; class Exporter { - protected $files; + use InteractsWithFilesystem; + protected $exportPath; + protected $files; protected $vendorName; + protected $modules; /** * Instantiate starter kit exporter. */ - public function __construct() + public function __construct(string $exportPath) { + $this->exportPath = $exportPath; + $this->files = app(Filesystem::class); } @@ -27,135 +34,97 @@ public function __construct() * * @throws StarterKitException */ - public function export(string $absolutePath): void + public function export(): void { - $this->exportPath = $absolutePath; - - if (! $this->files->exists($this->exportPath)) { - throw new StarterKitException("Path [$this->exportPath] does not exist."); - } - - if (! $this->files->exists(base_path('starter-kit.yaml'))) { - throw new StarterKitException('Export config [starter-kit.yaml] does not exist.'); - } - $this - ->exportFiles() + ->validateExportPath() + ->validateConfig() + ->instantiateModules() + ->exportModules() ->exportConfig() ->exportHooks() ->exportComposerJson(); } /** - * Export files and folders. + * Validate that export path exists. */ - protected function exportFiles(): self + protected function validateExportPath(): self { - $this - ->exportPaths() - ->each(function ($path) { - $this->ensureExportPathExists($path); - }) - ->each(function ($path) { - $this->copyPath($path); - }); - - $this - ->exportAsPaths() - ->each(function ($to, $from) { - $this->ensureExportPathExists($from); - }) - ->each(function ($to, $from) { - $this->copyPath($from, $to); - }); + if (! $this->files->exists($this->exportPath)) { + throw new StarterKitException("Path [$this->exportPath] does not exist."); + } return $this; } /** - * Ensure export path exists. - * - * @throws StarterKitException + * Validate starter kit config. */ - protected function ensureExportPathExists(string $path) + protected function validateConfig(): self { - if (! $this->files->exists(base_path($path))) { - throw new StarterKitException("Export path [{$path}] does not exist."); + if (! $this->files->exists(base_path('starter-kit.yaml'))) { + throw new StarterKitException('Export config [starter-kit.yaml] does not exist.'); } + + return $this; } /** - * Copy path to new export path location. + * Instantiate and validate modules that are to be installed. */ - protected function copyPath(string $fromPath, ?string $toPath = null): void + protected function instantiateModules(): self { - $toPath = $toPath - ? "{$this->exportPath}/{$toPath}" - : "{$this->exportPath}/{$fromPath}"; + $topLevelConfig = $this->config()->all(); - $fromPath = base_path($fromPath); + $nestedConfigs = $this->config('modules'); - $this->preparePath($fromPath, $toPath); + $this->modules = collect(['top_level' => $topLevelConfig]) + ->merge($nestedConfigs) + ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) + ->flatten() + ->filter() + ->each(fn ($module) => $module->validate()); - $this->files->isDirectory($fromPath) - ? $this->files->copyDirectory($fromPath, $toPath) - : $this->files->copy($fromPath, $toPath); + return $this; } /** - * Prepare path directory. + * Instantiate individual module. */ - protected function preparePath(string $fromPath, string $toPath): void + protected function instantiateModule(array $config, string $key): ExportableModule|array { - $directory = $this->files->isDirectory($fromPath) - ? $toPath - : preg_replace('/(.*)\/[^\/]*/', '$1', $toPath); - - if (! $this->files->exists($directory)) { - $this->files->makeDirectory($directory, 0755, true); + if (Arr::has($config, 'options')) { + return collect($config['options']) + ->map(fn ($option) => $this->instantiateModule($option, $key)) + ->all(); } - } - /** - * Get starter kit config. - */ - protected function config(): Collection - { - return collect(YAML::parse($this->files->get(base_path('starter-kit.yaml')))); + return new ExportableModule($config, $key); } /** - * Get starter kit `export_paths` paths from config. - * - * @throws StarterKitException + * Export all the modules. */ - protected function exportPaths(): Collection + protected function exportModules(): self { - $paths = collect($this->config()->get('export_paths')); + $this->modules->each(fn ($module) => $module->export($this->exportPath)); - if ($paths->isEmpty()) { - throw new StarterKitException('Export config [starter-kit.yaml] does not contain any export paths.'); - } elseif ($paths->contains('composer.json')) { - throw new StarterKitException('Cannot export [composer.json]. Please use `dependencies` array!'); - } - - return $paths; + return $this; } /** - * Get starter kit 'export_as' paths (to be renamed on export) from config. - * - * @throws StarterKitException + * Get starter kit config. */ - protected function exportAsPaths(): Collection + protected function config(?string $key = null): mixed { - $paths = collect($this->config()->get('export_as')); + $config = collect(YAML::parse($this->files->get(base_path('starter-kit.yaml')))); - if ($paths->keys()->contains('composer.json')) { - throw new StarterKitException('Cannot export [composer.json]. Please use `dependencies` array!'); + if ($key) { + return $config->get($key); } - return $paths; + return $config; } /** @@ -163,9 +132,9 @@ protected function exportAsPaths(): Collection */ protected function exportConfig(): self { - $config = $this->config(); - - $config = $this->exportDependenciesFromComposerJson($config); + $config = $this + ->versionModuleDependencies() + ->syncWithModuleConfigs(); $this->files->put("{$this->exportPath}/starter-kit.yaml", YAML::dump($config->all())); @@ -173,83 +142,66 @@ protected function exportConfig(): self } /** - * Export starter kit hooks. + * Version module dependencies from composer.json. */ - protected function exportHooks(): self + protected function versionModuleDependencies(): self { - $hooks = ['StarterKitPostInstall.php']; - - collect($hooks) - ->filter(fn ($hook) => $this->files->exists(base_path($hook))) - ->each(fn ($hook) => $this->copyPath($hook)); + $this->modules->map(fn ($module) => $module->versionDependencies()); return $this; } /** - * Export dependencies from composer.json. - */ - protected function exportDependenciesFromComposerJson(Collection $config): Collection - { - $exportableDependencies = $this->getExportableDependenciesFromConfig($config); - - $config - ->forget('dependencies') - ->forget('dependenices_dev'); - - if ($dependencies = $this->exportDependenciesFromComposerRequire('require', $exportableDependencies)) { - $config->put('dependencies', $dependencies->all()); - } - - if ($devDependencies = $this->exportDependenciesFromComposerRequire('require-dev', $exportableDependencies)) { - $config->put('dependencies_dev', $devDependencies->all()); - } - - return $config; - } - - /** - * Get exportable dependencies without versions from config. + * Get synced config from newly versioned module dependencies. */ - protected function getExportableDependenciesFromConfig(Collection $config): Collection + protected function syncWithModuleConfigs(): Collection { - if ($this->hasDependenciesWithoutVersions($config)) { - return collect($config->get('dependencies') ?? []); - } - - return collect() - ->merge($config->get('dependencies') ?? []) - ->merge($config->get('dependencies_dev') ?? []) - ->keys(); + $config = $this->config()->all(); + + $this->modules->each(function ($module) use (&$config) { + if ($dependencies = $module->config('dependencies')) { + Arr::forget($config, $this->dottedModulePath($module, 'dependencies')); + Arr::set($config, $this->dottedModulePath($module, 'dependencies'), $dependencies); + } + }); + + $this->modules->each(function ($module) use (&$config) { + if ($dependenciesDev = $module->config('dependencies_dev')) { + Arr::forget($config, $this->dottedModulePath($module, 'dependencies_dev')); + Arr::set($config, $this->dottedModulePath($module, 'dependencies_dev'), $dependenciesDev); + } + }); + + return collect($config); } /** - * Check if config has dependencies without versions. + * Get dotted module path. */ - protected function hasDependenciesWithoutVersions(Collection $config): bool + protected function dottedModulePath(Module $module, string $key): string { - if (! $config->has('dependencies')) { - return false; + if ($module->isTopLevelModule()) { + return $key; } - return isset($config['dependencies'][0]); + return 'modules.'.$module->key().'.'.$key; } /** - * Export dependencies from composer.json using specific require key. + * Export starter kit hooks. */ - protected function exportDependenciesFromComposerRequire(string $requireKey, Collection $exportableDependencies): mixed + protected function exportHooks(): self { - $composerJson = json_decode($this->files->get(base_path('composer.json')), true); + $hooks = ['StarterKitPostInstall.php']; - $dependencies = collect($composerJson[$requireKey] ?? []) - ->filter(function ($version, $dependency) use ($exportableDependencies) { - return $exportableDependencies->contains($dependency); - }); + collect($hooks) + ->filter(fn ($hook) => $this->files->exists(base_path($hook))) + ->each(fn ($hook) => $this->exportPath( + from: $hook, + starterKitPath: $this->exportPath, + )); - return $dependencies->isNotEmpty() - ? $dependencies - : false; + return $this; } /** diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 37ae03b588..9b4ca093d0 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -3,8 +3,10 @@ namespace Tests\StarterKits; use Illuminate\Filesystem\Filesystem; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\YAML; +use Statamic\Support\Arr; use Tests\TestCase; class ExportTest extends TestCase @@ -72,15 +74,15 @@ public function it_can_export_files() ]); $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); - $this->assertFileDoesNotExist($composerJson = $this->exportPath('resources/views/welcome.blade.php')); + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); $this->exportCoolRunnings(); $this->assertFileExists($filesystemsConfig); $this->assertFileHasContent("'disks' => [", $filesystemsConfig); - $this->assertFileExists($composerJson); - $this->assertFileHasContent('assertFileExists($welcomeView); + $this->assertFileHasContent('assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); - $this->assertFileDoesNotExist($composerJson = $this->exportPath('resources/views/errors')); + $this->assertFileDoesNotExist($errorsFolder = $this->exportPath('resources/views/errors')); $this->assertFileDoesNotExist($renamedFile = $this->exportPath('README-new-site.md')); $this->assertFileDoesNotExist($renamedFolder = $this->exportPath('test-renamed-folder')); $this->exportCoolRunnings(); $this->assertFileExists($filesystemsConfig); - $this->assertFileExists($composerJson); + $this->assertFileExists($errorsFolder); $this->assertFileExists($renamedFile); $this->assertFileExists($renamedFolder); @@ -293,7 +295,9 @@ public function it_exports_only_dev_dependencies_from_versionless_array() ); $this->setExportableDependencies([ - 'statamic/ssg', + 'dependencies_dev' => [ + 'statamic/ssg', + ], ]); $this->exportCoolRunnings(); @@ -408,7 +412,7 @@ public function it_overrides_dev_dependencies_from_composer_json() ); $this->setExportableDependencies([ - 'dependencies' => [ + 'dependencies_dev' => [ 'statamic/ssg' => '10.0.0', ], ]); @@ -512,11 +516,245 @@ public function it_uses_existing_composer_json_file() , $this->files->get($this->exportPath('composer.json'))); } + #[Test] + public function it_can_export_module_files() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + ], + 'ssg' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'resources/views/you-are-so-welcome.blade.php', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/you-are-so-welcome.blade.php')); + + $this->exportCoolRunnings(); + + $this->assertFileExists($filesystemsConfig); + $this->assertFileHasContent("'disks' => [", $filesystemsConfig); + + $this->assertFileExists($welcomeView); + $this->assertFileHasContent('files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'dependencies' => [ + 'statamic/seo-pro', + ], + ], + 'ssg' => [ + 'dependencies_dev' => [ + 'statamic/ssg', + ], + ], + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigEquals('modules.seo.dependencies', [ + 'statamic/seo-pro' => '^2.2', + ]); + + $this->assertExportedConfigDoesNotHave('modules.seo.dependencies_dev'); + + $this->assertExportedConfigEquals('modules.ssg.dependencies_dev', [ + 'statamic/ssg' => '^0.4.0', + ]); + + $this->assertExportedConfigDoesNotHave('modules.ssg.dependencies'); + } + + #[Test] + public function it_requires_valid_module_config() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + // no exportable config! + ], + ], + ]); + + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); + + $this + ->exportCoolRunnings() + // ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') // TODO: Why does this work in InstallTest? + ->assertFailed(); + + $this->assertFileDoesNotExist($welcomeView); + } + + #[Test] + public function it_doesnt_require_anything_exportable_in_top_level_if_user_wants_to_organize_using_modules_only() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'resources/views/welcome.blade.php', + ], + ], + ], + ]); + + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); + + $this + ->exportCoolRunnings() + ->assertSuccessful(); + + $this->assertFileExists($welcomeView); + } + + #[Test] + #[DataProvider('validModuleConfigs')] + public function it_passes_validation_if_module_export_paths_or_dependencies_are_properly_configured($config) + { + $this->setConfig([ + 'modules' => [ + 'seo' => array_merge(['prompt' => false], $config), + ], + ]); + + $this + ->exportCoolRunnings() + ->assertSuccessful(); + } + + public static function validModuleConfigs() + { + return [ + 'export paths' => [[ + 'export_paths' => [ + 'resources/views/welcome.blade.php', + ], + ]], + 'export as paths' => [[ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'resources/js/vue.js', + ], + ]], + 'dependencies' => [[ + 'dependencies' => [ + 'statamic/seo-pro' => '^1.0', + ], + ]], + 'dev dependencies' => [[ + 'dependencies_dev' => [ + 'statamic/seo-pro' => '^1.0', + ], + ]], + ]; + } + + #[Test] + #[DataProvider('configsExportingComposerJson')] + public function it_doesnt_allow_exporting_of_composer_json_file($config) + { + $this->setConfig($config); + + $this + ->exportCoolRunnings() + // ->expectsOutput('Cannot export [composer.json]. Please use `dependencies` array!') // TODO: Why does this work in InstallTest? + ->assertFailed(); + } + + public static function configsExportingComposerJson() + { + return [ + 'top level export' => [[ + 'export_paths' => [ + 'composer.json', + ], + ]], + 'top level export as from' => [[ + 'export_as' => [ + 'composer.json' => 'resources/views/welcome.blade.php', + ], + ]], + 'top level export as to' => [[ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'composer.json', + ], + ]], + 'modules export' => [[ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'composer.json', + ], + ], + ], + ]], + 'modules export as from' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'composer.json' => 'resources/views/welcome.blade.php', + ], + ], + ], + ]], + 'modules export as to' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'composer.json', + ], + ], + ], + ]], + ]; + } + private function exportPath($path = null) { return collect([$this->exportPath, $path])->filter()->implode('/'); } + private function setConfig($config) + { + $this->files->put($this->configPath, YAML::dump($config)); + + if (! $this->files->exists($this->exportPath)) { + $this->files->makeDirectory($this->exportPath); + } + } + private function setExportPaths($paths, $exportAs = null) { $config['export_paths'] = $paths; @@ -525,11 +763,7 @@ private function setExportPaths($paths, $exportAs = null) $config['export_as'] = $exportAs; } - $this->files->put($this->configPath, YAML::dump($config)); - - if (! $this->files->exists($this->exportPath)) { - $this->files->makeDirectory($this->exportPath); - } + $this->setConfig($config); } private function setExportableDependencies($dependencies) @@ -542,31 +776,30 @@ private function setExportableDependencies($dependencies) $config['dependencies'] = $dependencies; } - $this->files->put($this->configPath, YAML::dump($config)); - - if (! $this->files->exists($this->exportPath)) { - $this->files->makeDirectory($this->exportPath); - } + $this->setConfig($config); } private function assertExportedConfigEquals($key, $expectedConfig) { return $this->assertEquals( $expectedConfig, - YAML::parse($this->files->get($this->exportPath('starter-kit.yaml')))[$key] + Arr::get(YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))), $key) ); } private function assertExportedConfigDoesNotHave($key) { return $this->assertFalse( - isset(YAML::parse($this->files->get($this->exportPath('starter-kit.yaml')))[$key]) + Arr::has(YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))), $key) ); } private function exportCoolRunnings() { - $this->artisan('statamic:starter-kit:export', ['path' => '../cool-runnings', '--no-interaction' => true]); + return $this->artisan('statamic:starter-kit:export', [ + 'path' => '../cool-runnings', + '--no-interaction' => true, + ]); } private function assertFileHasContent($expected, $path) From fe4bf5e3f008c2b08832c89ad40dcfad97cea739 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 18:35:49 -0400 Subject: [PATCH 28/44] Use new `expectsChoice()` fixes, when available. --- tests/StarterKits/InstallTest.php | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 8f533fa889..d62c3a0275 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -966,21 +966,24 @@ public function it_display_custom_module_prompts() $this->assertFileDoesNotExist(base_path('resources/js/vue.js')); $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); - $this + $command = $this ->installCoolRunningsModules() - ->expectsConfirmation('Want some extra SEO magic?', 'yes') - ->expectsQuestion('Want one of these fancy JS options?', 'svelte'); - - // TODO: Also assert custom option labels using `expectsChoice()`, - // but there is currently a bug with the third `$answers` param, - // so maybe we can revisit this after. For example... - // - // ->expectsChoice('Want one of these fancy JS options?', 'svelte', [ - // 'skip_module' => 'No', - // 'react' => 'React JS', - // 'vue' => 'Vue JS', - // 'svelte' => 'Svelte', - // ]); + ->expectsConfirmation('Want some extra SEO magic?', 'yes'); + + // Some fixes to `expectsChoice()` were merged for us, but are not available on 11.20.0 and below + // See: https://github.com/laravel/framework/pull/52408 + if (version_compare(app()->version(), '11.20.0', '>')) { + $command->expectsChoice('Want one of these fancy JS options?', 'svelte', [ + 'skip_module' => 'No', + 'react' => 'React JS', + 'vue' => 'Vue JS', + 'svelte' => 'Svelte', + ]); + } else { + $command->expectsQuestion('Want one of these fancy JS options?', 'svelte'); + } + + $command->run(); $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); $this->assertFileDoesNotExist(base_path('resources/js/react.js')); From 81de239c2f2a1ea53b764b93da8c6cce90e950d3 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Thu, 8 Aug 2024 23:14:07 -0400 Subject: [PATCH 29/44] Fix a long-standing bug with how dependencies are exported. --- src/StarterKits/ExportableModule.php | 3 ++ src/StarterKits/Exporter.php | 31 +++++++----- tests/StarterKits/ExportTest.php | 76 ++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/StarterKits/ExportableModule.php b/src/StarterKits/ExportableModule.php index 1b43406538..d430f6cc41 100644 --- a/src/StarterKits/ExportableModule.php +++ b/src/StarterKits/ExportableModule.php @@ -49,6 +49,9 @@ public function versionDependencies(): self { $exportableDependencies = $this->getExportableDependencies(); + $this->config->forget('dependencies'); + $this->config->forget('dependencies_dev'); + if ($dependencies = $this->exportDependenciesFromComposerRequire('require', $exportableDependencies)) { $this->config->put('dependencies', $dependencies->all()); } diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 831555e5a7..4744b9f8fc 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -134,7 +134,7 @@ protected function exportConfig(): self { $config = $this ->versionModuleDependencies() - ->syncWithModuleConfigs(); + ->syncConfigWithModules(); $this->files->put("{$this->exportPath}/starter-kit.yaml", YAML::dump($config->all())); @@ -154,31 +154,34 @@ protected function versionModuleDependencies(): self /** * Get synced config from newly versioned module dependencies. */ - protected function syncWithModuleConfigs(): Collection + protected function syncConfigWithModules(): Collection { $config = $this->config()->all(); $this->modules->each(function ($module) use (&$config) { - if ($dependencies = $module->config('dependencies')) { - Arr::forget($config, $this->dottedModulePath($module, 'dependencies')); - Arr::set($config, $this->dottedModulePath($module, 'dependencies'), $dependencies); - } - }); - - $this->modules->each(function ($module) use (&$config) { - if ($dependenciesDev = $module->config('dependencies_dev')) { - Arr::forget($config, $this->dottedModulePath($module, 'dependencies_dev')); - Arr::set($config, $this->dottedModulePath($module, 'dependencies_dev'), $dependenciesDev); - } + $this->syncConfigWithIndividualModule($config, $module, 'dependencies'); + $this->syncConfigWithIndividualModule($config, $module, 'dependencies_dev'); }); return collect($config); } + /** + * Sync config with individual module + */ + protected function syncConfigWithIndividualModule(array &$config, ExportableModule $module, string $key) + { + Arr::forget($config, $this->dottedModulePath($module, $key)); + + if ($dependenciesDev = $module->config($key)) { + Arr::set($config, $this->dottedModulePath($module, $key), $dependenciesDev); + } + } + /** * Get dotted module path. */ - protected function dottedModulePath(Module $module, string $key): string + protected function dottedModulePath(ExportableModule $module, string $key): string { if ($module->isTopLevelModule()) { return $key; diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 9b4ca093d0..0170b0829e 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -426,6 +426,82 @@ public function it_overrides_dev_dependencies_from_composer_json() ]); } + #[Test] + public function it_correctly_categorizes_non_dev_dependencies_from_composer_json() + { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} + +EOT + ); + + // this is actually a dev dependency, so it should get converted to dev dependency + $this->setExportableDependencies([ + 'dependencies' => [ + 'statamic/ssg', + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigDoesNotHave('dependencies'); + + $this->assertExportedConfigEquals('dependencies_dev', [ + 'statamic/ssg' => '^0.4.0', + ]); + } + + #[Test] + public function it_correctly_categorizes_dev_dependencies_from_composer_json() + { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} + +EOT + ); + + // this is actually a non-dev dependency, so it should get converted to non-dev dependency + $this->setExportableDependencies([ + 'dependencies_dev' => [ + 'hansolo/falcon', + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigEquals('dependencies', [ + 'hansolo/falcon' => '*', + ]); + + $this->assertExportedConfigDoesNotHave('dependencies_dev'); + } + #[Test] public function it_does_not_export_opinionated_app_composer_json() { From b9779a93f8062f7fe544e986116bf95bb24447e8 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 9 Aug 2024 00:06:32 -0400 Subject: [PATCH 30/44] Less bugs, more tests. --- src/StarterKits/Exporter.php | 21 ++- src/StarterKits/Installer.php | 2 +- tests/StarterKits/ExportTest.php | 267 ++++++++++++++++++++++++++++++- 3 files changed, 280 insertions(+), 10 deletions(-) diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 4744b9f8fc..d7259428e2 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -96,7 +96,7 @@ protected function instantiateModule(array $config, string $key): ExportableModu { if (Arr::has($config, 'options')) { return collect($config['options']) - ->map(fn ($option) => $this->instantiateModule($option, $key)) + ->map(fn ($option, $optionKey) => $this->instantiateModule($option, "{$key}.options.{$optionKey}")) ->all(); } @@ -158,9 +158,18 @@ protected function syncConfigWithModules(): Collection { $config = $this->config()->all(); - $this->modules->each(function ($module) use (&$config) { - $this->syncConfigWithIndividualModule($config, $module, 'dependencies'); - $this->syncConfigWithIndividualModule($config, $module, 'dependencies_dev'); + $normalizedModuleKeyOrder = [ + 'export_paths', + 'export_as', + 'dependencies', + 'dependencies_dev', + 'modules', + ]; + + $this->modules->each(function ($module) use ($normalizedModuleKeyOrder, &$config) { + foreach ($normalizedModuleKeyOrder as $key) { + $this->syncConfigWithIndividualModule($config, $module, $key); + } }); return collect($config); @@ -173,8 +182,8 @@ protected function syncConfigWithIndividualModule(array &$config, ExportableModu { Arr::forget($config, $this->dottedModulePath($module, $key)); - if ($dependenciesDev = $module->config($key)) { - Arr::set($config, $this->dottedModulePath($module, $key), $dependenciesDev); + if ($module->config()->has($key)) { + Arr::set($config, $this->dottedModulePath($module, $key), $module->config($key)); } } diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index ccc7269b92..8f51e0e43b 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -350,7 +350,7 @@ protected function instantiateOptionsModule(array $config, string $key): Install return false; } - return (new InstallableModule($config['options'][$choice], $key))->installer($this); + return (new InstallableModule($config['options'][$choice], "{$key}_{$choice}"))->installer($this); } /** diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 0170b0829e..7da72725cf 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -622,6 +622,40 @@ public function it_can_export_module_files() $this->assertFileHasContent('setConfig([ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + ], + 'react' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'resources/views/you-are-so-welcome.blade.php', + ], + ], + ], + ], + ], + ]); + + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/you-are-so-welcome.blade.php')); + + $this->exportCoolRunnings(); + + $this->assertFileExists($filesystemsConfig); + $this->assertFileHasContent("'disks' => [", $filesystemsConfig); + + $this->assertFileExists($welcomeView); + $this->assertFileHasContent('assertExportedConfigDoesNotHave('modules.ssg.dependencies'); } + #[Test] + public function it_can_export_options_module_dependencies() + { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + + $this->setConfig([ + 'modules' => [ + 'first_party' => [ + 'options' => [ + 'seo' => [ + 'dependencies' => [ + 'statamic/seo-pro', + ], + ], + 'ssg' => [ + 'dependencies_dev' => [ + 'statamic/ssg', + ], + ], + ], + ], + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigEquals('modules.first_party.options.seo.dependencies', [ + 'statamic/seo-pro' => '^2.2', + ]); + + $this->assertExportedConfigDoesNotHave('modules.first_party.seo.dependencies_dev'); + + $this->assertExportedConfigEquals('modules.first_party.options.ssg.dependencies_dev', [ + 'statamic/ssg' => '^0.4.0', + ]); + + $this->assertExportedConfigDoesNotHave('modules.first_party.ssg.dependencies'); + } + #[Test] public function it_requires_valid_module_config() { @@ -787,7 +876,7 @@ public static function configsExportingComposerJson() 'resources/views/welcome.blade.php' => 'composer.json', ], ]], - 'modules export' => [[ + 'module export' => [[ 'modules' => [ 'seo' => [ 'export_paths' => [ @@ -796,7 +885,7 @@ public static function configsExportingComposerJson() ], ], ]], - 'modules export as from' => [[ + 'module export as from' => [[ 'modules' => [ 'seo' => [ 'export_as' => [ @@ -805,7 +894,7 @@ public static function configsExportingComposerJson() ], ], ]], - 'modules export as to' => [[ + 'module export as to' => [[ 'modules' => [ 'seo' => [ 'export_as' => [ @@ -814,9 +903,173 @@ public static function configsExportingComposerJson() ], ], ]], + 'options module export' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'composer.json', + ], + ], + ], + ], + ], + ]], + 'options module export as from' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'composer.json' => 'resources/views/welcome.blade.php', + ], + ], + ], + ], + ], + ]], + 'options module export as to' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'composer.json', + ], + ], + ], + ], + ], + ]], ]; } + #[Test] + public function it_normalizes_module_key_order() + { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + + $paths = $this->cleanPaths([ + base_path('README.md'), + base_path('test-folder'), + resource_path('vue.js'), + ]); + + $this->files->put(base_path('README.md'), 'This is a readme!'); + $this->files->makeDirectory(base_path('test-folder')); + $this->files->put(base_path('test-folder/one.txt'), 'One.'); + $this->files->put(resource_path('vue.js'), 'Vue!'); + + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'dependencies_dev' => [ + 'statamic/ssg', + ], + 'prompt' => false, + 'export_as' => [ + 'README.md' => 'README-new-site.md', + ], + 'dependencies' => [ + 'statamic/seo-pro', + ], + 'export_paths' => [ + 'resources/views', + ], + ], + 'js' => [ + 'prompt' => 'Pick the best JS framework!', + 'skip_option' => 'Nah', + 'options' => [ + 'vue' => [ + 'label' => 'Vue JS', + 'dependencies' => [ + 'hansolo/falcon', + ], + 'export_paths' => [ + 'resources/vue.js', + ], + ], + ], + ], + ], + 'export_as' => [ + 'test-folder' => 'test-renamed-folder', + ], + 'dependencies_dev' => [ + 'statamic/ssg', + ], + 'export_paths' => [ + 'config/filesystems.php', + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertConfigSameOrder([ + 'export_paths' => [ + 'config/filesystems.php', + ], + 'export_as' => [ + 'test-folder' => 'test-renamed-folder', + ], + 'dependencies_dev' => [ + 'statamic/ssg' => '^0.4.0', + ], + 'modules' => [ + 'seo' => [ + 'prompt' => false, + 'export_paths' => [ + 'resources/views', + ], + 'export_as' => [ + 'README.md' => 'README-new-site.md', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^2.2', + ], + 'dependencies_dev' => [ + 'statamic/ssg' => '^0.4.0', + ], + ], + 'js' => [ + 'prompt' => 'Pick the best JS framework!', + 'skip_option' => 'Nah', + 'options' => [ + 'vue' => [ + 'label' => 'Vue JS', + 'export_paths' => [ + 'resources/vue.js', + ], + 'dependencies' => [ + 'hansolo/falcon' => '*', + ], + ], + ], + ], + ], + ]); + + $this->cleanPaths($paths); + } + private function exportPath($path = null) { return collect([$this->exportPath, $path])->filter()->implode('/'); @@ -870,6 +1123,14 @@ private function assertExportedConfigDoesNotHave($key) ); } + private function assertConfigSameOrder($expectedConfig) + { + return $this->assertSame( + $expectedConfig, + YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))) + ); + } + private function exportCoolRunnings() { return $this->artisan('statamic:starter-kit:export', [ From a8a33c2904f25d423116c0b9028ad371ded6dcde Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Fri, 9 Aug 2024 00:43:05 -0400 Subject: [PATCH 31/44] Validate dependencies are actually installed in composer.json, so that they are properly exported. --- src/StarterKits/ExportableModule.php | 29 ++++- tests/StarterKits/ExportTest.php | 162 +++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/src/StarterKits/ExportableModule.php b/src/StarterKits/ExportableModule.php index d430f6cc41..8bf988b5c4 100644 --- a/src/StarterKits/ExportableModule.php +++ b/src/StarterKits/ExportableModule.php @@ -19,7 +19,8 @@ public function validate(): void $this ->ensureModuleConfigNotEmpty() ->ensureNotExportingComposerJson() - ->ensureExportablePathsExist(); + ->ensureExportablePathsExist() + ->ensureExportableDependenciesExist(); } /** @@ -47,7 +48,7 @@ public function export(string $starterKitPath): void public function versionDependencies(): self { - $exportableDependencies = $this->getExportableDependencies(); + $exportableDependencies = $this->exportableDependencies(); $this->config->forget('dependencies'); $this->config->forget('dependencies_dev'); @@ -66,7 +67,7 @@ public function versionDependencies(): self /** * Get exportable dependencies without versions from module config. */ - protected function getExportableDependencies(): Collection + protected function exportableDependencies(): Collection { $config = $this->config(); @@ -135,4 +136,26 @@ protected function ensureExportablePathsExist(): self return $this; } + + /** + * Ensure export dependencies exist in app's composer.json. + * + * @throws StarterKitException + */ + protected function ensureExportableDependenciesExist(): self + { + $installedDependencies = collect(json_decode($this->files->get(base_path('composer.json')), true)) + ->only(['require', 'require-dev']) + ->map(fn ($dependencies) => array_keys($dependencies)) + ->flatten(); + + $this + ->exportableDependencies() + ->reject(fn ($dependency) => $installedDependencies->contains($dependency)) + ->each(function ($dependency) { + throw new StarterKitException("Cannot export [{$dependency}], because it does not exist in your composer.json!"); + }); + + return $this; + } } diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 7da72725cf..c1c1e4268c 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -809,6 +809,24 @@ public function it_doesnt_require_anything_exportable_in_top_level_if_user_wants #[DataProvider('validModuleConfigs')] public function it_passes_validation_if_module_export_paths_or_dependencies_are_properly_configured($config) { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + $this->setConfig([ 'modules' => [ 'seo' => array_merge(['prompt' => false], $config), @@ -846,6 +864,150 @@ public static function validModuleConfigs() ]; } + #[Test] + #[DataProvider('nonExistentExportPaths')] + public function it_fails_validation_if_module_export_paths_do_not_exist($config) + { + $this->setConfig($config); + + $this + ->exportCoolRunnings() + // ->expectsOutput('Cannot export [non-existent.txt], because it does not exist in your app!') // TODO: Why does this work in InstallTest? + ->assertFailed(); + } + + public static function nonExistentExportPaths() + { + return [ + 'top level export' => [[ + 'export_paths' => [ + 'non-existent.txt', + ], + ]], + 'top level export as from' => [[ + 'export_as' => [ + 'non-existent.txt' => 'resources/views/welcome.blade.php', + ], + ]], + 'module export' => [[ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'non-existent.txt', + ], + ], + ], + ]], + 'module export as from' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'non-existent.txt' => 'resources/views/welcome.blade.php', + ], + ], + ], + ]], + 'options module export' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'non-existent.txt', + ], + ], + ], + ], + ], + ]], + 'options module export as from' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'non-existent.txt' => 'resources/views/welcome.blade.php', + ], + ], + ], + ], + ], + ]], + ]; + } + + #[Test] + #[DataProvider('nonExistentDependencies')] + public function it_fails_validation_if_module_dependencies_are_not_installed_in_composer_json($config) + { + $this->setConfig($config); + + $this + ->exportCoolRunnings() + // ->expectsOutput('Cannot export [non-existent.txt], because it does not exist in your app!') // TODO: Why does this work in InstallTest? + ->assertFailed(); + } + + public static function nonExistentDependencies() + { + return [ + 'top level dependencies' => [[ + 'dependencies' => [ + 'non/existent', + ], + ]], + 'top level dev dependencies' => [[ + 'dependencies_dev' => [ + 'non/existent', + ], + ]], + 'module dependencies' => [[ + 'modules' => [ + 'seo' => [ + 'dependencies' => [ + 'non/existent', + ], + ], + ], + ]], + 'module dev dependencies' => [[ + 'modules' => [ + 'seo' => [ + 'dependencies_dev' => [ + 'non/existent', + ], + ], + ], + ]], + 'options module dependencies' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'dependencies' => [ + 'non/existent', + ], + ], + ], + ], + ], + ]], + 'options module dev dependencies' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'dependencies_dev' => [ + 'non/existent', + ], + ], + ], + ], + ], + ]], + ]; + } + #[Test] #[DataProvider('configsExportingComposerJson')] public function it_doesnt_allow_exporting_of_composer_json_file($config) From fcd81c13bde1f05d043d169e845babfa8544f191 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 14:36:02 -0400 Subject: [PATCH 32/44] Remove square brackets from prompts. --- src/StarterKits/Installer.php | 8 ++++++-- tests/StarterKits/InstallTest.php | 12 ++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 8f51e0e43b..b8482f5c47 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -322,7 +322,9 @@ protected function instantiateModule(array $config, string $key): InstallableMod $shouldPrompt = false; } - if ($shouldPrompt && $this->isInteractive && ! confirm(Arr::get($config, 'prompt', "Would you like to install the [{$key}] module?"), false)) { + $name = str_replace('_', ' ', $key); + + if ($shouldPrompt && $this->isInteractive && ! confirm(Arr::get($config, 'prompt', "Would you like to install the {$name} module?"), false)) { return false; } elseif ($shouldPrompt && ! $this->isInteractive) { return false; @@ -341,8 +343,10 @@ protected function instantiateOptionsModule(array $config, string $key): Install ->prepend(Arr::get($config, 'skip_option', 'No'), $skipModule = 'skip_module') ->all(); + $name = str_replace('_', ' ', $key); + $choice = select( - label: Arr::get($config, 'prompt', "Would you like to install one of the following [{$key}] modules?"), + label: Arr::get($config, 'prompt', "Would you like to install one of the following {$name} modules?"), options: $options, ); diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index d62c3a0275..ec5164c9a7 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -905,11 +905,11 @@ public function it_installs_only_the_modules_confirmed_interactively_via_prompt( $this ->installCoolRunningsModules() - ->expectsConfirmation('Would you like to install the [seo] module?', 'yes') - ->expectsConfirmation('Would you like to install the [bobsled] module?', 'no') - ->expectsConfirmation('Would you like to install the [jamaica] module?', 'yes') - ->expectsQuestion('Would you like to install one of the following [js] modules?', 'vue') - ->expectsQuestion('Would you like to install one of the following [oldschool_js] modules?', 'skip_module'); + ->expectsConfirmation('Would you like to install the seo module?', 'yes') + ->expectsConfirmation('Would you like to install the bobsled module?', 'no') + ->expectsConfirmation('Would you like to install the jamaica module?', 'yes') + ->expectsQuestion('Would you like to install one of the following js modules?', 'vue') + ->expectsQuestion('Would you like to install one of the following oldschool js modules?', 'skip_module'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); @@ -1020,7 +1020,7 @@ public function it_installs_modules_without_dependencies() $this ->installCoolRunningsModules(['--without-dependencies' => true]) - ->expectsConfirmation('Would you like to install the [seo] module?', 'yes'); + ->expectsConfirmation('Would you like to install the seo module?', 'yes'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); From bf233e36f6b1c9510aeddf21f92b1e95a2aa4724 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 14:37:06 -0400 Subject: [PATCH 33/44] Rename var for clarity. --- src/StarterKits/Installer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index b8482f5c47..ae5759be97 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -339,7 +339,7 @@ protected function instantiateModule(array $config, string $key): InstallableMod protected function instantiateOptionsModule(array $config, string $key): InstallableModule|bool { $options = collect($config['options']) - ->map(fn ($option, $key) => Arr::get($option, 'label', ucfirst($key))) + ->map(fn ($option, $optionKey) => Arr::get($option, 'label', ucfirst($optionKey))) ->prepend(Arr::get($config, 'skip_option', 'No'), $skipModule = 'skip_module') ->all(); From d19666bc4fd2d89f3b8f4afd52f04feeb113d0f9 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 14:39:18 -0400 Subject: [PATCH 34/44] Add support for nested modules. --- src/StarterKits/Installer.php | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index ae5759be97..6e74a6f10e 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -298,6 +298,7 @@ protected function instantiateModules(): self ->merge($nestedConfigs) ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) ->filter() + ->flatten() ->each(fn ($module) => $module->validate()); return $this; @@ -306,7 +307,7 @@ protected function instantiateModules(): self /** * Instantiate individual module. */ - protected function instantiateModule(array $config, string $key): InstallableModule|bool + protected function instantiateModule(array $config, string $key): InstallableModule|array|bool { $shouldPrompt = true; @@ -330,13 +331,17 @@ protected function instantiateModule(array $config, string $key): InstallableMod return false; } + if ($key !== 'top_level' && Arr::has($config, 'modules')) { + return $this->instantiateNestedModules($config['modules'], $key); + } + return (new InstallableModule($config, $key))->installer($this); } /** * Instantiate options module. */ - protected function instantiateOptionsModule(array $config, string $key): InstallableModule|bool + protected function instantiateOptionsModule(array $config, string $key): InstallableModule|array|bool { $options = collect($config['options']) ->map(fn ($option, $optionKey) => Arr::get($option, 'label', ucfirst($optionKey))) @@ -354,7 +359,29 @@ protected function instantiateOptionsModule(array $config, string $key): Install return false; } - return (new InstallableModule($config['options'][$choice], "{$key}_{$choice}"))->installer($this); + $selectedKey = "{$key}_{$choice}"; + $selectedModuleConfig = $config['options'][$choice]; + + if ($key !== 'top_level' && Arr::has($selectedModuleConfig, 'modules')) { + return $this->instantiateNestedModules($selectedModuleConfig['modules'], $selectedKey); + } + + return (new InstallableModule($selectedModuleConfig, $selectedKey))->installer($this); + } + + /** + * Instantiate nested modules. + */ + protected function instantiateNestedModules(array $modules, string $key): array|bool + { + if ($key === 'top_level') { + return false; + } + + return collect($modules) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}_{$childKey}")) + ->filter() + ->all(); } /** From ea3d6d7da06073d4c344089b8b69471f10c58925 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 15:38:31 -0400 Subject: [PATCH 35/44] =?UTF-8?q?And=20this=20is=20why=20we=20write=20test?= =?UTF-8?q?s!=20=F0=9F=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/StarterKits/Installer.php | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 6e74a6f10e..3a3d828d95 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -331,11 +331,7 @@ protected function instantiateModule(array $config, string $key): InstallableMod return false; } - if ($key !== 'top_level' && Arr::has($config, 'modules')) { - return $this->instantiateNestedModules($config['modules'], $key); - } - - return (new InstallableModule($config, $key))->installer($this); + return $this->instantiateRecursively($config, $key); } /** @@ -362,26 +358,29 @@ protected function instantiateOptionsModule(array $config, string $key): Install $selectedKey = "{$key}_{$choice}"; $selectedModuleConfig = $config['options'][$choice]; - if ($key !== 'top_level' && Arr::has($selectedModuleConfig, 'modules')) { - return $this->instantiateNestedModules($selectedModuleConfig['modules'], $selectedKey); - } - - return (new InstallableModule($selectedModuleConfig, $selectedKey))->installer($this); + return $this->instantiateRecursively($selectedModuleConfig, $selectedKey); } /** - * Instantiate nested modules. + * Instantiate module and check if nested modules should be recursively instantiated. */ - protected function instantiateNestedModules(array $modules, string $key): array|bool + protected function instantiateRecursively(array $config, string $key): InstallableModule|array { - if ($key === 'top_level') { - return false; + $instantiated = (new InstallableModule($config, $key))->installer($this); + + if ($instantiated->isTopLevelModule()) { + return $instantiated; } - return collect($modules) - ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}_{$childKey}")) - ->filter() - ->all(); + if ($modules = Arr::get($config, 'modules')) { + $instantiated = collect($modules) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}_{$childKey}")) + ->prepend($instantiated, $key) + ->filter() + ->all(); + } + + return $instantiated; } /** From 6fec4a04807f7b117673788083675d928e7137e4 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 18:35:35 -0400 Subject: [PATCH 36/44] Change terminology to better match docs. --- src/StarterKits/Installer.php | 6 +++--- tests/StarterKits/ExportTest.php | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 3a3d828d95..3040f399d8 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -316,7 +316,7 @@ protected function instantiateModule(array $config, string $key): InstallableMod } if (Arr::has($config, 'options')) { - return $this->instantiateOptionsModule($config, $key); + return $this->instantiateSelectModule($config, $key); } if (Arr::get($config, 'prompt') === false) { @@ -335,9 +335,9 @@ protected function instantiateModule(array $config, string $key): InstallableMod } /** - * Instantiate options module. + * Instantiate select module. */ - protected function instantiateOptionsModule(array $config, string $key): InstallableModule|array|bool + protected function instantiateSelectModule(array $config, string $key): InstallableModule|array|bool { $options = collect($config['options']) ->map(fn ($option, $optionKey) => Arr::get($option, 'label', ucfirst($optionKey))) diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index c1c1e4268c..f68ec14118 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -907,7 +907,7 @@ public static function nonExistentExportPaths() ], ], ]], - 'options module export' => [[ + 'select module export' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -920,7 +920,7 @@ public static function nonExistentExportPaths() ], ], ]], - 'options module export as from' => [[ + 'select module export as from' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -979,7 +979,7 @@ public static function nonExistentDependencies() ], ], ]], - 'options module dependencies' => [[ + 'select module dependencies' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -992,7 +992,7 @@ public static function nonExistentDependencies() ], ], ]], - 'options module dev dependencies' => [[ + 'select module dev dependencies' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -1065,7 +1065,7 @@ public static function configsExportingComposerJson() ], ], ]], - 'options module export' => [[ + 'select module export' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -1078,7 +1078,7 @@ public static function configsExportingComposerJson() ], ], ]], - 'options module export as from' => [[ + 'select module export as from' => [[ 'modules' => [ 'js' => [ 'options' => [ @@ -1091,7 +1091,7 @@ public static function configsExportingComposerJson() ], ], ]], - 'options module export as to' => [[ + 'select module export as to' => [[ 'modules' => [ 'js' => [ 'options' => [ From 976c298d330432d5329ec8b56376d0cb381ddb6c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 19:22:06 -0400 Subject: [PATCH 37/44] Test installation of nested modules. --- tests/StarterKits/InstallTest.php | 201 ++++++++++++++++++ .../cool-runnings/resources/css/hockey.css | 1 + .../dictionaries/american_players.yaml | 1 + .../dictionaries/canadian_players.yaml | 1 + .../resources/dictionaries/players.yaml | 1 + .../resources/js/react-testing-tools.js | 1 + .../resources/js/vue-testing-tools.js | 1 + 7 files changed, 207 insertions(+) create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/hockey.css create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/american_players.yaml create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/canadian_players.yaml create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/players.yaml create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/react-testing-tools.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue-testing-tools.js diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index ec5164c9a7..a5008e53d0 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -1114,6 +1114,207 @@ public static function validModuleConfigs() ]; } + #[Test] + public function it_installs_nested_modules_with_prompt_false_config_by_default_when_running_non_interactively() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'modules' => [ + 'canada' => [ + 'prompt' => false, // Setting prompt to false skips confirmation, so this module should still get installed non-interactively + 'export_paths' => [ + 'resources/css/hockey.css', + ], + 'modules' => [ + 'hockey_players' => [ + 'prompt' => false, // Setting prompt to false skips confirmation, so this module should still get installed non-interactively + 'export_paths' => [ + 'resources/dictionaries/players.yaml', + ], + 'dependencies' => [ + 'nhl/hockey-league' => '*', + ], + 'modules' => [ + 'hockey_night_in_usa' => [ + 'export_paths' => [ + 'resources/dictionaries/american_players.yaml', + ], + ], + 'hockey_night_in_canada' => [ + 'prompt' => false, // Setting prompt to false skips confirmation, so this module should still get installed non-interactively + 'export_paths' => [ + 'resources/dictionaries/canadian_players.yaml', + ], + ], + ], + ], + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/hockey.css')); + $this->assertComposerJsonDoesntHave('nhl/hockey-league'); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/players.yaml')); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/american_players.yaml')); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/canadian_players.yaml')); + + $this->installCoolRunnings(); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(base_path('resources/css/hockey.css')); + $this->assertComposerJsonHasPackageVersion('require', 'nhl/hockey-league', '*'); + $this->assertFileExists(base_path('resources/dictionaries/players.yaml')); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/american_players.yaml')); + $this->assertFileExists(base_path('resources/dictionaries/canadian_players.yaml')); + } + + #[Test] + public function it_installs_nested_modules_confirmed_interactively_via_prompt() + { + $this->setConfig([ + 'export_paths' => [ + 'copied.md', + ], + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'resources/css/seo.css', + ], + 'dependencies' => [ + 'statamic/seo-pro' => '^0.2.0', + ], + 'modules' => [ + 'js' => [ + 'options' => [ + 'react' => [ + 'export_paths' => [ + 'resources/js/react.js', + ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'resources/js/react-testing-tools.js', + ], + ], + ], + ], + 'vue' => [ + 'export_paths' => [ + 'resources/js/vue.js', + ], + 'dependencies_dev' => [ + 'i-love-vue/test-helpers' => '^1.5', + ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'resources/js/vue-testing-tools.js', + ], + ], + ], + ], + 'svelte' => [ + 'export_paths' => [ + 'resources/js/svelte.js', + ], + ], + ], + ], + 'oldschool_js' => [ + 'options' => [ + 'jquery' => [ + 'export_paths' => [ + 'resources/js/jquery.js', + ], + ], + 'mootools' => [ + 'export_paths' => [ + 'resources/js/jquery.js', + ], + ], + ], + ], + ], + ], + 'canada' => [ + 'export_paths' => [ + 'resources/css/hockey.css', + ], + 'modules' => [ + 'hockey_players' => [ + 'export_paths' => [ + 'resources/dictionaries/players.yaml', + ], + ], + ], + ], + 'jamaica' => [ + 'export_as' => [ + 'resources/css/theme.css' => 'resources/css/jamaica.css', + ], + 'modules' => [ + 'bobsled' => [ + 'export_paths' => [ + 'resources/css/bobsled.css', + ], + 'dependencies' => [ + 'bobsled/speed-calculator' => '^1.0.0', + ], + ], + ], + ], + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist(base_path('resources/css/seo.css')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileDoesNotExist(base_path('resources/js/react-testing-tools.js')); + $this->assertFileDoesNotExist(base_path('resources/js/vue.js')); + $this->assertFileDoesNotExist(base_path('resources/js/vue-testing-tools.js')); + $this->assertComposerJsonDoesntHave('i-love-vue/test-helpers'); + $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); + $this->assertFileDoesNotExist(base_path('resources/js/jquery.js')); + $this->assertFileDoesNotExist(base_path('resources/js/mootools.js')); + $this->assertFileDoesNotExist(base_path('resources/css/hockey.css')); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/players.yaml')); + $this->assertFileDoesNotExist(base_path('resources/css/theme.css')); + $this->assertFileDoesNotExist(base_path('resources/css/bobsled.css')); + $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); + + $this + ->installCoolRunningsModules() + ->expectsConfirmation('Would you like to install the seo module?', 'yes') + ->expectsQuestion('Would you like to install one of the following seo js modules?', 'vue') + ->expectsQuestion('Would you like to install the seo js vue testing tools module?', 'yes') + ->expectsQuestion('Would you like to install one of the following seo oldschool js modules?', 'skip_module') + ->expectsConfirmation('Would you like to install the canada module?', 'no') + ->expectsConfirmation('Would you like to install the jamaica module?', 'yes') + ->expectsConfirmation('Would you like to install the jamaica bobsled module?', 'yes'); + + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(base_path('resources/css/seo.css')); + $this->assertComposerJsonHasPackageVersion('require', 'statamic/seo-pro', '^0.2.0'); + $this->assertFileDoesNotExist(base_path('resources/js/react.js')); + $this->assertFileDoesNotExist(base_path('resources/js/react-testing-tools.js')); + $this->assertFileExists(base_path('resources/js/vue.js')); + $this->assertFileExists(base_path('resources/js/vue-testing-tools.js')); + $this->assertComposerJsonHasPackageVersion('require-dev', 'i-love-vue/test-helpers', '^1.5'); + $this->assertFileDoesNotExist(base_path('resources/js/svelte.js')); + $this->assertFileDoesNotExist(base_path('resources/js/jquery.js')); + $this->assertFileDoesNotExist(base_path('resources/js/mootools.js')); + $this->assertFileDoesNotExist(base_path('resources/css/hockey.css')); + $this->assertFileDoesNotExist(base_path('resources/dictionaries/players.yaml')); + $this->assertFileExists(base_path('resources/css/theme.css')); + $this->assertFileExists(base_path('resources/css/bobsled.css')); + $this->assertComposerJsonHasPackageVersion('require', 'bobsled/speed-calculator', '^1.0.0'); + } + private function kitRepoPath($path = null) { return collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/'); diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/css/hockey.css b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/hockey.css new file mode 100644 index 0000000000..99af60b45a --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/css/hockey.css @@ -0,0 +1 @@ +/* hockey! */ diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/american_players.yaml b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/american_players.yaml new file mode 100644 index 0000000000..7a82c2d318 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/american_players.yaml @@ -0,0 +1 @@ +# 'murican hockey players! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/canadian_players.yaml b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/canadian_players.yaml new file mode 100644 index 0000000000..0dc9e13b24 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/canadian_players.yaml @@ -0,0 +1 @@ +# hoser hockey players! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/players.yaml b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/players.yaml new file mode 100644 index 0000000000..2c12d36764 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/dictionaries/players.yaml @@ -0,0 +1 @@ +# hockey players! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react-testing-tools.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react-testing-tools.js new file mode 100644 index 0000000000..5eecba8367 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react-testing-tools.js @@ -0,0 +1 @@ +// react testing tools! diff --git a/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue-testing-tools.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue-testing-tools.js new file mode 100644 index 0000000000..e36ed28f37 --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue-testing-tools.js @@ -0,0 +1 @@ +// vue testing tools! From 345464aea99af29d59ed519ac752bea01184ce53 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 19:43:03 -0400 Subject: [PATCH 38/44] Improve install module validation. --- src/StarterKits/Module.php | 9 +++---- tests/StarterKits/InstallTest.php | 41 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index eb0726b391..acd102d63e 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -77,17 +77,14 @@ protected function exportAsPaths(): Collection */ protected function ensureModuleConfigNotEmpty(): self { - if ($this->isTopLevelModule()) { - return $this; - } - $hasConfig = $this->config()->has('export_paths') || $this->config()->has('export_as') || $this->config()->has('dependencies') - || $this->config()->has('dependencies_dev'); + || $this->config()->has('dependencies_dev') + || $this->config()->has('modules'); if (! $hasConfig) { - throw new StarterKitException('Starter-kit module is missing `export_paths` or `dependencies`!'); + throw new StarterKitException('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!'); } return $this; diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index a5008e53d0..88af4b3cb5 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -1028,6 +1028,23 @@ public function it_installs_modules_without_dependencies() $this->assertComposerJsonDoesntHave('bobsled/speed-calculator'); } + #[Test] + public function it_requires_valid_config_at_top_level() + { + $this->setConfig([ + // no installable config! + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + + $this + ->installCoolRunnings() + ->expectsOutput('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!') + ->assertFailed(); + + $this->assertFileDoesNotExist(base_path('copied.md')); + } + #[Test] public function it_requires_valid_module_config() { @@ -1044,21 +1061,26 @@ public function it_requires_valid_module_config() $this ->installCoolRunnings() - ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') + ->expectsOutput('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!') ->assertFailed(); $this->assertFileDoesNotExist(base_path('copied.md')); } #[Test] - public function it_doesnt_require_anything_installable_in_top_level_if_user_wants_to_organize_using_modules_only() + public function it_doesnt_require_anything_installable_if_module_contains_nested_modules() { $this->setConfig([ 'modules' => [ 'seo' => [ 'prompt' => false, - 'export_paths' => [ - 'copied.md', + 'modules' => [ + 'js' => [ + 'prompt' => false, + 'export_paths' => [ + 'copied.md', + ], + ], ], ], ], @@ -1075,7 +1097,7 @@ public function it_doesnt_require_anything_installable_in_top_level_if_user_want #[Test] #[DataProvider('validModuleConfigs')] - public function it_passes_validation_if_module_export_paths_or_dependencies_are_properly_configured($config) + public function it_passes_validation_if_module_export_paths_or_dependencies_or_nested_modules_are_properly_configured($config) { $this->setConfig([ 'modules' => [ @@ -1111,6 +1133,15 @@ public static function validModuleConfigs() 'statamic/seo-pro' => '^1.0', ], ]], + 'nested modules' => [[ + 'modules' => [ + 'js' => [ + 'export_paths' => [ + 'resources/js/vue.js', + ], + ], + ], + ]], ]; } From 40a8b36b07c2b0e3c1e4b376d2ed431054745948 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 19:49:06 -0400 Subject: [PATCH 39/44] Should filter after. --- src/StarterKits/Installer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 3040f399d8..2fc7fde943 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -297,8 +297,8 @@ protected function instantiateModules(): self $this->modules = collect(['top_level' => $topLevelConfig]) ->merge($nestedConfigs) ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) - ->filter() ->flatten() + ->filter() ->each(fn ($module) => $module->validate()); return $this; From 71dff520fe6fcf691e47112177fe51e1ff8c775c Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 20:39:31 -0400 Subject: [PATCH 40/44] Add support for nested modules in exporter. --- src/StarterKits/Exporter.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index d7259428e2..24cdf48b96 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -96,11 +96,33 @@ protected function instantiateModule(array $config, string $key): ExportableModu { if (Arr::has($config, 'options')) { return collect($config['options']) - ->map(fn ($option, $optionKey) => $this->instantiateModule($option, "{$key}.options.{$optionKey}")) + ->map(fn ($option, $optionKey) => $this->instantiateRecursively($option, "{$key}.options.{$optionKey}")) ->all(); } - return new ExportableModule($config, $key); + return $this->instantiateRecursively($config, $key); + } + + /** + * Instantiate module and check if nested modules should be recursively instantiated. + */ + protected function instantiateRecursively(array $config, string $key): ExportableModule|array + { + $instantiated = new ExportableModule($config, $key); + + if ($instantiated->isTopLevelModule()) { + return $instantiated; + } + + if ($modules = Arr::get($config, 'modules')) { + $instantiated = collect($modules) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}.modules.{$childKey}")) + ->prepend($instantiated, $key) + ->filter() + ->all(); + } + + return $instantiated; } /** From 533512c96d688a03d0100f3eb9cd7e8c9ce99825 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 20:39:40 -0400 Subject: [PATCH 41/44] Add test coverage for exporter. --- tests/StarterKits/ExportTest.php | 254 ++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 7 deletions(-) diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index f68ec14118..75d4d4d69c 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -623,7 +623,73 @@ public function it_can_export_module_files() } #[Test] - public function it_can_export_options_module_files() + public function it_can_export_nested_module_files() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + 'modules' => [ + 'ssg' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'resources/views/you-are-so-welcome.blade.php', + ], + ], + ], + ], + ], + ]); + + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/you-are-so-welcome.blade.php')); + + $this->exportCoolRunnings(); + + $this->assertFileExists($filesystemsConfig); + $this->assertFileHasContent("'disks' => [", $filesystemsConfig); + + $this->assertFileExists($welcomeView); + $this->assertFileHasContent('setConfig([ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + ], + 'react' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'resources/views/you-are-so-welcome.blade.php', + ], + ], + ], + ], + ], + ]); + + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/you-are-so-welcome.blade.php')); + + $this->exportCoolRunnings(); + + $this->assertFileExists($filesystemsConfig); + $this->assertFileHasContent("'disks' => [", $filesystemsConfig); + + $this->assertFileExists($welcomeView); + $this->assertFileHasContent('setConfig([ 'modules' => [ @@ -633,6 +699,13 @@ public function it_can_export_options_module_files() 'export_paths' => [ 'config/filesystems.php', ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'config/app.php', + ], + ], + ], ], 'react' => [ 'export_as' => [ @@ -645,6 +718,7 @@ public function it_can_export_options_module_files() ]); $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + $this->assertFileDoesNotExist($appConfig = $this->exportPath('config/app.php')); $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/you-are-so-welcome.blade.php')); $this->exportCoolRunnings(); @@ -652,6 +726,9 @@ public function it_can_export_options_module_files() $this->assertFileExists($filesystemsConfig); $this->assertFileHasContent("'disks' => [", $filesystemsConfig); + $this->assertFileExists($appConfig); + $this->assertFileHasContent("'url' => env(", $appConfig); + $this->assertFileExists($welcomeView); $this->assertFileHasContent('files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'dependencies' => [ + 'statamic/seo-pro', + ], + 'modules' => [ + 'ssg' => [ + 'dependencies_dev' => [ + 'statamic/ssg', + ], + ], + ], + ], + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigEquals('modules.seo.dependencies', [ + 'statamic/seo-pro' => '^2.2', + ]); + + $this->assertExportedConfigDoesNotHave('modules.seo.dependencies_dev'); + + $this->assertExportedConfigEquals('modules.seo.modules.ssg.dependencies_dev', [ + 'statamic/ssg' => '^0.4.0', + ]); + + $this->assertExportedConfigDoesNotHave('modules.ssg.dependencies'); + } + + #[Test] + public function it_can_export_select_module_dependencies() { $this->files->put(base_path('composer.json'), <<<'EOT' { @@ -762,6 +892,80 @@ public function it_can_export_options_module_dependencies() $this->assertExportedConfigDoesNotHave('modules.first_party.ssg.dependencies'); } + #[Test] + public function it_can_export_nested_select_module_dependencies() + { + $this->files->put(base_path('composer.json'), <<<'EOT' +{ + "type": "project", + "require": { + "php": "^7.3 || ^8.0", + "laravel/framework": "^8.0", + "statamic/cms": "3.1.*", + "statamic/seo-pro": "^2.2", + "hansolo/falcon": "*" + }, + "require-dev": { + "statamic/ssg": "^0.4.0" + }, + "prefer-stable": true +} +EOT + ); + + $this->setConfig([ + 'modules' => [ + 'first_party' => [ + 'options' => [ + 'seo' => [ + 'dependencies' => [ + 'statamic/seo-pro', + ], + 'modules' => [ + 'ssg' => [ + 'dependencies_dev' => [ + 'statamic/ssg', + ], + ], + ], + ], + ], + ], + ], + ]); + + $this->exportCoolRunnings(); + + $this->assertExportedConfigEquals('modules.first_party.options.seo.dependencies', [ + 'statamic/seo-pro' => '^2.2', + ]); + + $this->assertExportedConfigDoesNotHave('modules.first_party.seo.dependencies_dev'); + + $this->assertExportedConfigEquals('modules.first_party.options.seo.modules.ssg.dependencies_dev', [ + 'statamic/ssg' => '^0.4.0', + ]); + + $this->assertExportedConfigDoesNotHave('modules.first_party.ssg.dependencies'); + } + + #[Test] + public function it_requires_valid_config_at_top_level() + { + $this->setConfig([ + // no installable config! + ]); + + $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); + + $this + ->exportCoolRunnings() + // ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') // TODO: Why does this work in InstallTest? + ->assertFailed(); + + $this->assertFileDoesNotExist($welcomeView); + } + #[Test] public function it_requires_valid_module_config() { @@ -784,13 +988,17 @@ public function it_requires_valid_module_config() } #[Test] - public function it_doesnt_require_anything_exportable_in_top_level_if_user_wants_to_organize_using_modules_only() + public function it_doesnt_require_anything_installable_if_module_contains_nested_modules() { $this->setConfig([ 'modules' => [ 'seo' => [ - 'export_paths' => [ - 'resources/views/welcome.blade.php', + 'modules' => [ + 'js' => [ + 'export_paths' => [ + 'resources/views/welcome.blade.php', + ], + ], ], ], ], @@ -807,7 +1015,7 @@ public function it_doesnt_require_anything_exportable_in_top_level_if_user_wants #[Test] #[DataProvider('validModuleConfigs')] - public function it_passes_validation_if_module_export_paths_or_dependencies_are_properly_configured($config) + public function it_passes_validation_if_module_export_paths_or_dependencies_or_nested_modules_are_properly_configured($config) { $this->files->put(base_path('composer.json'), <<<'EOT' { @@ -861,6 +1069,15 @@ public static function validModuleConfigs() 'statamic/seo-pro' => '^1.0', ], ]], + 'nested modules' => [[ + 'modules' => [ + 'filesystem' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + ], + ], + ]], ]; } @@ -1118,7 +1335,8 @@ public function it_normalizes_module_key_order() "laravel/framework": "^8.0", "statamic/cms": "3.1.*", "statamic/seo-pro": "^2.2", - "hansolo/falcon": "*" + "hansolo/falcon": "*", + "luke/x-wing": "*" }, "require-dev": { "statamic/ssg": "^0.4.0" @@ -1132,12 +1350,14 @@ public function it_normalizes_module_key_order() base_path('README.md'), base_path('test-folder'), resource_path('vue.js'), + resource_path('vue-testing-tools.js'), ]); $this->files->put(base_path('README.md'), 'This is a readme!'); $this->files->makeDirectory(base_path('test-folder')); $this->files->put(base_path('test-folder/one.txt'), 'One.'); $this->files->put(resource_path('vue.js'), 'Vue!'); + $this->files->put(resource_path('vue-testing-tools.js'), 'Vue testing tools!'); $this->setConfig([ 'modules' => [ @@ -1165,6 +1385,16 @@ public function it_normalizes_module_key_order() 'dependencies' => [ 'hansolo/falcon', ], + 'modules' => [ + 'testing_tools' => [ + 'dependencies' => [ + 'luke/x-wing' => '*', + ], + 'export_paths' => [ + 'resources/vue-testing-tools.js', + ], + ], + ], 'export_paths' => [ 'resources/vue.js', ], @@ -1223,6 +1453,16 @@ public function it_normalizes_module_key_order() 'dependencies' => [ 'hansolo/falcon' => '*', ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'resources/vue-testing-tools.js', + ], + 'dependencies' => [ + 'luke/x-wing' => '*', + ], + ], + ], ], ], ], From 2481a0b991481d789844524f8aabd6989d92d800 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Mon, 12 Aug 2024 21:01:23 -0400 Subject: [PATCH 42/44] Bring back the square brackets as shown in the docs, it looks fine. --- src/StarterKits/Installer.php | 4 ++-- tests/StarterKits/InstallTest.php | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 2fc7fde943..9ac7f1b846 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -325,7 +325,7 @@ protected function instantiateModule(array $config, string $key): InstallableMod $name = str_replace('_', ' ', $key); - if ($shouldPrompt && $this->isInteractive && ! confirm(Arr::get($config, 'prompt', "Would you like to install the {$name} module?"), false)) { + if ($shouldPrompt && $this->isInteractive && ! confirm(Arr::get($config, 'prompt', "Would you like to install the [{$name}] module?"), false)) { return false; } elseif ($shouldPrompt && ! $this->isInteractive) { return false; @@ -347,7 +347,7 @@ protected function instantiateSelectModule(array $config, string $key): Installa $name = str_replace('_', ' ', $key); $choice = select( - label: Arr::get($config, 'prompt', "Would you like to install one of the following {$name} modules?"), + label: Arr::get($config, 'prompt', "Would you like to install one of the following [{$name}] modules?"), options: $options, ); diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 88af4b3cb5..10c85e1c2e 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -905,11 +905,11 @@ public function it_installs_only_the_modules_confirmed_interactively_via_prompt( $this ->installCoolRunningsModules() - ->expectsConfirmation('Would you like to install the seo module?', 'yes') - ->expectsConfirmation('Would you like to install the bobsled module?', 'no') - ->expectsConfirmation('Would you like to install the jamaica module?', 'yes') - ->expectsQuestion('Would you like to install one of the following js modules?', 'vue') - ->expectsQuestion('Would you like to install one of the following oldschool js modules?', 'skip_module'); + ->expectsConfirmation('Would you like to install the [seo] module?', 'yes') + ->expectsConfirmation('Would you like to install the [bobsled] module?', 'no') + ->expectsConfirmation('Would you like to install the [jamaica] module?', 'yes') + ->expectsQuestion('Would you like to install one of the following [js] modules?', 'vue') + ->expectsQuestion('Would you like to install one of the following [oldschool js] modules?', 'skip_module'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); @@ -1020,7 +1020,7 @@ public function it_installs_modules_without_dependencies() $this ->installCoolRunningsModules(['--without-dependencies' => true]) - ->expectsConfirmation('Would you like to install the seo module?', 'yes'); + ->expectsConfirmation('Would you like to install the [seo] module?', 'yes'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); @@ -1320,13 +1320,13 @@ public function it_installs_nested_modules_confirmed_interactively_via_prompt() $this ->installCoolRunningsModules() - ->expectsConfirmation('Would you like to install the seo module?', 'yes') - ->expectsQuestion('Would you like to install one of the following seo js modules?', 'vue') - ->expectsQuestion('Would you like to install the seo js vue testing tools module?', 'yes') - ->expectsQuestion('Would you like to install one of the following seo oldschool js modules?', 'skip_module') - ->expectsConfirmation('Would you like to install the canada module?', 'no') - ->expectsConfirmation('Would you like to install the jamaica module?', 'yes') - ->expectsConfirmation('Would you like to install the jamaica bobsled module?', 'yes'); + ->expectsConfirmation('Would you like to install the [seo] module?', 'yes') + ->expectsQuestion('Would you like to install one of the following [seo js] modules?', 'vue') + ->expectsQuestion('Would you like to install the [seo js vue testing tools] module?', 'yes') + ->expectsQuestion('Would you like to install one of the following [seo oldschool js] modules?', 'skip_module') + ->expectsConfirmation('Would you like to install the [canada] module?', 'no') + ->expectsConfirmation('Would you like to install the [jamaica] module?', 'yes') + ->expectsConfirmation('Would you like to install the [jamaica bobsled] module?', 'yes'); $this->assertFileExists(base_path('copied.md')); $this->assertFileExists(base_path('resources/css/seo.css')); From b91fd068f5a9b3ce9aa1c281db2f91ba14e68be9 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 13 Aug 2024 10:40:44 -0400 Subject: [PATCH 43/44] Simplify recursion logic. --- src/StarterKits/Exporter.php | 43 ++++++++++++++++++++--------------- src/StarterKits/Installer.php | 33 ++++++++++++--------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 24cdf48b96..f79df6efc9 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -75,13 +75,8 @@ protected function validateConfig(): self */ protected function instantiateModules(): self { - $topLevelConfig = $this->config()->all(); - - $nestedConfigs = $this->config('modules'); - - $this->modules = collect(['top_level' => $topLevelConfig]) - ->merge($nestedConfigs) - ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) + $this->modules = collect(['top_level' => $this->config()->all()]) + ->map(fn ($config, $key) => $this->instantiateModuleRecursively($config, $key)) ->flatten() ->filter() ->each(fn ($module) => $module->validate()); @@ -94,29 +89,33 @@ protected function instantiateModules(): self */ protected function instantiateModule(array $config, string $key): ExportableModule|array { - if (Arr::has($config, 'options')) { - return collect($config['options']) - ->map(fn ($option, $optionKey) => $this->instantiateRecursively($option, "{$key}.options.{$optionKey}")) - ->all(); + if (Arr::has($config, 'options') && $key !== 'top_level') { + return $this->instantiateSelectModule($config, $key); } - return $this->instantiateRecursively($config, $key); + return $this->instantiateModuleRecursively($config, $key); + } + + /** + * Instantiate select module. + */ + protected function instantiateSelectModule(array $config, string $key): ExportableModule|array + { + return collect($config['options']) + ->map(fn ($option, $optionKey) => $this->instantiateModuleRecursively($option, "{$key}.options.{$optionKey}")) + ->all(); } /** * Instantiate module and check if nested modules should be recursively instantiated. */ - protected function instantiateRecursively(array $config, string $key): ExportableModule|array + protected function instantiateModuleRecursively(array $config, string $key): ExportableModule|array { $instantiated = new ExportableModule($config, $key); - if ($instantiated->isTopLevelModule()) { - return $instantiated; - } - if ($modules = Arr::get($config, 'modules')) { $instantiated = collect($modules) - ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}.modules.{$childKey}")) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) ->prepend($instantiated, $key) ->filter() ->all(); @@ -125,6 +124,14 @@ protected function instantiateRecursively(array $config, string $key): Exportabl return $instantiated; } + /** + * Normalize module key, as dotted array key for location in starter-kit.yaml. + */ + protected function normalizeModuleKey(string $key, string $childKey): string + { + return $key !== 'top_level' ? "{$key}.modules.{$childKey}" : $childKey; + } + /** * Export all the modules. */ diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 9ac7f1b846..6f5c57f2fe 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -290,13 +290,8 @@ protected function ensureConfig(): self */ protected function instantiateModules(): self { - $topLevelConfig = $this->config()->all(); - - $nestedConfigs = $this->config('modules'); - - $this->modules = collect(['top_level' => $topLevelConfig]) - ->merge($nestedConfigs) - ->map(fn ($config, $key) => $this->instantiateModule($config, $key)) + $this->modules = collect(['top_level' => $this->config()->all()]) + ->map(fn ($config, $key) => $this->instantiateModuleRecursively($config, $key)) ->flatten() ->filter() ->each(fn ($module) => $module->validate()); @@ -311,10 +306,6 @@ protected function instantiateModule(array $config, string $key): InstallableMod { $shouldPrompt = true; - if ($key === 'top_level') { - $shouldPrompt = false; - } - if (Arr::has($config, 'options')) { return $this->instantiateSelectModule($config, $key); } @@ -331,7 +322,7 @@ protected function instantiateModule(array $config, string $key): InstallableMod return false; } - return $this->instantiateRecursively($config, $key); + return $this->instantiateModuleRecursively($config, $key); } /** @@ -358,23 +349,19 @@ protected function instantiateSelectModule(array $config, string $key): Installa $selectedKey = "{$key}_{$choice}"; $selectedModuleConfig = $config['options'][$choice]; - return $this->instantiateRecursively($selectedModuleConfig, $selectedKey); + return $this->instantiateModuleRecursively($selectedModuleConfig, $selectedKey); } /** * Instantiate module and check if nested modules should be recursively instantiated. */ - protected function instantiateRecursively(array $config, string $key): InstallableModule|array + protected function instantiateModuleRecursively(array $config, string $key): InstallableModule|array { $instantiated = (new InstallableModule($config, $key))->installer($this); - if ($instantiated->isTopLevelModule()) { - return $instantiated; - } - if ($modules = Arr::get($config, 'modules')) { $instantiated = collect($modules) - ->map(fn ($config, $childKey) => $this->instantiateModule($config, "{$key}_{$childKey}")) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) ->prepend($instantiated, $key) ->filter() ->all(); @@ -383,6 +370,14 @@ protected function instantiateRecursively(array $config, string $key): Installab return $instantiated; } + /** + * Normalize module key. + */ + protected function normalizeModuleKey(string $key, string $childKey): string + { + return $key !== 'top_level' ? "{$key}_{$childKey}" : $childKey; + } + /** * Install all the modules. */ From 814de4cd3f376c12d683530893d1021a67a0222a Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 13 Aug 2024 10:41:17 -0400 Subject: [PATCH 44/44] =?UTF-8?q?Move=20method=20up=20to=20where=20it?= =?UTF-8?q?=E2=80=99s=20called.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/StarterKits/Exporter.php | 36 +++++++++++++++++------------------ src/StarterKits/Installer.php | 36 +++++++++++++++++------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index f79df6efc9..a62a54f996 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -84,6 +84,24 @@ protected function instantiateModules(): self return $this; } + /** + * Instantiate module and check if nested modules should be recursively instantiated. + */ + protected function instantiateModuleRecursively(array $config, string $key): ExportableModule|array + { + $instantiated = new ExportableModule($config, $key); + + if ($modules = Arr::get($config, 'modules')) { + $instantiated = collect($modules) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) + ->prepend($instantiated, $key) + ->filter() + ->all(); + } + + return $instantiated; + } + /** * Instantiate individual module. */ @@ -106,24 +124,6 @@ protected function instantiateSelectModule(array $config, string $key): Exportab ->all(); } - /** - * Instantiate module and check if nested modules should be recursively instantiated. - */ - protected function instantiateModuleRecursively(array $config, string $key): ExportableModule|array - { - $instantiated = new ExportableModule($config, $key); - - if ($modules = Arr::get($config, 'modules')) { - $instantiated = collect($modules) - ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) - ->prepend($instantiated, $key) - ->filter() - ->all(); - } - - return $instantiated; - } - /** * Normalize module key, as dotted array key for location in starter-kit.yaml. */ diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 6f5c57f2fe..a17f8adfc3 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -299,6 +299,24 @@ protected function instantiateModules(): self return $this; } + /** + * Instantiate module and check if nested modules should be recursively instantiated. + */ + protected function instantiateModuleRecursively(array $config, string $key): InstallableModule|array + { + $instantiated = (new InstallableModule($config, $key))->installer($this); + + if ($modules = Arr::get($config, 'modules')) { + $instantiated = collect($modules) + ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) + ->prepend($instantiated, $key) + ->filter() + ->all(); + } + + return $instantiated; + } + /** * Instantiate individual module. */ @@ -352,24 +370,6 @@ protected function instantiateSelectModule(array $config, string $key): Installa return $this->instantiateModuleRecursively($selectedModuleConfig, $selectedKey); } - /** - * Instantiate module and check if nested modules should be recursively instantiated. - */ - protected function instantiateModuleRecursively(array $config, string $key): InstallableModule|array - { - $instantiated = (new InstallableModule($config, $key))->installer($this); - - if ($modules = Arr::get($config, 'modules')) { - $instantiated = collect($modules) - ->map(fn ($config, $childKey) => $this->instantiateModule($config, $this->normalizeModuleKey($key, $childKey))) - ->prepend($instantiated, $key) - ->filter() - ->all(); - } - - return $instantiated; - } - /** * Normalize module key. */