diff --git a/CHANGELOG.md b/CHANGELOG.md index fab8431..ca6d4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `laranail::pdf.install` now extends laranail/package-tools' `InstallCommand` and takes + laranail/console's display API and run lifecycle from its `InteractsWithConsoleServices` and + `InteractsWithConsoleWriter` traits instead of its `Command` base. Name, option, description, + listing visibility, output and exit codes are unchanged and pinned by a new contract test. The + command is bound in the container because the new base takes the `Package` in its constructor, + and `handle()` now takes the `DriverRegistry` as an optional parameter, still injected by the + container, because the base declares `handle(): int`. + ## [0.1.0] - 2026-08-15 ### Fixed diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index 9cbe290..863704a 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -5,8 +5,10 @@ namespace Simtabi\Laranail\Pdf\Commands; use Simtabi\Laranail\Pdf\DriverRegistry; -use Simtabi\Laranail\Console\Tools\Commands\Command; -use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames; +use Simtabi\Laranail\Package\Tools\Package; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\InteractsWithConsoleWriter; +use Simtabi\Laranail\Console\Tools\Commands\Concerns\InteractsWithConsoleServices; +use Simtabi\Laranail\Package\Tools\Commands\InstallCommand as PackageToolsInstallCommand; /** * Publishes the config and says what is still needed. @@ -15,19 +17,47 @@ * package is installed and, for Gotenberg, a container is running. Publishing a * config file and stopping would leave the reader with a working-looking install * and a driver that renders nothing. + * + * The base is package-tools' install command, which carries the `::` name support. laranail/console's + * display API and managed run lifecycle come from its two traits rather than its base class, so + * neither package has to depend on the other. `handle()` is this command's own: the base's generic + * publish pipeline would print different steps, and the output here is the contract. */ -final class InstallCommand extends Command +final class InstallCommand extends PackageToolsInstallCommand { - use SupportsNamespacedNames; + use InteractsWithConsoleServices; + use InteractsWithConsoleWriter; - /** @var string */ - protected $signature = 'laranail::pdf.install {--force : Overwrite an existing config file}'; + public const string SIGNATURE = 'laranail::pdf.install {--force : Overwrite an existing config file}'; - /** @var string */ - protected $description = 'Publish the laranail/pdf config and report what else is needed.'; + public const string DESCRIPTION = 'Publish the laranail/pdf config and report what else is needed.'; - public function handle(DriverRegistry $registry): int + public function __construct(Package $package) { + // Listed in `php artisan list`, as it always has been: the base hides install commands by + // default, so visibility is passed explicitly rather than inherited. + parent::__construct($package, self::SIGNATURE, hidden: false); + + // The base writes `Install {package}` as the description during construction; restore the + // one this command has always shown. Both the property and Symfony's copy are set, because + // the parent constructor has already pushed the property through setDescription(). + $this->description = self::DESCRIPTION; + $this->setDescription(self::DESCRIPTION); + + // Booted eagerly, as console's own base does, so `$this->services` exists straight after + // construction rather than only once run() has been entered. + $this->bootConsoleSupport(); + } + + /** + * The registry is still injected by the container when Artisan calls this. It is optional + * only because the base declares `handle(): int`, and a required parameter would not be a + * compatible override. + */ + public function handle(?DriverRegistry $registry = null): int + { + $registry ??= $this->laravel->make(DriverRegistry::class); + $this->callSilently('vendor:publish', array_filter([ '--tag' => 'pdf-config', '--force' => (bool) $this->option('force'), diff --git a/src/Providers/PdfServiceProvider.php b/src/Providers/PdfServiceProvider.php index e4e5138..0881e51 100644 --- a/src/Providers/PdfServiceProvider.php +++ b/src/Providers/PdfServiceProvider.php @@ -41,6 +41,10 @@ public function configurePackage(Package $package): void #[Override] public function packageRegistered(): void { + // The install command's base takes the Package in its constructor, which the container + // cannot supply on its own. + $this->app->bind(InstallCommand::class, fn (): InstallCommand => new InstallCommand($this->package)); + $this->app->singleton( PdfConfig::class, static fn (Application $app): PdfConfig => new PdfConfig($app->make(ConfigRepository::class)), diff --git a/tests/Feature/InstallCommandContractTest.php b/tests/Feature/InstallCommandContractTest.php new file mode 100644 index 0000000..af13592 --- /dev/null +++ b/tests/Feature/InstallCommandContractTest.php @@ -0,0 +1,117 @@ +installCommand(); + + self::assertSame('laranail::pdf.install', $command->getName()); + self::assertSame([], $command->getAliases()); + self::assertSame('Publish the laranail/pdf config and report what else is needed.', $command->getDescription()); + self::assertFalse($command->isHidden()); + } + + #[Test] + public function it_keeps_exactly_one_option_of_its_own_and_no_arguments(): void + { + $definition = $this->installCommand()->getNativeDefinition(); + + self::assertSame(['force'], array_keys($definition->getOptions())); + self::assertSame([], $definition->getArguments()); + + $option = $definition->getOption('force'); + + self::assertFalse($option->acceptValue()); + self::assertSame('Overwrite an existing config file', $option->getDescription()); + } + + #[Test] + public function it_publishes_the_config_and_reports_every_driver(): void + { + $this->artisan('laranail::pdf.install') + ->expectsOutputToContain('Published config/pdf.php.') + ->expectsOutputToContain('gotenberg') + ->expectsOutputToContain('dompdf') + ->expectsOutputToContain($this->closingLine()) + ->assertExitCode(0); + } + + #[Test] + public function it_accepts_force_and_still_exits_zero(): void + { + $this->artisan('laranail::pdf.install', ['--force' => true]) + ->expectsOutputToContain('Published config/pdf.php.') + ->assertExitCode(0); + } + + #[Test] + public function it_names_the_next_steps_when_a_driver_is_unavailable(): void + { + config()->set('laranail.pdf.drivers.gotenberg.base_url', ''); + $this->app->make(DriverRegistry::class)->flush(); + + $this->artisan('laranail::pdf.install') + ->expectsOutputToContain('Next steps') + ->expectsOutputToContain('Then: php artisan laranail::pdf.doctor') + ->assertExitCode(0); + } + + #[Test] + public function it_extends_the_package_tools_install_base_and_keeps_both_console_traits(): void + { + self::assertTrue(is_subclass_of(InstallCommand::class, PackageToolsInstallCommand::class)); + + $traits = class_uses(InstallCommand::class); + + self::assertArrayHasKey(InteractsWithConsoleServices::class, $traits); + self::assertArrayHasKey(InteractsWithConsoleWriter::class, $traits); + } + + private function installCommand(): InstallCommand + { + $command = Artisan::all()['laranail::pdf.install'] ?? null; + + self::assertInstanceOf(InstallCommand::class, $command); + + return $command; + } + + /** + * The last line depends on what happens to be installed, which differs between the full CI + * job and the optional-dependencies-removed one. Either way the command must end on one of + * these two lines. + */ + private function closingLine(): string + { + foreach ($this->app->make(DriverRegistry::class)->all() as $driver) { + if (! $driver->isAvailable()) { + return 'Then: php artisan laranail::pdf.doctor'; + } + } + + return 'Every driver is ready. Try: php artisan laranail::pdf.doctor --probe'; + } +}