diff --git a/AGENTS.md b/AGENTS.md index 2283b59..b682211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Initial packages: - `stellarwp/foundation-log` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` +- `stellarwp/foundation-cli` ## Namespaces @@ -42,6 +43,26 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. + +Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files. + +Project-level stub overrides should use `foundation/stubs//`, for example `foundation/stubs/wpcli/command.stub`. + +When generating classes intended for WordPress projects, use Snake_Case class names and WordPress formatting in the generated stub output, even though Foundation source follows this repository's formatter. + +Generators that write references to Foundation classes should detect `extra.strauss.namespace_prefix` from the consuming project's `composer.json` and render those Foundation imports with the configured prefix. + +## CLI Tooling Boundary + +`stellarwp/foundation-cli` is developer tooling and should normally be installed by consuming projects with `composer require --dev stellarwp/foundation-cli`. It should not be packaged into production WordPress plugin zips. + +Do not instruct consuming WordPress plugins to register `StellarWP\Foundation\Cli\CliProvider` in their application providers. `CliProvider` boots the Foundation Symfony Console application for the `foundation` binary only. + +When generated code depends on runtime APIs, require the runtime package normally. For WP-CLI commands, install `stellarwp/foundation-wpcli` in `require` if the plugin ships those commands, and install `stellarwp/foundation-cli` in `require-dev` only for generation. + +If local scaffolding assets such as `foundation/stubs/` should not be included in a consuming project's release archive, add them to that project's `.gitattributes` production zip exclusions. + ## Container Providers When writing providers or container registration code, prefer container-driven construction over inline factories with explicit `new` calls. Bind classes and interfaces directly when the container can autowire them. diff --git a/README.md b/README.md index 53f6caf..21c0f1a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) +- [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) ## Installation @@ -114,6 +115,38 @@ composer run foundation -- package:create --apply The command creates the `stellarwp/foundation-` repository with the standard `[READ ONLY]` description, disables issues, wiki, and projects, and relies on the package's `close-pull-request.yml` workflow to close pull requests. +#### Generating WP-CLI Commands + +In a consuming WordPress project, install the generator as a development dependency: + +```bash +composer require --dev stellarwp/foundation-cli +``` + +If the generated command will ship with the plugin, install the runtime WP-CLI package as a normal dependency: + +```bash +composer require stellarwp/foundation-wpcli +``` + +Then generate a starter WP-CLI command: + +```bash +composer run foundation -- make:wpcli-command Sync_Products_Command +``` + +The command reads the project's PSR-4 Composer autoload namespace, writes a Snake_Case command class under `src/Cli/Commands`, and uses the default WP-CLI command stub from `foundation-wpcli`. + +`stellarwp/foundation-cli` is build-time developer tooling. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin application, and do not include the CLI package in production plugin zips. Production installs should normally run with Composer's `--no-dev` mode so the generator, Symfony Console, and other dev tooling are not packaged. + +To customize the generated command stub in a project, create: + +```text +foundation/stubs/wpcli/command.stub +``` + +Project-level stubs are local scaffolding assets. Add them to the consuming project's production zip exclusions, such as `.gitattributes`, when they should not be included in release archives. + ## License Copyright © 2026 Nexcess Corp. diff --git a/composer.json b/composer.json index d4c583f..5180439 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,9 @@ "StellarWP\\Foundation\\Tests\\": "tests/" } }, + "bin": [ + "src/Cli/bin/foundation" + ], "config": { "optimize-autoloader": true, "preferred-install": "dist", diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index 7c277d2..d582ada 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Cli; use lucatume\DI52\Container; +use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; use StellarWP\Foundation\Cli\Commands\Package\GitHubPackageRepositoryCreator; @@ -10,6 +11,11 @@ use StellarWP\Foundation\Cli\Commands\Package\PackageRepositoryPlanFactory; use StellarWP\Foundation\Cli\Commands\Package\PackageResolver; use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder; +use StellarWP\Foundation\Cli\Generation\ClassNameResolver; +use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; +use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\StubRenderer; +use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Process\Contracts\ProcessRunner; use StellarWP\Foundation\Cli\Process\ShellProcessRunner; use StellarWP\Foundation\Container\Contracts\Provider; @@ -35,10 +41,23 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(ComposerAutoloadResolver::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(StubResolver::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(WPCliCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(Application::class) ->needs('$commands') ->give(static fn (Container $c): array => [ $c->get(CreateCommand::class), + $c->get(WPCliCommand::class), ]); $this->container->singleton(PackageResolver::class); @@ -49,6 +68,12 @@ public function register(): void { $this->container->bind(ProcessRunner::class, ShellProcessRunner::class); $this->container->bind(PackageRepositoryCreator::class, GitHubPackageRepositoryCreator::class); $this->container->singleton(CreateCommand::class); + $this->container->singleton(ClassNameResolver::class); + $this->container->singleton(ComposerAutoloadResolver::class); + $this->container->singleton(GeneratedFileWriter::class); + $this->container->singleton(StubRenderer::class); + $this->container->singleton(StubResolver::class); + $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } } diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php new file mode 100644 index 0000000..4ee18b4 --- /dev/null +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -0,0 +1,143 @@ +setDescription('Generate a WP-CLI command class that extends the Foundation command base.') + ->addArgument('name', InputArgument::REQUIRED, 'Command class name, e.g. Sync_Products_Command, SyncProducts, or sync-products.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated command class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the command class should be written.') + ->addOption('subcommand', null, InputOption::VALUE_REQUIRED, 'WP-CLI subcommand name under the configured command prefix.') + ->addOption('description', null, InputOption::VALUE_REQUIRED, 'Command description shown in WP-CLI help.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Register this command from your WP-CLI provider and configure its $commandPrefix container argument.'); + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->commandClass((string) $input->getArgument('name')); + $autoload = $this->autoloadResolver->firstPsr4Namespace(); + $namespace = $this->namespace($input, $autoload); + $path = $this->path($input, $namespace, $autoload); + $stub = $this->stubResolver->resolve('wpcli', 'command', WPCliStubPath::command()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $description = (string) ($input->getOption('description') ?: $this->classNameResolver->description($className)); + $subcommand = (string) ($input->getOption('subcommand') ?: $this->classNameResolver->subcommand($className)); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'foundation_wpcli_command' => $this->foundationClass('StellarWP\\Foundation\\WPCli\\Command'), + 'subcommand' => $subcommand, + 'description' => $description, + ]) + ); + } + + private function foundationClass(string $class): string { + return ($this->autoloadResolver->straussNamespacePrefix() ?? '') . $class; + } + + private function namespace(InputInterface $input, AutoloadNamespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return trim($namespace, '\\'); + } + + return trim($autoload->namespace, '\\') . '\\Cli\\Commands'; + } + + private function path(InputInterface $input, string $namespace, AutoloadNamespace $autoload): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoloadNamespace = trim($autoload->namespace, '\\'); + $relativeNamespace = ''; + + if (str_starts_with($namespace, $autoloadNamespace)) { + $relativeNamespace = trim(substr($namespace, strlen($autoloadNamespace)), '\\'); + } + + $segments = $relativeNamespace === '' ? '' : '/' . str_replace('\\', '/', $relativeNamespace); + + return $this->rootPath . '/' . trim($autoload->path, '/') . $segments; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } +} diff --git a/src/Cli/Generation/ClassNameResolver.php b/src/Cli/Generation/ClassNameResolver.php new file mode 100644 index 0000000..2ececc9 --- /dev/null +++ b/src/Cli/Generation/ClassNameResolver.php @@ -0,0 +1,62 @@ +words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a class name from "%s".', $input)); + } + + if (strtolower((string) end($words)) !== 'command') { + $words[] = 'command'; + } + + return implode('_', array_map($this->pascalWord(...), $words)); + } + + public function subcommand(string $className): string { + $words = $this->words((string) preg_replace('/_?Command$/', '', $className)); + + return implode('-', array_map(strtolower(...), $words)); + } + + public function description(string $className): string { + $words = $this->words((string) preg_replace('/_?Command$/', '', $className)); + + if ($words === []) { + return 'Run the command.'; + } + + return ucfirst(implode(' ', array_map(strtolower(...), $words))) . '.'; + } + + /** + * @return list + */ + private function words(string $input): array { + $input = trim($input); + $input = str_replace('\\', '/', $input); + $input = basename($input); + $input = (string) preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $input); + $input = (string) preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', $input); + $input = (string) preg_replace('/[^A-Za-z0-9]+/', ' ', $input); + $words = preg_split('/\s+/', trim($input)) ?: []; + + return array_values(array_filter($words, static fn (string $word): bool => $word !== '')); + } + + private function pascalWord(string $word): string { + $word = strtolower($word); + + return ucfirst($word); + } +} diff --git a/src/Cli/Generation/ComposerAutoloadResolver.php b/src/Cli/Generation/ComposerAutoloadResolver.php new file mode 100644 index 0000000..99de021 --- /dev/null +++ b/src/Cli/Generation/ComposerAutoloadResolver.php @@ -0,0 +1,77 @@ +composer(); + $psr4 = $composer['autoload']['psr-4'] ?? []; + + if (! is_array($psr4) || $psr4 === []) { + throw new RuntimeException('Could not find an autoload.psr-4 namespace in composer.json.'); + } + + foreach ($psr4 as $namespace => $paths) { + if (! is_string($namespace)) { + continue; + } + + $path = is_array($paths) ? reset($paths) : $paths; + + if (! is_string($path) || $path === '') { + continue; + } + + return new AutoloadNamespace( + namespace: trim($namespace, '\\') . '\\', + path: trim($path, '/') + ); + } + + throw new RuntimeException('Could not find a valid autoload.psr-4 namespace in composer.json.'); + } + + public function straussNamespacePrefix(): ?string { + $prefix = $this->composer()['extra']['strauss']['namespace_prefix'] ?? null; + + if (! is_string($prefix) || trim($prefix, '\\') === '') { + return null; + } + + return trim($prefix, '\\') . '\\'; + } + + /** + * @return array + */ + private function composer(): array { + $composerPath = $this->rootPath . '/composer.json'; + + if (! file_exists($composerPath)) { + throw new RuntimeException(sprintf('Could not find composer.json at "%s".', $composerPath)); + } + + $composer = json_decode((string) file_get_contents($composerPath), true, 512, JSON_THROW_ON_ERROR); + + if (! is_array($composer)) { + throw new RuntimeException(sprintf('Could not read composer.json at "%s".', $composerPath)); + } + + return $composer; + } +} diff --git a/src/Cli/Generation/GeneratedFileWriter.php b/src/Cli/Generation/GeneratedFileWriter.php new file mode 100644 index 0000000..991d020 --- /dev/null +++ b/src/Cli/Generation/GeneratedFileWriter.php @@ -0,0 +1,28 @@ +path) && ! $force) { + throw new RuntimeException(sprintf('File already exists: %s. Use --force to overwrite it.', $file->relativePath)); + } + + $directory = dirname($file->path); + + if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException(sprintf('Could not create directory "%s".', $directory)); + } + + if (file_put_contents($file->path, $file->contents) === false) { + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); + } + } +} diff --git a/src/Cli/Generation/StubRenderer.php b/src/Cli/Generation/StubRenderer.php new file mode 100644 index 0000000..f283cad --- /dev/null +++ b/src/Cli/Generation/StubRenderer.php @@ -0,0 +1,31 @@ + $replacements + */ + public function render(string $stubPath, array $replacements): string { + if (! is_readable($stubPath)) { + throw new RuntimeException(sprintf('Could not read stub "%s".', $stubPath)); + } + + $contents = (string) file_get_contents($stubPath); + + foreach ($replacements as $key => $value) { + $contents = str_replace([ + '{{ ' . $key . ' }}', + '{{' . $key . '}}', + ], $value, $contents); + } + + return $contents; + } +} diff --git a/src/Cli/Generation/StubResolver.php b/src/Cli/Generation/StubResolver.php new file mode 100644 index 0000000..db640f3 --- /dev/null +++ b/src/Cli/Generation/StubResolver.php @@ -0,0 +1,24 @@ +rootPath, trim($feature, '/'), trim($stubName, '/')); + + if (file_exists($override)) { + return $override; + } + + return $defaultPath; + } +} diff --git a/src/Cli/Generation/ValueObjects/AutoloadNamespace.php b/src/Cli/Generation/ValueObjects/AutoloadNamespace.php new file mode 100644 index 0000000..b745a8b --- /dev/null +++ b/src/Cli/Generation/ValueObjects/AutoloadNamespace.php @@ -0,0 +1,15 @@ +bind(Container::class, $container); -$container->bind(ContainerInterface::class, $container); -$container->singleton(Dot::class, new Dot()); -$container->register(CliProvider::class); + if (! is_string($autoload) || ! is_readable($autoload)) { + throw new FoundationCliBootstrapException( + 'Could not find Composer autoload.php. Run this command from a project with Composer dependencies installed, or run composer install first.' + ); + } -exit($container->get(Application::class)->run()); + require $autoload; + + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + $container->register(CliProvider::class); + + exit($container->get(Application::class)->run()); +} catch (FoundationCliBootstrapException $e) { + fwrite(STDERR, 'Foundation CLI bootstrap failed: ' . $e->getMessage() . PHP_EOL); + + exit(1); +} catch (Throwable $e) { + fwrite(STDERR, 'Foundation CLI failed to start: ' . $e->getMessage() . PHP_EOL); + + exit(1); +} diff --git a/src/Cli/composer.json b/src/Cli/composer.json index 4d9923e..c440ab9 100644 --- a/src/Cli/composer.json +++ b/src/Cli/composer.json @@ -10,6 +10,7 @@ "require": { "php": ">=8.3", "stellarwp/foundation-container": "^1.0", + "stellarwp/foundation-wpcli": "^1.0", "symfony/console": ">=5.4" }, "autoload": { diff --git a/src/WPCli/README.md b/src/WPCli/README.md index b3efdad..eedd62c 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -13,10 +13,22 @@ composer require stellarwp/foundation-wpcli WP-CLI is expected to provide the `WP_CLI` and `WP_CLI_Command` runtime classes. This package includes `wp-cli/wp-cli` as a development dependency for tests and static analysis, but applications normally do not need to install it separately when running inside WP-CLI. +Install `stellarwp/foundation-wpcli` as a normal dependency when the plugin ships WP-CLI commands. Install `stellarwp/foundation-cli` separately with `composer require --dev stellarwp/foundation-cli` only when developers need generators such as `make:wpcli-command`. + ## Commands Extend `StellarWP\Foundation\WPCli\Command` for commands that should receive the Foundation container. +If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a command with: + +```bash +composer run foundation -- make:wpcli-command Sync_Products_Command +``` + +Generated WP project command classes use Snake_Case names and WordPress formatting. The default command stub includes examples for positional, associative, and flag arguments. + +When generated through `foundation-cli`, projects using Strauss with `extra.strauss.namespace_prefix` receive prefixed Foundation imports automatically. + ```php container. return self::SUCCESS; @@ -57,6 +69,8 @@ final class SyncCommand extends Command Applications should register their own provider so they control the command namespace and command list. +Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. + ```php configureCommands(); $this->registerTimestampedLogger(); - add_action('cli_init', function (): void { - foreach (self::COMMANDS as $commandClass) { - $command = $this->container->get($commandClass); + add_action( 'cli_init', function (): void { + foreach ( self::COMMANDS as $commandClass ) { + $command = $this->container->get( $commandClass ); - if ($command instanceof Command) { + if ( $command instanceof Command ) { $command->register(); } } - }, 0, 0); + }, 0, 0 ); } private function configureCommands(): void { - foreach (self::COMMANDS as $commandClass) { - $this->container->when($commandClass) - ->needs('$commandPrefix') - ->give(self::COMMAND_PREFIX); + foreach ( self::COMMANDS as $commandClass ) { + $this->container->when( $commandClass ) + ->needs( '$commandPrefix' ) + ->give( self::COMMAND_PREFIX ); } } private function registerTimestampedLogger(): void { $wpCliLogger = WP_CLI::get_logger(); - if ($wpCliLogger instanceof Regular) { - WP_CLI::set_logger(new TimestampedLogger($wpCliLogger)); + if ( $wpCliLogger instanceof Regular ) { + WP_CLI::set_logger( new TimestampedLogger( $wpCliLogger ) ); } } } diff --git a/src/WPCli/WPCliStubPath.php b/src/WPCli/WPCliStubPath.php new file mode 100644 index 0000000..f50f884 --- /dev/null +++ b/src/WPCli/WPCliStubPath.php @@ -0,0 +1,16 @@ + self::POSITIONAL, + 'name' => self::ARG_ITEM, + 'description' => 'The item to process.', + 'optional' => false, + ], + [ + 'type' => self::ASSOCIATIVE, + 'name' => self::OPTION_FORMAT, + 'description' => 'The output format.', + 'optional' => true, + 'default' => self::DEFAULT_FORMAT, + 'options' => [ + 'table', + 'json', + 'ids', + ], + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_DRY_RUN, + 'description' => 'Preview the command without making changes.', + 'optional' => true, + ], + ]; + } + +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 0c55e5d..64f158f 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -7,11 +7,17 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Cli\Application; use StellarWP\Foundation\Cli\CliProvider; +use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; use StellarWP\Foundation\Cli\Commands\Package\GitHubPackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\PackageResolver; use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder; +use StellarWP\Foundation\Cli\Generation\ClassNameResolver; +use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; +use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\StubRenderer; +use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Tests\TestCase; @@ -27,9 +33,16 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); + $this->assertInstanceOf(WPCliCommand::class, $container->get(WPCliCommand::class)); $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); + $this->assertInstanceOf(ClassNameResolver::class, $container->get(ClassNameResolver::class)); + $this->assertInstanceOf(ComposerAutoloadResolver::class, $container->get(ComposerAutoloadResolver::class)); + $this->assertInstanceOf(GeneratedFileWriter::class, $container->get(GeneratedFileWriter::class)); + $this->assertInstanceOf(StubRenderer::class, $container->get(StubRenderer::class)); + $this->assertInstanceOf(StubResolver::class, $container->get(StubResolver::class)); $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($container->get(Application::class)->has('package:create')); + $this->assertTrue($container->get(Application::class)->has('make:wpcli-command')); } } diff --git a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php new file mode 100644 index 0000000..6d2b20a --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php @@ -0,0 +1,256 @@ + + */ + private array $temporaryRoots = []; + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_generates_a_wpcli_command_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $command = $this->command($root); + $tester = new CommandTester($command); + + $statusCode = $tester->execute([ + 'name' => 'sync-products', + ]); + + $path = $root . '/src/Cli/Commands/Sync_Products_Command.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Cli/Commands/Sync_Products_Command.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Cli\\Commands;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\WPCli\\Command;', $contents); + $this->assertStringContainsString('final class Sync_Products_Command extends Command {', $contents); + $this->assertStringContainsString('public const string ARG_ITEM', $contents); + $this->assertStringContainsString('public function runCommand( array $args = [], array $assocArgs = [] ): int {', $contents); + $this->assertStringContainsString('WP_CLI::line( sprintf(', $contents); + $this->assertStringContainsString("return 'sync-products';", $contents); + $this->assertStringContainsString("return 'Sync products.';", $contents); + } + + public function test_it_accepts_generation_options(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->command($root)); + + $statusCode = $tester->execute([ + 'name' => 'Import_Customers', + '--namespace' => 'Acme\\Plugin\\Admin\\Cli', + '--path' => 'custom/commands', + '--subcommand' => 'customers:import', + '--description' => 'Import customers.', + ]); + + $contents = (string) file_get_contents($root . '/custom/commands/Import_Customers_Command.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('namespace Acme\\Plugin\\Admin\\Cli;', $contents); + $this->assertStringContainsString("return 'customers:import';", $contents); + $this->assertStringContainsString("return 'Import customers.';", $contents); + } + + public function test_it_uses_strauss_namespace_prefix_for_foundation_imports(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + $tester = new CommandTester($this->command($root)); + + $statusCode = $tester->execute([ + 'name' => 'Sync_Products', + ]); + + $contents = (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\WPCli\\Command;', $contents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\WPCli\\Command;', $contents); + } + + public function test_it_uses_unprefixed_foundation_imports_when_strauss_namespace_prefix_is_blank(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => '', + ], + ], + ]); + $tester = new CommandTester($this->command($root)); + + $statusCode = $tester->execute([ + 'name' => 'Sync_Products', + ]); + + $contents = (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('use StellarWP\\Foundation\\WPCli\\Command;', $contents); + } + + public function test_it_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-wpcli-output-'); + $tester = new CommandTester($this->command($root)); + + $statusCode = $tester->execute([ + 'name' => 'Export_Customers', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Export_Customers_Command.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Export_Customers_Command.php', $tester->getDisplay()); + } + + public function test_it_reports_autoload_resolution_errors(): void { + $root = $this->temporaryRoot('foundation-make-wpcli-invalid-'); + + file_put_contents($root . '/composer.json', json_encode(['autoload' => []], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $tester = new CommandTester($this->command($root)); + $statusCode = $tester->execute([ + 'name' => 'Sync_Products', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Could not find an autoload.psr-4 namespace in composer.json.', $tester->getDisplay()); + } + + public function test_it_uses_project_stub_overrides(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/foundation/stubs/wpcli', 0777, true); + file_put_contents($root . '/foundation/stubs/wpcli/command.stub', 'Generated {{ class }} in {{ namespace }}'); + + $tester = new CommandTester($this->command($root)); + $tester->execute([ + 'name' => 'Sync_Products', + ]); + + $this->assertSame( + 'Generated Sync_Products_Command in Acme\\Plugin\\Cli\\Commands', + (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php') + ); + } + + public function test_it_refuses_to_overwrite_existing_files_without_force(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Cli/Commands', 0777, true); + file_put_contents($root . '/src/Cli/Commands/Sync_Products_Command.php', 'existing'); + + $tester = new CommandTester($this->command($root)); + $statusCode = $tester->execute([ + 'name' => 'Sync_Products', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('File already exists: src/Cli/Commands/Sync_Products_Command.php', $tester->getDisplay()); + $this->assertSame('existing', (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php')); + } + + public function test_it_overwrites_existing_files_with_force(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Cli/Commands', 0777, true); + file_put_contents($root . '/src/Cli/Commands/Sync_Products_Command.php', 'existing'); + + $tester = new CommandTester($this->command($root)); + $statusCode = $tester->execute([ + 'name' => 'Sync_Products', + '--force' => true, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('final class Sync_Products_Command extends Command {', (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php')); + } + + private function command(string $root): WPCliCommand { + return new WPCliCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new ClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter() + ); + } + + /** + * @param array $composer + */ + private function temporaryProject(array $composer = []): string { + $root = $this->temporaryRoot('foundation-make-wpcli-test-'); + + file_put_contents($root . '/composer.json', json_encode(array_replace_recursive([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ], $composer), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $root; + } + + private function temporaryRoot(string $prefix): string { + $root = sys_get_temp_dir() . '/' . $prefix . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Cli/Generation/ClassNameResolverTest.php b/tests/Unit/Cli/Generation/ClassNameResolverTest.php new file mode 100644 index 0000000..147d7d2 --- /dev/null +++ b/tests/Unit/Cli/Generation/ClassNameResolverTest.php @@ -0,0 +1,63 @@ + + */ + public static function commandClassProvider(): array { + return [ + 'snake command' => [ + 'input' => 'Sync_Products_Command', + 'expected' => 'Sync_Products_Command', + ], + 'snake base' => [ + 'input' => 'Sync_Products', + 'expected' => 'Sync_Products_Command', + ], + 'kebab base' => [ + 'input' => 'sync-products', + 'expected' => 'Sync_Products_Command', + ], + 'camel command' => [ + 'input' => 'SyncProductsCommand', + 'expected' => 'Sync_Products_Command', + ], + 'path-like name' => [ + 'input' => 'Cli/SyncProducts', + 'expected' => 'Sync_Products_Command', + ], + ]; + } + + #[DataProvider('commandClassProvider')] + public function test_it_normalizes_command_class_names(string $input, string $expected): void { + $this->assertSame($expected, (new ClassNameResolver())->commandClass($input)); + } + + public function test_it_creates_a_subcommand_from_a_command_class(): void { + $this->assertSame('sync-products', (new ClassNameResolver())->subcommand('Sync_Products_Command')); + } + + public function test_it_creates_a_description_from_a_command_class(): void { + $this->assertSame('Sync products.', (new ClassNameResolver())->description('Sync_Products_Command')); + } + + public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a class name from "@@@".'); + + (new ClassNameResolver())->commandClass('@@@'); + } + + public function test_it_uses_a_default_description_when_the_class_has_no_words(): void { + $this->assertSame('Run the command.', (new ClassNameResolver())->description('Command')); + } +} diff --git a/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php b/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php new file mode 100644 index 0000000..3db4e24 --- /dev/null +++ b/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php @@ -0,0 +1,150 @@ + + */ + private array $temporaryRoots = []; + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_resolves_the_first_psr4_namespace(): void { + $root = $this->temporaryRoot([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ]); + + $namespace = (new ComposerAutoloadResolver($root))->firstPsr4Namespace(); + + $this->assertSame('Acme\\Plugin\\', $namespace->namespace); + $this->assertSame('src', $namespace->path); + } + + public function test_it_skips_invalid_psr4_namespace_keys(): void { + $root = $this->temporaryRoot([ + 'autoload' => [ + 'psr-4' => [ + 123 => 'invalid', + 'Acme\\Plugin\\' => 'src', + ], + ], + ]); + + $namespace = (new ComposerAutoloadResolver($root))->firstPsr4Namespace(); + + $this->assertSame('Acme\\Plugin\\', $namespace->namespace); + $this->assertSame('src', $namespace->path); + } + + public function test_it_resolves_strauss_namespace_prefix(): void { + $root = $this->temporaryRoot([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $this->assertSame('Acme\\Product\\', (new ComposerAutoloadResolver($root))->straussNamespacePrefix()); + } + + public function test_it_returns_null_when_strauss_namespace_prefix_is_missing(): void { + $this->assertNull((new ComposerAutoloadResolver($this->temporaryRoot([])))->straussNamespacePrefix()); + } + + public function test_it_fails_when_composer_json_is_missing(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not find composer.json'); + + (new ComposerAutoloadResolver($this->temporaryRoot()))->firstPsr4Namespace(); + } + + public function test_it_fails_when_composer_json_root_is_not_an_object(): void { + $root = $this->temporaryRoot(); + + file_put_contents($root . '/composer.json', 'null'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not read composer.json'); + + (new ComposerAutoloadResolver($root))->firstPsr4Namespace(); + } + + public function test_it_fails_when_composer_json_has_no_psr4_autoload(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not find an autoload.psr-4 namespace in composer.json.'); + + (new ComposerAutoloadResolver($this->temporaryRoot([ + 'autoload' => [], + ])))->firstPsr4Namespace(); + } + + public function test_it_fails_when_psr4_autoload_has_no_valid_path(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not find a valid autoload.psr-4 namespace in composer.json.'); + + (new ComposerAutoloadResolver($this->temporaryRoot([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => '', + ], + ], + ])))->firstPsr4Namespace(); + } + + /** + * @param array|null $composer + */ + private function temporaryRoot(?array $composer = null): string { + $root = sys_get_temp_dir() . '/foundation-autoload-test-' . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + if ($composer !== null) { + file_put_contents($root . '/composer.json', (string) json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Cli/Generation/StubRendererTest.php b/tests/Unit/Cli/Generation/StubRendererTest.php new file mode 100644 index 0000000..ac206f6 --- /dev/null +++ b/tests/Unit/Cli/Generation/StubRendererTest.php @@ -0,0 +1,37 @@ +assertSame( + 'Class Sync_Command in Acme\\Plugin', + (new StubRenderer())->render($stub, [ + 'class' => 'Sync_Command', + 'namespace' => 'Acme\\Plugin', + ]) + ); + } finally { + unlink($stub); + } + } + + public function test_it_fails_when_the_stub_cannot_be_read(): void { + $missingStub = sys_get_temp_dir() . '/foundation-missing-stub-' . bin2hex(random_bytes(8)) . '.stub'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Could not read stub "%s".', $missingStub)); + + (new StubRenderer())->render($missingStub, []); + } +} diff --git a/tests/Unit/Cli/Generation/StubResolverTest.php b/tests/Unit/Cli/Generation/StubResolverTest.php new file mode 100644 index 0000000..33f7b29 --- /dev/null +++ b/tests/Unit/Cli/Generation/StubResolverTest.php @@ -0,0 +1,74 @@ + + */ + private array $temporaryRoots = []; + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_uses_a_project_override_before_the_default_stub(): void { + $root = $this->temporaryRoot(); + + mkdir($root . '/foundation/stubs/wpcli', 0777, true); + file_put_contents($root . '/foundation/stubs/wpcli/command.stub', 'override'); + + $this->assertSame( + $root . '/foundation/stubs/wpcli/command.stub', + (new StubResolver($root))->resolve('wpcli', 'command', '/default/command.stub') + ); + } + + public function test_it_uses_the_default_stub_when_no_override_exists(): void { + $this->assertSame( + '/default/command.stub', + (new StubResolver($this->temporaryRoot()))->resolve('wpcli', 'command', '/default/command.stub') + ); + } + + private function temporaryRoot(): string { + $root = sys_get_temp_dir() . '/foundation-stub-test-' . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/WPCli/WPCliStubPathTest.php b/tests/Unit/WPCli/WPCliStubPathTest.php new file mode 100644 index 0000000..db6f0b9 --- /dev/null +++ b/tests/Unit/WPCli/WPCliStubPathTest.php @@ -0,0 +1,14 @@ +assertFileExists(WPCliStubPath::command()); + $this->assertStringEndsWith('/src/WPCli/stubs/command.stub', WPCliStubPath::command()); + } +}