Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<feature>/`, 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.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ composer run foundation -- package:create <Package> --apply

The command creates the `stellarwp/foundation-<package>` 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.
Expand Down
25 changes: 25 additions & 0 deletions src/Cli/CliProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
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;
use StellarWP\Foundation\Cli\Commands\Package\PackageFilesValidator;
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;
Expand All @@ -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);
Expand All @@ -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);
}
}
138 changes: 138 additions & 0 deletions src/Cli/Commands/Make/WPCliCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php declare(strict_types=1);

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\WPCli\WPCliStubs;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Generates a WP-CLI command class that extends the Foundation WPCli command base.
*
* Use this from a consuming WordPress project to quickly create a command with
* the expected Snake_Case class name, synopsis constants, and WP formatting.
*/
final class WPCliCommand extends Command
{
private const string NAME = 'make:wpcli-command';

public function __construct(
private readonly string $rootPath,
private readonly ComposerAutoloadResolver $autoloadResolver,
private readonly ClassNameResolver $classNameResolver,
private readonly StubResolver $stubResolver,
private readonly StubRenderer $stubRenderer,
private readonly GeneratedFileWriter $fileWriter
) {
parent::__construct(self::NAME);
}

protected function configure(): void {
$this->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('<error>' . $exception->getMessage() . '</error>');

return Command::FAILURE;
}

$output->writeln(sprintf('<info>Created:</info> %s', $file->relativePath));
$output->writeln('');
$output->writeln('<comment>Register this command from your WP-CLI provider and configure its $commandPrefix container argument.</comment>');

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;
}
}
15 changes: 15 additions & 0 deletions src/Cli/Generation/AutoloadNamespace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Generation;

/**
* Represents a Composer PSR-4 namespace root in the target project.
*/
final readonly class AutoloadNamespace
{
public function __construct(
public string $namespace,
public string $path
) {
}
}
62 changes: 62 additions & 0 deletions src/Cli/Generation/ClassNameResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Generation;

use RuntimeException;

/**
* Normalizes generator input into WP-style class, command, and description names.
*/
final class ClassNameResolver
{
public function commandClass(string $input): string {
$words = $this->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<string>
*/
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);
}
}
53 changes: 53 additions & 0 deletions src/Cli/Generation/ComposerAutoloadResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Generation;

use RuntimeException;

/**
* Reads a project's Composer autoload configuration for generator defaults.
*
* Make commands use this to infer where application classes should be written
* and which namespace they should use when the developer does not pass options.
*/
final readonly class ComposerAutoloadResolver
{
public function __construct(
private string $rootPath
) {
}

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);
$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.');
}
}
16 changes: 16 additions & 0 deletions src/Cli/Generation/GeneratedFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Generation;

/**
* Value object describing a generated file before it is written.
*/
final readonly class GeneratedFile
{
public function __construct(
public string $path,
public string $relativePath,
public string $contents
) {
}
}
Loading
Loading