diff --git a/.github/workflows/analyse.yaml b/.github/workflows/analyse.yaml index 0b77382..cef680a 100644 --- a/.github/workflows/analyse.yaml +++ b/.github/workflows/analyse.yaml @@ -26,6 +26,9 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pcntl, sqlite3, pdo_sqlite, bcmath, fileinfo coverage: none + - name: Set package version + run: composer config version "7.x-dev" + - name: Install dependencies uses: "ramsey/composer-install@v3" with: diff --git a/.github/workflows/audits.yaml b/.github/workflows/audits.yaml index afa7261..ef527d9 100644 --- a/.github/workflows/audits.yaml +++ b/.github/workflows/audits.yaml @@ -1,7 +1,6 @@ name: audits on: - push: workflow_dispatch: jobs: @@ -33,6 +32,9 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, intl, fileinfo coverage: none + - name: Set package version + run: composer config version "7.x-dev" + - name: Install dependencies uses: "ramsey/composer-install@v3" with: diff --git a/.github/workflows/coveralls.yaml b/.github/workflows/coveralls.yaml index e4c6a61..5cbf380 100644 --- a/.github/workflows/coveralls.yaml +++ b/.github/workflows/coveralls.yaml @@ -25,6 +25,9 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pcntl, sqlite3, pdo_sqlite, bcmath, fileinfo coverage: xdebug + - name: Set package version + run: composer config version "7.x-dev" + - name: Install dependencies uses: "ramsey/composer-install@v3" with: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0be31d8..4ab0df9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -40,6 +40,9 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, intl, fileinfo coverage: none + - name: Set package version + run: composer config version "7.x-dev" + - name: Add PHP 8.1 Requirements run: composer require "ramsey/collection:~1.2" --no-interaction --no-update if: matrix.php >= 8.1 diff --git a/composer.json b/composer.json index 766bbd1..b338c86 100644 --- a/composer.json +++ b/composer.json @@ -29,16 +29,17 @@ "require": { "php": "^8.0", "illuminate/console": "^9.52.15", - "illuminate/filesystem": "^9.52.15" + "illuminate/filesystem": "^9.52.15", + "orchestra/sidekick": "^1.1.14" }, "require-dev": { "fakerphp/faker": "^1.21", "laravel/framework": "^9.52.16", "laravel/pint": "^1.4", "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^7.47.4", - "orchestra/workbench": "^7.0", - "phpstan/phpstan": "^2.0", + "orchestra/testbench-core": "^7.57.0", + "orchestra/workbench": "^7.18.0", + "phpstan/phpstan": "^2.1.17", "phpunit/phpunit": "^9.6", "symfony/yaml": "^6.0.9" }, diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4e9278e..3b594c6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,10 +7,11 @@ parameters: - src # The level 8 is the highest level - level: 7 + level: 8 ignoreErrors: - identifier: function.alreadyNarrowedType + - identifier: missingType.iterableValue - identifier: trait.unused - '#Method [a-zA-Z\\\<\>]+::handle\(\) should return bool\|null but returns int.#' diff --git a/src/Actions/DumpComposerAutoloads.php b/src/Actions/DumpComposerAutoloads.php new file mode 100644 index 0000000..b74e266 --- /dev/null +++ b/src/Actions/DumpComposerAutoloads.php @@ -0,0 +1,26 @@ +setWorkingPath($this->workingPath) + ->dumpAutoloads(); + } +} diff --git a/src/Actions/ModifyComposer.php b/src/Actions/ModifyComposer.php new file mode 100644 index 0000000..4873a38 --- /dev/null +++ b/src/Actions/ModifyComposer.php @@ -0,0 +1,45 @@ +workingPath, 'composer.json'); + + if (! file_exists($composerFile)) { + throw new RuntimeException("Unable to locate `composer.json` file at [{$this->workingPath}]."); + } + + $composer = json_decode((string) file_get_contents($composerFile), true, 512, JSON_THROW_ON_ERROR); + + $composer = \call_user_func($callback, $composer); + + file_put_contents( + $composerFile, + json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) + ); + } +} diff --git a/src/Actions/WriteEnvironmentVariables.php b/src/Actions/WriteEnvironmentVariables.php new file mode 100644 index 0000000..2d75a5a --- /dev/null +++ b/src/Actions/WriteEnvironmentVariables.php @@ -0,0 +1,129 @@ + $variables + * + * @throws \RuntimeException + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + public function handle(array $variables, bool $overwrite = false): void + { + if (! \is_string($this->filename)) { + throw new FileNotFoundException; + } + + $this->writeVariables($variables, $this->filename, $overwrite); + } + + /** + * Write an array of key-value pairs to the environment file. + * + * @laravel-overrides + * + * @param array $variables + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + protected function writeVariables(array $variables, string $filename, bool $overwrite = false): void + { + if ($this->filesystem->missing($filename)) { + throw new FileNotFoundException("The file [{$filename}] does not exist."); + } + + $lines = explode(PHP_EOL, $this->filesystem->get($filename)); + + foreach ($variables as $key => $value) { + $lines = $this->addVariableToEnvContents($key, $value, $lines, $overwrite); + } + + $this->filesystem->put($filename, implode(PHP_EOL, $lines)); + } + + /** + * Add a variable to the environment file contents. + * + * @laravel-overrides + */ + protected function addVariableToEnvContents(string $key, mixed $value, array $envLines, bool $overwrite): array + { + $prefix = explode('_', $key)[0].'_'; + $lastPrefixIndex = -1; + + $shouldQuote = \is_string($value) && preg_match('/^[a-zA-z0-9]+$/', $value) === 0; + + $lineToAddVariations = [ + $key.'='.(\is_string($value) ? '"'.addslashes($value).'"' : Env::encode($value)), + $key.'='.(\is_string($value) ? "'".addslashes($value)."'" : Env::encode($value)), + $key.'='.Env::encode($value), + ]; + + $lineToAdd = $shouldQuote ? $lineToAddVariations[0] : $lineToAddVariations[2]; + + if ($value === '') { + $lineToAdd = $key.'='; + } + + foreach ($envLines as $index => $line) { + if (str_starts_with($line, $prefix)) { + $lastPrefixIndex = $index; + } + + if (\in_array($line, $lineToAddVariations)) { + // This exact line already exists, so we don't need to add it again. + return $envLines; + } + + if ($line === $key.'=') { + // If the value is empty, we can replace it with the new value. + $envLines[$index] = $lineToAdd; + + return $envLines; + } + + if (str_starts_with($line, $key.'=')) { + if (! $overwrite) { + return $envLines; + } + + $envLines[$index] = $lineToAdd; + + return $envLines; + } + } + + if ($lastPrefixIndex === -1) { + if (\count($envLines) && $envLines[\count($envLines) - 1] !== '') { + $envLines[] = ''; + } + + return array_merge($envLines, [$lineToAdd]); + } + + return array_merge( + \array_slice($envLines, 0, $lastPrefixIndex + 1), + [$lineToAdd], + \array_slice($envLines, $lastPrefixIndex + 1) + ); + } +} diff --git a/src/LaravelServiceProvider.php b/src/LaravelServiceProvider.php index cac9650..79d9f5c 100644 --- a/src/LaravelServiceProvider.php +++ b/src/LaravelServiceProvider.php @@ -3,6 +3,8 @@ namespace Orchestra\Canvas\Core; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Composer; use Illuminate\Support\ServiceProvider; class LaravelServiceProvider extends ServiceProvider implements DeferrableProvider @@ -14,6 +16,8 @@ class LaravelServiceProvider extends ServiceProvider implements DeferrableProvid */ public function register() { + $this->app->bind('canvas.composer', static fn () => new Composer(new Filesystem)); + $this->app->singleton(PresetManager::class, fn ($app) => new PresetManager($app)); } diff --git a/tests/Actions/DumpComposerAutoloadsTest.php b/tests/Actions/DumpComposerAutoloadsTest.php new file mode 100644 index 0000000..818866e --- /dev/null +++ b/tests/Actions/DumpComposerAutoloadsTest.php @@ -0,0 +1,32 @@ +instance('canvas.composer', $composer = m::mock(Composer::class, ['files' => $filesystem])); + + $composer->shouldReceive('setWorkingPath')->once()->with($workingPath)->andReturnSelf(); + $composer->shouldReceive('dumpAutoloads')->once()->andReturnNull(); + + $action = new DumpComposerAutoloads($workingPath); + + $action->handle(); + } +} diff --git a/tests/Actions/ModifyComposerTest.php b/tests/Actions/ModifyComposerTest.php new file mode 100644 index 0000000..7308439 --- /dev/null +++ b/tests/Actions/ModifyComposerTest.php @@ -0,0 +1,65 @@ +afterApplicationCreated(function () { + copy(join_paths(__DIR__, 'stubs', 'composer.json'), join_paths(__DIR__, 'tmp', 'composer.json')); + }); + + $this->beforeApplicationDestroyed(function () { + @unlink(join_paths(__DIR__, 'tmp', 'composer.json')); + }); + + parent::setUp(); + } + + /** @test */ + public function it_can_modify_composer_file() + { + $workingPath = join_paths(__DIR__, 'tmp'); + + $action = new ModifyComposer($workingPath); + + $this->assertTrue(is_file(join_paths($workingPath, 'composer.json'))); + $this->assertSame('{}'.PHP_EOL, file_get_contents(join_paths($workingPath, 'composer.json'))); + + $action->handle(function (array $content) { + $content['$schema'] = 'https://getcomposer.org/schema.json'; + + return $content; + }); + + $this->assertTrue(is_file(join_paths($workingPath, 'composer.json'))); + $this->assertSame('{ + "$schema": "https://getcomposer.org/schema.json" +}', file_get_contents(join_paths($workingPath, 'composer.json'))); + } + + /** @test */ + public function it_throws_exception_when_composer_file_does_not_exists() + { + $workingPath = __DIR__; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('Unable to locate `composer.json` file at [%s]', $workingPath)); + + $action = new ModifyComposer($workingPath); + $action->handle(static fn (array $content) => $content); + } +} diff --git a/tests/Actions/TestCase.php b/tests/Actions/TestCase.php new file mode 100644 index 0000000..dac4335 --- /dev/null +++ b/tests/Actions/TestCase.php @@ -0,0 +1,17 @@ +afterApplicationCreated(function () { + copy(join_paths(__DIR__, 'stubs', '.env'), join_paths(__DIR__, 'tmp', '.env')); + }); + + $this->beforeApplicationDestroyed(function () { + @unlink(join_paths(__DIR__, 'tmp', '.env')); + }); + + parent::setUp(); + } + + /** @test */ + public function it_can_write_to_environment_file() + { + $filesystem = new Filesystem; + + $action = new WriteEnvironmentVariables($filesystem, join_paths(__DIR__, 'tmp', '.env')); + + $action->handle([ + 'APP_NAME' => 'Workbench', + 'APP_KEY' => 'Hello World', + 'APP_DEBUG' => true, + 'TELESCOPE_ENABLED' => false, + 'NOVA_DOMAIN' => null, + ]); + + $this->assertSame( + implode(PHP_EOL, [ + 'APP_NAME=Laravel', + 'APP_KEY="Hello World"', + 'APP_DEBUG=(true)', + '', + 'TELESCOPE_ENABLED=(false)', + '', + 'NOVA_DOMAIN=(null)', + ]), + file_get_contents(join_paths(__DIR__, 'tmp', '.env')) + ); + } + + /** @test */ + public function it_can_write_and_replace_existing_variable_to_environment_file() + { + $filesystem = new Filesystem; + + $action = new WriteEnvironmentVariables($filesystem, join_paths(__DIR__, 'tmp', '.env')); + + $action->handle(['APP_NAME' => 'Workbench', 'APP_KEY' => 'Hello World'], overwrite: true); + + $this->assertSame( + implode(PHP_EOL, [ + 'APP_NAME=Workbench', + 'APP_KEY="Hello World"', + ]).PHP_EOL, + $filesystem->get(join_paths(__DIR__, 'tmp', '.env')) + ); + } + + /** + * @test + * + * @testWith [false] + * [null] + * ["./invalid-env-file"] + */ + public function it_throws_exception_when_env_file_is_not_available(mixed $filename) + { + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage(\is_string($filename) ? "The file [{$filename}] does not exist." : ''); + + $filesystem = new Filesystem; + + $action = new WriteEnvironmentVariables($filesystem, $filename); + + $action->handle(['APP_NAME' => 'Laravel']); + } +} diff --git a/tests/Actions/stubs/.env b/tests/Actions/stubs/.env new file mode 100644 index 0000000..e35821a --- /dev/null +++ b/tests/Actions/stubs/.env @@ -0,0 +1 @@ +APP_NAME=Laravel diff --git a/tests/Actions/stubs/composer.json b/tests/Actions/stubs/composer.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/Actions/stubs/composer.json @@ -0,0 +1 @@ +{} diff --git a/tests/Actions/tmp/.gitignore b/tests/Actions/tmp/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/Actions/tmp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore