From 205f642c692e5e0355595228ef97b20857086fbe Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:18:31 -0600 Subject: [PATCH 01/11] Update AGENTS.md and symlink to CLAUDE.md --- AGENTS.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- CLAUDE.md | 1 + 2 files changed, 86 insertions(+), 1 deletion(-) create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 74e8d56..cf886bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,90 @@ Initial packages: - `stellarwp/foundation-log` - `stellarwp/foundation-pipeline` +## Namespaces + Use package namespaces under `StellarWP\Foundation\\`. -TODO: finish this. +## Code Organization + +Prefer feature-first organization, also known as vertical slice architecture or package-by-feature, when adding command/tooling features. Group a command and its private collaborators under the command feature namespace. + +For example, use: + +```text +Commands/ + Subrepo/ + CreateCommand.php + PackageResolver.php + PackageFilesValidator.php + GitHubSubrepoCreator.php +``` + +instead of splitting those private collaborators into broad technical folders too early. + +If a collaborator is only useful for one command group, keep it under that command group's feature folder. If it becomes useful across multiple command groups, promote it to a broader domain or infrastructure namespace such as `Package/`, `GitHub/`, `Console/`, or `Process/`. + +## Split Packages + +Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. + +When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. + +When adding dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. + +### Required Files + +Each split package should include: + +- `composer.json` +- `README.md` +- `.gitattributes` +- `.gitignore` +- `.github/workflows/close-pull-request.yml` + +### GitHub Repositories + +When creating a new split repository on GitHub, use the description `[READ ONLY] Subtree split of the Foundation component (see stellarwp/foundation)` and disable wikis, issues, projects, and pull requests. + +## PHP Feature Policy + +Allowed for current PHP 8.3 source: + +- constructor property promotion +- union types +- intersection types +- readonly properties/classes +- enums +- nullsafe operator +- match expressions +- named arguments +- first-class callables +- typed class constants + +Avoid unless there is a clear reason: + +- enums in public APIs +- reflection-heavy code +- attributes that affect runtime behavior +- DNF types +- `never` in public APIs + +Banned while the project targets PHP 8.3: + +- PHP 8.4 property hooks +- PHP 8.4 asymmetric visibility +- PHP 8.4 lazy objects API +- `#[Deprecated]`; use `@deprecated` PHPDoc instead +- PHP 8.4-only functions/classes/constants + +## Monorepo Commands + +After adding or changing split package dependencies, run `composer monorepo merge` and then `composer update` so root `composer.json`/lock state includes package dependency changes. + +Use `composer monorepo list` to inspect available Monorepo Builder commands. + +## Releases + +- Run `composer monorepo bump-interdependency ` when planning a major version release so Foundation packages that depend on each other require the new major line. It may also be useful for a minor release when one package must require APIs added in that new minor. +- Run `composer monorepo package-alias` when `dev-main` should move to a new development line, usually after a minor or major release. Do not run it for every patch release when the current branch alias is still correct. +- The monorepo split workflow deploys package code to each sub-repository after a GitHub release is drafted. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 1279b11d470b699b3f2cac9c88539e1c90fa7a38 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:21:58 -0600 Subject: [PATCH 02/11] Add composer version exceptions to AGENTS.md --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cf886bc..3c4d7ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,9 @@ Split packages live in `src//` and are split to read-only repositories When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. -When adding dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. +When adding external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. + +Important exception: dependencies on this monorepo's own split packages, such as `stellarwp/foundation-container`, should use the correct Composer release constraint like `^1.0`. Do not use `>=` for internal Foundation package dependencies; Monorepo Builder commands such as `composer monorepo bump-interdependency` are expected to bump those constraints during releases. ### Required Files From 88cc67661dc5f7c151ececd16b2f51e2c4592c84 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:35:20 -0600 Subject: [PATCH 03/11] Update AGENTS.md for interface folder --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3c4d7ba..8f2cd9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ For example, use: ```text Commands/ Subrepo/ + Contracts/ + SubrepoCreator.php CreateCommand.php PackageResolver.php PackageFilesValidator.php @@ -33,6 +35,8 @@ instead of splitting those private collaborators into broad technical folders to If a collaborator is only useful for one command group, keep it under that command group's feature folder. If it becomes useful across multiple command groups, promote it to a broader domain or infrastructure namespace such as `Package/`, `GitHub/`, `Console/`, or `Process/`. +Feature-local interfaces should live in a `Contracts/` folder inside the feature slice, for example `Commands/Subrepo/Contracts/SubrepoCreator.php`. Only promote contracts to a package-level `Contracts/` namespace when they are intended to be shared across multiple features or consumed as public extension points. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. @@ -53,6 +57,13 @@ Each split package should include: - `.gitignore` - `.github/workflows/close-pull-request.yml` +Each split package `README.md` must include this warning immediately after the package heading: + +```markdown +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). +``` + ### GitHub Repositories When creating a new split repository on GitHub, use the description `[READ ONLY] Subtree split of the Foundation component (see stellarwp/foundation)` and disable wikis, issues, projects, and pull requests. From eff10fac3ec9a4fc71fb8414d6782a5b379c9709 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:36:22 -0600 Subject: [PATCH 04/11] Exclude AI folders from pinte --- pinte.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pinte.json b/pinte.json index 1aa7649..35d8bd8 100644 --- a/pinte.json +++ b/pinte.json @@ -1,6 +1,11 @@ { "preset": "laravel", "indent": "\t", + "exclude": [ + ".agents", + ".codex", + ".claude" + ], "rules": { "blank_line_after_opening_tag": false, "linebreak_after_opening_tag": false, From b7df027ef85c2b40e146921594b4fd7bcb7fa163 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:37:07 -0600 Subject: [PATCH 05/11] Add data dir support for tests --- tests/Support/Traits/WithDataDir.php | 18 ++++++++++++++++++ tests/TestCase.php | 2 ++ 2 files changed, 20 insertions(+) create mode 100644 tests/Support/Traits/WithDataDir.php diff --git a/tests/Support/Traits/WithDataDir.php b/tests/Support/Traits/WithDataDir.php new file mode 100644 index 0000000..a7d360b --- /dev/null +++ b/tests/Support/Traits/WithDataDir.php @@ -0,0 +1,18 @@ +container->get(TestCase::DATA_DIR) . $appendPath; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 319d5db..7b017af 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,10 +10,12 @@ use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Container\Contracts\Providable; use StellarWP\Foundation\Log\LogProvider; +use StellarWP\Foundation\Tests\Support\Traits\WithDataDir; class TestCase extends \PHPUnit\Framework\TestCase { use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; + use WithDataDir; public const string TEST_DIR = 'test_dir'; public const string DATA_DIR = 'data_dir'; From b29d718709b2027e0f8f2792fc6c52ab004cd53f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:44:34 -0600 Subject: [PATCH 06/11] Update AGENTS.md --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8f2cd9a..f95e3f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,8 @@ instead of splitting those private collaborators into broad technical folders to If a collaborator is only useful for one command group, keep it under that command group's feature folder. If it becomes useful across multiple command groups, promote it to a broader domain or infrastructure namespace such as `Package/`, `GitHub/`, `Console/`, or `Process/`. +When it is very clear that a class will be reused by many similar features, promote it immediately instead of burying it in the first feature slice. This is especially true for command/tooling infrastructure where many commands will need the same capability, such as shell command formatting, process execution, console IO helpers, package discovery, or GitHub API clients. + Feature-local interfaces should live in a `Contracts/` folder inside the feature slice, for example `Commands/Subrepo/Contracts/SubrepoCreator.php`. Only promote contracts to a package-level `Contracts/` namespace when they are intended to be shared across multiple features or consumed as public extension points. ## Split Packages @@ -107,6 +109,7 @@ Use `composer monorepo list` to inspect available Monorepo Builder commands. ## Releases +- Adding a new split package is usually a minor SemVer release because it introduces new functionality without breaking existing packages. Use a major release only if the change also breaks an existing public API or package contract. - Run `composer monorepo bump-interdependency ` when planning a major version release so Foundation packages that depend on each other require the new major line. It may also be useful for a minor release when one package must require APIs added in that new minor. - Run `composer monorepo package-alias` when `dev-main` should move to a new development line, usually after a minor or major release. Do not run it for every patch release when the current branch alias is still correct. - The monorepo split workflow deploys package code to each sub-repository after a GitHub release is drafted. From c22baa9261926602ea4578b92f896a068046d862 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 11:48:54 -0600 Subject: [PATCH 07/11] Update AGENTS.md --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f95e3f5..8ed7139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ When it is very clear that a class will be reused by many similar features, prom Feature-local interfaces should live in a `Contracts/` folder inside the feature slice, for example `Commands/Subrepo/Contracts/SubrepoCreator.php`. Only promote contracts to a package-level `Contracts/` namespace when they are intended to be shared across multiple features or consumed as public extension points. +Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. @@ -107,6 +109,10 @@ After adding or changing split package dependencies, run `composer monorepo merg Use `composer monorepo list` to inspect available Monorepo Builder commands. +## Verification + +After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. + ## Releases - Adding a new split package is usually a minor SemVer release because it introduces new functionality without breaking existing packages. Use a major release only if the change also breaks an existing public API or package contract. From 4649700b021fef21a4d09990701f0449e6e4f713 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 12:13:04 -0600 Subject: [PATCH 08/11] Add static_lambda pinte rule --- pinte.json | 1 + 1 file changed, 1 insertion(+) diff --git a/pinte.json b/pinte.json index 35d8bd8..387e0fe 100644 --- a/pinte.json +++ b/pinte.json @@ -18,6 +18,7 @@ "combine_consecutive_unsets": true, "no_blank_lines_after_class_opening": false, "use_arrow_functions": true, + "static_lambda": true, "blank_line_between_import_groups": true, "no_superfluous_phpdoc_tags": { "allow_mixed": true From 8768beff78079276598aff1ac281b4e38c49e43b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 12:13:48 -0600 Subject: [PATCH 09/11] Add `composer foundation` cli and the `package:create` command --- AGENTS.md | 16 ++- README.md | 20 +++- composer.json | 4 + src/Cli/.gitattributes | 7 ++ .../.github/workflows/close-pull-request.yml | 13 +++ src/Cli/.gitignore | 2 + src/Cli/Application.php | 38 ++++++ src/Cli/CliProvider.php | 48 ++++++++ .../Contracts/PackageRepositoryCreator.php | 21 ++++ src/Cli/Commands/Package/CreateCommand.php | 89 +++++++++++++++ .../GitHubPackageRepositoryCreator.php | 58 ++++++++++ src/Cli/Commands/Package/Package.php | 25 ++++ .../Package/PackageFilesValidator.php | 38 ++++++ .../Package/PackageRepositoryPlan.php | 23 ++++ .../Package/PackageRepositoryPlanFactory.php | 25 ++++ src/Cli/Commands/Package/PackageResolver.php | 72 ++++++++++++ src/Cli/Contracts/CommandProvider.php | 19 +++ src/Cli/Process/Contracts/ProcessRunner.php | 17 +++ src/Cli/Process/ShellCommand.php | 19 +++ src/Cli/Process/ShellProcessRunner.php | 23 ++++ src/Cli/README.md | 81 +++++++++++++ src/Cli/bin/foundation | 26 +++++ src/Cli/composer.json | 28 +++++ src/Pipeline/Pipeline.php | 2 +- tests/Unit/Cli/ApplicationTest.php | 44 +++++++ tests/Unit/Cli/CliProviderTest.php | 33 ++++++ .../Commands/Package/CreateCommandTest.php | 108 ++++++++++++++++++ .../GitHubPackageRepositoryCreatorTest.php | 85 ++++++++++++++ .../Package/PackageFilesValidatorTest.php | 29 +++++ .../PackageRepositoryPlanFactoryTest.php | 25 ++++ .../Commands/Package/PackageResolverTest.php | 30 +++++ .../Cli/Process/ShellProcessRunnerTest.php | 17 +++ .../missing-files-root/src/Log/composer.json | 3 + .../package/valid-root/src/Log/.gitattributes | 3 + .../.github/workflows/close-pull-request.yml | 5 + .../cli/package/valid-root/src/Log/.gitignore | 2 + .../cli/package/valid-root/src/Log/README.md | 4 + .../package/valid-root/src/Log/composer.json | 3 + .../src/NotFoundation/composer.json | 3 + 39 files changed, 1102 insertions(+), 6 deletions(-) create mode 100644 src/Cli/.gitattributes create mode 100644 src/Cli/.github/workflows/close-pull-request.yml create mode 100644 src/Cli/.gitignore create mode 100644 src/Cli/Application.php create mode 100644 src/Cli/CliProvider.php create mode 100644 src/Cli/Commands/Package/Contracts/PackageRepositoryCreator.php create mode 100644 src/Cli/Commands/Package/CreateCommand.php create mode 100644 src/Cli/Commands/Package/GitHubPackageRepositoryCreator.php create mode 100644 src/Cli/Commands/Package/Package.php create mode 100644 src/Cli/Commands/Package/PackageFilesValidator.php create mode 100644 src/Cli/Commands/Package/PackageRepositoryPlan.php create mode 100644 src/Cli/Commands/Package/PackageRepositoryPlanFactory.php create mode 100644 src/Cli/Commands/Package/PackageResolver.php create mode 100644 src/Cli/Contracts/CommandProvider.php create mode 100644 src/Cli/Process/Contracts/ProcessRunner.php create mode 100644 src/Cli/Process/ShellCommand.php create mode 100644 src/Cli/Process/ShellProcessRunner.php create mode 100644 src/Cli/README.md create mode 100644 src/Cli/bin/foundation create mode 100644 src/Cli/composer.json create mode 100644 tests/Unit/Cli/ApplicationTest.php create mode 100644 tests/Unit/Cli/CliProviderTest.php create mode 100644 tests/Unit/Cli/Commands/Package/CreateCommandTest.php create mode 100644 tests/Unit/Cli/Commands/Package/GitHubPackageRepositoryCreatorTest.php create mode 100644 tests/Unit/Cli/Commands/Package/PackageFilesValidatorTest.php create mode 100644 tests/Unit/Cli/Commands/Package/PackageRepositoryPlanFactoryTest.php create mode 100644 tests/Unit/Cli/Commands/Package/PackageResolverTest.php create mode 100644 tests/Unit/Cli/Process/ShellProcessRunnerTest.php create mode 100644 tests/_data/cli/package/missing-files-root/src/Log/composer.json create mode 100644 tests/_data/cli/package/valid-root/src/Log/.gitattributes create mode 100644 tests/_data/cli/package/valid-root/src/Log/.github/workflows/close-pull-request.yml create mode 100644 tests/_data/cli/package/valid-root/src/Log/.gitignore create mode 100644 tests/_data/cli/package/valid-root/src/Log/README.md create mode 100644 tests/_data/cli/package/valid-root/src/Log/composer.json create mode 100644 tests/_data/cli/package/valid-root/src/NotFoundation/composer.json diff --git a/AGENTS.md b/AGENTS.md index 8ed7139..3788f09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,13 @@ For example, use: ```text Commands/ - Subrepo/ + Package/ Contracts/ - SubrepoCreator.php + PackageRepositoryCreator.php CreateCommand.php PackageResolver.php PackageFilesValidator.php - GitHubSubrepoCreator.php + GitHubPackageRepositoryCreator.php ``` instead of splitting those private collaborators into broad technical folders too early. @@ -37,10 +37,16 @@ If a collaborator is only useful for one command group, keep it under that comma When it is very clear that a class will be reused by many similar features, promote it immediately instead of burying it in the first feature slice. This is especially true for command/tooling infrastructure where many commands will need the same capability, such as shell command formatting, process execution, console IO helpers, package discovery, or GitHub API clients. -Feature-local interfaces should live in a `Contracts/` folder inside the feature slice, for example `Commands/Subrepo/Contracts/SubrepoCreator.php`. Only promote contracts to a package-level `Contracts/` namespace when they are intended to be shared across multiple features or consumed as public extension points. +Feature-local interfaces should live in a `Contracts/` folder inside the feature slice, for example `Commands/Package/Contracts/PackageRepositoryCreator.php`. Only promote contracts to a package-level `Contracts/` namespace when they are intended to be shared across multiple features or consumed as public extension points. Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +## 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. + +Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. @@ -111,6 +117,8 @@ Use `composer monorepo list` to inspect available Monorepo Builder commands. ## Verification +When `composer lint` reports style-only issues, run `composer format` to let the project formatter fix them before making manual formatting edits. + After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. ## Releases diff --git a/README.md b/README.md index ca30574..d2046b6 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ After any needed command, commit the updated `composer.json` files. Then draft a The [monorepo split GitHub workflow](./.github/workflows/monorepo-split.yml) will deploy each project's code to their sub-repository. +Adding a new split package is usually a minor [semver](https://semver.org/) release because it adds new functionality without breaking existing packages. Use a major release only if the change also breaks an existing public API or package contract. + ### Monorepo This uses [Symplify's Monorepo Builder](https://github.com/symplify/monorepo-builder). There is a shortcut composer script you can @@ -90,7 +92,23 @@ run to access their CLI: `composer monorepo list` to see the available commands. 1. Copy the composer.json from one of the packages and modify the `name` and `psr-4` autoload namespace. 2. Ensure you have the `close-pull-request.yml` GitHub workflow, a `.gitattributes`, `.gitignore` and `README.md` files. 3. Once you've added the specific dependencies your package needs to its composer.json, run `composer monorepo merge` and then `composer update` and commit the changes. This will merge the dependencies into the root composer.json. -4. Create your [new repository](https://github.com/organizations/stellarwp/repositories/new), add the description: `[READ ONLY] Subtree split of the Foundation component (see stellarwp/foundation)` and disable wikis, issues, projects and pull requests. +4. Preview the split repository that will be created: + +```bash +composer run foundation -- package:create +``` + +The package argument can be the package directory, short package name, or Composer package name, for example `Log`, `foundation-log`, or `stellarwp/foundation-log`. + +The command runs as a dry run by default. It validates the required split package files, prints the target repository name and description, and shows the GitHub CLI commands it will run. + +5. Create and configure the read-only split repository: + +```bash +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. ## License diff --git a/composer.json b/composer.json index 3d2d8bb..fed6669 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "monolog/monolog": "^2.11", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", + "symfony/console": ">=5.4", "vlucas/phpdotenv": ">=4.3" }, "require-dev": { @@ -29,6 +30,7 @@ "zenphp/pinte": "^1.2" }, "replace": { + "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version" @@ -37,6 +39,7 @@ "prefer-stable": true, "autoload": { "psr-4": { + "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/" @@ -56,6 +59,7 @@ } }, "scripts": { + "foundation": "@php src/Cli/bin/foundation", "monorepo": "@php vendor/bin/monorepo-builder", "test": "@php vendor/bin/phpunit -c phpunit.xml.dist --colors=always -d memory_limit=2G", "test:unit": "@test --testsuite Unit", diff --git a/src/Cli/.gitattributes b/src/Cli/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Cli/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Cli/.github/workflows/close-pull-request.yml b/src/Cli/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Cli/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Cli/.gitignore b/src/Cli/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Cli/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Cli/Application.php b/src/Cli/Application.php new file mode 100644 index 0000000..d412043 --- /dev/null +++ b/src/Cli/Application.php @@ -0,0 +1,38 @@ + $commands + * @param iterable $commandProviders + */ + public function __construct(iterable $commands = [], iterable $commandProviders = []) { + parent::__construct('Foundation'); + + foreach ($commands as $command) { + $this->addCommands([$command]); + } + + foreach ($commandProviders as $commandProvider) { + $this->addCommandProvider($commandProvider); + } + } + + public function addCommandProvider(CommandProvider $commandProvider): void { + foreach ($commandProvider->commands() as $command) { + $this->addCommands([$command]); + } + } +} diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php new file mode 100644 index 0000000..0a97bbb --- /dev/null +++ b/src/Cli/CliProvider.php @@ -0,0 +1,48 @@ +container->singleton(self::ROOT_PATH, getcwd() ?: dirname(__DIR__, 2)); + + $this->container->when(PackageResolver::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), + ]); + + $this->container->singleton(PackageResolver::class); + $this->container->singleton(PackageFilesValidator::class); + $this->container->singleton(PackageRepositoryPlanFactory::class); + $this->container->singleton(ShellProcessRunner::class); + $this->container->bind(ProcessRunner::class, ShellProcessRunner::class); + $this->container->bind(PackageRepositoryCreator::class, GitHubPackageRepositoryCreator::class); + $this->container->singleton(CreateCommand::class); + $this->container->singleton(Application::class); + } +} diff --git a/src/Cli/Commands/Package/Contracts/PackageRepositoryCreator.php b/src/Cli/Commands/Package/Contracts/PackageRepositoryCreator.php new file mode 100644 index 0000000..0814cfc --- /dev/null +++ b/src/Cli/Commands/Package/Contracts/PackageRepositoryCreator.php @@ -0,0 +1,21 @@ +> + */ + public function commands(PackageRepositoryPlan $plan): array; + + public function create(PackageRepositoryPlan $plan): void; +} diff --git a/src/Cli/Commands/Package/CreateCommand.php b/src/Cli/Commands/Package/CreateCommand.php new file mode 100644 index 0000000..c7e0035 --- /dev/null +++ b/src/Cli/Commands/Package/CreateCommand.php @@ -0,0 +1,89 @@ +` and before relying + * on the monorepo split workflow to publish that package to GitHub. + */ +final class CreateCommand extends Command +{ + private const string NAME = 'package:create'; + private const string PULL_REQUEST_NOTE = 'Manual step: GitHub CLI cannot disable pull requests. Confirm pull requests are disabled or restricted in GitHub settings; otherwise keep close-pull-request.yml in place to close incoming pull requests.'; + + public function __construct( + private readonly PackageResolver $packageResolver, + private readonly PackageFilesValidator $packageFilesValidator, + private readonly PackageRepositoryPlanFactory $packageRepositoryPlanFactory, + private readonly PackageRepositoryCreator $packageRepositoryCreator + ) { + parent::__construct(self::NAME); + } + + protected function configure(): void { + $this->setDescription('Create and configure a read-only GitHub sub-repository for a Foundation split package.') + ->addArgument('package', InputArgument::REQUIRED, 'Package directory, package short name, or Composer package name.') + ->addOption('apply', null, InputOption::VALUE_NONE, 'Run the generated GitHub actions. Without this option, the command is a dry run.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $package = $this->packageResolver->resolve((string) $input->getArgument('package')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $missingFiles = $this->packageFilesValidator->missingFiles($package); + + if ($missingFiles !== []) { + $output->writeln('The package is missing required split repository files:'); + + foreach ($missingFiles as $missingFile) { + $output->writeln(sprintf(' - %s', $missingFile)); + } + + return Command::FAILURE; + } + + $plan = $this->packageRepositoryPlanFactory->create($package); + + $output->writeln(sprintf('Package: %s', $package->name)); + $output->writeln(sprintf('Directory: %s', $package->directory)); + $output->writeln(sprintf('Repository: %s', $plan->fullName())); + $output->writeln(sprintf('Description: %s', $plan->description)); + + if (! (bool) $input->getOption('apply')) { + $output->writeln(''); + $output->writeln('Dry run. Run with --apply to create/configure the repository.'); + + foreach ($this->packageRepositoryCreator->commands($plan) as $command) { + $output->writeln(' - ' . ShellCommand::format($command)); + } + + $output->writeln(''); + $output->writeln('' . self::PULL_REQUEST_NOTE . ''); + + return Command::SUCCESS; + } + + $this->packageRepositoryCreator->create($plan); + + $output->writeln('Package repository created/configured.'); + $output->writeln('' . self::PULL_REQUEST_NOTE . ''); + + return Command::SUCCESS; + } +} diff --git a/src/Cli/Commands/Package/GitHubPackageRepositoryCreator.php b/src/Cli/Commands/Package/GitHubPackageRepositoryCreator.php new file mode 100644 index 0000000..d7dd8d1 --- /dev/null +++ b/src/Cli/Commands/Package/GitHubPackageRepositoryCreator.php @@ -0,0 +1,58 @@ +> + */ + public function commands(PackageRepositoryPlan $plan): array { + return [ + [ + 'gh', + 'repo', + 'create', + $plan->fullName(), + '--public', + '--description', + $plan->description, + '--disable-issues', + '--disable-wiki', + ], + [ + 'gh', + 'repo', + 'edit', + $plan->fullName(), + '--enable-projects=false', + ], + ]; + } + + public function create(PackageRepositoryPlan $plan): void { + foreach ($this->commands($plan) as $command) { + $exitCode = $this->processRunner->run($command); + + if ($exitCode !== 0) { + throw new RuntimeException(sprintf('Command failed with exit code %d: %s', $exitCode, ShellCommand::format($command))); + } + } + } +} diff --git a/src/Cli/Commands/Package/Package.php b/src/Cli/Commands/Package/Package.php new file mode 100644 index 0000000..ae2abec --- /dev/null +++ b/src/Cli/Commands/Package/Package.php @@ -0,0 +1,25 @@ +name); + } +} diff --git a/src/Cli/Commands/Package/PackageFilesValidator.php b/src/Cli/Commands/Package/PackageFilesValidator.php new file mode 100644 index 0000000..417b057 --- /dev/null +++ b/src/Cli/Commands/Package/PackageFilesValidator.php @@ -0,0 +1,38 @@ + + */ + private const array REQUIRED_FILES = [ + 'composer.json', + 'README.md', + '.gitattributes', + '.gitignore', + '.github/workflows/close-pull-request.yml', + ]; + + /** + * @return list + */ + public function missingFiles(Package $package): array { + $missingFiles = []; + + foreach (self::REQUIRED_FILES as $requiredFile) { + if (! file_exists($package->path . '/' . $requiredFile)) { + $missingFiles[] = $requiredFile; + } + } + + return $missingFiles; + } +} diff --git a/src/Cli/Commands/Package/PackageRepositoryPlan.php b/src/Cli/Commands/Package/PackageRepositoryPlan.php new file mode 100644 index 0000000..e26fcc9 --- /dev/null +++ b/src/Cli/Commands/Package/PackageRepositoryPlan.php @@ -0,0 +1,23 @@ +organization . '/' . $this->repository; + } +} diff --git a/src/Cli/Commands/Package/PackageRepositoryPlanFactory.php b/src/Cli/Commands/Package/PackageRepositoryPlanFactory.php new file mode 100644 index 0000000..769cc3c --- /dev/null +++ b/src/Cli/Commands/Package/PackageRepositoryPlanFactory.php @@ -0,0 +1,25 @@ +repoName(), + description: sprintf( + '[READ ONLY] Subtree split of the Foundation %s component (see stellarwp/foundation)', + $package->component + ) + ); + } +} diff --git a/src/Cli/Commands/Package/PackageResolver.php b/src/Cli/Commands/Package/PackageResolver.php new file mode 100644 index 0000000..cd39ab9 --- /dev/null +++ b/src/Cli/Commands/Package/PackageResolver.php @@ -0,0 +1,72 @@ +normalizeInput($input); + + foreach ($this->packages() as $package) { + if ($this->matches($package, $normalizedInput)) { + return $package; + } + } + + throw new RuntimeException(sprintf('Could not find a Foundation split package matching "%s".', $input)); + } + + /** + * @return list + */ + private function packages(): array { + $composerPaths = glob($this->rootPath . '/src/*/composer.json') ?: []; + $packages = []; + + foreach ($composerPaths as $composerPath) { + $packagePath = dirname($composerPath); + $composer = json_decode((string) file_get_contents($composerPath), true, 512, JSON_THROW_ON_ERROR); + $name = $composer['name'] ?? ''; + + if (! is_string($name) || ! str_starts_with($name, 'stellarwp/foundation-')) { + continue; + } + + $packages[] = new Package( + name: $name, + component: basename($packagePath), + directory: 'src/' . basename($packagePath), + path: $packagePath, + composerPath: $composerPath + ); + } + + return $packages; + } + + private function matches(Package $package, string $input): bool { + return in_array($input, [ + $this->normalizeInput($package->component), + $this->normalizeInput($package->name), + $this->normalizeInput($package->repoName()), + $this->normalizeInput(str_replace('foundation-', '', $package->repoName())), + ], true); + } + + private function normalizeInput(string $input): string { + return strtolower(trim($input)); + } +} diff --git a/src/Cli/Contracts/CommandProvider.php b/src/Cli/Contracts/CommandProvider.php new file mode 100644 index 0000000..462188b --- /dev/null +++ b/src/Cli/Contracts/CommandProvider.php @@ -0,0 +1,19 @@ + + */ + public function commands(): iterable; +} diff --git a/src/Cli/Process/Contracts/ProcessRunner.php b/src/Cli/Process/Contracts/ProcessRunner.php new file mode 100644 index 0000000..6cf36d8 --- /dev/null +++ b/src/Cli/Process/Contracts/ProcessRunner.php @@ -0,0 +1,17 @@ + $command + */ + public function run(array $command): int; +} diff --git a/src/Cli/Process/ShellCommand.php b/src/Cli/Process/ShellCommand.php new file mode 100644 index 0000000..aa09d2b --- /dev/null +++ b/src/Cli/Process/ShellCommand.php @@ -0,0 +1,19 @@ +` values and need a + * consistently escaped human-readable shell form. + */ +final class ShellCommand +{ + /** + * @param list $command + */ + public static function format(array $command): string { + return implode(' ', array_map('escapeshellarg', $command)); + } +} diff --git a/src/Cli/Process/ShellProcessRunner.php b/src/Cli/Process/ShellProcessRunner.php new file mode 100644 index 0000000..f9a2ea8 --- /dev/null +++ b/src/Cli/Process/ShellProcessRunner.php @@ -0,0 +1,23 @@ + $command + */ + public function run(array $command): int { + passthru(ShellCommand::format($command), $exitCode); + + return $exitCode; + } +} diff --git a/src/Cli/README.md b/src/Cli/README.md new file mode 100644 index 0000000..a118b7a --- /dev/null +++ b/src/Cli/README.md @@ -0,0 +1,81 @@ +# Foundation CLI + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation CLI tooling for maintaining the Foundation monorepo and split repositories. + +## Usage + +List all available commands: +```bash +composer run foundation -- list +``` + +Create a split repository for a new package: +```bash +composer run foundation -- package:create Log +``` + +By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated actions. + +## Custom Commands + +Applications can build their own Foundation CLI by creating Symfony Console commands and registering them with `StellarWP\Foundation\Cli\Application`. + +```php +writeln('Cache cleared.'); + + return Command::SUCCESS; + } +} +``` + +For one or more related commands, group them behind a command provider. + +```php +run()); +``` + +When commands need shared services, register them in your container and pass constructed commands or command providers into the `Application`. diff --git a/src/Cli/bin/foundation b/src/Cli/bin/foundation new file mode 100644 index 0000000..9eaa842 --- /dev/null +++ b/src/Cli/bin/foundation @@ -0,0 +1,26 @@ +#!/usr/bin/env php +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()); diff --git a/src/Cli/composer.json b/src/Cli/composer.json new file mode 100644 index 0000000..4d9923e --- /dev/null +++ b/src/Cli/composer.json @@ -0,0 +1,28 @@ +{ + "name": "stellarwp/foundation-cli", + "type": "library", + "description": "Foundation CLI tooling.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^1.0", + "symfony/console": ">=5.4" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Cli\\": "" + } + }, + "bin": [ + "bin/foundation" + ], + "extra": { + "branch-alias": { + "dev-main": "1.1.x-dev" + } + } +} diff --git a/src/Pipeline/Pipeline.php b/src/Pipeline/Pipeline.php index a87a4a4..c8b1fd4 100644 --- a/src/Pipeline/Pipeline.php +++ b/src/Pipeline/Pipeline.php @@ -113,7 +113,7 @@ public function then(Closure $destination): mixed { * Run the pipeline and return the result. */ public function thenReturn(): mixed { - return $this->then(fn ($passable) => $passable); + return $this->then(static fn ($passable) => $passable); } /** diff --git a/tests/Unit/Cli/ApplicationTest.php b/tests/Unit/Cli/ApplicationTest.php new file mode 100644 index 0000000..7714adf --- /dev/null +++ b/tests/Unit/Cli/ApplicationTest.php @@ -0,0 +1,44 @@ +assertTrue($application->has('example:direct')); + } + + public function test_it_registers_commands_from_command_providers(): void { + $application = new Application(commandProviders: [ + new TestCommandProvider(), + ]); + + $this->assertTrue($application->has('example:provider')); + } +} + +final class NamedCommand extends Command +{ + public function __construct(string $name) { + parent::__construct($name); + } +} + +final class TestCommandProvider implements CommandProvider +{ + /** + * @return iterable + */ + public function commands(): iterable { + yield new NamedCommand('example:provider'); + } +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php new file mode 100644 index 0000000..1b0c227 --- /dev/null +++ b/tests/Unit/Cli/CliProviderTest.php @@ -0,0 +1,33 @@ +bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + $container->register(CliProvider::class); + + $this->assertInstanceOf(Application::class, $container->get(Application::class)); + $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); + $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); + $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); + $this->assertTrue($container->get(Application::class)->has('package:create')); + } +} diff --git a/tests/Unit/Cli/Commands/Package/CreateCommandTest.php b/tests/Unit/Cli/Commands/Package/CreateCommandTest.php new file mode 100644 index 0000000..7a8a7e1 --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/CreateCommandTest.php @@ -0,0 +1,108 @@ +data_dir('cli/package/valid-root'); + $packageRepositoryCreator = new FakePackageRepositoryCreator(); + + $command = new CreateCommand( + new PackageResolver($rootPath), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + $packageRepositoryCreator + ); + + $tester = new CommandTester($command); + $statusCode = $tester->execute(['package' => 'Log']); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Package: stellarwp/foundation-log', $tester->getDisplay()); + $this->assertStringContainsString('Dry run. Run with --apply', $tester->getDisplay()); + $this->assertStringContainsString("'gh' 'repo' 'create'", $tester->getDisplay()); + $this->assertStringContainsString('Manual step: GitHub CLI cannot disable pull requests.', $tester->getDisplay()); + $this->assertFalse($packageRepositoryCreator->created); + } + + public function test_it_creates_the_package_repository_when_apply_is_passed(): void { + $packageRepositoryCreator = new FakePackageRepositoryCreator(); + $command = new CreateCommand( + new PackageResolver($this->data_dir('cli/package/valid-root')), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + $packageRepositoryCreator + ); + + $tester = new CommandTester($command); + $statusCode = $tester->execute([ + 'package' => 'Log', + '--apply' => true, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertTrue($packageRepositoryCreator->created); + $this->assertStringContainsString('Package repository created/configured.', $tester->getDisplay()); + $this->assertStringContainsString('Manual step: GitHub CLI cannot disable pull requests.', $tester->getDisplay()); + } + + public function test_it_fails_when_the_package_cannot_be_resolved(): void { + $command = new CreateCommand( + new PackageResolver($this->data_dir('cli/package/valid-root')), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + new FakePackageRepositoryCreator() + ); + + $tester = new CommandTester($command); + $statusCode = $tester->execute(['package' => 'missing']); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Could not find a Foundation split package matching "missing".', $tester->getDisplay()); + } + + public function test_it_fails_when_required_split_package_files_are_missing(): void { + $command = new CreateCommand( + new PackageResolver($this->data_dir('cli/package/missing-files-root')), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + new FakePackageRepositoryCreator() + ); + + $tester = new CommandTester($command); + $statusCode = $tester->execute(['package' => 'Log']); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('The package is missing required split repository files:', $tester->getDisplay()); + $this->assertStringContainsString('README.md', $tester->getDisplay()); + } +} + +final class FakePackageRepositoryCreator implements PackageRepositoryCreator +{ + public bool $created = false; + + /** + * @return list> + */ + public function commands(PackageRepositoryPlan $plan): array { + return [ + ['gh', 'repo', 'create', $plan->fullName()], + ]; + } + + public function create(PackageRepositoryPlan $plan): void { + $this->created = true; + } +} diff --git a/tests/Unit/Cli/Commands/Package/GitHubPackageRepositoryCreatorTest.php b/tests/Unit/Cli/Commands/Package/GitHubPackageRepositoryCreatorTest.php new file mode 100644 index 0000000..6de020b --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/GitHubPackageRepositoryCreatorTest.php @@ -0,0 +1,85 @@ +assertSame([ + [ + 'gh', + 'repo', + 'create', + 'stellarwp/foundation-log', + '--public', + '--description', + '[READ ONLY] Subtree split of the Foundation Log component (see stellarwp/foundation)', + '--disable-issues', + '--disable-wiki', + ], + [ + 'gh', + 'repo', + 'edit', + 'stellarwp/foundation-log', + '--enable-projects=false', + ], + ], $creator->commands($this->plan())); + } + + public function test_it_runs_github_cli_commands(): void { + $processRunner = new TestShellProcessRunner(); + $creator = new GitHubPackageRepositoryCreator($processRunner); + + $creator->create($this->plan()); + + $this->assertCount(2, $processRunner->commands); + } + + public function test_it_throws_when_a_github_cli_command_fails(): void { + $creator = new GitHubPackageRepositoryCreator(new TestShellProcessRunner(exitCode: 1)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Command failed with exit code 1'); + + $creator->create($this->plan()); + } + + private function plan(): PackageRepositoryPlan { + return new PackageRepositoryPlan( + organization: 'stellarwp', + repository: 'foundation-log', + description: '[READ ONLY] Subtree split of the Foundation Log component (see stellarwp/foundation)' + ); + } +} + +final class TestShellProcessRunner implements ProcessRunner +{ + /** + * @var list> + */ + public array $commands = []; + + public function __construct( + private readonly int $exitCode = 0 + ) { + } + + /** + * @param list $command + */ + public function run(array $command): int { + $this->commands[] = $command; + + return $this->exitCode; + } +} diff --git a/tests/Unit/Cli/Commands/Package/PackageFilesValidatorTest.php b/tests/Unit/Cli/Commands/Package/PackageFilesValidatorTest.php new file mode 100644 index 0000000..8637c72 --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/PackageFilesValidatorTest.php @@ -0,0 +1,29 @@ +data_dir('cli/package/missing-files-root/src/Log'); + + $missingFiles = (new PackageFilesValidator())->missingFiles(new Package( + name: 'stellarwp/foundation-log', + component: 'Log', + directory: 'src/Log', + path: $path, + composerPath: $path . '/composer.json' + )); + + $this->assertSame([ + 'README.md', + '.gitattributes', + '.gitignore', + '.github/workflows/close-pull-request.yml', + ], $missingFiles); + } +} diff --git a/tests/Unit/Cli/Commands/Package/PackageRepositoryPlanFactoryTest.php b/tests/Unit/Cli/Commands/Package/PackageRepositoryPlanFactoryTest.php new file mode 100644 index 0000000..c348d60 --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/PackageRepositoryPlanFactoryTest.php @@ -0,0 +1,25 @@ +create(new Package( + name: 'stellarwp/foundation-log', + component: 'Log', + directory: 'src/Log', + path: '/repo/src/Log', + composerPath: '/repo/src/Log/composer.json' + )); + + $this->assertSame('stellarwp', $plan->organization); + $this->assertSame('foundation-log', $plan->repository); + $this->assertSame('stellarwp/foundation-log', $plan->fullName()); + $this->assertSame('[READ ONLY] Subtree split of the Foundation Log component (see stellarwp/foundation)', $plan->description); + } +} diff --git a/tests/Unit/Cli/Commands/Package/PackageResolverTest.php b/tests/Unit/Cli/Commands/Package/PackageResolverTest.php new file mode 100644 index 0000000..a562032 --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/PackageResolverTest.php @@ -0,0 +1,30 @@ +data_dir('cli/package/valid-root')))->resolve('Log'); + + $this->assertSame('stellarwp/foundation-log', $package->name); + $this->assertSame('Log', $package->component); + $this->assertSame('src/Log', $package->directory); + } + + public function test_it_resolves_packages_by_repository_name(): void { + $package = (new PackageResolver($this->data_dir('cli/package/valid-root')))->resolve('foundation-log'); + + $this->assertSame('stellarwp/foundation-log', $package->name); + } + + public function test_it_throws_when_a_package_cannot_be_resolved(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not find a Foundation split package matching "missing".'); + + (new PackageResolver($this->data_dir('cli/package/valid-root')))->resolve('missing'); + } +} diff --git a/tests/Unit/Cli/Process/ShellProcessRunnerTest.php b/tests/Unit/Cli/Process/ShellProcessRunnerTest.php new file mode 100644 index 0000000..e54b956 --- /dev/null +++ b/tests/Unit/Cli/Process/ShellProcessRunnerTest.php @@ -0,0 +1,17 @@ +assertSame(0, (new ShellProcessRunner())->run([ + 'php', + '-r', + '', + ])); + } +} diff --git a/tests/_data/cli/package/missing-files-root/src/Log/composer.json b/tests/_data/cli/package/missing-files-root/src/Log/composer.json new file mode 100644 index 0000000..69103ad --- /dev/null +++ b/tests/_data/cli/package/missing-files-root/src/Log/composer.json @@ -0,0 +1,3 @@ +{ + "name": "stellarwp/foundation-log" +} diff --git a/tests/_data/cli/package/valid-root/src/Log/.gitattributes b/tests/_data/cli/package/valid-root/src/Log/.gitattributes new file mode 100644 index 0000000..02b13c8 --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/Log/.gitattributes @@ -0,0 +1,3 @@ +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/tests/_data/cli/package/valid-root/src/Log/.github/workflows/close-pull-request.yml b/tests/_data/cli/package/valid-root/src/Log/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..e51ed73 --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/Log/.github/workflows/close-pull-request.yml @@ -0,0 +1,5 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] diff --git a/tests/_data/cli/package/valid-root/src/Log/.gitignore b/tests/_data/cli/package/valid-root/src/Log/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/Log/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/tests/_data/cli/package/valid-root/src/Log/README.md b/tests/_data/cli/package/valid-root/src/Log/README.md new file mode 100644 index 0000000..72133b3 --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/Log/README.md @@ -0,0 +1,4 @@ +# Foundation Log + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). diff --git a/tests/_data/cli/package/valid-root/src/Log/composer.json b/tests/_data/cli/package/valid-root/src/Log/composer.json new file mode 100644 index 0000000..69103ad --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/Log/composer.json @@ -0,0 +1,3 @@ +{ + "name": "stellarwp/foundation-log" +} diff --git a/tests/_data/cli/package/valid-root/src/NotFoundation/composer.json b/tests/_data/cli/package/valid-root/src/NotFoundation/composer.json new file mode 100644 index 0000000..e490072 --- /dev/null +++ b/tests/_data/cli/package/valid-root/src/NotFoundation/composer.json @@ -0,0 +1,3 @@ +{ + "name": "stellarwp/not-foundation" +} From 6eeff167ddbe7500d70f8afa6dd63f4be22385d6 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 13:24:58 -0600 Subject: [PATCH 10/11] Ensure `composer foundation package:create` actually scaffolds the package --- README.md | 15 +- src/Cli/CliProvider.php | 6 + src/Cli/Commands/Package/CreateCommand.php | 91 +++++++- src/Cli/Commands/Package/PackageScaffold.php | 21 ++ .../Commands/Package/PackageScaffolder.php | 194 ++++++++++++++++++ src/Cli/README.md | 4 +- tests/Unit/Cli/CliProviderTest.php | 2 + .../Commands/Package/CreateCommandTest.php | 153 +++++++++++++- .../Package/PackageScaffolderTest.php | 114 ++++++++++ 9 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 src/Cli/Commands/Package/PackageScaffold.php create mode 100644 src/Cli/Commands/Package/PackageScaffolder.php create mode 100644 tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php diff --git a/README.md b/README.md index d2046b6..a88972e 100644 --- a/README.md +++ b/README.md @@ -89,20 +89,23 @@ run to access their CLI: `composer monorepo list` to see the available commands. #### Adding a New Package -1. Copy the composer.json from one of the packages and modify the `name` and `psr-4` autoload namespace. -2. Ensure you have the `close-pull-request.yml` GitHub workflow, a `.gitattributes`, `.gitignore` and `README.md` files. -3. Once you've added the specific dependencies your package needs to its composer.json, run `composer monorepo merge` and then `composer update` and commit the changes. This will merge the dependencies into the root composer.json. -4. Preview the split repository that will be created: +1. Run the package creation command: ```bash composer run foundation -- package:create ``` -The package argument can be the package directory, short package name, or Composer package name, for example `Log`, `foundation-log`, or `stellarwp/foundation-log`. +The package argument can be a new package component such as `WPCli`, an existing package directory, short package name, or Composer package name, for example `Log`, `foundation-log`, or `stellarwp/foundation-log`. + +If the package does not exist yet, the command asks whether to create the local scaffold in `src/` and asks for the Composer package name with a default such as `stellarwp/foundation-wpcli`. The scaffold includes the required `composer.json`, `README.md`, `.gitattributes`, `.gitignore`, and `close-pull-request.yml` files. After scaffolding, the command runs `composer monorepo merge` so the root `composer.json` includes the new package. The command runs as a dry run by default. It validates the required split package files, prints the target repository name and description, and shows the GitHub CLI commands it will run. -5. Create and configure the read-only split repository: +2. Add source code, tests, and any package-specific dependencies to the new package. + +3. Once you've added the specific dependencies your package needs to its composer.json, run `composer monorepo merge` again and then `composer update` and commit the changes. This will merge the dependency changes into the root composer.json. + +4. Create and configure the read-only split repository: ```bash composer run foundation -- package:create --apply diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index 0a97bbb..7c277d2 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -9,6 +9,7 @@ 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\Process\Contracts\ProcessRunner; use StellarWP\Foundation\Cli\Process\ShellProcessRunner; use StellarWP\Foundation\Container\Contracts\Provider; @@ -30,6 +31,10 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(PackageScaffolder::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 => [ @@ -37,6 +42,7 @@ public function register(): void { ]); $this->container->singleton(PackageResolver::class); + $this->container->singleton(PackageScaffolder::class); $this->container->singleton(PackageFilesValidator::class); $this->container->singleton(PackageRepositoryPlanFactory::class); $this->container->singleton(ShellProcessRunner::class); diff --git a/src/Cli/Commands/Package/CreateCommand.php b/src/Cli/Commands/Package/CreateCommand.php index c7e0035..7ea326a 100644 --- a/src/Cli/Commands/Package/CreateCommand.php +++ b/src/Cli/Commands/Package/CreateCommand.php @@ -4,18 +4,22 @@ use RuntimeException; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; +use StellarWP\Foundation\Cli\Process\Contracts\ProcessRunner; use StellarWP\Foundation\Cli\Process\ShellCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; /** - * Creates the read-only GitHub repository for an existing Foundation package. + * Creates or prepares a Foundation split package and its read-only repository. * - * Run this after adding the package files in `src/` and before relying - * on the monorepo split workflow to publish that package to GitHub. + * Run this when adding a new package or preparing an existing package for the + * monorepo split workflow. Missing packages are scaffolded after confirmation. */ final class CreateCommand extends Command { @@ -24,9 +28,11 @@ final class CreateCommand extends Command public function __construct( private readonly PackageResolver $packageResolver, + private readonly PackageScaffolder $packageScaffolder, private readonly PackageFilesValidator $packageFilesValidator, private readonly PackageRepositoryPlanFactory $packageRepositoryPlanFactory, - private readonly PackageRepositoryCreator $packageRepositoryCreator + private readonly PackageRepositoryCreator $packageRepositoryCreator, + private readonly ProcessRunner $processRunner ) { parent::__construct(self::NAME); } @@ -38,12 +44,18 @@ protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { - try { - $package = $this->packageResolver->resolve((string) $input->getArgument('package')); - } catch (RuntimeException $exception) { - $output->writeln('' . $exception->getMessage() . ''); + $packageInput = (string) $input->getArgument('package'); - return Command::FAILURE; + try { + $package = $this->packageResolver->resolve($packageInput); + } catch (RuntimeException) { + try { + $package = $this->scaffoldPackage($packageInput, $input, $output); + } catch (RuntimeException $scaffoldException) { + $output->writeln('' . $scaffoldException->getMessage() . ''); + + return Command::FAILURE; + } } $missingFiles = $this->packageFilesValidator->missingFiles($package); @@ -86,4 +98,65 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + private function scaffoldPackage(string $packageInput, InputInterface $input, OutputInterface $output): Package { + $defaultPackageName = $this->packageScaffolder->defaultPackageName($packageInput); + $defaultDirectory = $this->packageScaffolder->defaultDirectory($packageInput); + + $output->writeln(sprintf('No existing Foundation split package matched "%s".', $packageInput)); + + if (! $this->questionHelper()->ask( + $input, + $output, + new ConfirmationQuestion(sprintf('Create local package scaffold in %s? [y/N] ', $defaultDirectory), false) + )) { + throw new RuntimeException('Package scaffold was not created.'); + } + + $packageName = $this->questionHelper()->ask( + $input, + $output, + new Question(sprintf('Composer package name [%s]: ', $defaultPackageName), $defaultPackageName) + ); + + $scaffold = $this->packageScaffolder->create($packageInput, (string) $packageName); + + $output->writeln(sprintf('Created package scaffold: %s', $scaffold->package->directory)); + $output->writeln(sprintf('Composer package: %s', $scaffold->package->name)); + + foreach ($scaffold->createdFiles as $createdFile) { + $output->writeln(sprintf(' - %s', $createdFile)); + } + + if ($scaffold->createdFiles === []) { + $output->writeln(sprintf('No package files were written for %s; all scaffold files already exist.', $scaffold->package->directory)); + } + + $this->runMonorepoMerge($output); + + return $scaffold->package; + } + + private function runMonorepoMerge(OutputInterface $output): void { + $command = ['composer', 'monorepo', 'merge']; + + $output->writeln(''); + $output->writeln(sprintf('Running %s...', ShellCommand::format($command))); + + $exitCode = $this->processRunner->run($command); + + if ($exitCode !== 0) { + throw new RuntimeException(sprintf('Command failed with exit code %d: %s', $exitCode, ShellCommand::format($command))); + } + } + + private function questionHelper(): QuestionHelper { + $helper = $this->getHelper('question'); + + if (! $helper instanceof QuestionHelper) { + throw new RuntimeException('The Symfony question helper is not available.'); + } + + return $helper; + } } diff --git a/src/Cli/Commands/Package/PackageScaffold.php b/src/Cli/Commands/Package/PackageScaffold.php new file mode 100644 index 0000000..5d8c3f7 --- /dev/null +++ b/src/Cli/Commands/Package/PackageScaffold.php @@ -0,0 +1,21 @@ + $createdFiles + */ + public function __construct( + public Package $package, + public array $createdFiles + ) { + } +} diff --git a/src/Cli/Commands/Package/PackageScaffolder.php b/src/Cli/Commands/Package/PackageScaffolder.php new file mode 100644 index 0000000..c0c569f --- /dev/null +++ b/src/Cli/Commands/Package/PackageScaffolder.php @@ -0,0 +1,194 @@ +componentFromInput($input); + + return 'stellarwp/foundation-' . strtolower($component); + } + + public function defaultDirectory(string $input): string { + return 'src/' . $this->componentFromInput($input); + } + + public function create(string $input, string $packageName): PackageScaffold { + $component = $this->componentFromInput($input); + $packageName = $this->normalizePackageName($packageName); + $path = $this->rootPath . '/src/' . $component; + + if (file_exists($path) && ! is_dir($path)) { + throw new RuntimeException(sprintf('Cannot create package scaffold because "%s" already exists and is not a directory.', $path)); + } + + $package = new Package( + name: $packageName, + component: $component, + directory: 'src/' . $component, + path: $path, + composerPath: $path . '/composer.json' + ); + + return new PackageScaffold($package, $this->writeFiles($package)); + } + + private function componentFromInput(string $input): string { + $input = trim($input); + $input = preg_replace('#^stellarwp/foundation-#i', '', $input) ?? $input; + $input = preg_replace('#^foundation-#i', '', $input) ?? $input; + + if (preg_match('/[^A-Za-z0-9]/', $input) === 1) { + $input = str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', strtolower($input)))); + } + + $component = preg_replace('/[^A-Za-z0-9]/', '', $input) ?? ''; + + if ($component === '') { + throw new RuntimeException(sprintf('Cannot create package scaffold from invalid package name "%s".', $input)); + } + + return ucfirst($component); + } + + private function normalizePackageName(string $packageName): string { + $packageName = strtolower(trim($packageName)); + + if (! preg_match('#^stellarwp/foundation-[a-z0-9][a-z0-9-]*$#', $packageName)) { + throw new RuntimeException(sprintf('Invalid Foundation package name "%s". Expected stellarwp/foundation-.', $packageName)); + } + + return $packageName; + } + + /** + * @return list + */ + private function writeFiles(Package $package): array { + $files = [ + 'composer.json' => $this->composerJson($package), + 'README.md' => $this->readme($package), + '.gitattributes' => $this->gitAttributes(), + '.gitignore' => $this->gitIgnore(), + '.github/workflows/close-pull-request.yml' => $this->closePullRequestWorkflow(), + ]; + + $createdFiles = []; + + foreach ($files as $relativePath => $contents) { + $path = $package->path . '/' . $relativePath; + + if (file_exists($path)) { + continue; + } + + $directory = dirname($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($path, $contents) === false) { + throw new RuntimeException(sprintf('Could not write package scaffold file "%s".', $path)); + } + + $createdFiles[] = $relativePath; + } + + return $createdFiles; + } + + private function composerJson(Package $package): string { + $composer = [ + 'name' => $package->name, + 'type' => 'library', + 'description' => sprintf('Foundation %s package.', $package->component), + 'license' => 'GPL-2.0-or-later', + 'config' => [ + 'vendor-dir' => 'vendor', + 'preferred-install' => 'dist', + ], + 'require' => [ + 'php' => '>=8.3', + ], + 'autoload' => [ + 'psr-4' => [ + sprintf('StellarWP\\Foundation\\%s\\', $package->component) => '', + ], + ], + 'extra' => [ + 'branch-alias' => [ + 'dev-main' => '1.1.x-dev', + ], + ], + ]; + + return (string) json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; + } + + private function readme(Package $package): string { + return <<component} + + > [!WARNING] + > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + + ## Installation + + ```shell + composer require {$package->name} + ``` + README . PHP_EOL; + } + + private function gitAttributes(): string { + return <<<'GITATTRIBUTES' + # Path-based git attributes + # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + + # Ignore paths when git creates an archive of this package + .gitattributes export-ignore + .gitignore export-ignore + .github export-ignore + GITATTRIBUTES . PHP_EOL; + } + + private function gitIgnore(): string { + return <<<'GITIGNORE' + vendor/ + composer.lock + GITIGNORE . PHP_EOL; + } + + private function closePullRequestWorkflow(): string { + return <<<'WORKFLOW' + name: Close Pull Request + + on: + pull_request_target: + types: [opened] + + jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" + WORKFLOW . PHP_EOL; + } +} diff --git a/src/Cli/README.md b/src/Cli/README.md index a118b7a..dc3195a 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -17,7 +17,9 @@ Create a split repository for a new package: composer run foundation -- package:create Log ``` -By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated actions. +If the package does not exist yet, the command asks whether to create the local scaffold in `src/` and asks for the Composer package name. For example, `WPCli` defaults to `stellarwp/foundation-wpcli`. After scaffolding, it runs `composer monorepo merge` so the root package metadata includes the new split package. + +By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated repository actions. ## Custom Commands diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 1b0c227..0c55e5d 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -11,6 +11,7 @@ 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\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Tests\TestCase; @@ -27,6 +28,7 @@ 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(PackageResolver::class, $container->get(PackageResolver::class)); + $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($container->get(Application::class)->has('package:create')); } diff --git a/tests/Unit/Cli/Commands/Package/CreateCommandTest.php b/tests/Unit/Cli/Commands/Package/CreateCommandTest.php index 7a8a7e1..37b3205 100644 --- a/tests/Unit/Cli/Commands/Package/CreateCommandTest.php +++ b/tests/Unit/Cli/Commands/Package/CreateCommandTest.php @@ -8,21 +8,40 @@ use StellarWP\Foundation\Cli\Commands\Package\PackageRepositoryPlan; 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\Process\Contracts\ProcessRunner; use StellarWP\Foundation\Tests\TestCase; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; final class CreateCommandTest extends TestCase { + /** + * @var list + */ + private array $temporaryRoots = []; + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + public function test_it_outputs_a_dry_run_by_default(): void { $rootPath = $this->data_dir('cli/package/valid-root'); $packageRepositoryCreator = new FakePackageRepositoryCreator(); $command = new CreateCommand( new PackageResolver($rootPath), + new PackageScaffolder($rootPath), new PackageFilesValidator(), new PackageRepositoryPlanFactory(), - $packageRepositoryCreator + $packageRepositoryCreator, + new FakeProcessRunner() ); $tester = new CommandTester($command); @@ -40,9 +59,11 @@ public function test_it_creates_the_package_repository_when_apply_is_passed(): v $packageRepositoryCreator = new FakePackageRepositoryCreator(); $command = new CreateCommand( new PackageResolver($this->data_dir('cli/package/valid-root')), + new PackageScaffolder($this->data_dir('cli/package/valid-root')), new PackageFilesValidator(), new PackageRepositoryPlanFactory(), - $packageRepositoryCreator + $packageRepositoryCreator, + new FakeProcessRunner() ); $tester = new CommandTester($command); @@ -60,24 +81,85 @@ public function test_it_creates_the_package_repository_when_apply_is_passed(): v public function test_it_fails_when_the_package_cannot_be_resolved(): void { $command = new CreateCommand( new PackageResolver($this->data_dir('cli/package/valid-root')), + new PackageScaffolder($this->data_dir('cli/package/valid-root')), new PackageFilesValidator(), new PackageRepositoryPlanFactory(), - new FakePackageRepositoryCreator() + new FakePackageRepositoryCreator(), + new FakeProcessRunner() ); + $command->setHelperSet($this->questionHelperSet()); $tester = new CommandTester($command); $statusCode = $tester->execute(['package' => 'missing']); $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('Could not find a Foundation split package matching "missing".', $tester->getDisplay()); + $this->assertStringContainsString('No existing Foundation split package matched "missing".', $tester->getDisplay()); + $this->assertStringContainsString('Package scaffold was not created.', $tester->getDisplay()); + } + + public function test_it_prompts_to_scaffold_a_missing_package_before_creating_the_repository(): void { + $rootPath = $this->temporaryRoot(); + $packageRepositoryCreator = new FakePackageRepositoryCreator(); + $processRunner = new FakeProcessRunner(); + $command = new CreateCommand( + new PackageResolver($rootPath), + new PackageScaffolder($rootPath), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + $packageRepositoryCreator, + $processRunner + ); + $command->setHelperSet($this->questionHelperSet()); + + $tester = new CommandTester($command); + $tester->setInputs(['yes', '']); + $statusCode = $tester->execute(['package' => 'WPCli']); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFalse($packageRepositoryCreator->created); + $this->assertStringContainsString('No existing Foundation split package matched "WPCli".', $tester->getDisplay()); + $this->assertStringContainsString('Created package scaffold: src/WPCli', $tester->getDisplay()); + $this->assertStringContainsString('Composer package: stellarwp/foundation-wpcli', $tester->getDisplay()); + $this->assertStringContainsString("Running 'composer' 'monorepo' 'merge'...", $tester->getDisplay()); + $this->assertStringContainsString('Repository: stellarwp/foundation-wpcli', $tester->getDisplay()); + $this->assertSame([ + ['composer', 'monorepo', 'merge'], + ], $processRunner->commands); + $this->assertFileExists($rootPath . '/src/WPCli/composer.json'); + $this->assertFileExists($rootPath . '/src/WPCli/.github/workflows/close-pull-request.yml'); + $this->assertStringContainsString('"name": "stellarwp/foundation-wpcli"', (string) file_get_contents($rootPath . '/src/WPCli/composer.json')); + } + + public function test_it_fails_when_monorepo_merge_fails_after_scaffolding(): void { + $rootPath = $this->temporaryRoot(); + $packageRepositoryCreator = new FakePackageRepositoryCreator(); + $command = new CreateCommand( + new PackageResolver($rootPath), + new PackageScaffolder($rootPath), + new PackageFilesValidator(), + new PackageRepositoryPlanFactory(), + $packageRepositoryCreator, + new FakeProcessRunner(exitCode: 1) + ); + $command->setHelperSet($this->questionHelperSet()); + + $tester = new CommandTester($command); + $tester->setInputs(['yes', '']); + $statusCode = $tester->execute(['package' => 'WPCli']); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertFalse($packageRepositoryCreator->created); + $this->assertStringContainsString("Command failed with exit code 1: 'composer' 'monorepo' 'merge'", $tester->getDisplay()); } public function test_it_fails_when_required_split_package_files_are_missing(): void { $command = new CreateCommand( new PackageResolver($this->data_dir('cli/package/missing-files-root')), + new PackageScaffolder($this->data_dir('cli/package/missing-files-root')), new PackageFilesValidator(), new PackageRepositoryPlanFactory(), - new FakePackageRepositoryCreator() + new FakePackageRepositoryCreator(), + new FakeProcessRunner() ); $tester = new CommandTester($command); @@ -87,6 +169,45 @@ public function test_it_fails_when_required_split_package_files_are_missing(): v $this->assertStringContainsString('The package is missing required split repository files:', $tester->getDisplay()); $this->assertStringContainsString('README.md', $tester->getDisplay()); } + + private function temporaryRoot(): string { + $root = sys_get_temp_dir() . '/foundation-cli-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 questionHelperSet(): HelperSet { + return new HelperSet([ + 'question' => new QuestionHelper(), + ]); + } + + 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); + } } final class FakePackageRepositoryCreator implements PackageRepositoryCreator @@ -106,3 +227,25 @@ public function create(PackageRepositoryPlan $plan): void { $this->created = true; } } + +final class FakeProcessRunner implements ProcessRunner +{ + /** + * @var list> + */ + public array $commands = []; + + public function __construct( + private readonly int $exitCode = 0 + ) { + } + + /** + * @param list $command + */ + public function run(array $command): int { + $this->commands[] = $command; + + return $this->exitCode; + } +} diff --git a/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php new file mode 100644 index 0000000..977fb5a --- /dev/null +++ b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php @@ -0,0 +1,114 @@ + + */ + private array $temporaryRoots = []; + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_creates_a_package_scaffold_with_the_default_package_name(): void { + $rootPath = $this->temporaryRoot(); + $scaffolder = new PackageScaffolder($rootPath); + + $scaffold = $scaffolder->create('WPCli', $scaffolder->defaultPackageName('WPCli')); + + $this->assertSame('stellarwp/foundation-wpcli', $scaffold->package->name); + $this->assertSame('WPCli', $scaffold->package->component); + $this->assertSame('src/WPCli', $scaffold->package->directory); + $this->assertSame([ + 'composer.json', + 'README.md', + '.gitattributes', + '.gitignore', + '.github/workflows/close-pull-request.yml', + ], $scaffold->createdFiles); + $this->assertFileExists($rootPath . '/src/WPCli/composer.json'); + $this->assertStringContainsString('"StellarWP\\\\Foundation\\\\WPCli\\\\": ""', (string) file_get_contents($rootPath . '/src/WPCli/composer.json')); + } + + public function test_it_accepts_a_custom_foundation_package_name(): void { + $scaffold = (new PackageScaffolder($this->temporaryRoot()))->create('WPCli', 'stellarwp/foundation-wp-cli'); + + $this->assertSame('stellarwp/foundation-wp-cli', $scaffold->package->name); + } + + public function test_it_skips_scaffold_files_that_already_exist(): void { + $rootPath = $this->temporaryRoot(); + $path = $rootPath . '/src/WPCli'; + + mkdir($path, 0777, true); + file_put_contents($path . '/composer.json', '{}'); + + $scaffold = (new PackageScaffolder($rootPath))->create('WPCli', 'stellarwp/foundation-wpcli'); + + $this->assertNotContains('composer.json', $scaffold->createdFiles); + $this->assertSame('{}', file_get_contents($path . '/composer.json')); + } + + public function test_it_rejects_invalid_package_names(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid Foundation package name'); + + (new PackageScaffolder($this->temporaryRoot()))->create('WPCli', 'stellarwp/not-foundation-wpcli'); + } + + public function test_it_fails_when_the_target_path_is_a_file(): void { + $rootPath = $this->temporaryRoot(); + + mkdir($rootPath . '/src', 0777, true); + file_put_contents($rootPath . '/src/WPCli', 'not a directory'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already exists and is not a directory'); + + (new PackageScaffolder($rootPath))->create('WPCli', 'stellarwp/foundation-wpcli'); + } + + private function temporaryRoot(): string { + $root = sys_get_temp_dir() . '/foundation-cli-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); + } +} From aec462381442ab77c95c6a1eeab51b40c64ff9ee Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 2 Jun 2026 14:30:13 -0600 Subject: [PATCH 11/11] Add `stellarwp/foundation-wpcli` package --- AGENTS.md | 2 + composer.json | 7 +- src/WPCli/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 ++ src/WPCli/.gitignore | 2 + src/WPCli/Command.php | 91 +++++++++++++ src/WPCli/README.md | 121 +++++++++++++++++ src/WPCli/TimestampedLogger.php | 58 ++++++++ src/WPCli/composer.json | 30 ++++ tests/Support/Fixtures/WPCli/TestCommand.php | 128 ++++++++++++++++++ .../Fixtures/WPCli/TestWpCliLogger.php | 66 +++++++++ tests/Unit/WPCli/CommandTest.php | 71 ++++++++++ tests/Unit/WPCli/TimestampedLoggerTest.php | 30 ++++ 13 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 src/WPCli/.gitattributes create mode 100644 src/WPCli/.github/workflows/close-pull-request.yml create mode 100644 src/WPCli/.gitignore create mode 100644 src/WPCli/Command.php create mode 100644 src/WPCli/README.md create mode 100644 src/WPCli/TimestampedLogger.php create mode 100644 src/WPCli/composer.json create mode 100644 tests/Support/Fixtures/WPCli/TestCommand.php create mode 100644 tests/Support/Fixtures/WPCli/TestWpCliLogger.php create mode 100644 tests/Unit/WPCli/CommandTest.php create mode 100644 tests/Unit/WPCli/TimestampedLoggerTest.php diff --git a/AGENTS.md b/AGENTS.md index 3788f09..1196995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,8 @@ Use `composer monorepo list` to inspect available Monorepo Builder commands. When `composer lint` reports style-only issues, run `composer format` to let the project formatter fix them before making manual formatting edits. +Reusable test fixtures, sample classes, and test doubles should live under `tests/Support/Fixtures//` instead of being declared inline in a test class file. Keep truly local one-off fakes inline only when they are not reusable and do not represent a domain/package fixture. + After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. ## Releases diff --git a/composer.json b/composer.json index fed6669..d4c583f 100644 --- a/composer.json +++ b/composer.json @@ -27,13 +27,15 @@ "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^12.5", + "wp-cli/wp-cli": ">=2.12", "zenphp/pinte": "^1.2" }, "replace": { "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", "stellarwp/foundation-log": "self.version", - "stellarwp/foundation-pipeline": "self.version" + "stellarwp/foundation-pipeline": "self.version", + "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, @@ -42,7 +44,8 @@ "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Log\\": "src/Log/", - "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/" + "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", + "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" } }, "autoload-dev": { diff --git a/src/WPCli/.gitattributes b/src/WPCli/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/WPCli/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/WPCli/.github/workflows/close-pull-request.yml b/src/WPCli/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/WPCli/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/WPCli/.gitignore b/src/WPCli/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/WPCli/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php new file mode 100644 index 0000000..bf97272 --- /dev/null +++ b/src/WPCli/Command.php @@ -0,0 +1,91 @@ + $args + * @param array $assocArgs + * + * @return int 0 is success; any other value is an error. + */ + abstract public function runCommand(array $args = [], array $assocArgs = []): int; + + /** + * The command name under the configured prefix, e.g. "sync". + */ + abstract protected function subcommand(): string; + + /** + * The command description as it appears in "wp help". + */ + abstract protected function description(): string; + + /** + * The array of command arguments/options the command accepts. + * + * @return array{}|list}> + */ + abstract protected function arguments(): array; + + /** + * Register the command with WP-CLI. + */ + public function register(): void { + WP_CLI::add_command($this->command(), [$this, 'runCommand'], [ + 'shortdesc' => $this->description(), + 'synopsis' => $this->arguments(), + ]); + } + + protected function command(): string { + return trim($this->commandPrefix . ' ' . $this->subcommand()); + } + + /** + * Ask a question and retrieve a normalized answer from STDIN. + */ + protected function ask(string $question): string { + fwrite($this->output(), $question . ' '); + + return strtolower(trim((string) fgets($this->input()))); + } + + /** + * @return resource + */ + protected function input(): mixed { + return STDIN; + } + + /** + * @return resource + */ + protected function output(): mixed { + return STDOUT; + } +} diff --git a/src/WPCli/README.md b/src/WPCli/README.md new file mode 100644 index 0000000..b3efdad --- /dev/null +++ b/src/WPCli/README.md @@ -0,0 +1,121 @@ +# Foundation WP-CLI + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation helpers for building WP-CLI commands with the Foundation container. + +## Installation + +```shell +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. + +## Commands + +Extend `StellarWP\Foundation\WPCli\Command` for commands that should receive the Foundation container. + +```php +container. + + return self::SUCCESS; + } + + protected function subcommand(): string { + return 'sync'; + } + + protected function description(): string { + return 'Sync Acme data.'; + } + + protected function arguments(): array { + return [ + [ + 'type' => self::FLAG, + 'name' => 'dry-run', + 'description' => 'Preview the sync without writing changes.', + 'optional' => true, + ], + ]; + } +} +``` + +## Provider Setup + +Applications should register their own provider so they control the command namespace and command list. + +```php +> + */ + private const array COMMANDS = [ + SyncCommand::class, + ]; + + public function register(): void { + if (! defined('WP_CLI') || ! WP_CLI) { + return; + } + + $this->configureCommands(); + $this->registerTimestampedLogger(); + + add_action('cli_init', function (): void { + foreach (self::COMMANDS as $commandClass) { + $command = $this->container->get($commandClass); + + if ($command instanceof Command) { + $command->register(); + } + } + }, 0, 0); + } + + private function configureCommands(): void { + 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)); + } + } +} +``` + +Use `cli_init` so commands are registered only during WP-CLI command bootstrap, after WordPress has loaded enough for plugin providers and hooks to be available. + +If your application does not use WordPress hooks during bootstrap, call the command registration loop at the point where WP-CLI is active and your container has been configured. diff --git a/src/WPCli/TimestampedLogger.php b/src/WPCli/TimestampedLogger.php new file mode 100644 index 0000000..2fa8e12 --- /dev/null +++ b/src/WPCli/TimestampedLogger.php @@ -0,0 +1,58 @@ +wpLogger->debug($this->prependTimestamp($message), $group); + } + + public function info(string $message): void { + $this->wpLogger->info($this->prependTimestamp($message)); + } + + public function success(string $message): void { + $this->wpLogger->success($this->prependTimestamp($message)); + } + + public function warning(string $message): void { + $this->wpLogger->warning($this->prependTimestamp($message)); + } + + /** + * @param list $messageLines Messages to write. + */ + public function error_multi_line(array $messageLines): void { + $this->wpLogger->error_multi_line(array_map([$this, 'prependTimestamp'], $messageLines)); + } + + private function prependTimestamp(string $message): string { + $timestamp = (new DateTimeImmutable( + 'now', + new DateTimeZone($this->timezone) + ))->format($this->dateFormat); + + return sprintf('[%s] %s', $timestamp, $message); + } +} diff --git a/src/WPCli/composer.json b/src/WPCli/composer.json new file mode 100644 index 0000000..f615cbf --- /dev/null +++ b/src/WPCli/composer.json @@ -0,0 +1,30 @@ +{ + "name": "stellarwp/foundation-wpcli", + "type": "library", + "description": "Foundation WP-CLI helpers.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^1.0" + }, + "require-dev": { + "wp-cli/wp-cli": ">=2.12" + }, + "suggest": { + "wp-cli/wp-cli": "Install when developing or testing WP-CLI command integrations outside a WP-CLI runtime." + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\WPCli\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.1.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/WPCli/TestCommand.php b/tests/Support/Fixtures/WPCli/TestCommand.php new file mode 100644 index 0000000..e00f67b --- /dev/null +++ b/tests/Support/Fixtures/WPCli/TestCommand.php @@ -0,0 +1,128 @@ + + */ + public array $args = []; + + /** + * @var array + */ + public array $assocArgs = []; + + /** + * @var resource|null + */ + private mixed $input = null; + + /** + * @var resource|null + */ + private mixed $output = null; + + /** + * @param list $args + * @param array $assocArgs + */ + public function runCommand(array $args = [], array $assocArgs = []): int { + $this->args = $args; + $this->assocArgs = $assocArgs; + + return self::SUCCESS; + } + + public function name(): string { + return $this->command(); + } + + public function shortDescription(): string { + return $this->description(); + } + + /** + * @return array{}|list}> + */ + public function synopsis(): array { + return $this->arguments(); + } + + public function prompt(string $question): string { + return $this->ask($question); + } + + public function defaultInput(): mixed { + return $this->input(); + } + + public function defaultOutput(): mixed { + return $this->output(); + } + + /** + * @return array{answer: string, output: string} + */ + public function promptWithInput(string $question, string $input): array { + $inputStream = fopen('php://memory', 'r+'); + + if ($inputStream === false) { + throw new \RuntimeException('Could not open memory input stream.'); + } + + $this->input = $inputStream; + fwrite($this->input, $input); + rewind($this->input); + + $outputStream = fopen('php://memory', 'w+'); + + if ($outputStream === false) { + throw new \RuntimeException('Could not open memory output stream.'); + } + + $this->output = $outputStream; + + $answer = $this->ask($question); + + rewind($this->output); + $output = stream_get_contents($this->output); + + $this->input = null; + $this->output = null; + + return [ + 'answer' => $answer, + 'output' => $output, + ]; + } + + protected function subcommand(): string { + return 'example'; + } + + protected function description(): string { + return 'Example command.'; + } + + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => 'value', + 'description' => 'Value to process.', + ], + ]; + } + + protected function input(): mixed { + return $this->input ?? parent::input(); + } + + protected function output(): mixed { + return $this->output ?? parent::output(); + } +} diff --git a/tests/Support/Fixtures/WPCli/TestWpCliLogger.php b/tests/Support/Fixtures/WPCli/TestWpCliLogger.php new file mode 100644 index 0000000..8dcc0c6 --- /dev/null +++ b/tests/Support/Fixtures/WPCli/TestWpCliLogger.php @@ -0,0 +1,66 @@ + + */ + public array $infoMessages = []; + + /** + * @var list + */ + public array $successMessages = []; + + /** + * @var list + */ + public array $warningMessages = []; + + /** + * @var list + */ + public array $debugMessages = []; + + /** + * @var list + */ + public array $debugGroups = []; + + /** + * @var list + */ + public array $errorLines = []; + + public function __construct() { + parent::__construct(false); + } + + public function info($message): void { + $this->infoMessages[] = $message; + } + + public function success($message): void { + $this->successMessages[] = $message; + } + + public function warning($message): void { + $this->warningMessages[] = $message; + } + + public function debug($message, $group = false): void { + $this->debugMessages[] = $message; + $this->debugGroups[] = $group; + } + + /** + * @param list $messageLines + */ + public function error_multi_line($messageLines): void { + $this->errorLines = $messageLines; + } +} diff --git a/tests/Unit/WPCli/CommandTest.php b/tests/Unit/WPCli/CommandTest.php new file mode 100644 index 0000000..a04c395 --- /dev/null +++ b/tests/Unit/WPCli/CommandTest.php @@ -0,0 +1,71 @@ +container, 'foundation'); + + $this->assertSame(0, $command->runCommand(['value'], ['flag' => true])); + $this->assertSame(['value'], $command->args); + $this->assertSame(['flag' => true], $command->assocArgs); + $this->assertSame('foundation example', $command->name()); + $this->assertSame('Example command.', $command->shortDescription()); + $this->assertSame([ + [ + 'type' => 'positional', + 'name' => 'value', + 'description' => 'Value to process.', + ], + ], $command->synopsis()); + } + + public function test_it_asks_for_normalized_input(): void { + $command = new TestCommand($this->container, 'foundation'); + + $result = $command->promptWithInput('Continue?', 'YES' . PHP_EOL); + + $this->assertSame('yes', $result['answer']); + $this->assertSame('Continue? ', $result['output']); + } + + public function test_it_exposes_default_input_and_output_streams(): void { + $command = new TestCommand($this->container, 'foundation'); + + $this->assertIsResource($command->defaultInput()); + $this->assertIsResource($command->defaultOutput()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_it_registers_with_wp_cli_using_the_prefixed_command_name(): void { + if (! defined('WP_CLI')) { + define('WP_CLI', true); + } + + $wpCliRoot = dirname(__DIR__, 3) . '/vendor/wp-cli/wp-cli'; + + if (! defined('WP_CLI_ROOT')) { + define('WP_CLI_ROOT', $wpCliRoot); + } + + require_once $wpCliRoot . '/php/utils.php'; + + $command = new TestCommand($this->container, 'foundation'); + + $command->register(); + $deferredAdditions = WP_CLI::get_deferred_additions(); + + $this->assertArrayHasKey('foundation example', $deferredAdditions); + $this->assertSame('foundation', $deferredAdditions['foundation example']['parent']); + $this->assertSame('Example command.', $deferredAdditions['foundation example']['args']['shortdesc']); + $this->assertSame($command->synopsis(), $deferredAdditions['foundation example']['args']['synopsis']); + } +} diff --git a/tests/Unit/WPCli/TimestampedLoggerTest.php b/tests/Unit/WPCli/TimestampedLoggerTest.php new file mode 100644 index 0000000..2936d6a --- /dev/null +++ b/tests/Unit/WPCli/TimestampedLoggerTest.php @@ -0,0 +1,30 @@ +info('Information.'); + $logger->success('Success.'); + $logger->warning('Warning.'); + $logger->debug('Debug.', 'group'); + $logger->error_multi_line(['First.', 'Second.']); + + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] Information\.$/', $wpLogger->infoMessages[0]); + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] Success\.$/', $wpLogger->successMessages[0]); + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] Warning\.$/', $wpLogger->warningMessages[0]); + $this->assertSame('group', $wpLogger->debugGroups[0]); + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] Debug\.$/', $wpLogger->debugMessages[0]); + $this->assertCount(2, $wpLogger->errorLines); + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] First\.$/', $wpLogger->errorLines[0]); + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\] Second\.$/', $wpLogger->errorLines[1]); + } +}