diff --git a/src/Console/bin/bones b/src/Console/bin/bones index 98b21c8..35c0dc1 100644 --- a/src/Console/bin/bones +++ b/src/Console/bin/bones @@ -466,6 +466,9 @@ namespace Bones\Traits { trait Console { + /** Set by ask() once stdin has ended: no answer will ever come. */ + protected bool $inputEnded = false; + /** * Commodity to display a message in the console with color. * @@ -501,12 +504,10 @@ namespace Bones\Traits { echo $newLine ? "\n" : ''; } - /* Commodity to display an error message in the console. */ + /* Commodity to display an error message in the console, on STDERR since 2.0.10. */ protected function error(string $str, $newLine = true) { - echo '❌ '; - $this->color($str, WPBONES_COLOR_BOLD_RED); - echo $newLine ? "\n" : ''; + fwrite(STDERR, '❌ ' . WPBONES_COLOR_BOLD_RED . $str . WPBONES_COLOR_RESET . ($newLine ? "\n" : '')); } /* Commodity to display an info message in the console. */ @@ -562,10 +563,18 @@ namespace Bones\Traits { // Use readline to get the user input $line = readline($str); + // false is the end of input (Ctrl-D, or a pipe that ran dry): the default answers, and the + // caller can tell that nobody will ever answer a question asked in a loop. + if ($line === false) { + $this->inputEnded = true; + echo "\n"; + } + // Trim the input to remove extra spaces or newlines - $line = trim($line); + $line = trim((string) $line); - return $line ?: $default; + // A null default used to come back as null from a function declared to return string. + return $line !== '' ? $line : (string) $default; } /** @@ -905,13 +914,242 @@ namespace Bones { $className = $this->ask('ClassName'); if (empty($className)) { $this->error('ClassName is required'); - exit(0); + exit(1); + } + } + + return $className; + } + + /** + * The first argument after the command that is not an option, e.g. the class name in + * `make:controller --force Shop/Cart`. + * + * @since 2.0.10 + */ + protected function getCommandArgument(): ?string + { + foreach ($this->getCommandParams() ?? [] as $param) { + if (strpos((string) $param, '--') !== 0) { + return $param; + } + } + + return null; + } + + /** + * Whether an option such as `--force` was passed after the command. + * + * @since 2.0.10 + */ + protected function hasOption(string $option): bool + { + return in_array($option, $this->getCommandParams() ?? [], true); + } + + /** + * Refuse a class name that is not a PHP class, or a path that climbs out of the folder. + * + * Up to 2.0.9 the name was used as it came: `../../Escaped` wrote outside the target + * folder and `my-model` wrote a file that does not parse. + * + * @since 2.0.10 + * @param string $className `Name`, or `Folder/Name` when $allowFolders is true. + * @param bool $allowFolders Whether `Folder/` segments are accepted. + */ + protected function validateClassName(string $className, bool $allowFolders = true): string + { + $segments = explode('/', $className); + + if (!$allowFolders && count($segments) > 1) { + $this->error("'{$className}': this command does not take a folder, only a class name."); + exit(1); + } + + foreach ($segments as $segment) { + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) { + $this->error( + "'{$className}' is not a valid class name: use letters, digits and underscores, not starting with a digit" . + ($allowFolders ? ', and "/" between folders.' : '.'), + ); + exit(1); } } return $className; } + /** + * Write a file generated by a `make:*` command, creating its folder. + * + * Up to 2.0.9 every generator overwrote an existing file without asking, and reported + * "Created" even when the write had failed. + * + * @since 2.0.10 + */ + protected function writeGeneratedFile(string $file, string $content): void + { + if (file_exists($file) && !$this->hasOption('--force')) { + $this->error("{$file} already exists, nothing was written. Pass --force to overwrite it."); + exit(1); + } + + $this->mkdirIfNotExists(dirname($file)); + + if (file_put_contents($file, $content) === false) { + $this->error("Could not write {$file}."); + exit(1); + } + + $this->line(" Created {$file}"); + } + + /** + * Run a shell command with its output shown as it happens, and return its exit status. + * + * Up to 2.0.9 these ran in backticks: nothing appeared until the command ended, the exit + * status was lost, and a command that wrote only to stderr handed line() a null, which is + * a TypeError (composer dump-autoload in a plugin without post-autoload-dump scripts). + * + * @since 2.0.10 + */ + protected function runShell(string $command): int + { + $status = 0; + passthru($command, $status); + + return $status; + } + + /** + * An absolute path with "." and ".." resolved, and symlinks followed for the part that + * already exists, so that two spellings of one folder compare equal. + * + * @since 2.0.10 + */ + protected function resolvePath(string $path): string + { + // On Windows "\foo" is the root of the current drive, not a path without one: give it the + // drive, or it compares as a folder that contains nothing (Codex review of #111). + if (PHP_OS_FAMILY === 'Windows' && preg_match('#^[\\\\/](?![\\\\/])#', $path)) { + $path = substr((string) getcwd(), 0, 2) . $path; + } + + if (!preg_match('#^([A-Za-z]:)?[\\\\/]#', $path)) { + $path = getcwd() . DIRECTORY_SEPARATOR . $path; + } + + $segments = preg_split('#[\\\\/]+#', $path, -1, PREG_SPLIT_NO_EMPTY); + $drive = isset($segments[0]) && preg_match('/^[A-Za-z]:$/', $segments[0]) ? strtoupper(array_shift($segments)) : ''; + + // One segment at a time, the way the filesystem does it: a symlink is followed BEFORE the + // ".." after it applies, so `out/alias/..` climbs from wherever alias points. Collapsing + // ".." first checked a different folder from the one deleteDirectory() would empty. + $current = $drive . DIRECTORY_SEPARATOR; + $exists = true; + + foreach ($segments as $segment) { + if ($segment === '.') { + continue; + } + + if ($segment === '..') { + $current = dirname($current); + continue; + } + + $next = rtrim($current, '\\/') . DIRECTORY_SEPARATOR . $segment; + + if ($exists && ($real = realpath($next)) !== false) { + $current = $real; + continue; + } + + // Past the part that exists there is no link left to follow. + $exists = false; + $current = $next; + } + + return $current; + } + + /** + * Refuse a deploy destination that would destroy something. + * + * Up to 2.0.9 an existing destination was deleted after a five-second pause and nothing + * else: `deploy ..` removed every plugin beside this one and the plugin itself, and + * `deploy build` copied the plugin into its own subfolder until the path was too long. + * + * The plugin, its parents and anything inside it are refused whatever the flags. An existing + * folder is replaced when it is empty or holds a previous deploy of this plugin (its main + * file, with this plugin's name); anything else needs --force. + * + * @since 2.0.10 + */ + protected function assertSafeDeployDestination(string $path, bool $force): void + { + $source = $this->resolvePath(__DIR__); + $target = $this->resolvePath($path); + $sep = DIRECTORY_SEPARATOR; + + // Windows paths are case-insensitive: C:\Plugin and c:\plugin are one folder. + if (PHP_OS_FAMILY === 'Windows') { + $source = strtolower($source); + $target = strtolower($target); + } + + if ($target === $source) { + $this->error("Deploy refused: '{$path}' is the plugin itself."); + exit(1); + } + + if (strpos($source . $sep, rtrim($target, '\\/') . $sep) === 0) { + $this->error("Deploy refused: '{$path}' contains the plugin, and deploying there would delete it."); + exit(1); + } + + if (strpos($target, $source . $sep) === 0) { + $this->error("Deploy refused: '{$path}' is inside the plugin, which would be copied into itself."); + exit(1); + } + + if ($force || !is_dir($target) || $this->isPreviousDeploy($target)) { + return; + } + + $this->error( + "Deploy refused: '{$path}' is not empty and is not a previous deploy of this plugin, so it was left alone. Pass --force to replace it.", + ); + exit(1); + } + + /** + * Whether a folder is empty, or holds a deploy of this plugin: its main file, declaring the + * same Plugin Name. + * + * @since 2.0.10 + */ + protected function isPreviousDeploy(string $folder): bool + { + if (count(scandir($folder) ?: []) <= 2) { + return true; + } + + $mainFile = $folder . DIRECTORY_SEPARATOR . $this->getMainPluginFile(); + + if (!is_file($mainFile)) { + return false; + } + + $ours = $this->extractPluginHeaderInfo('Plugin Name')['Plugin Name'] ?? null; + $theirs = preg_match('/^[ \t\/*#@]*Plugin Name:[ \t]*(.+)$/m', (string) file_get_contents($mainFile, false, null, 0, 8192), $matches) + ? trim($matches[1]) + : null; + + return $ours !== null && $ours === $theirs; + } + /** * Ask the user which package manager to use * @@ -1380,6 +1618,7 @@ namespace Bones { $this->line(' make:shortcode Create a new Shortcode service provider class'); $this->line(' make:provider Create a new service provider class'); $this->line(' make:widget Create a new Widget service provider class'); + $this->line(' (make:* never overwrite an existing file: pass --force to do it)'); if ($this->kernel && $this->kernel->hasCommands()) { $this->info('Extensions'); @@ -1398,7 +1637,7 @@ namespace Bones { } // optimize elseif ($this->isCommand('optimize')) { - $this->optimize(); + exit($this->optimize()); } // plugin elseif ($this->isCommand('plugin')) { @@ -1424,7 +1663,7 @@ namespace Bones { // // migrate:create {table_name} elseif ($this->isCommand('migrate:create')) { - $this->createMigrate($this->getCommandParams(0)); + $this->createMigrate($this->getCommandArgument()); } // migrate:to-v2 elseif ($this->isCommand('migrate:to-v2')) { @@ -1434,55 +1673,55 @@ namespace Bones { // // make:ajax {className} elseif ($this->isCommand('make:ajax')) { - $this->createAjax($this->getCommandParams(0)); + $this->createAjax($this->getCommandArgument()); } // make:api {className} elseif ($this->isCommand('make:api')) { - $this->createAPIController($this->getCommandParams(0)); + $this->createAPIController($this->getCommandArgument()); } // make:app {appName} elseif ($this->isCommand('make:app')) { - $this->createApp($this->getCommandParams(0)); + $this->createApp($this->getCommandArgument()); } // make:console {command_name} elseif ($this->isCommand('make:console')) { - $this->createCommand($this->getCommandParams(0)); + $this->createCommand($this->getCommandArgument()); } // make:controller {controller_name} elseif ($this->isCommand('make:controller')) { - $this->createController($this->getCommandParams(0)); + $this->createController($this->getCommandArgument()); } // make:cpt {className} elseif ($this->isCommand('make:cpt')) { - $this->createCustomPostType($this->getCommandParams(0)); + $this->createCustomPostType($this->getCommandArgument()); } // make:ctt {className} elseif ($this->isCommand('make:ctt')) { - $this->createCustomTaxonomyType($this->getCommandParams(0)); + $this->createCustomTaxonomyType($this->getCommandArgument()); } // make:eloquent-model {className} elseif ($this->isCommand('make:eloquent-model')) { - $this->createEloquentModel($this->getCommandParams(0)); + $this->createEloquentModel($this->getCommandArgument()); } // make:model {className} elseif ($this->isCommand('make:model')) { - $this->createModel($this->getCommandParams(0)); + $this->createModel($this->getCommandArgument()); } // make:schedule {className} elseif ($this->isCommand('make:schedule')) { - $this->createSchedule($this->getCommandParams(0)); + $this->createSchedule($this->getCommandArgument()); } // make:shortcode {className} elseif ($this->isCommand('make:shortcode')) { - $this->createShortcode($this->getCommandParams(0)); + $this->createShortcode($this->getCommandArgument()); } // make:provider {className} elseif ($this->isCommand('make:provider')) { - $this->createProvider($this->getCommandParams(0)); + $this->createProvider($this->getCommandArgument()); } // make:widget {className} elseif ($this->isCommand('make:widget')) { - $this->createWidget($this->getCommandParams(0)); + $this->createWidget($this->getCommandArgument()); } // -- kernel -------------------------------------------------- // @@ -1495,7 +1734,8 @@ namespace Bones { } if (!$extended) { - $this->info("\nUnknown command! Use --help for commands list\n"); + $this->error("Unknown command '{$this->arguments(0)}'. Use --help for the list of commands."); + exit(1); } } } @@ -1744,6 +1984,12 @@ namespace Bones { $plugin_name = $this->ask('Plugin name', $plugin_name); $namespace = $this->ask('Namespace', $namespace); + // With nothing left to read the loop would ask forever. + if (empty($plugin_name) && $this->inputEnded) { + $this->error('No plugin name given: nothing was renamed.'); + exit(1); + } + // both plugin name and namespace don't have to contains 'WP Kirk' or 'WPKirk' if (strpos($plugin_name, 'WP Kirk') !== false || strpos($plugin_name, 'WPKirk') !== false) { $this->warning('Plugin name cannot contain "WP Kirk" or "WPKirk"'); @@ -1857,7 +2103,7 @@ namespace Bones { $this->installPackages(); } - $this->line(`composer install`); + exit($this->runShell('composer install')); } /** @@ -1880,8 +2126,18 @@ namespace Bones { protected function optimize() { $this->startCommand('Optimize'); - $this->line(`composer dump-autoload -o`); + + $status = $this->runShell('composer dump-autoload -o'); + + if ($status !== 0) { + $this->warning("composer dump-autoload -o exited with status {$status}: run it by hand to see why."); + + return $status; + } + $this->processCompleted('Optimize process completed!'); + + return 0; } /** @@ -1938,7 +2194,8 @@ namespace Bones { ); $yesno = $this->ask('Continue (y/n)', 'n'); if (strtolower($yesno) != 'y') { - return; + $this->error('Aborted: the plugin was not renamed.'); + exit(1); } $this->setPluginNameAndNamespace($search_plugin_name, $search_namespace, $plugin_name, $namespace); $this->optimize(); @@ -1959,7 +2216,7 @@ namespace Bones { $this->deleteDirectory('vendor/wpbones/wpbones'); // update composer module - $this->line(`composer update`); + exit($this->runShell('composer update')); } /** @@ -2006,6 +2263,9 @@ namespace Bones { // Check if there is '--keep-dev' in the arguments array. $keep_dev = in_array('--keep-dev', $argv, true); + // Check if there is '--force' in the arguments array. + $force = in_array('--force', $argv, true); + // Check if there is '--pkgm=' in the arguments array, // and get the name. $package_name = $this->getOptionValue($argv, '--pkgm='); @@ -2018,6 +2278,7 @@ namespace Bones { '--no-build', '--keep-ignored', '--keep-dev', + '--force', '--pkgm=' . $package_name, ]); @@ -2045,6 +2306,9 @@ namespace Bones { $this->info( " --keep-dev\t\t\tPackages Composer's require-dev packages as well (they are removed by default).", ); + $this->info( + " --force\t\t\tReplaces a destination folder that is neither empty nor a previous deploy of this plugin.", + ); exit(0); } @@ -2055,6 +2319,9 @@ namespace Bones { exit(1); } + // Checked before anything is built: the folder the deploy is about to delete and refill. + $this->assertSafeDeployDestination($is_create_zip ? $path . '-tmp' : $path, $force); + // Alternative method to customize the deployment. It is included BEFORE the first hook fires: // until 2.0.6 wpbones_console_deploy_start ran first, so a deploy.php callback for it was // registered after the action and never ran. @@ -2113,7 +2380,6 @@ namespace Bones { // Check if the destination folder exists. if (is_dir($path)) { $this->startProgress("Delete destination folder 📁 {$path}"); - sleep(5); $this->deleteDirectory($path); $this->endProgress(); } @@ -2566,7 +2832,16 @@ namespace Bones { return in_array($single, $this->skipWhenDeploy); } - /* Start a Tinker emulation */ + /** + * Start a Tinker emulation: read a line, run it, print what it returns, repeat. + * + * Up to 2.0.9 each prompt was a recursive call from `finally`, so input that ended without + * `exit` recursed until the process died; only Exception was caught, so an Error (a typo, an + * undefined function) printed nothing at all; and the catch block ran eval() on the + * exception's message instead of printing it. + * + * @since 2.0.10 A loop that ends with `exit` or with its input, and reports every Throwable. + */ protected function tinker() { if ($this->isHelp()) { @@ -2579,26 +2854,46 @@ namespace Bones { $this->warning("Note: WordPress is not loaded! This means you can't use the WP functions.\n"); } - $eval = trim(readline(WPBONES_COLOR_BOLD_GREEN . '〉' . WPBONES_COLOR_LIGHT_GREEN), " \t\n\r\0\x0B"); + while (true) { + $input = readline(WPBONES_COLOR_BOLD_GREEN . '〉' . WPBONES_COLOR_LIGHT_GREEN); + echo WPBONES_COLOR_RESET; - try { - if ($eval == 'exit') { - exit(); + // Ctrl-D, or the end of piped input. + if ($input === false) { + echo "\n"; + + return; + } + + $eval = trim($input); + + if ($eval === 'exit') { + return; } - if (!empty($eval) && substr($eval, -1) != ';') { + if ($eval === '') { + continue; + } + + if (function_exists('readline_add_history')) { + readline_add_history($eval); + } + + if (substr($eval, -1) !== ';') { $eval .= ';'; } - if (!empty($eval)) { - echo WPBONES_COLOR_RESET; - $this->line(eval($eval)); + try { + $result = eval($eval); + + if ($result !== null) { + echo is_scalar($result) ? $result : print_r($result, true); + } + } catch (\Throwable $e) { + $this->error(get_class($e) . ': ' . $e->getMessage()); } - } catch (Exception $e) { - $this->info(eval($e->getMessage())); - } finally { - echo empty($eval) ? '' : "\n"; - $this->tinker(); + + echo "\n"; } } @@ -2625,7 +2920,12 @@ namespace Bones { exit(1); } - $this->line(`composer require {$package}`); + $status = $this->runShell('composer require ' . escapeshellarg($package)); + + if ($status !== 0) { + $this->error("composer require {$package} exited with status {$status}: nothing was renamed."); + exit($status); + } // rename as it is $this->rename(['--update']); @@ -2744,7 +3044,9 @@ namespace Bones { $yesno = $this->ask("The new version of your plugin will be {$version}, is it ok? (y/n)", 'n'); if (strtolower($yesno) != 'y') { - return; + // Also what a script gets when nothing answers: say so, and fail, so it cannot pass for a bump. + $this->error("Aborted: the version was not changed. In a script, answer the question: echo y | php bones version {$version}"); + exit(1); } if ($version != $version_number_from_index_php || $version != $version_number_from_readme_txt) { @@ -2907,30 +3209,36 @@ namespace Bones { /** * Create a migrate file * - * @param string $tablename + * @param string|null $tablename + * + * @since 2.0.10 Asks for a missing name instead of a TypeError, creates the folder. */ - protected function createMigrate(string $tablename) + protected function createMigrate(?string $tablename = '') { if ($this->isHelp($tablename)) { - $this->info('Use php bones migrate:make '); + $this->info('Use php bones migrate:create '); return; } - $filename = sprintf('%s_create_%s_table.php', date('Y_m_d_His'), strtolower($tablename)); + if (empty($tablename)) { + $tablename = $this->ask('Table name'); + } - // current plugin name and namespace - $namespace = $this->getNamespace(); + if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $tablename)) { + $this->error('A table name is required: letters, digits and underscores.'); + exit(1); + } + + $filename = sprintf('%s_create_%s_table.php', date('Y_m_d_His'), strtolower($tablename)); // stubbing $content = $this->prepareStub('migrate', [ - '{Namespace}' => $namespace, + '{Namespace}' => $this->getNamespace(), '{Tablename}' => $tablename, ]); - file_put_contents("database/migrations/{$filename}", $content); - - $this->line(" Created database/migrations/{$filename}"); + $this->writeGeneratedFile("database/migrations/{$filename}", $content); } /** @@ -2947,6 +3255,44 @@ namespace Bones { return str_replace(array_keys($replacements), array_values($replacements), $stub); } + /** + * The part every make:* command shares: validate the name, fill the stub with the plugin + * namespace, the class and the sub-namespace of its folder, and write it under $folder. + * + * `Shop/Cart` becomes `{$folder}/Shop/Cart.php`, class `Cart`, and `{Path}` = `\Shop`, so the + * namespace follows the folder (PSR-4). Up to 2.0.9 each command did this by hand, and they + * had drifted: make:provider kept `Shop/Cart` as the class name, and six commands wrote into + * a folder they never created. + * + * @since 2.0.10 + * @param string $className The name as typed, `Name` or `Folder/Name`. + * @param string $stub The stub, without extension. + * @param string $folder Where the class goes, relative to the plugin root. + * @param array $replacements Extra placeholders of that stub. + * + * @return string The class name without its folder. + */ + protected function generateClass(string $className, string $stub, string $folder, array $replacements = []): string + { + [$path, $namespacePath, $className] = $this->getPathFromAskedClass($this->validateClassName($className)); + + $content = $this->prepareStub( + $stub, + array_merge( + [ + '{Namespace}' => $this->getNamespace(), + '{ClassName}' => $className, + '{Path}' => $namespacePath, + ], + $replacements, + ), + ); + + $this->writeGeneratedFile("{$folder}/{$path}{$className}.php", $content); + + return $className; + } + /** * Create a controller * @@ -2962,36 +3308,7 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // get additional path - [$path, $namespacePath, $className] = $this->getPathFromAskedClass($className); - - // stubbing - $content = $this->prepareStub('controller', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Http/Controllers'); - - if (!empty($path)) { - $this->mkdirIfNotExists("plugin/Http/Controllers/{$path}"); - $content = str_replace('{Path}', $namespacePath, $content); - } else { - $content = str_replace('{Path}', '', $content); - } - - $filename = sprintf('%s.php', $className); - - file_put_contents("plugin/Http/Controllers/{$path}{$filename}", $content); - - $this->line(" Created plugin/Http/Controllers/{$path}{$filename}"); + $this->generateClass($this->askClassNameIfEmpty($className), 'controller', 'plugin/Http/Controllers'); $this->optimize(); } @@ -3009,10 +3326,8 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); + // The kernel loads plugin/Console/Commands/*.php, not subfolders. + $className = $this->validateClassName($this->askClassNameIfEmpty($className), false); // current plugin name and namespace [$pluginName, $namespace] = $this->getPluginNameAndNamespace(); @@ -3031,15 +3346,11 @@ namespace Bones { '{CommandName}' => $command, ]); - $this->mkdirIfNotExists('plugin/Console/Commands'); - - file_put_contents("plugin/Console/Commands/{$filename}", $content); - - $this->line(" Created plugin/Console/Commands/{$filename}"); + $this->writeGeneratedFile("plugin/Console/Commands/{$className}.php", $content); // check if plugin/Console/Kernel.php already exists if (file_exists('plugin/Console/Kernel.php')) { - $this->info("Remember to add {$className} in the plugin/Console/Commands/Kernel.php property array \$commands"); + $this->info("Remember to add {$className} in the \$commands array of plugin/Console/Kernel.php"); } else { // stubbing $content = $this->prepareStub('kernel', [ @@ -3047,9 +3358,7 @@ namespace Bones { '{ClassName}' => $className, ]); - file_put_contents('plugin/Console/Kernel.php', $content); - - $this->line(' Created plugin/Console/Kernel.php'); + $this->writeGeneratedFile('plugin/Console/Kernel.php', $content); } } @@ -3066,13 +3375,11 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); + // Validated before the questions, so a bad name is not refused after three answers. + $className = $this->validateClassName($this->askClassNameIfEmpty($className)); - // current plugin name and namespace - [$pluginName, $namespace] = $this->getPluginNameAndNamespace(); + // current plugin name + [$pluginName] = $this->getPluginNameAndNamespace(); $slug = str_replace('-', '_', $this->sanitize($pluginName)); @@ -3084,23 +3391,13 @@ namespace Bones { $id = $slug; } - // stubbing - $content = $this->prepareStub('cpt', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, + $class = $this->generateClass($className, 'cpt', 'plugin/CustomPostTypes', [ '{ID}' => $id, '{Name}' => $name, '{Plural}' => $plural, ]); - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/CustomPostTypes'); - - file_put_contents("plugin/CustomPostTypes/{$filename}", $content); - - $this->line(" Created plugin/CustomPostTypes/{$filename}"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'custom_post_types' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'custom_post_types' key."); } /** @@ -3116,28 +3413,9 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // stubbing - $content = $this->prepareStub('shortcode', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Shortcodes'); - - file_put_contents("plugin/Shortcodes/{$filename}", $content); + $class = $this->generateClass($this->askClassNameIfEmpty($className), 'shortcode', 'plugin/Shortcodes'); - $this->line(" Created plugin/Shortcodes/{$filename}"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'shortcodes' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'shortcodes' key."); } /** @@ -3155,28 +3433,9 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); + $class = $this->generateClass($this->askClassNameIfEmpty($className), 'schedule', 'plugin/Providers'); - // stubbing - $content = $this->prepareStub('schedule', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Providers'); - - file_put_contents("plugin/Providers/{$filename}", $content); - - $this->line(" Created plugin/Providers/{$filename}"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'providers' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'providers' key."); } /** @@ -3192,36 +3451,7 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // get additional path - [$path, $namespacePath] = $this->getPathFromAskedClass($className); - - // stubbing - $content = $this->prepareStub('provider', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Providers'); - - if (!empty($path)) { - $this->mkdirIfNotExists("plugin/Providers/{$path}"); - $content = str_replace('{Path}', $namespacePath, $content); - } else { - $content = str_replace('{Path}', '', $content); - } - - $filename = sprintf('%s.php', $className); - - file_put_contents("plugin/Providers/{$path}{$filename}", $content); - - $this->line(" Created plugin/Providers/{$path}{$filename}"); + $this->generateClass($this->askClassNameIfEmpty($className), 'provider', 'plugin/Providers'); $this->optimize(); } @@ -3239,28 +3469,9 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // stubbing - $content = $this->prepareStub('ajax', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Ajax'); - - $filename = sprintf('%s.php', $className); + $class = $this->generateClass($this->askClassNameIfEmpty($className), 'ajax', 'plugin/Ajax'); - file_put_contents("plugin/Ajax/{$filename}", $content); - - $this->line(" Created plugin/Ajax/{$filename}"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'ajax' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'ajax' key."); } /** @@ -3276,13 +3487,8 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); + // Validated before the questions, so a bad name is not refused after four answers. + $className = $this->validateClassName($this->askClassNameIfEmpty($className)); $slug = $this->getPluginId(); @@ -3298,24 +3504,14 @@ namespace Bones { $id = $slug; } - // stubbing - $content = $this->prepareStub('ctt', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, + $class = $this->generateClass($className, 'ctt', 'plugin/CustomTaxonomyTypes', [ '{ID}' => $id, '{Name}' => $name, '{Plural}' => $plural, '{ObjectType}' => $objectType, ]); - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/CustomTaxonomyTypes'); - - file_put_contents("plugin/CustomTaxonomyTypes/{$filename}", $content); - - $this->line(" Created plugin/CustomTaxonomyTypes/{$filename}"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'custom_taxonomy_types' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'custom_taxonomy_types' key."); } /** @@ -3331,42 +3527,33 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - $filename = sprintf('%s.php', $className); - - // current plugin name and namespace - [$pluginName, $namespace] = $this->getPluginNameAndNamespace(); + // current plugin name + [$pluginName] = $this->getPluginNameAndNamespace(); $slug = $this->getPluginId(); - // stubbing - $content = $this->prepareStub('widget', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, + $class = $this->generateClass($this->askClassNameIfEmpty($className), 'widget', 'plugin/Widgets', [ '{PluginName}' => $pluginName, '{Slug}' => $slug, ]); - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Widgets'); - - file_put_contents("plugin/Widgets/{$filename}", $content); + // The views are named after the plugin, not the widget: a second widget finds them in place + // and must not overwrite what the developer wrote in them. + $views = [ + "resources/views/widgets/{$slug}-form.php" => '

Backend form

', + "resources/views/widgets/{$slug}-index.php" => '

Frontend Widget output

', + ]; - $this->line(" Created plugin/Widgets/{$filename}"); + foreach ($views as $view => $html) { + if (file_exists($view)) { + $this->line(" Kept {$view} (shared by every widget of the plugin: --force does not overwrite it)"); + continue; + } - if (!is_dir('resources/views/widgets')) { - mkdir('resources/views/widgets', 0777, true); + $this->writeGeneratedFile($view, $html); } - file_put_contents("resources/views/widgets/{$slug}-form.php", '

Backend form

'); - file_put_contents("resources/views/widgets/{$slug}-index.php", '

Frontend Widget output

'); - - $this->line(" Created resources/views/widgets/{$slug}-form.php"); - $this->line(" Created resources/views/widgets/{$slug}-index.php"); - - $this->info("Remember to add {$className} in the config/plugin.php array in the 'widgets' key."); + $this->info("Remember to add {$class} in the config/plugin.php array in the 'widgets' key."); } /** @@ -3382,36 +3569,7 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // get additional path - [$path, $namespacePath, $className] = $this->getPathFromAskedClass($className); - - // stubbing - $content = $this->prepareStub('model', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Models'); - - if (!empty($path)) { - $this->mkdirIfNotExists("plugin/Models/{$path}"); - $content = str_replace('{Path}', $namespacePath, $content); - } else { - $content = str_replace('{Path}', '', $content); - } - - $filename = sprintf('%s.php', $className); - - file_put_contents("plugin/Models/{$path}{$filename}", $content); - - $this->line(" Created plugin/Models/{$path}{$filename}"); + $this->generateClass($this->askClassNameIfEmpty($className), 'model', 'plugin/Models'); $this->optimize(); } @@ -3429,40 +3587,12 @@ namespace Bones { return; } - // ask className if empty $className = $this->askClassNameIfEmpty($className); - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // get additional path - [$path, $namespacePath, $className] = $this->getPathFromAskedClass($className); - - // create the table - $table = strtolower($className); - - // stubbing - $content = $this->prepareStub('eloquent-model', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - '{Table}' => $table, - ]); + // The table is named after the class, not its folder: Shop/Book → "book". + $table = strtolower(basename($className)); - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/Models'); - - if (!empty($path)) { - $this->mkdirIfNotExists("plugin/Models/{$path}"); - $content = str_replace('{Path}', $namespacePath, $content); - } else { - $content = str_replace('{Path}', '', $content); - } - - $filename = sprintf('%s.php', $className); - - file_put_contents("plugin/Models/{$path}{$filename}", $content); - - $this->line(" Created plugin/Models/{$path}{$filename}"); + $this->generateClass($className, 'eloquent-model', 'plugin/Models', ['{Table}' => $table]); $this->optimize(); } @@ -3480,36 +3610,7 @@ namespace Bones { return; } - // ask className if empty - $className = $this->askClassNameIfEmpty($className); - - // current plugin name and namespace - $namespace = $this->getNamespace(); - - // get additional path - [$path, $namespacePath, $className] = $this->getPathFromAskedClass($className); - - // stubbing - $content = $this->prepareStub('api', [ - '{Namespace}' => $namespace, - '{ClassName}' => $className, - ]); - - // Create the folder if it doesn't exist - $this->mkdirIfNotExists('plugin/API'); - - if (!empty($path)) { - $this->mkdirIfNotExists("plugin/API/{$path}"); - $content = str_replace('{Path}', $namespacePath, $content); - } else { - $content = str_replace('{Path}', '', $content); - } - - $filename = sprintf('%s.php', $className); - - file_put_contents("plugin/API/{$path}{$filename}", $content); - - $this->line(" Created plugin/API/{$path}{$filename}"); + $this->generateClass($this->askClassNameIfEmpty($className), 'api', 'plugin/API'); $this->optimize(); } @@ -3541,9 +3642,9 @@ namespace Bones { $appName = $this->ask('App name (lowercase, e.g. "dashboard")'); } - if (!preg_match('/^[a-z][a-z0-9-]*$/', $appName)) { + if (!preg_match('/^[a-z][a-z0-9-]*$/', (string) $appName)) { $this->error('Invalid app name. Use lowercase letters, digits, and dashes (must start with a letter).'); - return; + exit(1); } // Names that collide with scripts WordPress core registers. If we let @@ -3562,7 +3663,7 @@ namespace Bones { if (in_array($appName, $reservedHandles, true)) { $this->error("'{$appName}' collides with a reserved WordPress script handle — wp_enqueue_script would silently drop your bundle."); $this->line(" Pick a different name, e.g. {$appName}-app, my-{$appName}, or a plugin-specific prefix."); - return; + exit(1); } $flat = in_array('--flat', $this->arguments(), true); @@ -3586,7 +3687,7 @@ namespace Bones { $filepath = "resources/assets/apps/{$appName}.tsx"; if (file_exists($filepath)) { $this->error("File already exists: {$filepath}"); - return; + exit(1); } file_put_contents($filepath, $content); $this->line(" Created {$filepath}"); @@ -3594,7 +3695,7 @@ namespace Bones { $folder = "resources/assets/apps/{$appName}"; if (file_exists($folder)) { $this->error("Folder already exists: {$folder}"); - return; + exit(1); } mkdir($folder, 0755, true); $filepath = "{$folder}/index.tsx"; @@ -3649,8 +3750,8 @@ namespace Bones { $answer = $this->ask('Continue? (y/N)'); if (strtolower(trim($answer)) !== 'y') { - $this->line('Migration aborted.'); - return; + $this->error('Migration aborted: nothing was changed.'); + exit(1); } // Detect the developer's PM *before* we delete their lockfile, so the diff --git a/src/Console/stubs/ajax.stub b/src/Console/stubs/ajax.stub index ca4b273..9bffe76 100644 --- a/src/Console/stubs/ajax.stub +++ b/src/Console/stubs/ajax.stub @@ -1,6 +1,6 @@ ` deleted whatever 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', "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', "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')); + } +} diff --git a/tests/Console/ExitCodeTest.php b/tests/Console/ExitCodeTest.php new file mode 100644 index 0000000..d90fa3f --- /dev/null +++ b/tests/Console/ExitCodeTest.php @@ -0,0 +1,118 @@ +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']); + } +} diff --git a/tests/Console/MakeCommandsTest.php b/tests/Console/MakeCommandsTest.php new file mode 100644 index 0000000..9c54505 --- /dev/null +++ b/tests/Console/MakeCommandsTest.php @@ -0,0 +1,178 @@ +bones = (new BonesProcess())->withStubs(); + } + + protected function tearDown(): void + { + $this->bones->remove(); + + parent::tearDown(); + } + + private function assertParses(string $file): void + { + $output = []; + $status = 0; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $status); + + $this->assertSame(0, $status, "{$file} does not parse:\n" . implode("\n", $output)); + } + + /** + * @return array + */ + public static function generators(): array + { + return [ + 'controller in a folder' => [['make:controller', 'Shop/Cart'], '', 'plugin/Http/Controllers/Shop/Cart.php', 'WPKirk\Http\Controllers\Shop'], + 'model in a folder' => [['make:model', 'Shop/Item'], '', 'plugin/Models/Shop/Item.php', 'WPKirk\Models\Shop'], + 'eloquent model' => [['make:eloquent-model', 'Book'], '', 'plugin/Models/Book.php', 'WPKirk\Models'], + 'eloquent model in a folder' => [['make:eloquent-model', 'Shop/Book'], '', 'plugin/Models/Shop/Book.php', 'WPKirk\Models\Shop'], + 'api controller in a folder' => [['make:api', 'Shop/Orders'], '', 'plugin/API/Shop/Orders.php', 'WPKirk\API\Shop'], + 'provider' => [['make:provider', 'Billing'], '', 'plugin/Providers/Billing.php', 'WPKirk\Providers'], + 'provider in a folder' => [['make:provider', 'Shop/Billing'], '', 'plugin/Providers/Shop/Billing.php', 'WPKirk\Providers\Shop'], + 'ajax in a folder' => [['make:ajax', 'Shop/Search'], '', 'plugin/Ajax/Shop/Search.php', 'WPKirk\Ajax\Shop'], + 'schedule' => [['make:schedule', 'Nightly'], '', 'plugin/Providers/Nightly.php', 'WPKirk\Providers'], + 'shortcode in a folder' => [['make:shortcode', 'Shop/Badge'], '', 'plugin/Shortcodes/Shop/Badge.php', 'WPKirk\Shortcodes\Shop'], + 'widget in a folder' => [['make:widget', 'Shop/Latest'], '', 'plugin/Widgets/Shop/Latest.php', 'WPKirk\Widgets\Shop'], + 'custom post type' => [['make:cpt', 'Book'], "book\nBook\nBooks\n", 'plugin/CustomPostTypes/Book.php', 'WPKirk\CustomPostTypes'], + 'custom taxonomy' => [['make:ctt', 'Genre'], "genre\nGenre\nGenres\nbook\n", 'plugin/CustomTaxonomyTypes/Genre.php', 'WPKirk\CustomTaxonomyTypes'], + 'console command' => [['make:console', 'Hello'], "\n\n", 'plugin/Console/Commands/Hello.php', 'WPKirk\Console\Commands'], + ]; + } + + /** + * @param string[] $arguments + */ + #[DataProvider('generators')] + public function test_every_generator_writes_a_file_that_parses(array $arguments, string $stdin, string $file, string $namespace): void + { + $run = $this->bones->run($arguments, $stdin); + $path = $this->bones->plugin . '/' . $file; + + $this->assertSame(0, $run['status'], $run['output']); + $this->assertFileExists($path, $run['output']); + $this->assertParses($path); + + $content = (string) file_get_contents($path); + $class = basename($file, '.php'); + + $this->assertStringContainsString("namespace {$namespace};", $content); + $this->assertMatchesRegularExpression("/^\\s*(final\\s+)?class\\s+{$class}\\b/m", $content); + $this->assertStringContainsString("Created {$file}", $run['stdout']); + } + + public function test_an_existing_file_is_not_overwritten(): void + { + mkdir($this->bones->plugin . '/plugin/Http/Controllers', 0777, true); + file_put_contents($this->bones->plugin . '/plugin/Http/Controllers/Probe.php', 'bones->run(['make:controller', 'Probe']); + + $this->assertNotSame(0, $run['status'], $run['output']); + $this->assertSame('bones->plugin . '/plugin/Http/Controllers/Probe.php')); + $this->assertStringContainsString('--force', $run['stderr']); + $this->assertStringNotContainsString('Created', $run['stdout']); + } + + public function test_force_overwrites_an_existing_file_wherever_it_is_written(): void + { + mkdir($this->bones->plugin . '/plugin/Http/Controllers', 0777, true); + file_put_contents($this->bones->plugin . '/plugin/Http/Controllers/Probe.php', 'bones->run(['make:controller', '--force', 'Probe']); + + $this->assertSame(0, $run['status'], $run['output']); + $this->assertStringContainsString('class Probe', (string) file_get_contents($this->bones->plugin . '/plugin/Http/Controllers/Probe.php')); + } + + public function test_a_name_that_climbs_out_of_the_folder_is_refused(): void + { + $run = $this->bones->run(['make:controller', '../../Escaped']); + + $this->assertNotSame(0, $run['status'], $run['output']); + $this->assertFileDoesNotExist($this->bones->plugin . '/plugin/Escaped.php'); + $this->assertFileDoesNotExist($this->bones->plugin . '/Escaped.php'); + } + + public function test_a_name_that_is_not_a_php_class_is_refused(): void + { + $run = $this->bones->run(['make:model', 'my-model']); + + $this->assertNotSame(0, $run['status'], $run['output']); + $this->assertSame([], glob($this->bones->plugin . '/plugin/Models/*') ?: []); + } + + public function test_a_console_command_cannot_go_in_a_folder_the_kernel_does_not_read(): void + { + $run = $this->bones->run(['make:console', 'Shop/Hello'], "\n\n"); + + $this->assertNotSame(0, $run['status'], $run['output']); + $this->assertFileDoesNotExist($this->bones->plugin . '/plugin/Console/Commands/Shop/Hello.php'); + } + + public function test_a_second_widget_keeps_the_views_the_first_one_wrote(): void + { + $this->assertSame(0, $this->bones->run(['make:widget', 'First'])['status']); + + $form = $this->bones->plugin . '/resources/views/widgets/wp-kirk-form.php'; + $this->assertFileExists($form); + file_put_contents($form, '

edited

'); + + $run = $this->bones->run(['make:widget', 'Second']); + + $this->assertSame(0, $run['status'], $run['output']); + $this->assertSame('

edited

', file_get_contents($form)); + } + + public function test_migrate_create_without_a_name_fails_cleanly(): void + { + $run = $this->bones->run(['migrate:create']); + + $this->assertNotSame(0, $run['status'], $run['output']); + $this->assertStringNotContainsString('TypeError', $run['output']); + } + + public function test_migrate_create_writes_its_folder_and_a_file_that_parses(): void + { + $run = $this->bones->run(['migrate:create', 'books']); + + $this->assertSame(0, $run['status'], $run['output']); + + $files = glob($this->bones->plugin . '/database/migrations/*_create_books_table.php') ?: []; + $this->assertCount(1, $files, $run['output']); + $this->assertParses($files[0]); + } +} diff --git a/tests/Console/TinkerTest.php b/tests/Console/TinkerTest.php new file mode 100644 index 0000000..36f9ca7 --- /dev/null +++ b/tests/Console/TinkerTest.php @@ -0,0 +1,78 @@ +bones = new BonesProcess(); + } + + protected function tearDown(): void + { + $this->bones->remove(); + + parent::tearDown(); + } + + public function test_an_error_is_shown(): void + { + $run = $this->bones->run(['tinker'], "undefined_fn_xyz();\nexit\n"); + + $this->assertFalse($run['timedOut'], $run['output']); + $this->assertStringContainsString('Call to undefined function undefined_fn_xyz()', $run['output']); + } + + public function test_an_exception_message_is_printed_not_evaluated(): void + { + $run = $this->bones->run(['tinker'], "throw new \\Exception('print 6*7;');\nexit\n"); + + $this->assertStringContainsString('print 6*7;', $run['output']); + $this->assertStringNotContainsString('42', $run['output'], 'the exception message was run as code'); + } + + public function test_the_session_ends_with_its_input(): void + { + $run = $this->bones->run(['tinker'], "print 1+1;\n", 10); + + $this->assertFalse($run['timedOut'], 'tinker kept waiting after its input ended'); + $this->assertSame(0, $run['status'], $run['output']); + $this->assertStringContainsString('2', $run['stdout']); + } + + public function test_a_returned_value_is_printed(): void + { + $run = $this->bones->run(['tinker'], "return 6*7;\nexit\n"); + + $this->assertSame(0, $run['status'], $run['output']); + $this->assertStringContainsString('42', $run['stdout']); + } + + public function test_a_session_keeps_going_after_an_error(): void + { + $run = $this->bones->run(['tinker'], "undefined_fn_xyz();\nprint 'still here';\nexit\n"); + + $this->assertStringContainsString('still here', $run['stdout']); + } +} diff --git a/tests/Support/BonesProcess.php b/tests/Support/BonesProcess.php new file mode 100644 index 0000000..1d771ce --- /dev/null +++ b/tests/Support/BonesProcess.php @@ -0,0 +1,144 @@ +root = sys_get_temp_dir() . '/wpbones-bones-' . bin2hex(random_bytes(6)); + $this->plugin = $this->root . '/fixture-plugin'; + + mkdir($this->plugin, 0777, true); + + copy(self::source() . '/src/Console/bin/bones', $this->plugin . '/bones'); + file_put_contents($this->plugin . '/namespace', 'WP Kirk,WPKirk'); + file_put_contents($this->plugin . '/wp-kirk.php', "plugin . '/readme.txt', "=== WP Kirk ===\nStable tag: 1.0.0\n"); + } + + /** Put the framework's stubs where the `make:*` commands read them. */ + public function withStubs(): self + { + $from = self::source() . '/src/Console/stubs'; + $to = $this->plugin . '/vendor/wpbones/wpbones/src/Console/stubs'; + + mkdir($to, 0777, true); + + foreach (glob($from . '/*.stub') ?: [] as $stub) { + copy($stub, $to . '/' . basename($stub)); + } + + return $this; + } + + /** + * @param string[] $arguments What follows `php bones`. + * + * @return array{status:int, stdout:string, stderr:string, output:string, timedOut:bool} + */ + public function run(array $arguments, string $stdin = '', int $timeout = 20): array + { + $command = escapeshellarg(PHP_BINARY) . ' bones ' . implode(' ', array_map('escapeshellarg', $arguments)); + + $process = proc_open( + $command, + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $this->plugin, + // proc_open inherits the parent environment, so PWD would still name the test + // runner's directory, and that is what the CLI reads to locate WordPress. + ['PWD' => $this->plugin, 'PATH' => getenv('PATH') ?: '/usr/bin:/bin', 'HOME' => getenv('HOME') ?: '/tmp'] + ); + + fwrite($pipes[0], $stdin); + fclose($pipes[0]); + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $timedOut = false; + $deadline = microtime(true) + $timeout; + + do { + $stdout .= (string) stream_get_contents($pipes[1]); + $stderr .= (string) stream_get_contents($pipes[2]); + $state = proc_get_status($process); + + if ($state['running'] && microtime(true) > $deadline) { + proc_terminate($process, 9); + $timedOut = true; + break; + } + + if ($state['running']) { + usleep(20000); + } + } while ($state['running']); + + $stdout .= (string) stream_get_contents($pipes[1]); + $stderr .= (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $closed = proc_close($process); + + // proc_get_status() reports the exit code once, on the call that first sees the + // process gone; proc_close() then answers -1. + $status = $timedOut ? -1 : ($state['running'] ? $closed : $state['exitcode']); + + return [ + 'status' => $status, + 'stdout' => $stdout, + 'stderr' => $stderr, + 'output' => $stdout . $stderr, + 'timedOut' => $timedOut, + ]; + } + + /** Delete everything the fixture created, including what a failed run left behind. */ + public function remove(): void + { + if (!is_dir($this->root)) { + return; + } + + $entries = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($entries as $entry) { + $entry->isDir() && !$entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + + rmdir($this->root); + } + + private static function source(): string + { + return dirname(__DIR__, 2); + } +}