diff --git a/docs/cli.md b/docs/cli.md
index fb109cf..5c07d44 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -44,6 +44,16 @@ Values are automatically coerced: `true`/`false` to bool, numeric strings to int
Available on both direct task commands (`sputnik deploy -D ...`) and the run command (`sputnik run deploy -D ...`).
+### `--format` on `list`
+
+`list` takes Symfony's `--format` (`txt`, `xml`, `json`, `md`) and `--raw`. For
+anything other than the default `txt`, Sputnik leaves the output alone: no
+header, no grouped task section, so the result is exactly what a parser expects.
+
+```bash
+sputnik list --format=json | jq '.commands[].name'
+```
+
### `-v` / `--verbose`
Show additional output including log messages and stack traces on errors.
@@ -152,6 +162,12 @@ built-in command - rename the task or give it a group prefix
The warning appears on every run, not only the one that filled the container
cache -- the task stays missing until someone renames it.
+It is written to **stderr**, along with every other diagnostic Sputnik emits
+about itself. That keeps stdout usable as data: `sputnik completion bash > file`
+writes only the script, and `sputnik list --format=json` parses. Redirect stderr
+if you want it gone -- `--silent` and `-q` are the wrong tool, they suppress the
+payload with it.
+
`init` is different: a project task may take it, and then the built-in scaffold
is no longer reachable. Scaffolding a project happens once, while a project
command called `init` may well be a daily one, so the project wins.
diff --git a/src/Console/Application.php b/src/Console/Application.php
index 25c2179..a19aa1f 100644
--- a/src/Console/Application.php
+++ b/src/Console/Application.php
@@ -8,6 +8,7 @@
use Sputnik\Task\TaskMetadata;
use Symfony\Component\Console\Application as BaseApplication;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class Application extends BaseApplication
@@ -70,20 +71,33 @@ public function doRun(InputInterface $input, OutputInterface $output): int
$commandName = $this->getCommandName($input);
$isList = $commandName === null || $commandName === 'list';
- // Reported here, not while the application is assembled: -v is parsed by
- // run(), so verbosity is not known any earlier.
+ // Anything else is a format meant for a machine - json, xml, md - or the
+ // raw list used to embed a command runner. Decoration would land in the
+ // middle of it.
+ $isReadableList = $isList
+ && $input->getParameterOption('--format', 'txt') === 'txt'
+ && !$input->hasParameterOption('--raw');
+
+ // Diagnostics go to stderr: `completion bash > file` and
+ // `list --format=json` both put stdout into something that parses it, and
+ // --silent cannot separate the two - it removes the payload as well.
+ //
+ // Reported here rather than while the application is assembled, because
+ // -v is parsed by run() and verbosity is not known any earlier.
+ $diagnostics = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
+
foreach ($this->discoveryWarnings as $warning) {
- $output->writeln('' . $warning . '');
+ $diagnostics->writeln('' . $warning . '');
}
if ($output->isVerbose()) {
foreach ($this->discoveryNotices as $notice) {
- $output->writeln('' . $notice . '');
+ $diagnostics->writeln('' . $notice . '');
}
}
// Show our header instead of Symfony's "AppName version" for list
- if ($isList) {
+ if ($isReadableList) {
$output->writeln(\sprintf(
"\xF0\x9F\x9B\xB0 Sputnik %s> │> %s │> %s",
self::VERSION,
@@ -95,7 +109,7 @@ public function doRun(InputInterface $input, OutputInterface $output): int
$result = parent::doRun($input, $output);
- if ($isList && $this->taskDiscovery instanceof TaskDiscovery) {
+ if ($isReadableList && $this->taskDiscovery instanceof TaskDiscovery) {
$this->renderTaskList($output);
}
diff --git a/tests/E2E/SputnikBinaryTest.php b/tests/E2E/SputnikBinaryTest.php
index f38d7dd..19139a3 100644
--- a/tests/E2E/SputnikBinaryTest.php
+++ b/tests/E2E/SputnikBinaryTest.php
@@ -874,6 +874,74 @@ public function testAMissingWorkingDirSaysSoInsteadOfFailingOnItsCache(): void
$this->assertStringNotContainsString('mkdir', $output, 'The cache is a symptom, not the problem');
}
+ public function testCompletionScriptIsNotPollutedByDiagnostics(): void
+ {
+ // The completion script is written to a file. A warning on stdout landed
+ // in it as line one, and --silent/-q cannot help: they suppress the
+ // script along with the warning.
+ $this->scaffoldProject([
+ 'list' => <<<'PHP'
+ #[Task(name: 'list', description: 'Collides with a built-in')]
+ final class ListTask implements TaskInterface
+ {
+ public function __invoke(TaskContext $ctx): TaskResult
+ {
+ return TaskResult::success();
+ }
+ }
+ PHP,
+ ]);
+
+ $result = $this->sputnik(['completion', 'bash'], $this->tempDir);
+
+ $this->assertStringStartsWith('#', ltrim($result->getOutput()));
+ $this->assertStringNotContainsString('Skipped task', $result->getOutput());
+ $this->assertStringContainsString('Skipped task', $result->getErrorOutput());
+ }
+
+ public function testListInAMachineReadableFormatIsParseable(): void
+ {
+ $this->scaffoldProject([
+ 'deploy' => <<<'PHP'
+ #[Task(name: 'deploy', description: 'Deploy it')]
+ final class DeployTask implements TaskInterface
+ {
+ public function __invoke(TaskContext $ctx): TaskResult
+ {
+ return TaskResult::success();
+ }
+ }
+ PHP,
+ ]);
+
+ $result = $this->sputnik(['list', '--format=json'], $this->tempDir);
+
+ $this->assertSame(0, $result->getExitCode());
+ $this->assertIsArray(json_decode($result->getOutput(), true), 'The header and the task section used to surround the JSON');
+ }
+
+ public function testTheDefaultListKeepsItsHeaderAndTaskSection(): void
+ {
+ $this->scaffoldProject([
+ 'deploy' => <<<'PHP'
+ #[Task(name: 'deploy', description: 'Deploy it')]
+ final class DeployTask implements TaskInterface
+ {
+ public function __invoke(TaskContext $ctx): TaskResult
+ {
+ return TaskResult::success();
+ }
+ }
+ PHP,
+ ]);
+
+ $output = $this->sputnik(['list'], $this->tempDir)->getOutput();
+
+ $this->assertStringContainsString('Sputnik', $output);
+ $this->assertStringContainsString('Available tasks', $output);
+ $this->assertStringContainsString('deploy', $output);
+ }
+
private function sputnik(array $args, ?string $cwd = null): Process
{
$process = new Process(