From b47480942311202801a339018fc9613d5719dacb Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 5 Jun 2026 16:52:16 -0600 Subject: [PATCH 1/8] First pass at cli stub generation --- AGENTS.md | 8 + README.md | 16 ++ src/Cli/CliProvider.php | 25 +++ src/Cli/Commands/Make/WPCliCommand.php | 138 ++++++++++++ src/Cli/Generation/AutoloadNamespace.php | 15 ++ src/Cli/Generation/ClassNameResolver.php | 62 +++++ .../Generation/ComposerAutoloadResolver.php | 53 +++++ src/Cli/Generation/GeneratedFile.php | 16 ++ src/Cli/Generation/GeneratedFileWriter.php | 27 +++ src/Cli/Generation/StubRenderer.php | 31 +++ src/Cli/Generation/StubResolver.php | 24 ++ src/Cli/README.md | 52 +++++ src/Cli/composer.json | 1 + src/WPCli/README.md | 8 + src/WPCli/WPCliStubs.php | 16 ++ src/WPCli/stubs/command.stub | 71 ++++++ tests/Unit/Cli/CliProviderTest.php | 13 ++ .../Cli/Commands/Make/WPCliCommandTest.php | 211 ++++++++++++++++++ .../Cli/Generation/ClassNameResolverTest.php | 63 ++++++ .../ComposerAutoloadResolverTest.php | 107 +++++++++ .../Unit/Cli/Generation/StubRendererTest.php | 37 +++ .../Unit/Cli/Generation/StubResolverTest.php | 74 ++++++ tests/Unit/WPCli/WPCliStubsTest.php | 14 ++ 23 files changed, 1082 insertions(+) create mode 100644 src/Cli/Commands/Make/WPCliCommand.php create mode 100644 src/Cli/Generation/AutoloadNamespace.php create mode 100644 src/Cli/Generation/ClassNameResolver.php create mode 100644 src/Cli/Generation/ComposerAutoloadResolver.php create mode 100644 src/Cli/Generation/GeneratedFile.php create mode 100644 src/Cli/Generation/GeneratedFileWriter.php create mode 100644 src/Cli/Generation/StubRenderer.php create mode 100644 src/Cli/Generation/StubResolver.php create mode 100644 src/WPCli/WPCliStubs.php create mode 100644 src/WPCli/stubs/command.stub create mode 100644 tests/Unit/Cli/Commands/Make/WPCliCommandTest.php create mode 100644 tests/Unit/Cli/Generation/ClassNameResolverTest.php create mode 100644 tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php create mode 100644 tests/Unit/Cli/Generation/StubRendererTest.php create mode 100644 tests/Unit/Cli/Generation/StubResolverTest.php create mode 100644 tests/Unit/WPCli/WPCliStubsTest.php diff --git a/AGENTS.md b/AGENTS.md index 2283b59..df7e18a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,14 @@ 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. + ## 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..58a9815 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,22 @@ 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 that installs `stellarwp/foundation-cli` and `stellarwp/foundation-wpcli`, 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`. + +To customize the generated command stub in a project, create: + +```text +foundation/stubs/wpcli/command.stub +``` + ## License Copyright © 2026 Nexcess Corp. 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..e1cf709 --- /dev/null +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -0,0 +1,138 @@ +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', WPCliStubs::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, + 'subcommand' => $subcommand, + 'description' => $description, + ]) + ); + } + + 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/AutoloadNamespace.php b/src/Cli/Generation/AutoloadNamespace.php new file mode 100644 index 0000000..b826df4 --- /dev/null +++ b/src/Cli/Generation/AutoloadNamespace.php @@ -0,0 +1,15 @@ +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..a020d44 --- /dev/null +++ b/src/Cli/Generation/ComposerAutoloadResolver.php @@ -0,0 +1,53 @@ +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); + $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.'); + } +} diff --git a/src/Cli/Generation/GeneratedFile.php b/src/Cli/Generation/GeneratedFile.php new file mode 100644 index 0000000..5b3bd4e --- /dev/null +++ b/src/Cli/Generation/GeneratedFile.php @@ -0,0 +1,16 @@ +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/README.md b/src/Cli/README.md index dc3195a..55ce9a1 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -21,6 +21,58 @@ If the package does not exist yet, the command asks whether to create the local By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated repository actions. +## Generators + +Generate a WP-CLI command class in a consuming WordPress project: + +```bash +composer run foundation -- make:wpcli-command Sync_Products_Command +``` + +This assumes the consuming project has a Composer script named `foundation` that points to the installed Foundation binary. Without a script, call `vendor/bin/foundation` directly. + +The generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes a Snake_Case command class under `src/Cli/Commands` by default. + +For example, a project with this Composer autoload entry: + +```json +{ + "autoload": { + "psr-4": { + "Acme\\Plugin\\": "src" + } + } +} +``` + +will generate: + +```text +src/Cli/Commands/Sync_Products_Command.php +``` + +with namespace: + +```php +Acme\Plugin\Cli\Commands +``` + +The generated class extends `StellarWP\Foundation\WPCli\Command` and includes example positional, associative, and flag arguments using constants. + +Available options: + +```bash +composer run foundation -- make:wpcli-command Sync_Products_Command --namespace="Acme\\Plugin\\Cli" --path=src/Cli --subcommand=sync-products --description="Sync products." --force +``` + +Project stub overrides live under: + +```text +foundation/stubs/wpcli/command.stub +``` + +When present, the override is used instead of the default stub from the `foundation-wpcli` package. + ## Custom Commands Applications can build their own Foundation CLI by creating Symfony Console commands and registering them with `StellarWP\Foundation\Cli\Application`. 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..273209c 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -17,6 +17,14 @@ WP-CLI is expected to provide the `WP_CLI` and `WP_CLI_Command` runtime classes. Extend `StellarWP\Foundation\WPCli\Command` for commands that should receive the Foundation container. +If the project also installs `stellarwp/foundation-cli`, 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. + ```php 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..bc7e628 --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php @@ -0,0 +1,211 @@ + + */ + 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('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_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() + ); + } + + private function temporaryProject(): string { + $root = $this->temporaryRoot('foundation-make-wpcli-test-'); + + file_put_contents($root . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ], 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..b2a7b34 --- /dev/null +++ b/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php @@ -0,0 +1,107 @@ + + */ + 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_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_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/WPCliStubsTest.php b/tests/Unit/WPCli/WPCliStubsTest.php new file mode 100644 index 0000000..5619501 --- /dev/null +++ b/tests/Unit/WPCli/WPCliStubsTest.php @@ -0,0 +1,14 @@ +assertFileExists(WPCliStubs::command()); + $this->assertStringEndsWith('/src/WPCli/stubs/command.stub', WPCliStubs::command()); + } +} From 7b18488f29c2ba2ec00fa709d137df68dd0acc2f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 14:49:03 -0600 Subject: [PATCH 2/8] Update docs for CLI package --- AGENTS.md | 11 +++++++++++ README.md | 19 ++++++++++++++++++- src/Cli/README.md | 20 ++++++++++++++++++++ src/WPCli/README.md | 32 ++++++++++++++++++-------------- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df7e18a..39fdf2e 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 @@ -50,6 +51,16 @@ Project-level stub overrides should use `foundation/stubs//`, for examp 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. +## 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 58a9815..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 @@ -116,7 +117,19 @@ The command creates the `stellarwp/foundation-` repository with the sta #### Generating WP-CLI Commands -In a consuming WordPress project that installs `stellarwp/foundation-cli` and `stellarwp/foundation-wpcli`, generate a starter WP-CLI command: +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 @@ -124,12 +137,16 @@ 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/src/Cli/README.md b/src/Cli/README.md index 55ce9a1..6b7dd42 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -5,6 +5,24 @@ Foundation CLI tooling for maintaining the Foundation monorepo and split repositories. +## Installation + +Install this package as a development dependency in consuming projects: + +```bash +composer require --dev stellarwp/foundation-cli +``` + +`foundation-cli` is build-time tooling. It should not be registered in a WordPress plugin application and should not be packaged in production plugin zips. Use Composer's `--no-dev` install mode for production builds so the CLI, Symfony Console, generators, and local tooling stay out of the runtime artifact. + +If a generated WP-CLI command ships with the plugin, install `stellarwp/foundation-wpcli` as a normal runtime dependency: + +```bash +composer require stellarwp/foundation-wpcli +``` + +The CLI package itself requires `foundation-wpcli` because its WP-CLI generator uses the default command stub owned by that runtime package. + ## Usage List all available commands: @@ -33,6 +51,8 @@ This assumes the consuming project has a Composer script named `foundation` that The generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes a Snake_Case command class under `src/Cli/Commands` by default. +Do not add `StellarWP\Foundation\Cli\CliProvider` to the consuming WordPress plugin's provider list. That provider only boots the Foundation Symfony Console application for the `foundation` binary. Register generated WP-CLI commands from the plugin's own WP-CLI provider using `stellarwp/foundation-wpcli`. + For example, a project with this Composer autoload entry: ```json diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 273209c..397b36b 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -13,11 +13,13 @@ 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`, scaffold a command with: +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 @@ -34,7 +36,7 @@ use StellarWP\Foundation\WPCli\Command; final class SyncCommand extends Command { - public function runCommand(array $args = [], array $assocArgs = []): int { + public function runCommand( array $args = [], array $assocArgs = [] ): int { // Run the command using services from $this->container. return self::SUCCESS; @@ -65,6 +67,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 ) ); } } } From fa5c6152aaa7813e1a1e26abeb3976aead46d4d3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 14:56:10 -0600 Subject: [PATCH 3/8] Remove useless doc --- src/Cli/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Cli/README.md b/src/Cli/README.md index 6b7dd42..cfdaad0 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -21,8 +21,6 @@ If a generated WP-CLI command ships with the plugin, install `stellarwp/foundati composer require stellarwp/foundation-wpcli ``` -The CLI package itself requires `foundation-wpcli` because its WP-CLI generator uses the default command stub owned by that runtime package. - ## Usage List all available commands: From 2b3b88af7d9d2e43d526af6639ad4047277804e3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 15:06:48 -0600 Subject: [PATCH 4/8] Include the foundation CLI binary --- composer.json | 3 +++ 1 file changed, 3 insertions(+) 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", From d8ca419fd305679ea0f400eac1f8235e38f6c387 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 15:17:44 -0600 Subject: [PATCH 5/8] Fix autoloading in foundation cli --- src/Cli/bin/foundation | 62 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/Cli/bin/foundation b/src/Cli/bin/foundation index 9eaa842..a9dedcc 100644 --- a/src/Cli/bin/foundation +++ b/src/Cli/bin/foundation @@ -9,18 +9,58 @@ use StellarWP\Foundation\Cli\CliProvider; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; -$autoload = dirname(__DIR__) . '/vendor/autoload.php'; - -if (! file_exists($autoload)) { - $autoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; +final class FoundationCliBootstrapException extends RuntimeException +{ } -require $autoload; +try { + $autoload = null; + + if (isset($GLOBALS['_composer_autoload_path']) && is_string($GLOBALS['_composer_autoload_path'])) { + $autoload = $GLOBALS['_composer_autoload_path']; + unset($GLOBALS['_composer_autoload_path']); + } + + foreach ([ + __DIR__ . '/../vendor/autoload.php', + dirname(__DIR__, 3) . '/vendor/autoload.php', + dirname(__DIR__, 3) . '/autoload.php', + dirname(__DIR__, 5) . '/autoload.php', + ] as $file) { + if (is_string($autoload) && is_readable($autoload)) { + break; + } + + if (is_readable($file)) { + $autoload = $file; + + break; + } + } + + unset($file); -$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); + 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); +} From 95cf62831461d4d7339c687058394871780ae525 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 15:29:50 -0600 Subject: [PATCH 6/8] Add automatic strauss detection when creating stub files --- AGENTS.md | 2 + src/Cli/Commands/Make/WPCliCommand.php | 13 +++-- .../Generation/ComposerAutoloadResolver.php | 37 +++++++++++--- src/Cli/README.md | 2 + src/WPCli/README.md | 2 + src/WPCli/stubs/command.stub | 2 +- .../Cli/Commands/Make/WPCliCommandTest.php | 51 +++++++++++++++++-- .../ComposerAutoloadResolverTest.php | 43 ++++++++++++++++ 8 files changed, 137 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39fdf2e..b682211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,8 @@ Project-level stub overrides should use `foundation/stubs//`, for examp 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. diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index e1cf709..8e569a8 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -79,14 +79,19 @@ private function generatedFile(InputInterface $input): GeneratedFile { path: $path . '/' . $className . '.php', relativePath: $relative, contents: $this->stubRenderer->render($stub, [ - 'namespace' => $namespace, - 'class' => $className, - 'subcommand' => $subcommand, - 'description' => $description, + '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'); diff --git a/src/Cli/Generation/ComposerAutoloadResolver.php b/src/Cli/Generation/ComposerAutoloadResolver.php index a020d44..cc11cde 100644 --- a/src/Cli/Generation/ComposerAutoloadResolver.php +++ b/src/Cli/Generation/ComposerAutoloadResolver.php @@ -18,13 +18,7 @@ public function __construct( } public function firstPsr4Namespace(): AutoloadNamespace { - $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); + $composer = $this->composer(); $psr4 = $composer['autoload']['psr-4'] ?? []; if (! is_array($psr4) || $psr4 === []) { @@ -50,4 +44,33 @@ public function firstPsr4Namespace(): AutoloadNamespace { 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/README.md b/src/Cli/README.md index cfdaad0..05f866a 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -77,6 +77,8 @@ Acme\Plugin\Cli\Commands The generated class extends `StellarWP\Foundation\WPCli\Command` and includes example positional, associative, and flag arguments using constants. +If the consuming project's `composer.json` contains `extra.strauss.namespace_prefix`, the generator uses that prefix for generated Foundation imports. For example, a prefix of `Acme\\Product\\` generates imports such as `Acme\Product\StellarWP\Foundation\WPCli\Command`. + Available options: ```bash diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 397b36b..eedd62c 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -27,6 +27,8 @@ 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 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); @@ -73,6 +74,47 @@ public function test_it_accepts_generation_options(): void { $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-'); @@ -162,16 +204,19 @@ classNameResolver: new ClassNameResolver(), ); } - private function temporaryProject(): string { + /** + * @param array $composer + */ + private function temporaryProject(array $composer = []): string { $root = $this->temporaryRoot('foundation-make-wpcli-test-'); - file_put_contents($root . '/composer.json', json_encode([ + file_put_contents($root . '/composer.json', json_encode(array_replace_recursive([ 'autoload' => [ 'psr-4' => [ 'Acme\\Plugin\\' => 'src', ], ], - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + ], $composer), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return $root; } diff --git a/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php b/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php index b2a7b34..3db4e24 100644 --- a/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php +++ b/tests/Unit/Cli/Generation/ComposerAutoloadResolverTest.php @@ -36,6 +36,38 @@ public function test_it_resolves_the_first_psr4_namespace(): void { $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'); @@ -43,6 +75,17 @@ public function test_it_fails_when_composer_json_is_missing(): void { (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.'); From 710da1ea7a9071da3f4c62ac2fa66ab285f165d4 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 15 Jun 2026 16:04:20 -0600 Subject: [PATCH 7/8] Rename class in line with our rules (no plurals) --- src/Cli/Commands/Make/WPCliCommand.php | 4 ++-- src/WPCli/{WPCliStubs.php => WPCliStubPath.php} | 2 +- .../WPCli/{WPCliStubsTest.php => WPCliStubPathTest.php} | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/WPCli/{WPCliStubs.php => WPCliStubPath.php} (93%) rename tests/Unit/WPCli/{WPCliStubsTest.php => WPCliStubPathTest.php} (60%) diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index 8e569a8..c857463 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -10,7 +10,7 @@ use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; -use StellarWP\Foundation\WPCli\WPCliStubs; +use StellarWP\Foundation\WPCli\WPCliStubPath; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -70,7 +70,7 @@ private function generatedFile(InputInterface $input): GeneratedFile { $autoload = $this->autoloadResolver->firstPsr4Namespace(); $namespace = $this->namespace($input, $autoload); $path = $this->path($input, $namespace, $autoload); - $stub = $this->stubResolver->resolve('wpcli', 'command', WPCliStubs::command()); + $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)); diff --git a/src/WPCli/WPCliStubs.php b/src/WPCli/WPCliStubPath.php similarity index 93% rename from src/WPCli/WPCliStubs.php rename to src/WPCli/WPCliStubPath.php index fac247f..f50f884 100644 --- a/src/WPCli/WPCliStubs.php +++ b/src/WPCli/WPCliStubPath.php @@ -8,7 +8,7 @@ * Foundation CLI owns rendering and writing generated files; the WPCli package * owns the default templates for classes that extend its public APIs. */ -final class WPCliStubs +final class WPCliStubPath { public static function command(): string { return __DIR__ . '/stubs/command.stub'; diff --git a/tests/Unit/WPCli/WPCliStubsTest.php b/tests/Unit/WPCli/WPCliStubPathTest.php similarity index 60% rename from tests/Unit/WPCli/WPCliStubsTest.php rename to tests/Unit/WPCli/WPCliStubPathTest.php index 5619501..db6f0b9 100644 --- a/tests/Unit/WPCli/WPCliStubsTest.php +++ b/tests/Unit/WPCli/WPCliStubPathTest.php @@ -3,12 +3,12 @@ namespace StellarWP\Foundation\Tests\Unit\WPCli; use StellarWP\Foundation\Tests\TestCase; -use StellarWP\Foundation\WPCli\WPCliStubs; +use StellarWP\Foundation\WPCli\WPCliStubPath; -final class WPCliStubsTest extends TestCase +final class WPCliStubPathTest extends TestCase { public function test_it_provides_the_command_stub_path(): void { - $this->assertFileExists(WPCliStubs::command()); - $this->assertStringEndsWith('/src/WPCli/stubs/command.stub', WPCliStubs::command()); + $this->assertFileExists(WPCliStubPath::command()); + $this->assertStringEndsWith('/src/WPCli/stubs/command.stub', WPCliStubPath::command()); } } From ba0884772a9231dc92e91dfd524138b588bc533b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 16 Jun 2026 11:24:51 -0600 Subject: [PATCH 8/8] Move valueobjects into their own namespace --- src/Cli/Commands/Make/WPCliCommand.php | 4 ++-- src/Cli/Generation/ComposerAutoloadResolver.php | 1 + src/Cli/Generation/GeneratedFileWriter.php | 1 + src/Cli/Generation/{ => ValueObjects}/AutoloadNamespace.php | 2 +- src/Cli/Generation/{ => ValueObjects}/GeneratedFile.php | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) rename src/Cli/Generation/{ => ValueObjects}/AutoloadNamespace.php (80%) rename src/Cli/Generation/{ => ValueObjects}/GeneratedFile.php (81%) diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index c857463..4ee18b4 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -3,13 +3,13 @@ namespace StellarWP\Foundation\Cli\Commands\Make; use RuntimeException; -use StellarWP\Foundation\Cli\Generation\AutoloadNamespace; use StellarWP\Foundation\Cli\Generation\ClassNameResolver; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; -use StellarWP\Foundation\Cli\Generation\GeneratedFile; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; +use StellarWP\Foundation\Cli\Generation\ValueObjects\AutoloadNamespace; +use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; use StellarWP\Foundation\WPCli\WPCliStubPath; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; diff --git a/src/Cli/Generation/ComposerAutoloadResolver.php b/src/Cli/Generation/ComposerAutoloadResolver.php index cc11cde..99de021 100644 --- a/src/Cli/Generation/ComposerAutoloadResolver.php +++ b/src/Cli/Generation/ComposerAutoloadResolver.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Cli\Generation; use RuntimeException; +use StellarWP\Foundation\Cli\Generation\ValueObjects\AutoloadNamespace; /** * Reads a project's Composer autoload configuration for generator defaults. diff --git a/src/Cli/Generation/GeneratedFileWriter.php b/src/Cli/Generation/GeneratedFileWriter.php index 5d0907d..991d020 100644 --- a/src/Cli/Generation/GeneratedFileWriter.php +++ b/src/Cli/Generation/GeneratedFileWriter.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Cli\Generation; use RuntimeException; +use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; /** * Writes generated files to disk with overwrite protection. diff --git a/src/Cli/Generation/AutoloadNamespace.php b/src/Cli/Generation/ValueObjects/AutoloadNamespace.php similarity index 80% rename from src/Cli/Generation/AutoloadNamespace.php rename to src/Cli/Generation/ValueObjects/AutoloadNamespace.php index b826df4..b745a8b 100644 --- a/src/Cli/Generation/AutoloadNamespace.php +++ b/src/Cli/Generation/ValueObjects/AutoloadNamespace.php @@ -1,6 +1,6 @@