From c6180f6f2a00e2cbb143cfcd439b73b6207c5883 Mon Sep 17 00:00:00 2001 From: Jesse Leite Date: Tue, 13 Aug 2024 13:51:48 -0400 Subject: [PATCH] [5.x] Starter kit modules and other misc improvements (#10559) Co-authored-by: Jason Varga --- src/Console/Commands/StarterKitExport.php | 16 +- src/Console/Commands/StarterKitInstall.php | 37 +- .../Commands/StarterKitRunPostInstall.php | 4 +- .../Concerns/InteractsWithFilesystem.php | 63 + src/StarterKits/ExportableModule.php | 161 +++ src/StarterKits/Exporter.php | 301 +++-- src/StarterKits/Hook.php | 2 +- src/StarterKits/InstallableModule.php | 274 +++++ src/StarterKits/Installer.php | 558 +++------ src/StarterKits/LicenseManager.php | 54 +- src/StarterKits/Module.php | 92 ++ tests/StarterKits/ExportTest.php | 1012 ++++++++++++++++- tests/StarterKits/InstallTest.php | 674 ++++++++++- .../cool-runnings/resources/css/bobsled.css | 1 + .../cool-runnings/resources/css/hockey.css | 1 + .../cool-runnings/resources/css/jamaica.css | 1 + .../cool-runnings/resources/css/seo.css | 1 + .../dictionaries/american_players.yaml | 1 + .../dictionaries/canadian_players.yaml | 1 + .../resources/dictionaries/players.yaml | 1 + .../cool-runnings/resources/js/jquery.js | 1 + .../cool-runnings/resources/js/mootools.js | 1 + .../resources/js/react-testing-tools.js | 1 + .../cool-runnings/resources/js/react.js | 1 + .../cool-runnings/resources/js/svelte.js | 1 + .../resources/js/vue-testing-tools.js | 1 + .../cool-runnings/resources/js/vue.js | 1 + 27 files changed, 2598 insertions(+), 664 deletions(-) create mode 100644 src/StarterKits/Concerns/InteractsWithFilesystem.php create mode 100644 src/StarterKits/ExportableModule.php create mode 100644 src/StarterKits/InstallableModule.php create mode 100644 src/StarterKits/Module.php create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/bobsled.css create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/css/hockey.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/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/jquery.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/mootools.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/react-testing-tools.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/svelte.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue-testing-tools.js create mode 100644 tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js diff --git a/src/Console/Commands/StarterKitExport.php b/src/Console/Commands/StarterKitExport.php index 3760878701..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()); @@ -56,7 +58,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 +77,8 @@ protected function askToStubStarterKitConfig() /** * Get absolute path. - * - * @return string */ - protected function getAbsolutePath() + protected function getAbsolutePath(): string { $path = $this->argument('path'); @@ -89,10 +89,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..d7db695ef2 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; @@ -28,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 }'; @@ -56,17 +58,17 @@ 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) + $installer = (new StarterKitInstaller($package, $this, $licenseManager)) ->branch($branch) ->fromLocalRepo($this->option('local')) ->withConfig($this->option('with-config')) ->withoutDependencies($this->option('without-dependencies')) - ->isInteractive($isInteractive = $this->input->isInteractive()) - ->withUser($cleared && $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')); @@ -93,10 +95,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'); @@ -111,10 +111,8 @@ protected function getPackageAndBranch() /** * Check if should clear site first. - * - * @return bool */ - protected function shouldClear() + protected function shouldClearSite(): bool { if ($this->option('clear-site')) { return true; @@ -125,7 +123,22 @@ protected function shouldClear() return false; } - private function oldCliToolInstallationDetected() + /** + * 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. + */ + 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/Console/Commands/StarterKitRunPostInstall.php b/src/Console/Commands/StarterKitRunPostInstall.php index 0db823cb58..d71f344013 100644 --- a/src/Console/Commands/StarterKitRunPostInstall.php +++ b/src/Console/Commands/StarterKitRunPostInstall.php @@ -42,10 +42,10 @@ public function handle() return 1; } - $installer = StarterKitInstaller::package($package, $this); + $installer = new StarterKitInstaller($package, $this); try { - $installer->runPostInstallHook(true)->removeStarterKit(); + $installer->runPostInstallHooks(true)->removeStarterKit(); } catch (StarterKitException $exception) { $this->components->error($exception->getMessage()); diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php new file mode 100644 index 0000000000..25dac3bf98 --- /dev/null +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -0,0 +1,63 @@ +line("Installing file [{$displayPath}]"); + + app(Filesystem::class)->copy($fromPath, $this->preparePath($toPath)); + + 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. + */ + protected function preparePath(string $path): string + { + $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); + } +} diff --git a/src/StarterKits/ExportableModule.php b/src/StarterKits/ExportableModule.php new file mode 100644 index 0000000000..8bf988b5c4 --- /dev/null +++ b/src/StarterKits/ExportableModule.php @@ -0,0 +1,161 @@ +ensureModuleConfigNotEmpty() + ->ensureNotExportingComposerJson() + ->ensureExportablePathsExist() + ->ensureExportableDependenciesExist(); + } + + /** + * 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->exportableDependencies(); + + $this->config->forget('dependencies'); + $this->config->forget('dependencies_dev'); + + 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 exportableDependencies(): 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; + } + + /** + * 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/src/StarterKits/Exporter.php b/src/StarterKits/Exporter.php index 63995587eb..a62a54f996 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -3,188 +3,167 @@ namespace Statamic\StarterKits; 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); } /** * Export starter kit. * - * @param string $absolutePath - * * @throws StarterKitException */ - public function export($absolutePath) + public function export(): void { - $this->exportPath = $absolutePath; + $this + ->validateExportPath() + ->validateConfig() + ->instantiateModules() + ->exportModules() + ->exportConfig() + ->exportHooks() + ->exportComposerJson(); + } + /** + * Validate that export path exists. + */ + protected function validateExportPath(): self + { if (! $this->files->exists($this->exportPath)) { throw new StarterKitException("Path [$this->exportPath] does not exist."); } + return $this; + } + + /** + * Validate starter kit config. + */ + protected function validateConfig(): self + { if (! $this->files->exists(base_path('starter-kit.yaml'))) { throw new StarterKitException('Export config [starter-kit.yaml] does not exist.'); } - $this - ->exportFiles() - ->exportConfig() - ->exportHooks() - ->exportComposerJson(); + return $this; } /** - * Export files and folders. - * - * @return $this + * Instantiate and validate modules that are to be installed. */ - protected function exportFiles() + protected function instantiateModules(): 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); - }); + $this->modules = collect(['top_level' => $this->config()->all()]) + ->map(fn ($config, $key) => $this->instantiateModuleRecursively($config, $key)) + ->flatten() + ->filter() + ->each(fn ($module) => $module->validate()); return $this; } /** - * Ensure export path exists. - * - * @param string $path - * - * @throws StarterKitException + * Instantiate module and check if nested modules should be recursively instantiated. */ - protected function ensureExportPathExists($path) + protected function instantiateModuleRecursively(array $config, string $key): ExportableModule|array { - if (! $this->files->exists(base_path($path))) { - throw new StarterKitException("Export path [{$path}] does not exist."); + $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; } /** - * Copy path to new export path location. - * - * @param string $fromPath - * @param string $toPath + * Instantiate individual module. */ - protected function copyPath($fromPath, $toPath = null) + protected function instantiateModule(array $config, string $key): ExportableModule|array { - $toPath = $toPath - ? "{$this->exportPath}/{$toPath}" - : "{$this->exportPath}/{$fromPath}"; - - $fromPath = base_path($fromPath); - - $this->preparePath($fromPath, $toPath); + if (Arr::has($config, 'options') && $key !== 'top_level') { + return $this->instantiateSelectModule($config, $key); + } - $this->files->isDirectory($fromPath) - ? $this->files->copyDirectory($fromPath, $toPath) - : $this->files->copy($fromPath, $toPath); + return $this->instantiateModuleRecursively($config, $key); } /** - * Prepare path directory. - * - * @param string $fromPath - * @param string $toPath + * Instantiate select module. */ - protected function preparePath($fromPath, $toPath) + protected function instantiateSelectModule(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); - } + return collect($config['options']) + ->map(fn ($option, $optionKey) => $this->instantiateModuleRecursively($option, "{$key}.options.{$optionKey}")) + ->all(); } /** - * Get starter kit config. - * - * @return \Illuminate\Support\Collection + * Normalize module key, as dotted array key for location in starter-kit.yaml. */ - protected function config() + protected function normalizeModuleKey(string $key, string $childKey): string { - return collect(YAML::parse($this->files->get(base_path('starter-kit.yaml')))); + return $key !== 'top_level' ? "{$key}.modules.{$childKey}" : $childKey; } /** - * Get starter kit `export_paths` paths from config. - * - * @return \Illuminate\Support\Collection - * - * @throws StarterKitException + * Export all the modules. */ - protected function exportPaths() + protected function exportModules(): self { - $paths = collect($this->config()->get('export_paths')); - - 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!'); - } + $this->modules->each(fn ($module) => $module->export($this->exportPath)); - return $paths; + return $this; } /** - * Get starter kit 'export_as' paths (to be renamed on export) from config. - * - * @return \Illuminate\Support\Collection - * - * @throws StarterKitException + * Get starter kit config. */ - protected function exportAsPaths() + 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; } /** * Export starter kit config. - * - * @return $this */ - protected function exportConfig() + protected function exportConfig(): self { - $config = $this->config(); - - $config = $this->exportDependenciesFromComposerJson($config); + $config = $this + ->versionModuleDependencies() + ->syncConfigWithModules(); $this->files->put("{$this->exportPath}/starter-kit.yaml", YAML::dump($config->all())); @@ -192,106 +171,84 @@ protected function exportConfig() } /** - * Export starter kit hooks. - * - * @return $this + * Version module dependencies from composer.json. */ - protected function exportHooks() + 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. - * - * @param \Illuminate\Support\Collection $config - * @return \Illuminate\Support\Collection + * Get synced config from newly versioned module dependencies. */ - protected function exportDependenciesFromComposerJson($config) + protected function syncConfigWithModules(): 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; + $config = $this->config()->all(); + + $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); } /** - * Get exportable dependencies without versions from config. - * - * @param \Illuminate\Support\Collection $config - * @return \Illuminate\Support\Collection + * Sync config with individual module */ - protected function getExportableDependenciesFromConfig($config) + protected function syncConfigWithIndividualModule(array &$config, ExportableModule $module, string $key) { - if ($this->hasDependenciesWithoutVersions($config)) { - return collect($config->get('dependencies') ?? []); - } + Arr::forget($config, $this->dottedModulePath($module, $key)); - return collect() - ->merge($config->get('dependencies') ?? []) - ->merge($config->get('dependencies_dev') ?? []) - ->keys(); + if ($module->config()->has($key)) { + Arr::set($config, $this->dottedModulePath($module, $key), $module->config($key)); + } } /** - * Check if config has dependencies without versions. - * - * @param \Illuminate\Support\Collection $config - * @return bool + * Get dotted module path. */ - protected function hasDependenciesWithoutVersions($config) + protected function dottedModulePath(ExportableModule $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. - * - * @param string $requireKey - * @param \Illuminate\Support\Collection $exportableDependencies - * @return \Illuminate\Support\Collection + * Export starter kit hooks. */ - protected function exportDependenciesFromComposerRequire($requireKey, $exportableDependencies) + 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; } /** * Export composer.json. - * - * @return $this */ - protected function exportComposerJson() + protected function exportComposerJson(): self { $composerJson = $this->prepareComposerJsonFromStub()->all(); @@ -305,10 +262,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 +281,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/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/Installer.php b/src/StarterKits/Installer.php index fd0500e966..a17f8adfc3 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -6,24 +6,27 @@ 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; use Statamic\Console\NullConsole; 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\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 { + use FluentlyGetsAndSets, InteractsWithFilesystem; + protected $package; protected $branch; protected $licenseManager; @@ -31,136 +34,117 @@ final class Installer protected $fromLocalRepo; protected $withConfig; protected $withoutDependencies; - protected $withUser; + protected $withUserPrompt; + protected $isInteractive; protected $usingSubProcess; protected $force; protected $console; protected $url; + protected $modules; protected $disableCleanup; /** * 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; $this->licenseManager = $licenseManager; - $this->console = $console ?? new Nullconsole; + $this->console = $console ?? new NullConsole; $this->files = app(Filesystem::class); } /** - * Instantiate starter kit installer. - * - * @param mixed $console - * @return static + * Get or set whether to install from specific branch. */ - public static function package(string $package, ?Command $console = null, ?LicenseManager $licenseManager = null) + public function branch(?string $branch = null): self|bool|null { - return new self($package, $console, $licenseManager); + return $this->fluentlyGetOrSet('branch')->args(func_get_args()); + + return $this; } /** - * Install from specific branch. - * - * @param string|null $branch - * @return $this + * Get or set whether to install from local repo configured in composer config.json. */ - public function branch($branch = null) + public function fromLocalRepo(bool $fromLocalRepo = false): self|bool|null { - $this->branch = $branch; + return $this->fluentlyGetOrSet('fromLocalRepo')->args(func_get_args()); return $this; } /** - * Install from local repo configured in composer config.json. - * - * @param bool $fromLocalRepo - * @return $this + * Get or set whether to install with starter-kit config for local development purposes. */ - public function fromLocalRepo($fromLocalRepo = false) + public function withConfig(bool $withConfig = false): self|bool|null { - $this->fromLocalRepo = $fromLocalRepo; + return $this->fluentlyGetOrSet('withConfig')->args(func_get_args()); return $this; } /** - * Install with starter-kit config for local development purposes. - * - * @param bool $withConfig - * @return $this + * Get or set whether to install without dependencies. */ - public function withConfig($withConfig = false) + public function withoutDependencies(?bool $withoutDependencies = false): self|bool|null { - $this->withConfig = $withConfig; - - return $this; + return $this->fluentlyGetOrSet('withoutDependencies')->args(func_get_args()); } /** - * Install without dependencies. - * - * @param bool $withoutDependencies - * @return $this + * Get or set whether to install with super user prompt. */ - public function withoutDependencies($withoutDependencies = false) + public function withUserPrompt(bool $withUserPrompt = false): self|bool|null { - $this->withoutDependencies = $withoutDependencies; + return $this->fluentlyGetOrSet('withUserPrompt')->args(func_get_args()); return $this; } - public function isInteractive($isInteractive = false) + /** + * Get or set whether command is being run interactively. + */ + public function isInteractive($isInteractive = false): self|bool|null { - Prompt::interactive($isInteractive); - - return $this; + return $this->fluentlyGetOrSet('isInteractive')->args(func_get_args()); } /** - * Install with super user. - * - * @param bool $withUser - * @return $this + * Get or set whether to install using sub-process. */ - public function withUser($withUser = false) + public function usingSubProcess(bool $usingSubProcess = false): self|bool|null { - $this->withUser = $withUser; + return $this->fluentlyGetOrSet('usingSubProcess')->args(func_get_args()); return $this; } /** - * Install using sub-process. - * - * @param bool $usingSubProcess - * @return $this + * Get or set whether to force install and allow dependency errors. */ - public function usingSubProcess($usingSubProcess = false) + public function force(bool $force = false): self|bool|null { - $this->usingSubProcess = $usingSubProcess; - - return $this; + return $this->fluentlyGetOrSet('force')->args(func_get_args()); } /** - * Force install and allow dependency errors. - * - * @param bool $force - * @return $this + * Get starter kit package. */ - public function force($force = false) + public function package(): string { - $this->force = $force; + return $this->package; + } - return $this; + /** + * Get console command instance. + */ + public function console(): Command|NullConsole + { + return $this->console; } /** @@ -168,7 +152,7 @@ public function force($force = false) * * @throws StarterKitException */ - public function install() + public function install(): void { $this ->validateLicense() @@ -177,12 +161,12 @@ public function install() ->prepareRepository() ->requireStarterKit() ->ensureConfig() - ->ensureExportPathsExist() - ->ensureCompatibleDependencies() - ->installFiles() - ->installDependencies() + ->instantiateModules() + ->installModules() + ->copyStarterKitConfig() + ->copyStarterKitHooks() ->makeSuperUser() - ->runPostInstallHook() + ->runPostInstallHooks() ->reticulateSplines() ->removeStarterKit() ->removeRepository() @@ -193,9 +177,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; @@ -206,10 +190,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')); @@ -218,10 +200,8 @@ protected function backupComposerJson() /** * Detect repository url. - * - * @return $this */ - protected function detectRepositoryUrl() + protected function detectRepositoryUrl(): self { if ($this->fromLocalRepo) { return $this; @@ -244,10 +224,8 @@ protected function detectRepositoryUrl() /** * Prepare repository. - * - * @return $this */ - protected function prepareRepository() + protected function prepareRepository(): self { if ($this->fromLocalRepo || ! $this->url) { return $this; @@ -272,10 +250,8 @@ protected function prepareRepository() /** * Require starter kit dependency. - * - * @return $this */ - protected function requireStarterKit() + protected function requireStarterKit(): self { spin( function () { @@ -298,11 +274,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.'); @@ -312,100 +286,126 @@ protected function ensureConfig() } /** - * Ensure export paths exist. - * - * @return $this - * - * @throws StarterKitException + * Instantiate and validate modules that are to be installed. */ - protected function ensureExportPathsExist() + protected function instantiateModules(): self { - $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."); - }); + $this->modules = collect(['top_level' => $this->config()->all()]) + ->map(fn ($config, $key) => $this->instantiateModuleRecursively($config, $key)) + ->flatten() + ->filter() + ->each(fn ($module) => $module->validate()); return $this; } /** - * Ensure compatible dependencies by performing a dry-run. - * - * @return $this + * Instantiate module and check if nested modules should be recursively instantiated. */ - protected function ensureCompatibleDependencies() + protected function instantiateModuleRecursively(array $config, string $key): InstallableModule|array { - if ($this->withoutDependencies || $this->force) { - return $this; + $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(); } - if ($packages = $this->installableDependencies('dependencies')) { - $this->ensureCanRequireDependencies($packages); + return $instantiated; + } + + /** + * Instantiate individual module. + */ + protected function instantiateModule(array $config, string $key): InstallableModule|array|bool + { + $shouldPrompt = true; + + if (Arr::has($config, 'options')) { + return $this->instantiateSelectModule($config, $key); } - if ($packages = $this->installableDependencies('dependencies_dev')) { - $this->ensureCanRequireDependencies($packages, true); + if (Arr::get($config, 'prompt') === false) { + $shouldPrompt = false; } - return $this; + $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; + } + + return $this->instantiateModuleRecursively($config, $key); } /** - * Install starter kit files. - * - * @return $this + * Instantiate select module. */ - protected function installFiles() + protected function instantiateSelectModule(array $config, string $key): InstallableModule|array|bool { - $this->console->info('Installing files...'); + $options = collect($config['options']) + ->map(fn ($option, $optionKey) => Arr::get($option, 'label', ucfirst($optionKey))) + ->prepend(Arr::get($config, 'skip_option', 'No'), $skipModule = 'skip_module') + ->all(); - $this->installableFiles()->each(function ($toPath, $fromPath) { - $this->copyFile($fromPath, $toPath); - }); + $name = str_replace('_', ' ', $key); - if ($this->withConfig) { - $this->copyStarterKitConfig(); - $this->copyStarterKitHooks(); + $choice = select( + label: Arr::get($config, 'prompt', "Would you like to install one of the following [{$name}] modules?"), + options: $options, + ); + + if ($choice === $skipModule) { + return false; } - return $this; + $selectedKey = "{$key}_{$choice}"; + $selectedModuleConfig = $config['options'][$choice]; + + return $this->instantiateModuleRecursively($selectedModuleConfig, $selectedKey); } /** - * Copy starter kit file. - * - * @param mixed $fromPath - * @param mixed $toPath + * Normalize module key. */ - protected function copyFile($fromPath, $toPath) + protected function normalizeModuleKey(string $key, string $childKey): string { - $displayPath = str_replace(Path::tidy(base_path().'/'), '', $toPath); + return $key !== 'top_level' ? "{$key}_{$childKey}" : $childKey; + } - $this->console->line("Installing file [{$displayPath}]"); + /** + * Install all the modules. + */ + protected function installModules(): self + { + $this->console->info('Installing starter kit...'); - $this->files->copy($fromPath, $this->preparePath($toPath)); + $this->modules->each(fn ($module) => $module->install()); + + return $this; } /** * Copy starter kit config without versions, to encourage dependency management using composer. */ - protected function copyStarterKitConfig() + protected function copyStarterKitConfig(): self { if (! $this->withConfig) { - return; + return $this; } 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')) @@ -420,100 +420,34 @@ protected function copyStarterKitConfig() } $this->files->put(base_path('starter-kit.yaml'), YAML::dump($config->all())); + + return $this; } /** * Copy starter kit hook scripts. */ - protected function copyStarterKitHooks() + protected function copyStarterKitHooks(): self { if (! $this->withConfig) { - return; + return $this; } $hooks = ['StarterKitPostInstall.php']; collect($hooks) ->filter(fn ($hook) => $this->files->exists($this->starterKitPath($hook))) - ->each(fn ($hook) => $this->copyFile($this->starterKitPath($hook), base_path($hook))); - } - - /** - * 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. - * - * @return $this */ - public function makeSuperUser() + public function makeSuperUser(): self { - if (! $this->withUser) { + if (! $this->withUserPrompt) { return $this; } @@ -527,11 +461,9 @@ public function makeSuperUser() /** * Run post-install hook, if one exists in the starter kit. * - * @return $this - * * @throws StarterKitException */ - public function runPostInstallHook($throwExceptions = false) + public function runPostInstallHooks(bool $throwExceptions = false): self { $postInstallHook = Hook::find($this->starterKitPath('StarterKitPostInstall.php')); @@ -558,10 +490,8 @@ public function runPostInstallHook($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')); @@ -580,10 +510,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(); @@ -599,11 +527,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 () { @@ -619,10 +545,8 @@ function () { /** * Remove starter kit dependency. - * - * @return $this */ - public function removeStarterKit() + public function removeStarterKit(): self { if ($this->disableCleanup) { return $this; @@ -642,10 +566,8 @@ function () { /** * Remove composer.json backup. - * - * @return $this */ - protected function removeComposerJsonBackup() + protected function removeComposerJsonBackup(): self { $this->files->delete(base_path('composer.json.bak')); @@ -654,10 +576,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(); @@ -666,10 +586,8 @@ protected function completeInstall() /** * Remove repository. - * - * @return $this */ - protected function removeRepository() + protected function removeRepository(): self { if ($this->fromLocalRepo || ! $this->url) { return $this; @@ -697,10 +615,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')); @@ -710,12 +626,9 @@ protected function restoreComposerJson() /** * Rollback with error. * - * @param string $error - * @param string|null $output - * * @throws StarterKitException */ - protected function rollbackWithError($error, $output = null) + public function rollbackWithError(string $error, ?string $output = null): void { $this ->removeStarterKit() @@ -731,11 +644,8 @@ protected 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([ @@ -751,138 +661,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('/'); } - /** - * 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. - * - * @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')))); @@ -892,34 +680,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/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 new file mode 100644 index 0000000000..acd102d63e --- /dev/null +++ b/src/StarterKits/Module.php @@ -0,0 +1,92 @@ +files = app(Filesystem::class); + + $this->config = collect($config); + + $this->key = $key; + } + + /** + * Get module key. + */ + public function key(): string + { + return $this->key; + } + + /** + * Check if this is a top level module. + */ + public function isTopLevelModule(): bool + { + return $this->key === 'top_level'; + } + + /** + * Get module config. + */ + public function config(?string $key = null): mixed + { + if ($key) { + return $this->config->get($key); + } + + return $this->config; + } + + /** + * Get `export_paths` paths as collection from config. + */ + protected function exportPaths(): Collection + { + return collect($this->config('export_paths') ?? []); + } + + /** + * Get `export_as` paths (to be renamed on install) as collection from config. + */ + protected function exportAsPaths(): Collection + { + return collect($this->config('export_as') ?? []); + } + + /** + * Ensure nested module config is not empty. + * + * @throws StarterKitException + */ + protected function ensureModuleConfigNotEmpty(): self + { + $hasConfig = $this->config()->has('export_paths') + || $this->config()->has('export_as') + || $this->config()->has('dependencies') + || $this->config()->has('dependencies_dev') + || $this->config()->has('modules'); + + if (! $hasConfig) { + throw new StarterKitException('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!'); + } + + return $this; + } +} diff --git a/tests/StarterKits/ExportTest.php b/tests/StarterKits/ExportTest.php index 37ae03b588..75d4d4d69c 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', ], ]); @@ -422,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() { @@ -512,11 +592,900 @@ 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('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' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'config/app.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($appConfig = $this->exportPath('config/app.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($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', + ], + ], + '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_can_export_nested_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' => [ + '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' +{ + "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_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() + { + $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_installable_if_module_contains_nested_modules() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'modules' => [ + 'js' => [ + '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_or_nested_modules_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), + ], + ]); + + $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', + ], + ]], + 'nested modules' => [[ + 'modules' => [ + 'filesystem' => [ + 'export_paths' => [ + 'config/filesystems.php', + ], + ], + ], + ]], + ]; + } + + #[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', + ], + ], + ], + ]], + 'select module export' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'non-existent.txt', + ], + ], + ], + ], + ], + ]], + 'select 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', + ], + ], + ], + ]], + 'select module dependencies' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'dependencies' => [ + 'non/existent', + ], + ], + ], + ], + ], + ]], + 'select 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) + { + $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', + ], + ]], + 'module export' => [[ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'composer.json', + ], + ], + ], + ]], + 'module export as from' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'composer.json' => 'resources/views/welcome.blade.php', + ], + ], + ], + ]], + 'module export as to' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'composer.json', + ], + ], + ], + ]], + 'select module export' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'composer.json', + ], + ], + ], + ], + ], + ]], + 'select module export as from' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'composer.json' => 'resources/views/welcome.blade.php', + ], + ], + ], + ], + ], + ]], + 'select 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": "*", + "luke/x-wing": "*" + }, + "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'), + 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' => [ + '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', + ], + 'modules' => [ + 'testing_tools' => [ + 'dependencies' => [ + 'luke/x-wing' => '*', + ], + 'export_paths' => [ + 'resources/vue-testing-tools.js', + ], + ], + ], + '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' => '*', + ], + 'modules' => [ + 'testing_tools' => [ + 'export_paths' => [ + 'resources/vue-testing-tools.js', + ], + 'dependencies' => [ + 'luke/x-wing' => '*', + ], + ], + ], + ], + ], + ], + ], + ]); + + $this->cleanPaths($paths); + } + 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 +1494,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 +1507,38 @@ 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 assertConfigSameOrder($expectedConfig) + { + return $this->assertSame( + $expectedConfig, + YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))) ); } 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) diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 5b259068de..10c85e1c2e 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -8,6 +8,7 @@ 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; @@ -371,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() { @@ -706,6 +722,630 @@ 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_only_the_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', + ], + ], + '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', + ], + ], + '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', + ], + ], + ], + ], + ], + ]); + + $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->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 + ->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_display_custom_module_prompts() + { + $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')); + + $command = $this + ->installCoolRunningsModules() + ->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')); + $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', + ], + '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 + ->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')); + $this->assertComposerJsonDoesntHave('statamic/seo-pro'); + $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() + { + $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`, `dependencies`, or nested `modules`!') + ->assertFailed(); + + $this->assertFileDoesNotExist(base_path('copied.md')); + } + + #[Test] + public function it_doesnt_require_anything_installable_if_module_contains_nested_modules() + { + $this->setConfig([ + 'modules' => [ + 'seo' => [ + 'prompt' => false, + 'modules' => [ + 'js' => [ + '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_or_nested_modules_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', + ], + ]], + 'nested modules' => [[ + 'modules' => [ + 'js' => [ + 'export_paths' => [ + 'resources/js/vue.js', + ], + ], + ], + ]], + ]; + } + + #[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('/'); @@ -737,18 +1377,40 @@ private function preparePath($path) return $path; } - private function installCoolRunnings($options = [], $customFake = null) + private function installCoolRunnings($options = [], $customHttpFake = null) + { + $this->httpFake($customHttpFake); + + return $this->artisan('statamic:starter-kit:install', array_merge([ + 'package' => 'statamic/cool-runnings', + '--no-interaction' => true, + ], $options)); + } + + private function installCoolRunningsInteractively($options = [], $customHttpFake = null) + { + $this->httpFake($customHttpFake); + + return $this->artisan('statamic:starter-kit:install', array_merge([ + 'package' => 'statamic/cool-runnings', + ], $options)); + } + + 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) { Http::fake($customFake ?? [ 'outpost.*' => Http::response(['data' => ['price' => null]], 200), 'repo.packagist.org/*' => Http::response('', 200), '*' => Http::response('', 404), ]); - - $this->artisan('statamic:starter-kit:install', array_merge([ - 'package' => 'statamic/cool-runnings', - '--no-interaction' => true, - ], $options)); } private function assertFileHasContent($expected, $path) 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/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/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/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/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-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/react.js b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js new file mode 100644 index 0000000000..58beb3ed0e --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/react.js @@ -0,0 +1 @@ +// 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-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! 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..3b9ba3fb4b --- /dev/null +++ b/tests/StarterKits/__fixtures__/cool-runnings/resources/js/vue.js @@ -0,0 +1 @@ +// vue!