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
801 changes: 451 additions & 350 deletions src/Console/bin/bones

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Console/stubs/ajax.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Ajax;
namespace {Namespace}\Ajax{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/cpt.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\CustomPostTypes;
namespace {Namespace}\CustomPostTypes{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/ctt.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\CustomTaxonomyTypes;
namespace {Namespace}\CustomTaxonomyTypes{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/eloquent-model.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Models\{Path};
namespace {Namespace}\Models{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/provider.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Providers;
namespace {Namespace}\Providers{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/schedule.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Providers;
namespace {Namespace}\Providers{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/shortcode.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Shortcodes;
namespace {Namespace}\Shortcodes{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
2 changes: 1 addition & 1 deletion src/Console/stubs/widget.stub
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace {Namespace}\Widgets;
namespace {Namespace}\Widgets{Path};

if (! defined('ABSPATH')) {
exit;
Expand Down
174 changes: 174 additions & 0 deletions tests/Console/DeploySafetyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

declare(strict_types=1);

namespace WPKirk\WPBones\Tests\Console;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use WPKirk\WPBones\Tests\Support\BonesProcess;

/**
* Up to 2.0.9 `bones deploy <path>` deleted whatever <path> was, after a five-second pause
* and nothing else. `deploy ..` removed every plugin beside this one and the plugin itself;
* `deploy build` copied the plugin into its own subfolder until the path was too long.
* Both were reproduced before this was written (bones CLI audit, 2026-09-24).
*
* The destination is now refused when it is the plugin, one of its parents or inside it,
* whatever the flags, and an existing folder is replaced only when it is empty or holds a
* previous deploy of this plugin; anything else needs --force.
*/
#[Group('console')]
final class DeploySafetyTest extends TestCase
{
private BonesProcess $bones;

protected function setUp(): void
{
parent::setUp();

$this->bones = new BonesProcess();

file_put_contents($this->bones->root . '/SENTINEL.txt', 'keep me');
mkdir($this->bones->root . '/neighbour-plugin');
file_put_contents($this->bones->root . '/neighbour-plugin/main.php', "<?php // another plugin\n");
}

protected function tearDown(): void
{
$this->bones->remove();

parent::tearDown();
}

private function assertNothingWasDeleted(string $output): void
{
$this->assertFileExists($this->bones->root . '/SENTINEL.txt', "a file beside the plugin was deleted:\n" . $output);
$this->assertFileExists($this->bones->root . '/neighbour-plugin/main.php', "a neighbouring plugin was deleted:\n" . $output);
$this->assertFileExists($this->bones->plugin . '/wp-kirk.php', "the plugin deleted itself:\n" . $output);
}

public function test_the_parent_folder_is_refused(): void
{
$run = $this->bones->run(['deploy', '..', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertNothingWasDeleted($run['output']);
$this->assertStringContainsString('contains the plugin', $run['stderr']);
}

public function test_force_does_not_unlock_the_parent_folder(): void
{
$run = $this->bones->run(['deploy', '..', '--no-build', '--force']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertNothingWasDeleted($run['output']);
}

public function test_the_plugin_folder_itself_is_refused(): void
{
$run = $this->bones->run(['deploy', '.', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertNothingWasDeleted($run['output']);
$this->assertStringContainsString('is the plugin itself', $run['stderr']);
}

/**
* A symlink followed by "..": the filesystem resolves the link first, so the ".." climbs from
* wherever the link points. Collapsing ".." first (the first version of the guard) checked
* out/ while deleteDirectory() emptied the plugin (Codex review of #111).
*/
public function test_a_symlink_followed_by_dot_dot_cannot_reach_the_plugin(): void
{
mkdir($this->bones->plugin . '/sub');
mkdir($this->bones->root . '/out');
symlink($this->bones->plugin . '/sub', $this->bones->root . '/out/alias');

$run = $this->bones->run(['deploy', '../out/alias/..', '--no-build', '--force']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertNothingWasDeleted($run['output']);
$this->assertStringContainsString('is the plugin itself', $run['stderr']);
}

public function test_a_folder_inside_the_plugin_is_refused(): void
{
$run = $this->bones->run(['deploy', 'build', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertDirectoryDoesNotExist($this->bones->plugin . '/build', 'the plugin was copied into itself');
$this->assertStringContainsString('inside the plugin', $run['stderr']);
}

public function test_a_zip_whose_work_folder_would_sit_inside_the_plugin_is_refused(): void
{
$run = $this->bones->run(['deploy', 'package', '--create-zip', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertDirectoryDoesNotExist($this->bones->plugin . '/package-tmp');
}

public function test_an_unrelated_folder_is_kept(): void
{
mkdir($this->bones->root . '/out');
file_put_contents($this->bones->root . '/out/important.txt', 'not a deploy');

$run = $this->bones->run(['deploy', '../out', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertFileExists($this->bones->root . '/out/important.txt', $run['output']);
$this->assertStringContainsString('--force', $run['stderr']);
}

public function test_force_replaces_an_unrelated_folder(): void
{
mkdir($this->bones->root . '/out');
file_put_contents($this->bones->root . '/out/important.txt', 'not a deploy');

$run = $this->bones->run(['deploy', '../out', '--no-build', '--force']);

$this->assertSame(0, $run['status'], $run['output']);
$this->assertFileDoesNotExist($this->bones->root . '/out/important.txt');
$this->assertFileExists($this->bones->root . '/out/wp-kirk.php');
}

public function test_an_empty_folder_is_used(): void
{
mkdir($this->bones->root . '/out');

$run = $this->bones->run(['deploy', '../out', '--no-build']);

$this->assertSame(0, $run['status'], $run['output']);
$this->assertFileExists($this->bones->root . '/out/wp-kirk.php');
}

public function test_a_previous_deploy_of_this_plugin_is_replaced_without_a_pause(): void
{
$first = $this->bones->run(['deploy', '../out', '--no-build']);
$this->assertSame(0, $first['status'], $first['output']);

file_put_contents($this->bones->root . '/out/stale.txt', 'left by the previous deploy');

$started = microtime(true);
$second = $this->bones->run(['deploy', '../out', '--no-build']);
$elapsed = microtime(true) - $started;

$this->assertSame(0, $second['status'], $second['output']);
$this->assertFileDoesNotExist($this->bones->root . '/out/stale.txt');
$this->assertFileExists($this->bones->root . '/out/wp-kirk.php');
$this->assertLessThan(4.5, $elapsed, 'the deploy still waits before deleting the previous one');
$this->assertNothingWasDeleted($second['output']);
}

public function test_a_deploy_of_another_plugin_is_not_mistaken_for_ours(): void
{
mkdir($this->bones->root . '/out');
file_put_contents($this->bones->root . '/out/wp-kirk.php', "<?php\n/**\n * Plugin Name: Somebody Else\n */\n");

$run = $this->bones->run(['deploy', '../out', '--no-build']);

$this->assertNotSame(0, $run['status'], $run['output']);
$this->assertStringContainsString('Somebody Else', (string) file_get_contents($this->bones->root . '/out/wp-kirk.php'));
}
}
118 changes: 118 additions & 0 deletions tests/Console/ExitCodeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace WPKirk\WPBones\Tests\Console;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use WPKirk\WPBones\Tests\Support\BonesProcess;

/**
* Up to 2.0.9 almost every failure exited 0 (bones CLI audit, 2026-09-24): an unknown
* command, a missing class name, and a confirmation answered "n" — or not answered at all,
* which is what a script gets — all looked like success. `echo y |` in front of
* `php bones version` was the only way a release script could tell.
*
* Errors now exit 1 and are written to STDERR; a declined confirmation says so and exits 1.
*/
#[Group('console')]
final class ExitCodeTest extends TestCase
{
private BonesProcess $bones;

protected function setUp(): void
{
parent::setUp();

$this->bones = new BonesProcess();
}

protected function tearDown(): void
{
$this->bones->remove();

parent::tearDown();
}

public function test_an_unknown_command_fails_on_stderr(): void
{
$run = $this->bones->run(['no-such-command']);

$this->assertSame(1, $run['status'], $run['output']);
$this->assertStringContainsString("Unknown command 'no-such-command'", $run['stderr']);
$this->assertStringNotContainsString('Unknown command', $run['stdout']);
}

public function test_the_help_succeeds(): void
{
$run = $this->bones->run(['--help']);

$this->assertSame(0, $run['status'], $run['output']);
}

public function test_a_missing_class_name_fails(): void
{
$run = $this->bones->run(['make:controller']);

$this->assertSame(1, $run['status'], $run['output']);
$this->assertStringContainsString('ClassName is required', $run['stderr']);
}

public function test_a_declined_version_fails_and_changes_nothing(): void
{
$run = $this->bones->run(['version', '2.0.0'], "n\n");

$this->assertSame(1, $run['status'], $run['output']);
$this->assertStringContainsString('Aborted', $run['output']);
$this->assertStringContainsString('Stable tag: 1.0.0', (string) file_get_contents($this->bones->plugin . '/readme.txt'));
}

public function test_a_version_nobody_answered_fails(): void
{
$run = $this->bones->run(['version', '2.0.0'], '');

$this->assertSame(1, $run['status'], $run['output']);
$this->assertStringContainsString('Stable tag: 1.0.0', (string) file_get_contents($this->bones->plugin . '/readme.txt'));
}

public function test_a_declined_rename_fails_and_changes_nothing(): void
{
$run = $this->bones->run(['rename', 'My Plugin'], "n\n");

$this->assertSame(1, $run['status'], $run['output']);
$this->assertSame('WP Kirk,WPKirk', file_get_contents($this->bones->plugin . '/namespace'));
}

public function test_a_rename_nobody_answers_ends_instead_of_asking_forever(): void
{
$run = $this->bones->run(['rename'], '', 10);

$this->assertFalse($run['timedOut'], 'rename kept asking after its input ended');
$this->assertSame(1, $run['status'], $run['output']);
$this->assertSame('WP Kirk,WPKirk', file_get_contents($this->bones->plugin . '/namespace'));
}

public function test_optimize_reports_a_failed_composer(): void
{
// No composer.json in the fixture: `composer dump-autoload` fails.
$run = $this->bones->run(['optimize']);

$this->assertNotSame(0, $run['status'], $run['output']);
}

public function test_make_app_fails_on_a_missing_or_invalid_name(): void
{
$this->assertSame(1, $this->bones->run(['make:app'], '')['status']);
$this->assertSame(1, $this->bones->run(['make:app', 'Not Valid'])['status']);
$this->assertSame(1, $this->bones->run(['make:app', 'dashboard'])['status'], 'a reserved WordPress handle was accepted');
}

public function test_a_declined_migration_fails(): void
{
$run = $this->bones->run(['migrate:to-v2'], "n\n");

$this->assertSame(1, $run['status'], $run['output']);
$this->assertStringContainsString('aborted', $run['output']);
}
}
Loading
Loading