diff --git a/src/Console/Commands/StarterKitExport.php b/src/Console/Commands/StarterKitExport.php index 3545c1e743..9777ea4cda 100644 --- a/src/Console/Commands/StarterKitExport.php +++ b/src/Console/Commands/StarterKitExport.php @@ -36,8 +36,8 @@ class StarterKitExport extends Command */ public function handle() { - if (! File::exists(base_path('starter-kit.yaml'))) { - return $this->askToStubStarterKitConfig(); + if ($this->isUsingLegacyExporterConventions()) { + $this->askToMigrateToPackageFolder(); } if (! File::exists($path = $this->getAbsolutePath())) { @@ -58,26 +58,6 @@ public function handle() $this->components->info("Starter kit was successfully exported to [$path]."); } - /** - * Ask to stub out starter kit config. - */ - protected function askToStubStarterKitConfig(): void - { - $stubPath = __DIR__.'/stubs/starter-kits/starter-kit.yaml.stub'; - $newPath = base_path($config = 'starter-kit.yaml'); - - if ($this->input->isInteractive()) { - if (! confirm("Config [{$config}] does not exist. Would you like to create it now?", true)) { - return; - } - } - - File::copy($stubPath, $newPath); - - $this->comment("A new config has been created at [{$config}]."); - $this->comment('Please configure your `export_paths` and re-run to begin your export!'); - } - /** * Get absolute path. */ @@ -105,4 +85,43 @@ protected function askToCreateExportPath(string $path): void $this->components->info("A new directory has been created at [{$path}]."); } + + /** + * Determine if dev sandbox has starter-kit.yaml at root and/or customized composer.json at target path. + */ + protected function isUsingLegacyExporterConventions(): bool + { + return File::exists(base_path('starter-kit.yaml')); + } + + /** + * Determine if dev sandbox has starter-kit.yaml at root and/or customized composer.json at target path. + */ + protected function askToMigrateToPackageFolder(): void + { + if ($this->input->isInteractive()) { + if (! confirm('Config should now live in the [package] folder. Would you like Statamic to move it for you?', true)) { + return; + } + } + + if (! File::exists($dir = base_path('package'))) { + File::makeDirectory($dir, 0755, true); + } + + if (File::exists($starterKitConfig = base_path('starter-kit.yaml'))) { + File::move($starterKitConfig, base_path('package/starter-kit.yaml')); + $this->components->info('Starter kit config moved to [package/starter-kit.yaml].'); + } + + if (File::exists($postInstallHook = base_path('StarterKitPostInstall.php'))) { + File::move($postInstallHook, base_path('package/StarterKitPostInstall.php')); + $this->components->info('Starter kit post-install hook moved to [package/StarterKitPostInstall.php].'); + } + + if (File::exists($packageComposerJson = $this->getAbsolutePath().'/composer.json')) { + File::move($packageComposerJson, base_path('package/composer.json')); + $this->components->info('Composer package config moved to [package/composer.json].'); + } + } } diff --git a/src/Console/Commands/StarterKitInstall.php b/src/Console/Commands/StarterKitInstall.php index cf3864745a..cfb0c6576a 100644 --- a/src/Console/Commands/StarterKitInstall.php +++ b/src/Console/Commands/StarterKitInstall.php @@ -75,7 +75,7 @@ public function handle() try { $installer->install(); } catch (StarterKitException $exception) { - $this->error($exception->getMessage()); + $this->components->error($exception->getMessage()); return 1; } diff --git a/src/StarterKits/Concerns/InteractsWithFilesystem.php b/src/StarterKits/Concerns/InteractsWithFilesystem.php index 25dac3bf98..d119a4ec4a 100644 --- a/src/StarterKits/Concerns/InteractsWithFilesystem.php +++ b/src/StarterKits/Concerns/InteractsWithFilesystem.php @@ -24,9 +24,9 @@ protected function installFile(string $fromPath, string $toPath, Command|NullCon } /** - * Export starter kit path. + * Export relative path to starter kit. */ - protected function exportPath(string $starterKitPath, string $from, ?string $to = null): void + protected function exportRelativePath(string $starterKitPath, string $from, ?string $to = null): void { $to = $to ? "{$starterKitPath}/{$to}" @@ -43,6 +43,18 @@ protected function exportPath(string $starterKitPath, string $from, ?string $to : $files->copy($from, $to); } + /** + * Copy directory contents into, file by file so that it does not stomp the whole target directory. + */ + protected function copyDirectoryContentsInto(string $from, string $to): void + { + $files = app(Filesystem::class); + + collect($files->allFiles($from)) + ->mapWithKeys(fn ($file) => [$from.'/'.$file->getRelativePathname() => $to.'/'.$file->getRelativePathname()]) + ->each(fn ($to, $from) => $files->copy(Path::tidy($from), $this->preparePath($to))); + } + /** * Prepare path directory. */ diff --git a/src/StarterKits/ExportableModule.php b/src/StarterKits/ExportableModule.php index 8bf988b5c4..2f972d383f 100644 --- a/src/StarterKits/ExportableModule.php +++ b/src/StarterKits/ExportableModule.php @@ -32,14 +32,14 @@ public function export(string $starterKitPath): void { $this ->exportPaths() - ->each(fn ($path) => $this->exportPath( + ->each(fn ($path) => $this->exportRelativePath( from: $path, starterKitPath: $starterKitPath, )); $this ->exportAsPaths() - ->each(fn ($to, $from) => $this->exportPath( + ->each(fn ($to, $from) => $this->exportRelativePath( from: $from, to: $to, starterKitPath: $starterKitPath, @@ -112,8 +112,12 @@ protected function ensureNotExportingComposerJson(): self ->merge($this->exportAsPaths()) ->merge($this->exportAsPaths()->keys()); + if ($flattenedExportPaths->contains('starter-kit.yaml')) { + throw new StarterKitException('Cannot export [starter-kit.yaml] config.'); + } + if ($flattenedExportPaths->contains('composer.json')) { - throw new StarterKitException('Cannot export [composer.json]. Please use `dependencies` array!'); + throw new StarterKitException('Cannot export [composer.json]. Please use `dependencies` array.'); } return $this; @@ -131,7 +135,7 @@ protected function ensureExportablePathsExist(): self ->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!"); + throw new StarterKitException("Cannot export [{$path}], because it does not exist in your app."); }); return $this; @@ -153,7 +157,7 @@ protected function ensureExportableDependenciesExist(): self ->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!"); + 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 51fe16346e..f3f635d4cb 100644 --- a/src/StarterKits/Exporter.php +++ b/src/StarterKits/Exporter.php @@ -8,7 +8,6 @@ use Statamic\StarterKits\Concerns\InteractsWithFilesystem; use Statamic\StarterKits\Exceptions\StarterKitException; use Statamic\Support\Arr; -use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; class Exporter @@ -52,9 +51,7 @@ public function export(): void ->instantiateModules() ->clearExportPath() ->exportModules() - ->exportConfig() - ->exportHooks() - ->exportComposerJson(); + ->exportPackage(); } /** @@ -74,8 +71,12 @@ protected function validateExportPath(): self */ protected function validateConfig(): self { - if (! $this->files->exists(base_path('starter-kit.yaml'))) { - throw new StarterKitException('Export config [starter-kit.yaml] does not exist.'); + if (! $this->files->exists(base_path('package/starter-kit.yaml'))) { + throw new StarterKitException('Starter kit config [package/starter-kit.yaml] does not exist.'); + } + + if (! $this->files->exists(base_path('package/composer.json'))) { + throw new StarterKitException('Package config [package/composer.json] does not exist.'); } return $this; @@ -162,7 +163,9 @@ protected function clearExportPath() */ protected function exportModules(): self { - $this->modules->each(fn ($module) => $module->export($this->exportPath)); + $exportPath = $this->exportPath.'/export'; + + $this->modules->each(fn ($module) => $module->export($exportPath)); return $this; } @@ -172,7 +175,7 @@ protected function exportModules(): self */ protected function config(?string $key = null): mixed { - $config = collect(YAML::parse($this->files->get(base_path('starter-kit.yaml')))); + $config = collect(YAML::parse($this->files->get(base_path('package/starter-kit.yaml')))); if ($key) { return $config->get($key); @@ -181,20 +184,6 @@ protected function config(?string $key = null): mixed return $config; } - /** - * Export starter kit config. - */ - protected function exportConfig(): self - { - $config = $this - ->versionModuleDependencies() - ->syncConfigWithModules(); - - $this->files->put("{$this->exportPath}/starter-kit.yaml", YAML::dump($config->all())); - - return $this; - } - /** * Version module dependencies from composer.json. */ @@ -254,69 +243,18 @@ protected function dottedModulePath(ExportableModule $module, string $key): stri } /** - * Export starter kit hooks. + * Export package config & other misc vendor files. */ - protected function exportHooks(): self + protected function exportPackage(): self { - $hooks = ['StarterKitPostInstall.php']; - - collect($hooks) - ->filter(fn ($hook) => $this->files->exists(base_path($hook))) - ->each(fn ($hook) => $this->exportPath( - from: $hook, - starterKitPath: $this->exportPath, - )); + $this->copyDirectoryContentsInto(base_path('package'), $this->exportPath); - return $this; - } - - /** - * Export composer.json. - */ - protected function exportComposerJson(): self - { - $composerJson = $this->prepareComposerJsonFromStub()->all(); + $config = $this + ->versionModuleDependencies() + ->syncConfigWithModules(); - $this->files->put( - "{$this->exportPath}/composer.json", - json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n" - ); + $this->files->put("{$this->exportPath}/starter-kit.yaml", YAML::dump($config->all())); return $this; } - - /** - * Prepare composer.json from stub. - */ - protected function prepareComposerJsonFromStub(): Collection - { - $stub = $this->getComposerJsonStub(); - - $directory = preg_replace('/.*\/([^\/]*)/', '$1', $this->exportPath); - $vendorName = $this->vendorName ?? 'my-vendor-name'; - $repoName = Str::slug($directory); - $package = "{$vendorName}/{$repoName}"; - $title = Str::slugToTitle($repoName); - - $stub = str_replace('dummy/package', $package, $stub); - $stub = str_replace('DummyTitle', $title, $stub); - - return collect(json_decode($stub, true)); - } - - /** - * Get composer.json stub. - */ - protected function getComposerJsonStub(): string - { - $stubPath = __DIR__.'/../Console/Commands/stubs/starter-kits/composer.json.stub'; - - $existingComposerJsonPath = "{$this->exportPath}/composer.json"; - - if ($this->files->exists($existingComposerJsonPath)) { - return $this->files->get($existingComposerJsonPath); - } - - return $this->files->get($stubPath); - } } diff --git a/src/StarterKits/InstallableModule.php b/src/StarterKits/InstallableModule.php index 88ef05ccd7..c11923d76b 100644 --- a/src/StarterKits/InstallableModule.php +++ b/src/StarterKits/InstallableModule.php @@ -125,8 +125,8 @@ protected function installableFiles(): Collection */ protected function expandExportDirectoriesToFiles(string $to, ?string $from = null): Collection { - $to = Path::tidy($this->starterKitPath($to)); - $from = Path::tidy($from ? $this->starterKitPath($from) : $to); + $to = Path::tidy($this->installableFilesPath($to)); + $from = Path::tidy($from ? $this->installableFilesPath($from) : $to); $paths = collect([$from => $to]); @@ -139,13 +139,24 @@ protected function expandExportDirectoriesToFiles(string $to, ?string $from = nu ]); } - $package = $this->installer->package(); - return $paths->mapWithKeys(fn ($to, $from) => [ - Path::tidy($from) => Path::tidy(str_replace("/vendor/{$package}", '', $to)), + Path::tidy($from) => Path::tidy($this->convertInstallableToDestinationPath($to)), ]); } + /** + * Convert installable vendor file path to destination path. + */ + protected function convertInstallableToDestinationPath(string $path): string + { + $package = $this->installer->package(); + + $path = str_replace("/vendor/{$package}/export", '', $path); + $path = str_replace("/vendor/{$package}", '', $path); + + return $path; + } + /** * Install dependency permanently into app. */ @@ -186,7 +197,7 @@ protected function ensureInstallableFilesExist(): self $this ->exportPaths() ->merge($this->exportAsPaths()) - ->reject(fn ($path) => $this->files->exists($this->starterKitPath($path))) + ->reject(fn ($path) => $this->files->exists($this->installableFilesPath($path))) ->each(function ($path) { throw new StarterKitException("Starter kit path [{$path}] does not exist."); }); @@ -229,13 +240,19 @@ protected function ensureCanRequireDependencies(array $packages, bool $dev = fal } /** - * Get starter kit vendor path. + * Get starter kit installable files path. */ - protected function starterKitPath(?string $path = null): string + protected function installableFilesPath(?string $path = null): string { $package = $this->installer->package(); - return collect([base_path("vendor/{$package}"), $path])->filter()->implode('/'); + // Scope to new `export` folder if it exists, otherwise we'll + // look in starter kit root for backwards compatibility + $scope = $this->files->exists(base_path("vendor/{$package}/export")) + ? 'export' + : null; + + return collect([base_path("vendor/{$package}"), $scope, $path])->filter()->implode('/'); } /** diff --git a/src/StarterKits/Installer.php b/src/StarterKits/Installer.php index 650ffcdb8d..448b07d2c8 100644 --- a/src/StarterKits/Installer.php +++ b/src/StarterKits/Installer.php @@ -12,6 +12,7 @@ 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; @@ -250,7 +251,7 @@ function () { : $this->package; try { - Composer::withoutQueue()->throwOnFailure()->requireDev($package); + Composer::withoutQueue()->throwOnFailure()->require($package); } catch (ProcessException $exception) { $this->rollbackWithError("Error installing starter kit [{$package}].", $exception->getMessage()); } @@ -548,14 +549,14 @@ function () { */ public function removeStarterKit(): self { - if ($this->disableCleanup) { + if ($this->isUpdatable() || $this->disableCleanup) { return $this; } spin( function () { if (Composer::isInstalled($this->package)) { - Composer::withoutQueue()->throwOnFailure(false)->removeDev($this->package); + Composer::withoutQueue()->throwOnFailure(false)->remove($this->package); } }, 'Cleaning up temporary files...' @@ -589,7 +590,7 @@ protected function completeInstall(): self */ protected function removeRepository(): self { - if ($this->fromLocalRepo || ! $this->url) { + if ($this->isUpdatable() || $this->fromLocalRepo || ! $this->url) { return $this; } @@ -664,7 +665,7 @@ protected function tidyComposerErrorOutput(string $output): string */ protected function starterKitPath(?string $path = null): string { - return collect([base_path("vendor/{$this->package}"), $path])->filter()->implode('/'); + return Path::tidy(collect([base_path("vendor/{$this->package}"), $path])->filter()->implode('/')); } /** @@ -672,7 +673,9 @@ protected function starterKitPath(?string $path = null): string */ protected function config(?string $key = null): mixed { - $config = collect(YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml')))); + $config = Blink::once('starter-kit-config', function () { + return collect(YAML::parse($this->files->get($this->starterKitPath('starter-kit.yaml')))); + }); if ($key) { return $config->get($key); @@ -680,4 +683,12 @@ protected function config(?string $key = null): mixed return $config; } + + /** + * Should starter kit be treated as an updatable package, and live on for future composer updates, etc? + */ + protected function isUpdatable(): bool + { + return (bool) $this->config('updatable'); + } } diff --git a/src/StarterKits/Module.php b/src/StarterKits/Module.php index acd102d63e..3959b34fb2 100644 --- a/src/StarterKits/Module.php +++ b/src/StarterKits/Module.php @@ -64,6 +64,10 @@ protected function exportPaths(): Collection /** * Get `export_as` paths (to be renamed on install) as collection from config. + * + * This is only here for backwards compatibility. Use new `export` folder convention instead. + * + * @deprecated */ protected function exportAsPaths(): Collection { @@ -84,7 +88,7 @@ protected function ensureModuleConfigNotEmpty(): self || $this->config()->has('modules'); if (! $hasConfig) { - throw new StarterKitException('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!'); + 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 59130fde1b..7a536a84ee 100644 --- a/tests/StarterKits/ExportTest.php +++ b/tests/StarterKits/ExportTest.php @@ -14,55 +14,78 @@ class ExportTest extends TestCase use Concerns\BacksUpComposerJson; protected $files; - protected $configPath; - protected $exportPath; - protected $postInstallHookPath; + protected $packagePath; + protected $targetPath; public function setUp(): void { parent::setUp(); $this->files = app(Filesystem::class); - $this->configPath = base_path('starter-kit.yaml'); - $this->postInstallHookPath = base_path('StarterKitPostInstall.php'); - $this->exportPath = base_path('../cool-runnings'); - - if ($this->files->exists($this->configPath)) { - $this->files->delete($this->configPath); - } - - if ($this->files->exists($this->exportPath)) { - $this->files->deleteDirectory($this->exportPath); - } + $this->packagePath = base_path('package'); + $this->targetPath = base_path('../cool-runnings'); + $this->cleanUp(); $this->restoreComposerJson(); $this->backupComposerJson(); } public function tearDown(): void { - if ($this->files->exists($this->configPath)) { - $this->files->delete($this->configPath); + $this->cleanUp(); + $this->restoreComposerJson(); + + parent::tearDown(); + } + + private function cleanUp() + { + if ($this->files->exists($this->packagePath)) { + $this->files->deleteDirectory($this->packagePath); } - if ($this->files->exists($this->postInstallHookPath)) { - $this->files->delete($this->postInstallHookPath); + if ($this->files->exists($this->targetPath)) { + $this->files->deleteDirectory($this->targetPath); } + } - $this->restoreComposerJson(); + #[Test] + public function it_requires_valid_starter_kit_config() + { + $this->assertFileDoesNotExist($source = base_path('package/starter-kit.yaml')); + $this->assertFileDoesNotExist($target = $this->targetPath('starter-kit.yaml')); - parent::tearDown(); + $this + ->exportCoolRunnings() + ->expectsOutputToContain('Starter kit config [package/starter-kit.yaml] does not exist.') + ->assertFailed(); + + $this->assertFileDoesNotExist($source); + $this->assertFileDoesNotExist($target); } #[Test] - public function it_can_stub_out_a_new_config() + public function it_requires_valid_package_composer_json_config() { - $this->assertFileDoesNotExist($this->configPath); + $this->setExportPaths([ + 'config/filesystems.php', + 'resources/views/welcome.blade.php', + ]); - $this->exportCoolRunnings(); + $this->files->delete(base_path('package/composer.json')); + + $this->assertFileExists(base_path('package/starter-kit.yaml')); + + $this->assertFileDoesNotExist($source = base_path('package/composer.json')); + $this->assertFileDoesNotExist($target = $this->targetPath('composer.json')); + + $this + ->exportCoolRunnings() + ->expectsOutputToContain('Package config [package/composer.json] does not exist.') + ->assertFailed(); - $this->assertFileExists($this->configPath); - $this->assertFileHasContent('# export_paths:', $this->configPath); + $this->assertFileDoesNotExist($source); + $this->assertFileDoesNotExist($target); } #[Test] @@ -93,8 +116,8 @@ public function it_can_export_folders() 'resources/views', ]); - $this->assertFileDoesNotExist($this->exportPath('config')); - $this->assertFileDoesNotExist($this->exportPath('resources/views')); + $this->assertFileDoesNotExist($this->targetPath('config')); + $this->assertFileDoesNotExist($this->targetPath('resources/views')); $this->exportCoolRunnings(); @@ -107,7 +130,7 @@ public function it_can_export_folders() } #[Test] - public function it_can_export_as_to_different_destination_path() + public function it_can_still_export_as_to_different_destination_path_for_backwards_compatibility() { $paths = $this->cleanPaths([ base_path('README.md'), @@ -119,23 +142,16 @@ public function it_can_export_as_to_different_destination_path() $this->files->put(base_path('test-folder/one.txt'), 'One.'); $this->files->put(base_path('test-folder/two.txt'), 'Two.'); - $this->setExportPaths([ - 'config/filesystems.php', - 'resources/views', - ], [ + $this->setExportPaths([], [ 'README.md' => 'README-new-site.md', 'test-folder' => 'test-renamed-folder', ]); - $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); - $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($errorsFolder); $this->assertFileExists($renamedFile); $this->assertFileExists($renamedFolder); @@ -195,7 +211,7 @@ public function it_copies_export_config() 'config', ]); - $this->assertFileDoesNotExist($starterKitConfig = $this->exportPath('starter-kit.yaml')); + $this->assertFileDoesNotExist($starterKitConfig = $this->targetPath('starter-kit.yaml')); $this->exportCoolRunnings(); @@ -210,9 +226,9 @@ public function it_copies_post_install_script_hook_when_available() 'config', ]); - $this->assertFileDoesNotExist($postInstallHook = $this->exportPath('StarterKitPostInstall.php')); + $this->assertFileDoesNotExist($postInstallHook = $this->targetPath('StarterKitPostInstall.php')); - $this->files->put(base_path('StarterKitPostInstall.php'), 'files->put(base_path('package/StarterKitPostInstall.php'), 'exportCoolRunnings(); @@ -258,20 +274,6 @@ public function it_exports_all_dependencies_from_versionless_array() $this->assertExportedConfigEquals('dependencies_dev', [ 'statamic/ssg' => '^0.4.0', ]); - - $this->assertEquals(<<<'EOT' -{ - "name": "my-vendor-name/cool-runnings", - "extra": { - "statamic": { - "name": "Cool Runnings", - "description": "Cool Runnings starter kit" - } - } -} - -EOT - , $this->files->get($this->exportPath('composer.json'))); } #[Test] @@ -552,83 +554,25 @@ public function it_does_not_export_opinionated_app_composer_json() $this->exportCoolRunnings(); - $this->assertFileDoesNotExist($this->exportPath('composer.json')); - } - - #[Test] - public function it_does_not_export_as_with_opinionated_app_composer_json() - { - $this->setExportPaths([ - 'config/filesystems.php', - ], [ - 'composer.json' => 'composer-renamed.json', - ]); - - $this->assertFileExists(base_path('composer.json')); - - $this->exportCoolRunnings(); - - $this->assertFileDoesNotExist($this->exportPath('composer.json')); + $this->assertFileDoesNotExist($this->targetPath('composer.json')); } #[Test] - public function it_exports_basic_composer_json_file() + public function it_exports_custom_package_composer_json_file() { $this->setExportPaths([ 'config', ]); - $this->assertFileExists(base_path('composer.json')); - - $this->exportCoolRunnings(); - - $this->assertEquals(<<<'EOT' -{ - "name": "my-vendor-name/cool-runnings", - "extra": { - "statamic": { - "name": "Cool Runnings", - "description": "Cool Runnings starter kit" - } - } -} - -EOT - , $this->files->get($this->exportPath('composer.json'))); - } - - #[Test] - public function it_uses_existing_composer_json_file() - { - $this->files->makeDirectory($this->exportPath); - $this->files->put($this->exportPath('composer.json'), <<<'EOT' -{ - "name": "custom/hot-runnings", - "keywords": [ - "jamaica", - "bob-sled" - ] -} -EOT - ); + $this->files->put(base_path('package/composer.json'), $composerJson = 'custom composer.json!'); - $this->setExportPaths([ - 'config', - ]); + $this->assertFileExists(base_path('composer.json')); + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); $this->exportCoolRunnings(); - $this->assertEquals(<<<'EOT' -{ - "name": "custom/hot-runnings", - "keywords": [ - "jamaica", - "bob-sled" - ] -} - -EOT - , $this->files->get($this->exportPath('composer.json'))); + $this->assertEquals($composerJson, $this->files->get($this->targetPath('composer.json'))); + $this->assertFileExists($filesystemsConfig); } #[Test] @@ -995,11 +939,11 @@ public function it_requires_valid_config_at_top_level() // no installable config! ]); - $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); + $this->assertFileDoesNotExist($welcomeView = $this->targetPath('resources/views/welcome.blade.php')); $this ->exportCoolRunnings() - // ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') // TODO: Why does this work in InstallTest? + ->expectsOutputToContain('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`.') ->assertFailed(); $this->assertFileDoesNotExist($welcomeView); @@ -1016,11 +960,11 @@ public function it_requires_valid_module_config() ], ]); - $this->assertFileDoesNotExist($welcomeView = $this->exportPath('resources/views/welcome.blade.php')); + $this->assertFileDoesNotExist($welcomeView = $this->targetPath('resources/views/welcome.blade.php')); $this ->exportCoolRunnings() - // ->expectsOutput('Starter-kit module is missing `export_paths` or `dependencies`!') // TODO: Why does this work in InstallTest? + ->expectsOutputToContain('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`.') ->assertFailed(); $this->assertFileDoesNotExist($welcomeView); @@ -1128,7 +1072,7 @@ public function it_fails_validation_if_module_export_paths_do_not_exist($config) $this ->exportCoolRunnings() - // ->expectsOutput('Cannot export [non-existent.txt], because it does not exist in your app!') // TODO: Why does this work in InstallTest? + ->expectsOutputToContain('Cannot export [non-existent.txt], because it does not exist in your app.') ->assertFailed(); } @@ -1200,7 +1144,7 @@ public function it_fails_validation_if_module_dependencies_are_not_installed_in_ $this ->exportCoolRunnings() - // ->expectsOutput('Cannot export [non-existent.txt], because it does not exist in your app!') // TODO: Why does this work in InstallTest? + ->expectsOutputToContain('Cannot export [non/existent], because it does not exist in your composer.json.') ->assertFailed(); } @@ -1264,15 +1208,114 @@ public static function nonExistentDependencies() ]; } + #[Test] + #[DataProvider('configsExportingStarterKitYaml')] + public function it_doesnt_allow_starter_kit_config_in_export_paths($config) + { + $this->setConfig($config); + + $this + ->exportCoolRunnings() + ->expectsOutputToContain('Cannot export [starter-kit.yaml] config.') + ->assertFailed(); + } + + public static function configsExportingStarterKitYaml() + { + return [ + 'top level export' => [[ + 'export_paths' => [ + 'starter-kit.yaml', + ], + ]], + 'top level export as from' => [[ + 'export_as' => [ + 'starter-kit.yaml' => 'resources/views/welcome.blade.php', + ], + ]], + 'top level export as to' => [[ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'starter-kit.yaml', + ], + ]], + 'module export' => [[ + 'modules' => [ + 'seo' => [ + 'export_paths' => [ + 'starter-kit.yaml', + ], + ], + ], + ]], + 'module export as from' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'starter-kit.yaml' => 'resources/views/welcome.blade.php', + ], + ], + ], + ]], + 'module export as to' => [[ + 'modules' => [ + 'seo' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'starter-kit.yaml', + ], + ], + ], + ]], + 'select module export' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_paths' => [ + 'starter-kit.yaml', + ], + ], + ], + ], + ], + ]], + 'select module export as from' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'starter-kit.yaml' => 'resources/views/welcome.blade.php', + ], + ], + ], + ], + ], + ]], + 'select module export as to' => [[ + 'modules' => [ + 'js' => [ + 'options' => [ + 'vue' => [ + 'export_as' => [ + 'resources/views/welcome.blade.php' => 'starter-kit.yaml', + ], + ], + ], + ], + ], + ]], + ]; + } + #[Test] #[DataProvider('configsExportingComposerJson')] - public function it_doesnt_allow_exporting_of_composer_json_file($config) + public function it_doesnt_allow_composer_json_in_export_paths($config) { $this->setConfig($config); $this ->exportCoolRunnings() - // ->expectsOutput('Cannot export [composer.json]. Please use `dependencies` array!') // TODO: Why does this work in InstallTest? + ->expectsOutputToContain('Cannot export [composer.json]. Please use `dependencies` array.') ->assertFailed(); } @@ -1511,17 +1554,123 @@ public function it_normalizes_module_key_order() $this->cleanPaths($paths); } + #[Test] + public function it_can_help_migrate_to_new_package_folder_convention() + { + $this->setExportPaths([ + 'config', + ]); + + $this->files->move(base_path('package/starter-kit.yaml'), base_path('starter-kit.yaml')); + $this->files->put(base_path('StarterKitPostInstall.php'), $postInstallHook = 'post install hook!'); + $this->files->put($this->targetPath('starter-kit.yaml'), 'this should get stomped!'); + $this->files->put($this->targetPath('composer.json'), $packageComposerJson = 'custom composer.json!'); + $this->files->deleteDirectory(base_path('package')); + + $this->assertFileDoesNotExist(base_path('package')); + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + + $this->exportCoolRunnings() + ->expectsOutputToContain('Starter kit config moved to [package/starter-kit.yaml].') + ->expectsOutputToContain('Starter kit post-install hook moved to [package/StarterKitPostInstall.php].') + ->expectsOutputToContain('Composer package config moved to [package/composer.json].') + ->assertSuccessful(); + + $expectedConfig = [ + 'export_paths' => [ + 'config', + ], + ]; + + $this->assertFileDoesNotExist(base_path('starter-kit.yaml')); + $this->assertFileExists(base_path('package/starter-kit.yaml')); + $this->assertEquals($expectedConfig, YAML::parse($this->files->get(base_path('package/starter-kit.yaml')))); + $this->assertEquals($expectedConfig, YAML::parse($this->files->get($this->targetPath('starter-kit.yaml')))); + + $this->assertFileDoesNotExist(base_path('StarterKitPostInstall.php')); + $this->assertFileExists(base_path('package/StarterKitPostInstall.php')); + $this->assertEquals($postInstallHook, $this->files->get(base_path('package/StarterKitPostInstall.php'))); + $this->assertEquals($postInstallHook, $this->files->get($this->targetPath('StarterKitPostInstall.php'))); + + $this->assertFileExists(base_path('package/composer.json')); + $this->assertEquals($packageComposerJson, $this->files->get(base_path('package/composer.json'))); + $this->assertEquals($packageComposerJson, $this->files->get($this->targetPath('composer.json'))); + + $this->assertFileExists($filesystemsConfig); + } + + #[Test] + public function it_can_help_migrate_starter_kit_config_to_new_package_folder_but_requires_composer_json_to_actually_export() + { + $this->setExportPaths([ + 'config', + ]); + + $this->files->move(base_path('package/starter-kit.yaml'), base_path('starter-kit.yaml')); + $this->files->deleteDirectory(base_path('package')); + $this->files->deleteDirectory($this->targetPath()); + + $this->assertFileDoesNotExist(base_path('package')); + $this->assertFileDoesNotExist($filesystemsConfig = $this->exportPath('config/filesystems.php')); + + $this->exportCoolRunnings() + ->expectsOutputToContain('Starter kit config moved to [package/starter-kit.yaml].') + ->doesntExpectOutputToContain('Starter kit post-install hook moved to [package/StarterKitPostInstall.php].') + ->doesntExpectOutputToContain('Composer package config moved to [package/composer.json].') + ->expectsOutputToContain('Package config [package/composer.json] does not exist.') + ->assertFailed(); + + $expectedConfig = [ + 'export_paths' => [ + 'config', + ], + ]; + + $this->assertFileDoesNotExist(base_path('starter-kit.yaml')); + $this->assertFileExists(base_path('package/starter-kit.yaml')); + $this->assertEquals($expectedConfig, YAML::parse($this->files->get(base_path('package/starter-kit.yaml')))); + + $this->assertFileDoesNotExist(base_path('package/StarterKitPostInstall.php')); + $this->assertFileDoesNotExist($this->targetPath('StarterKitPostInstall.php')); + + $this->assertFileDoesNotExist(base_path('package/composer.json')); + $this->assertFileDoesNotExist($this->targetPath('composer.json')); + + $this->assertFileDoesNotExist($filesystemsConfig); + } + + private function targetPath($path = null) + { + return collect([$this->targetPath, $path])->filter()->implode('/'); + } + private function exportPath($path = null) { - return collect([$this->exportPath, $path])->filter()->implode('/'); + return collect([$this->targetPath, 'export', $path])->filter()->implode('/'); } private function setConfig($config) { - $this->files->put($this->configPath, YAML::dump($config)); + if (! $this->files->exists($this->packagePath)) { + $this->files->makeDirectory($this->packagePath); + } + + $this->files->put($this->packagePath.'/starter-kit.yaml', YAML::dump($config)); + + $this->files->put($this->packagePath.'/composer.json', <<<'PACKAGE' +{ + "name": "my-vendor-name/cool-runnings", + "extra": { + "statamic": { + "name": "Cool Runnings", + "description": "Cool Runnings starter kit" + } + } +} +PACKAGE); - if (! $this->files->exists($this->exportPath)) { - $this->files->makeDirectory($this->exportPath); + if (! $this->files->exists($this->targetPath)) { + $this->files->makeDirectory($this->targetPath); } } @@ -1553,14 +1702,14 @@ private function assertExportedConfigEquals($key, $expectedConfig) { return $this->assertEquals( $expectedConfig, - Arr::get(YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))), $key) + Arr::get(YAML::parse($this->files->get($this->targetPath('starter-kit.yaml'))), $key) ); } private function assertExportedConfigDoesNotHave($key) { return $this->assertFalse( - Arr::has(YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))), $key) + Arr::has(YAML::parse($this->files->get($this->targetPath('starter-kit.yaml'))), $key) ); } @@ -1568,7 +1717,7 @@ private function assertConfigSameOrder($expectedConfig) { return $this->assertSame( $expectedConfig, - YAML::parse($this->files->get($this->exportPath('starter-kit.yaml'))) + YAML::parse($this->files->get($this->targetPath('starter-kit.yaml'))) ); } diff --git a/tests/StarterKits/InstallTest.php b/tests/StarterKits/InstallTest.php index 8303443d7c..6fc18b8aa1 100644 --- a/tests/StarterKits/InstallTest.php +++ b/tests/StarterKits/InstallTest.php @@ -13,7 +13,9 @@ use Statamic\Console\Commands\StarterKitInstall as InstallCommand; use Statamic\Facades\Blink; use Statamic\Facades\Config; +use Statamic\Facades\Path; use Statamic\Facades\YAML; +use Statamic\Support\Arr; use Statamic\Support\Str; use Tests\Fakes\Composer\FakeComposer; use Tests\TestCase; @@ -41,6 +43,10 @@ public function tearDown(): void { $this->restoreSite(); + if ($this->files->exists($kitRepo = $this->kitRepoPath())) { + $this->files->deleteDirectory($kitRepo); + } + parent::tearDown(); } @@ -53,6 +59,32 @@ public function it_installs_starter_kit() $this->installCoolRunnings(); + $this->assertFalse(Blink::has('starter-kit-repository-added')); + $this->assertFileDoesNotExist($this->kitVendorPath()); + $this->assertFileDoesNotExist(base_path('composer.json.bak')); + $this->assertComposerJsonDoesntHavePackage('statamic/cool-runnings'); + $this->assertComposerJsonDoesntHave('repositories'); + $this->assertFileExists(base_path('copied.md')); + } + + #[Test] + public function it_installs_starter_kit_from_updatable_package_with_export_directory() + { + // Move everything in the kit repo's `export` folder, except for `composer.json` and `starter-kit.yaml` + collect($this->files->allFiles($this->kitRepoPath())) + ->reject(fn ($file) => in_array($file->getRelativePathname(), ['composer.json', 'starter-kit.yaml'])) + ->each(fn ($file) => $this->files->move( + $this->kitRepoPath($file->getRelativePathname()), + $this->preparePath($this->kitRepoPath('export/'.$file->getRelativePathname())), + )); + + $this->assertFileDoesNotExist($this->kitRepoPath('copied.md')); + $this->assertFileExists($this->kitRepoPath('export/copied.md')); + $this->assertFileExists($this->kitRepoPath('composer.json')); + $this->assertFileExists($this->kitRepoPath('starter-kit.yaml')); + + $this->installCoolRunnings(); + $this->assertFalse(Blink::has('starter-kit-repository-added')); $this->assertFileDoesNotExist($this->kitVendorPath()); $this->assertFileDoesNotExist(base_path('composer.json.bak')); @@ -61,13 +93,34 @@ public function it_installs_starter_kit() } #[Test] - public function it_installs_from_custom_export_paths() + public function it_installs_from_export_paths() { $this->setConfig([ 'export_paths' => [ 'config', 'copied.md', ], + ]); + + $this->assertFileDoesNotExist($this->kitVendorPath()); + $this->assertComposerJsonDoesntHave('repositories'); + $this->assertFileDoesNotExist(base_path('copied.md')); + + $this->installCoolRunnings(); + + $this->assertFalse(Blink::has('starter-kit-repository-added')); + $this->assertFileDoesNotExist($this->kitVendorPath()); + $this->assertFileDoesNotExist(base_path('composer.json.bak')); + $this->assertComposerJsonDoesntHave('repositories'); + $this->assertFileExists(base_path('copied.md')); + $this->assertFileExists(config_path('filesystems.php')); + $this->assertFileHasContent('bobsled_pics', config_path('filesystems.php')); + } + + #[Test] + public function it_still_installs_from_export_as_paths_for_backwards_compatibility() + { + $this->setConfig([ 'export_as' => [ 'README.md' => 'README-for-new-site.md', 'original-dir' => 'renamed-dir', @@ -76,7 +129,6 @@ public function it_installs_from_custom_export_paths() $this->assertFileDoesNotExist($this->kitVendorPath()); $this->assertComposerJsonDoesntHave('repositories'); - $this->assertFileDoesNotExist(base_path('copied.md')); $this->assertFileDoesNotExist($renamedFile = base_path('README.md')); $this->assertFileDoesNotExist($renamedFolder = base_path('original-dir')); @@ -86,7 +138,6 @@ public function it_installs_from_custom_export_paths() $this->assertFileDoesNotExist($this->kitVendorPath()); $this->assertFileDoesNotExist(base_path('composer.json.bak')); $this->assertComposerJsonDoesntHave('repositories'); - $this->assertFileExists(base_path('copied.md')); $this->assertFileExists($renamedFile); $this->assertFileExists($renamedFolder); @@ -209,6 +260,70 @@ public function it_restores_existing_repositories_after_successful_install() $this->assertEquals($expectedRepositories, $composerJson['repositories']); } + #[Test] + public function it_installs_as_living_package_with_custom_config() + { + $this->setConfig([ + 'updatable' => true, // With `updatable: true`, kit should live on as composer updatable package + 'export_paths' => [ + 'copied.md', + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist($this->kitVendorPath()); + + $this->installCoolRunnings(); + + $this->assertFileExists(base_path('copied.md')); + $this->assertComposerJsonDoesntHave('repositories'); + + // Keep package around + $this->assertFileExists($this->kitVendorPath()); + $this->assertComposerJsonHasPackage('require', 'statamic/cool-runnings'); + + // But ensure we still delete backup composer.json, which is only used for error handling purposes + $this->assertFileDoesNotExist(base_path('composer.json.bak')); + } + + #[Test] + public function it_leaves_custom_repository_for_living_packages_that_need_it() + { + $this->setConfig([ + 'updatable' => true, // With `updatable: true`, kit should live on as composer updatable package + 'export_paths' => [ + 'copied.md', + ], + ]); + + $this->assertFileDoesNotExist(base_path('copied.md')); + $this->assertFileDoesNotExist($this->kitVendorPath()); + $this->assertComposerJsonDoesntHave('repositories'); + + $this->installCoolRunnings([], [ + 'outpost.*' => Http::response(['data' => ['price' => null]], 200), + 'github.com/*' => Http::response('', 200), + '*' => Http::response('', 404), + ]); + + $this->assertFileExists(base_path('copied.md')); + + // Keep package around + $this->assertFileExists($this->kitVendorPath()); + $this->assertComposerJsonHasPackage('require', 'statamic/cool-runnings'); + + // As well as custom repository, which will be needed for composer updates, if it was needed for install + $composerJson = json_decode($this->files->get(base_path('composer.json')), true); + $this->assertCount(1, $composerJson['repositories']); + $this->assertEquals([[ + 'type' => 'vcs', + 'url' => 'https://github.com/statamic/cool-runnings', + ]], $composerJson['repositories']); + + // But delete backup composer.json, which is only used for error handling purposes + $this->assertFileDoesNotExist(base_path('composer.json.bak')); + } + #[Test] public function it_fails_if_starter_kit_config_does_not_exist() { @@ -688,8 +803,8 @@ public function it_parses_branch_from_package_param_when_installing() ]); // Ensure `Composer::requireDev()` gets called with `package:branch` - $this->assertEquals(Blink::get('composer-require-dev-package'), 'statamic/cool-runnings'); - $this->assertEquals(Blink::get('composer-require-dev-branch'), 'dev-custom-branch'); + $this->assertEquals(Blink::get('composer-require-package'), 'statamic/cool-runnings'); + $this->assertEquals(Blink::get('composer-require-branch'), 'dev-custom-branch'); // But ensure the rest of the installer handles parsed `package` without branch messing things up $this->assertFalse(Blink::has('starter-kit-repository-added')); @@ -711,8 +826,8 @@ public function it_installs_branch_with_slash_without_failing_package_validation ]); // Ensure `Composer::requireDev()` gets called with `package:branch` - $this->assertEquals(Blink::get('composer-require-dev-package'), 'statamic/cool-runnings'); - $this->assertEquals(Blink::get('composer-require-dev-branch'), 'dev-feature/custom-branch'); + $this->assertEquals(Blink::get('composer-require-package'), 'statamic/cool-runnings'); + $this->assertEquals(Blink::get('composer-require-branch'), 'dev-feature/custom-branch'); // But ensure the rest of the installer handles parsed `package` without branch messing things up $this->assertFalse(Blink::has('starter-kit-repository-added')); @@ -1194,7 +1309,7 @@ public function it_requires_valid_config_at_top_level() $this ->installCoolRunnings() - ->expectsOutput('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!') + ->expectsOutputToContain('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`.') ->assertFailed(); $this->assertFileDoesNotExist(base_path('copied.md')); @@ -1216,7 +1331,7 @@ public function it_requires_valid_module_config() $this ->installCoolRunnings() - ->expectsOutput('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`!') + ->expectsOutputToContain('Starter-kit module is missing `export_paths`, `dependencies`, or nested `modules`.') ->assertFailed(); $this->assertFileDoesNotExist(base_path('copied.md')); @@ -1524,12 +1639,12 @@ public function it_installs_nested_modules_confirmed_interactively_via_prompt() private function kitRepoPath($path = null) { - return collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/'); + return Path::tidy(collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/')); } protected function kitVendorPath($path = null) { - return collect([base_path('vendor/statamic/cool-runnings'), $path])->filter()->implode('/'); + return Path::tidy(collect([base_path('vendor/statamic/cool-runnings'), $path])->filter()->implode('/')); } private function prepareRepo() @@ -1550,7 +1665,7 @@ private function preparePath($path) $this->files->makeDirectory($folder, 0755, true); } - return $path; + return Path::tidy($path); } private function installCoolRunnings($options = [], $customHttpFake = null) @@ -1603,6 +1718,21 @@ private function assertFileDoesntHaveContent($expected, $path) $this->assertStringNotContainsString($expected, $this->files->get($path)); } + private function assertComposerJsonHasPackage($requireKey, $package) + { + $composerJson = json_decode($this->files->get(base_path('composer.json')), true); + + $this->assertTrue(Arr::has($composerJson, "{$requireKey}.{$package}")); + } + + private function assertComposerJsonDoesntHavePackage($package) + { + $composerJson = json_decode($this->files->get(base_path('composer.json')), true); + + $this->assertFalse(Arr::has($composerJson, "require.{$package}")); + $this->assertFalse(Arr::has($composerJson, "require-dev.{$package}")); + } + private function assertComposerJsonHasPackageVersion($requireKey, $package, $version) { $composerJson = json_decode($this->files->get(base_path('composer.json')), true); diff --git a/tests/StarterKits/RunPostInstallTest.php b/tests/StarterKits/RunPostInstallTest.php index bf5ccccd7a..1b09f10e8a 100644 --- a/tests/StarterKits/RunPostInstallTest.php +++ b/tests/StarterKits/RunPostInstallTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Blink; +use Statamic\Facades\Path; use Tests\Fakes\Composer\FakeComposer; use Tests\TestCase; @@ -107,12 +108,12 @@ public function it_errors_gracefully_if_starter_kit_package_doesnt_exist_in_vend private function kitRepoPath($path = null) { - return collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/'); + return Path::tidy(collect([base_path('repo/cool-runnings'), $path])->filter()->implode('/')); } protected function kitVendorPath($path = null) { - return collect([base_path('vendor/statamic/cool-runnings'), $path])->filter()->implode('/'); + return Path::tidy(collect([base_path('vendor/statamic/cool-runnings'), $path])->filter()->implode('/')); } private function prepareRepo()