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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 20 additions & 6 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('<comment>' . $warning . '</comment>');
$diagnostics->writeln('<comment>' . $warning . '</comment>');
}

if ($output->isVerbose()) {
foreach ($this->discoveryNotices as $notice) {
$output->writeln('<comment>' . $notice . '</comment>');
$diagnostics->writeln('<comment>' . $notice . '</comment>');
}
}

// Show our header instead of Symfony's "AppName version" for list
if ($isList) {
if ($isReadableList) {
$output->writeln(\sprintf(
"\xF0\x9F\x9B\xB0 <fg=green;options=bold>Sputnik %s</> <fg=gray>│</> %s <fg=gray>│</> %s",
self::VERSION,
Expand All @@ -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);
}

Expand Down
68 changes: 68 additions & 0 deletions tests/E2E/SputnikBinaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down