diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 72770f3..4470dee 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -117,5 +117,9 @@ Build a release zip with `wptoolkit-build` (`wptoolkit-build init`, then `wptool If your plugin might share a site with another WPToolkit plugin, scope your copy first — see [scoping and coexistence](scoping-and-coexistence.md). +Commands the build runs (`npm run build`, Composer) are silent unless they fail. To watch them, add +`--verbose` (`wptoolkit-build package --verbose`), set `"verbose": true` in `wptoolkit.json`, or in a +`build.php` call `->verbose()` for every command or `->runVerbose('npm run build')` for one. + Next: [routes and security](routes-and-security.md), [data](data.md), [admin and front end](admin-and-front-end.md). diff --git a/docs/phases/track-p-packager.md b/docs/phases/track-p-packager.md index 1ab92ab..577195a 100644 --- a/docs/phases/track-p-packager.md +++ b/docs/phases/track-p-packager.md @@ -63,7 +63,9 @@ php wptoolkit/bin/wptoolkit package # standalone install (no Composer) | `wptoolkit scope ` | Scopes the bundled toolkit (ADR-0014) | Flags override config: `--out`, `--zip-name`, `--no-build`, `--no-composer`, `--no-scope`, `--pot`, -`--json` (machine-readable report for CI). +`--json` (machine-readable report for CI). `--verbose` (added 2026-10-12) prints each command's +output as it runs; `"verbose": true` and `Build::verbose()` / `runVerbose()` do the same from config +and from a build script. **Configuration** is one optional `wptoolkit.json` at the project root. It's JSON so PHP reads it without dependencies, and it has a JSON Schema so editors autocomplete it: diff --git a/docs/reference/work-log.md b/docs/reference/work-log.md index b8aa39e..53493bd 100644 --- a/docs/reference/work-log.md +++ b/docs/reference/work-log.md @@ -56,3 +56,5 @@ One dated entry per working session: what landed, what was learned, what's next. - **Landed:** the guard's `namespaces` option (ADR-0025): PSR-4 loading of a standalone plugin's own classes, which ADR-0014 promised and 0.x's `Autoloader::init()` used to do. The E2E fixtures now rely on it. +- **Landed:** `Build::verbose()`, `runVerbose()`, `--verbose` and `"verbose": true`: command output streamed as it + runs (`StreamingCommandRunner`); a failure after streaming repeats only the last 20 lines. diff --git a/packages/build/bin/wptoolkit-build b/packages/build/bin/wptoolkit-build index 47bf5c6..90349ba 100644 --- a/packages/build/bin/wptoolkit-build +++ b/packages/build/bin/wptoolkit-build @@ -4,7 +4,7 @@ /** * wptoolkit-build — package a WordPress plugin or theme from wptoolkit.json (or defaults). * - * vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] + * vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose] * vendor/bin/wptoolkit-build verify FILE.zip * vendor/bin/wptoolkit-build doctor [--project=DIR] * vendor/bin/wptoolkit-build init [--project=DIR] @@ -57,6 +57,9 @@ try { fwrite(STDOUT, "(dry run: commands, composer, scope and make-pot were not run)\n"); exit(0); } + if (in_array('--verbose', $argv, true)) { + $build->verbose(); + } $build->zip($options['zip'] ?? $zip); exit(0); @@ -98,7 +101,8 @@ try { default: fwrite(STDOUT, "Usage:\n" - . " wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run]\n" + . " wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose]\n" + . " --verbose print the output of every command as it runs\n" . " wptoolkit-build verify FILE.zip\n" . " wptoolkit-build doctor [--project=DIR]\n" . " wptoolkit-build init [--project=DIR]\n"); diff --git a/packages/build/src/Build.php b/packages/build/src/Build.php index c3876f8..04dd8b8 100644 --- a/packages/build/src/Build.php +++ b/packages/build/src/Build.php @@ -35,6 +35,9 @@ * * Phases: Prepare (in your project) → stage (copy to a temp directory) → Transform → prune * (excludes) → Verify (always) → zip. Your working tree is never modified. + * + * Commands are silent unless they fail; `->verbose()` prints their output as they run, and + * `->runVerbose(...)` does so for chosen commands only. */ final class Build { @@ -62,6 +65,8 @@ final class Build private string $composerBinary = 'composer'; + private bool $verbose = false; + /** @var list */ private array $excludes = []; @@ -127,6 +132,25 @@ public function run(string ...$commands): self return $this->step(new RunCommands(...$commands)); } + /** + * Like run(), but these commands' output is printed as they run even when the build is not verbose. + */ + public function runVerbose(string ...$commands): self + { + return $this->step(RunCommands::streamed(...$commands)); + } + + /** + * Print the output of every command the build runs (run(), composer(), scope(), make-pot) as it + * is produced, instead of only when a command fails. Useful in CI logs and for slow asset builds. + */ + public function verbose(bool $verbose = true): self + { + $this->verbose = $verbose; + + return $this; + } + /** * Reinstall Composer dependencies in staging; `noDev` drops dev packages. */ @@ -228,7 +252,7 @@ public function zip(string $target = 'dist/{slug}-{version}.zip'): BuildReport $zip = $this->absolute(strtr($target, ['{slug}' => $this->project->slug, '{version}' => $version])); $staging = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug; - $context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output); + $context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose); $context->say(sprintf('Building %s %s (%s)', $this->project->slug, $version, $this->project->type)); try { diff --git a/packages/build/src/BuildContext.php b/packages/build/src/BuildContext.php index 0e18fc3..c053917 100644 --- a/packages/build/src/BuildContext.php +++ b/packages/build/src/BuildContext.php @@ -26,30 +26,63 @@ public function __construct( public readonly string $staging, public readonly string $version, public readonly CommandRunner $runner, - private readonly Closure $output + private readonly Closure $output, + public readonly bool $verbose = false ) { } + /** How much of a streamed command's output a failure repeats: it was already printed once. */ + private const FAILURE_TAIL = 20; + /** * Run a shell command, failing the step on a non-zero exit code. + * + * @param bool|null $stream Print the command's output as it runs. Null follows `Build::verbose()`. */ - public function exec(string $step, string $command, string $workingDirectory): string + public function exec(string $step, string $command, string $workingDirectory, ?bool $stream = null): string { + $stream ??= $this->verbose; $this->say(" $ {$command}"); - $result = $this->runner->run($command, $workingDirectory); + + $print = function (string $line): void { + $this->say(' | ' . $line); + }; + if ($stream && $this->runner instanceof StreamingCommandRunner) { + $result = $this->runner->stream($command, $workingDirectory, $print); + } else { + $result = $this->runner->run($command, $workingDirectory); + if ($stream) { + // This runner can't stream: show the output now that the command has ended. + array_map($print, self::lines($result['output'])); + } + } + if ($result['exitCode'] !== 0) { + $lines = self::lines($result['output']); + $shown = $stream ? array_slice($lines, -self::FAILURE_TAIL) : $lines; throw BuildException::inStep($step, sprintf( - '"%s" exited with %d:%s%s', + '"%s" exited with %d:%s%s%s', $command, $result['exitCode'], PHP_EOL, - trim($result['output']) + count($shown) < count($lines) ? sprintf('(last %d lines)%s', count($shown), PHP_EOL) : '', + implode(PHP_EOL, $shown) )); } return $result['output']; } + /** + * @return list + */ + private static function lines(string $output): array + { + $output = trim(str_replace("\r\n", "\n", $output)); + + return $output === '' ? [] : explode("\n", $output); + } + public function say(string $line): void { ($this->output)($line); diff --git a/packages/build/src/JsonConfig.php b/packages/build/src/JsonConfig.php index 72c40d8..944f274 100644 --- a/packages/build/src/JsonConfig.php +++ b/packages/build/src/JsonConfig.php @@ -47,6 +47,9 @@ public static function load(string $projectDirectory, string $file = 'wptoolkit. $type = $config['type'] ?? (is_file($projectDirectory . '/style.css') ? 'theme' : 'plugin'); $build = $type === 'theme' ? Build::theme($projectDirectory) : Build::plugin($projectDirectory); + if (!empty($config['verbose'])) { + $build->verbose(); + } if (isset($config['version'])) { $build->version(self::versionSource((string) $config['version'])); } diff --git a/packages/build/src/ProcessCommandRunner.php b/packages/build/src/ProcessCommandRunner.php index 2394337..d506cc0 100644 --- a/packages/build/src/ProcessCommandRunner.php +++ b/packages/build/src/ProcessCommandRunner.php @@ -8,12 +8,20 @@ namespace Codad5\WPToolkit\Build; +use Closure; + /** * Runs commands through the system shell with proc_open, capturing stdout and stderr together. */ -final class ProcessCommandRunner implements CommandRunner +final class ProcessCommandRunner implements StreamingCommandRunner { public function run(string $command, string $workingDirectory): array + { + return $this->stream($command, $workingDirectory, static function (string $line): void { + }); + } + + public function stream(string $command, string $workingDirectory, Closure $onLine): array { // stderr joins stdout in the shell, so one pipe carries both and can't deadlock. $process = proc_open($command . ' 2>&1', [1 => ['pipe', 'w']], $pipes, $workingDirectory); @@ -22,7 +30,11 @@ public function run(string $command, string $workingDirectory): array return ['exitCode' => 127, 'output' => "Could not start: {$command}"]; } - $output = (string) stream_get_contents($pipes[1]); + $output = ''; + while (($line = fgets($pipes[1])) !== false) { + $output .= $line; + $onLine(rtrim($line, "\r\n")); + } fclose($pipes[1]); return ['exitCode' => proc_close($process), 'output' => $output]; diff --git a/packages/build/src/Steps/RunCommands.php b/packages/build/src/Steps/RunCommands.php index c6bd978..d141656 100644 --- a/packages/build/src/Steps/RunCommands.php +++ b/packages/build/src/Steps/RunCommands.php @@ -20,11 +20,25 @@ final class RunCommands implements BuildStep /** @var list */ private readonly array $commands; + /** True: always print these commands' output as they run. Null: follow `Build::verbose()`. */ + private ?bool $stream = null; + public function __construct(string ...$commands) { $this->commands = array_values($commands); } + /** + * Commands whose output is printed as they run, whether or not the build is verbose. + */ + public static function streamed(string ...$commands): self + { + $step = new self(...$commands); + $step->stream = true; + + return $step; + } + public function name(): string { return 'run'; @@ -38,7 +52,7 @@ public function phase(): Phase public function run(BuildContext $context): void { foreach ($this->commands as $command) { - $context->exec($this->name(), $command, $context->project->root); + $context->exec($this->name(), $command, $context->project->root, $this->stream); } } } diff --git a/packages/build/src/StreamingCommandRunner.php b/packages/build/src/StreamingCommandRunner.php new file mode 100644 index 0000000..41d5753 --- /dev/null +++ b/packages/build/src/StreamingCommandRunner.php @@ -0,0 +1,25 @@ + */ + private array $lines = []; + + protected function setUp(): void + { + $this->project = sys_get_temp_dir() . '/wptoolkit-verbose-' . bin2hex(random_bytes(4)) . '/my-plugin'; + mkdir($this->project, 0777, true); + file_put_contents($this->project . '/my-plugin.php', "lines = []; + } + + protected function tearDown(): void + { + $root = dirname($this->project); + $items = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($root); + } + + public function test_command_output_is_hidden_by_default(): void + { + $this->build()->run($this->script('quiet', 'echo "from-the-command\n";'))->zip(); + + self::assertStringNotContainsString('from-the-command', implode("\n", $this->lines)); + } + + public function test_verbose_streams_every_commands_output_under_the_command(): void + { + $this->build()->verbose() + ->run($this->script('one', 'echo "alpha\nbeta\n";'), $this->script('two', 'fwrite(STDERR, "to-stderr\n");')) + ->zip(); + + $output = implode("\n", $this->lines); + self::assertStringContainsString(" | alpha\n | beta", $output); + self::assertStringContainsString(' | to-stderr', $output, 'stderr is streamed too'); + self::assertLessThan(strpos($output, '| alpha'), strpos($output, 'one.php'), 'the command line is printed before its output'); + } + + public function test_run_verbose_streams_only_that_command(): void + { + $this->build() + ->run($this->script('hidden', 'echo "hidden-output\n";')) + ->runVerbose($this->script('shown', 'echo "shown-output\n";')) + ->zip(); + + $output = implode("\n", $this->lines); + self::assertStringContainsString('| shown-output', $output); + self::assertStringNotContainsString('hidden-output', $output); + } + + public function test_output_arrives_line_by_line_and_is_still_returned_whole(): void + { + $seen = []; + $result = (new ProcessCommandRunner())->stream( + $this->script('lines', 'echo "one\n"; echo "two\n"; echo "three";'), + $this->project, + static function (string $line) use (&$seen): void { + $seen[] = $line; + } + ); + + self::assertSame(['one', 'two', 'three'], $seen); + self::assertSame(0, $result['exitCode']); + self::assertSame("one\ntwo\nthree", str_replace("\r\n", "\n", $result['output'])); + } + + public function test_a_failing_streamed_command_repeats_only_the_end_of_its_output(): void + { + $noisy = $this->script('noisy', 'for ($i = 1; $i <= 60; $i++) { printf("line-%02d\n", $i); } exit(3);'); + + try { + $this->build()->verbose()->run($noisy)->zip(); + self::fail('Expected the build to fail.'); + } catch (BuildException $e) { + self::assertStringContainsString('exited with 3', $e->getMessage()); + self::assertStringContainsString('line-60', $e->getMessage()); + self::assertStringNotContainsString('line-01', $e->getMessage(), 'already printed once while streaming'); + } + self::assertStringContainsString('| line-01', implode("\n", $this->lines)); + } + + public function test_a_failing_quiet_command_still_shows_all_of_its_output(): void + { + $noisy = $this->script('noisy', 'for ($i = 1; $i <= 60; $i++) { printf("line-%02d\n", $i); } exit(3);'); + + try { + $this->build()->run($noisy)->zip(); + self::fail('Expected the build to fail.'); + } catch (BuildException $e) { + self::assertStringContainsString('line-01', $e->getMessage()); + self::assertStringContainsString('line-60', $e->getMessage()); + } + } + + public function test_a_runner_that_cannot_stream_prints_the_output_when_the_command_ends(): void + { + $runner = new class implements CommandRunner { + public function run(string $command, string $workingDirectory): array + { + return ['exitCode' => 0, 'output' => "captured-one\ncaptured-two\n"]; + } + }; + + $this->build()->withRunner($runner)->verbose()->run('anything')->zip(); + + self::assertStringContainsString(" | captured-one\n | captured-two", implode("\n", $this->lines)); + } + + public function test_the_config_file_can_turn_it_on(): void + { + file_put_contents($this->project . '/wptoolkit.json', (string) json_encode([ + 'verbose' => true, + 'run' => [$this->script('configured', 'echo "from-config\n";')], + 'zip' => 'dist/out.zip', + ])); + + [$build, $zip] = JsonConfig::load($this->project); + $build->withOutput(function (string $line): void { + $this->lines[] = $line; + })->zip($zip); + + self::assertStringContainsString('| from-config', implode("\n", $this->lines)); + } + + private function build(): Build + { + return Build::plugin($this->project)->withOutput(function (string $line): void { + $this->lines[] = $line; + }); + } + + /** + * A command that runs a small PHP script: no shell quoting to get wrong on any platform. + */ + private function script(string $name, string $body): string + { + $file = dirname($this->project) . '/' . $name . '.php'; + file_put_contents($file, "