diff --git a/features/scaffold-plugin-tests.feature b/features/scaffold-plugin-tests.feature index 7ccbab4e..020c9ffa 100644 --- a/features/scaffold-plugin-tests.feature +++ b/features/scaffold-plugin-tests.feature @@ -317,3 +317,41 @@ Feature: Scaffold plugin unit tests And the {PLUGIN_DIR}/hello-world/readme.txt file should exist And the {PLUGIN_DIR}/hello-world/bitbucket-pipelines.yml file should exist And the {PLUGIN_DIR}/hello-world/tests directory should exist + + Scenario: Scaffold plugin tests without WordPress loaded + Given an empty directory + And WP files + + When I run `mkdir -p my-plugin` + When I run `wp scaffold plugin-tests my-plugin --dir=my-plugin` + Then STDOUT should not be empty + And the my-plugin/tests directory should contain: + """ + bootstrap.php + test-sample.php + """ + And the my-plugin/tests/bootstrap.php file should contain: + """ + require dirname( __DIR__ ) . '/my-plugin.php'; + """ + And the my-plugin/tests/bootstrap.php file should contain: + """ + * @package My_Plugin + """ + And the my-plugin/tests/test-sample.php file should contain: + """ + * @package My_Plugin + """ + And the my-plugin/bin directory should contain: + """ + install-wp-tests.sh + """ + And the my-plugin/phpunit.xml.dist file should contain: + """ + ./tests/test-sample.php + """ + And the my-plugin/.phpcs.xml.dist file should exist + And the my-plugin/.circleci/config.yml file should contain: + """ + workflows: + """ diff --git a/features/scaffold.feature b/features/scaffold.feature index 272e625b..d56558c0 100644 --- a/features/scaffold.feature +++ b/features/scaffold.feature @@ -528,3 +528,21 @@ Feature: WordPress code scaffolding Error: Invalid plugin slug specified. """ And the return code should be 1 + + Scenario: Scaffold plugin without WordPress loaded + Given an empty directory + And WP files + + When I run `wp scaffold plugin my-plugin --dir=.` + Then STDOUT should not be empty + And the my-plugin/my-plugin.php file should exist + And the my-plugin/readme.txt file should exist + And the my-plugin/.editorconfig file should exist + And the my-plugin/my-plugin.php file should contain: + """ + * Plugin Name: My Plugin + """ + And the my-plugin/my-plugin.php file should contain: + """ + * Tested up to: 6.4 + """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index cf7010ea..d38c6a6b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -54,6 +54,7 @@ */src/Scaffold_Command\.php$ + */src/Scaffold_Filesystem_Fallback\.php$ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 20dfba73..e9ed6071 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,3 +11,5 @@ parameters: ignoreErrors: - identifier: missingType.parameter - identifier: missingType.return + # Pre-existing issues with array access in string interpolation + - '#Part \$data\[.+\] \(mixed\) of encapsed string cannot be cast to string\.#' diff --git a/src/Scaffold_Command.php b/src/Scaffold_Command.php index 6ff95389..665e7cdf 100644 --- a/src/Scaffold_Command.php +++ b/src/Scaffold_Command.php @@ -25,6 +25,12 @@ */ class Scaffold_Command extends WP_CLI_Command { + /** + * Fallback WordPress version to use when WordPress is not loaded. + * This should be updated periodically to reflect a recent stable version. + */ + const FALLBACK_WP_VERSION = '6.4'; + /** * Generates PHP code for registering a custom post type. * @@ -364,7 +370,7 @@ public function block( $args, $assoc_args ) { public function underscores( $args, $assoc_args ) { $theme_slug = $args[0]; - $theme_path = WP_CONTENT_DIR . '/themes'; + $theme_path = $this->get_content_dir() . '/themes'; $url = 'https://underscores.me'; $timeout = 30; @@ -377,7 +383,7 @@ public function underscores( $args, $assoc_args ) { 'author' => 'Me', 'author_uri' => '', ]; - $data = wp_parse_args( $assoc_args, $defaults ); + $data = $this->parse_args( $assoc_args, $defaults ); $_s_theme_path = "$theme_path/$data[theme_name]"; @@ -510,12 +516,12 @@ public function child_theme( $args, $assoc_args ) { 'theme_uri' => '', ]; - $data = wp_parse_args( $assoc_args, $defaults ); + $data = $this->parse_args( $assoc_args, $defaults ); $data['slug'] = $theme_slug; $data['prefix_safe'] = str_replace( [ ' ', '-' ], '_', $theme_slug ); $data['description'] = ucfirst( $data['parent_theme'] ) . ' child theme.'; - $theme_dir = WP_CONTENT_DIR . "/themes/{$theme_slug}"; + $theme_dir = $this->get_content_dir() . "/themes/{$theme_slug}"; $error_msg = $this->check_target_directory( 'theme', $theme_dir ); if ( ! empty( $error_msg ) ) { @@ -558,7 +564,7 @@ private function get_output_path( $assoc_args, $subdir ) { } } elseif ( $assoc_args['plugin'] ) { $plugin = $assoc_args['plugin']; - $path = WP_PLUGIN_DIR . "/{$plugin}"; + $path = $this->get_plugin_dir() . "/{$plugin}"; if ( ! is_dir( $path ) ) { WP_CLI::error( "Can't find '{$plugin}' plugin." ); } @@ -644,6 +650,8 @@ private function get_output_path( $assoc_args, $subdir ) { * $ wp scaffold plugin sample-plugin * Success: Created plugin files. * Success: Created test files. + * + * @when before_wp_load */ public function plugin( $args, $assoc_args ) { $plugin_slug = $args[0]; @@ -662,9 +670,9 @@ public function plugin( $args, $assoc_args ) { 'plugin_author' => 'YOUR NAME HERE', 'plugin_author_uri' => 'YOUR SITE HERE', 'plugin_uri' => 'PLUGIN SITE HERE', - 'plugin_tested_up_to' => get_bloginfo( 'version' ), + 'plugin_tested_up_to' => $this->get_wp_version(), ]; - $data = wp_parse_args( $assoc_args, $defaults ); + $data = $this->parse_args( $assoc_args, $defaults ); $data['textdomain'] = $plugin_slug; @@ -674,7 +682,7 @@ public function plugin( $args, $assoc_args ) { } $plugin_dir = "{$assoc_args['dir']}/{$plugin_slug}"; } else { - $plugin_dir = WP_PLUGIN_DIR . "/{$plugin_slug}"; + $plugin_dir = $this->get_plugin_dir() . "/{$plugin_slug}"; $this->maybe_create_plugins_dir(); $error_msg = $this->check_target_directory( 'plugin', $plugin_dir ); @@ -765,6 +773,7 @@ public function plugin( $args, $assoc_args ) { * Success: Created test files. * * @subcommand plugin-tests + * @when before_wp_load */ public function plugin_tests( $args, $assoc_args ) { $this->scaffold_plugin_theme_tests( $args, $assoc_args, 'plugin' ); @@ -832,14 +841,19 @@ private function scaffold_plugin_theme_tests( $args, $assoc_args, $type ) { WP_CLI::error( "Invalid {$type} slug specified. The slug cannot be '.' or '..'." ); } if ( 'theme' === $type ) { - $theme = wp_get_theme( $slug ); - if ( $theme->exists() ) { - $target_dir = $theme->get_stylesheet_directory(); + if ( function_exists( 'wp_get_theme' ) ) { + $theme = wp_get_theme( $slug ); + if ( $theme->exists() ) { + $target_dir = $theme->get_stylesheet_directory(); + } else { + WP_CLI::error( "Invalid {$type} slug specified. The theme '{$slug}' does not exist." ); + } } else { - WP_CLI::error( "Invalid {$type} slug specified. The theme '{$slug}' does not exist." ); + // Fallback when WordPress is not loaded. + $target_dir = $this->get_content_dir() . "/themes/{$slug}"; } } else { - $target_dir = WP_PLUGIN_DIR . "/{$slug}"; + $target_dir = $this->get_plugin_dir() . "/{$slug}"; } if ( empty( $assoc_args['dir'] ) && ! is_dir( $target_dir ) ) { WP_CLI::error( "Invalid {$type} slug specified. No such target directory '{$target_dir}'." ); @@ -890,22 +904,22 @@ private function scaffold_plugin_theme_tests( $args, $assoc_args, $type ) { $main_file = "{$slug}.php"; if ( 'plugin' === $type ) { - if ( ! function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } + $this->maybe_load_plugin_functions(); - $all_plugins = get_plugins(); + if ( function_exists( 'get_plugins' ) ) { + $all_plugins = get_plugins(); - if ( ! empty( $all_plugins ) ) { - $filtered = array_filter( - array_keys( $all_plugins ), - static function ( $item ) use ( $slug ) { - return ( false !== strpos( $item, "{$slug}/" ) ); - } - ); + if ( ! empty( $all_plugins ) ) { + $filtered = array_filter( + array_keys( $all_plugins ), + static function ( $item ) use ( $slug ) { + return ( false !== strpos( $item, "{$slug}/" ) ); + } + ); - if ( ! empty( $filtered ) ) { - $main_file = basename( reset( $filtered ) ); + if ( ! empty( $filtered ) ) { + $main_file = basename( reset( $filtered ) ); + } } } } @@ -972,12 +986,18 @@ static function ( $item ) use ( $slug ) { private function check_target_directory( $type, $target_dir ) { $parent_dir = dirname( self::canonicalize_path( str_replace( '\\', '/', $target_dir ) ) ); - if ( 'theme' === $type && str_replace( '\\', '/', WP_CONTENT_DIR . '/themes' ) !== $parent_dir ) { - return sprintf( 'The target directory \'%1$s\' is not in \'%2$s\'.', $target_dir, WP_CONTENT_DIR . '/themes' ); + if ( 'theme' === $type ) { + $themes_dir = str_replace( '\\', '/', $this->get_content_dir() . '/themes' ); + if ( defined( 'WP_CONTENT_DIR' ) && $themes_dir !== $parent_dir ) { + return sprintf( 'The target directory \'%1$s\' is not in \'%2$s\'.', $target_dir, $themes_dir ); + } } - if ( 'plugin' === $type && str_replace( '\\', '/', WP_PLUGIN_DIR ) !== $parent_dir ) { - return sprintf( 'The target directory \'%1$s\' is not in \'%2$s\'.', $target_dir, WP_PLUGIN_DIR ); + if ( 'plugin' === $type ) { + $plugin_dir = str_replace( '\\', '/', $this->get_plugin_dir() ); + if ( defined( 'WP_PLUGIN_DIR' ) && $plugin_dir !== $parent_dir ) { + return sprintf( 'The target directory \'%1$s\' is not in \'%2$s\'.', $target_dir, $plugin_dir ); + } } // Success. @@ -1128,20 +1148,129 @@ protected function maybe_create_themes_dir() { * Creates the plugins directory if it doesn't already exist. */ protected function maybe_create_plugins_dir() { + $plugin_dir = $this->get_plugin_dir(); + if ( ! is_dir( $plugin_dir ) ) { + if ( function_exists( 'wp_mkdir_p' ) ) { + wp_mkdir_p( $plugin_dir ); + } else { + // Fallback when WordPress is not loaded. + // Uses 0755 to match WordPress's default directory permissions. + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $plugin_dir, 0755, true ); + } + } + } - if ( ! is_dir( WP_PLUGIN_DIR ) ) { - wp_mkdir_p( WP_PLUGIN_DIR ); + /** + * Loads WordPress plugin functions if available. + * + * Attempts to load the WordPress plugin.php file which contains get_plugins() + * and other plugin-related functions. Silently fails if WordPress is not available. + */ + protected function maybe_load_plugin_functions() { + if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } } /** - * Initializes WP_Filesystem. + * Initializes WP_Filesystem or returns a fallback filesystem wrapper. + * + * When WordPress is available, uses WP_Filesystem for proper permissions/security. + * When WordPress is not available, returns a fallback wrapper using native PHP functions. + * + * @return \WP_Filesystem_Base|Scaffold_Filesystem_Fallback Filesystem handler object. */ protected function init_wp_filesystem() { - global $wp_filesystem; - WP_Filesystem(); + // Check if WordPress filesystem functions are available. + if ( function_exists( 'WP_Filesystem' ) ) { + global $wp_filesystem; + WP_Filesystem(); + return $wp_filesystem; + } - return $wp_filesystem; + // Return a fallback filesystem wrapper using native PHP functions. + return $this->get_fallback_filesystem(); + } + + /** + * Gets a fallback filesystem wrapper when WordPress is not available. + * + * Provides a compatible interface with WP_Filesystem using native PHP functions. + * + * @return Scaffold_Filesystem_Fallback Filesystem wrapper object. + */ + protected function get_fallback_filesystem() { + return new Scaffold_Filesystem_Fallback(); + } + + /** + * Gets the plugin directory path with fallback. + * + * @param string|null $base_dir Optional base directory to use as fallback. + * @return string Plugin directory path. + */ + protected function get_plugin_dir( $base_dir = null ) { + if ( defined( 'WP_PLUGIN_DIR' ) ) { + return WP_PLUGIN_DIR; + } + // Fallback when WordPress is not loaded. + if ( null !== $base_dir ) { + return $base_dir; + } + // Default fallback to current directory. + $cwd = getcwd(); + return false !== $cwd ? $cwd : '.'; + } + + /** + * Gets the content directory path with fallback. + * + * @return string Content directory path. + */ + protected function get_content_dir() { + if ( defined( 'WP_CONTENT_DIR' ) ) { + return WP_CONTENT_DIR; + } + // Fallback when WordPress is not loaded. + if ( defined( 'ABSPATH' ) ) { + return ABSPATH . 'wp-content'; + } + $cwd = getcwd(); + return false !== $cwd ? $cwd : '.'; + } + + /** + * Parses arguments with defaults, compatible with wp_parse_args. + * + * Note: The fallback implementation uses array_merge which only works + * with array arguments. WP-CLI always passes associative arrays to + * $assoc_args, so this limitation is acceptable. + * + * @param array $args Arguments to parse. + * @param array $defaults Default values. + * @return array Merged array of arguments with defaults. + */ + protected function parse_args( $args, $defaults ) { + if ( function_exists( 'wp_parse_args' ) ) { + return wp_parse_args( $args, $defaults ); + } + // Fallback implementation for when WordPress is not loaded. + // array_merge produces same result as wp_parse_args for arrays. + return array_merge( $defaults, $args ); + } + + /** + * Gets WordPress version with fallback. + * + * @return string WordPress version or fallback value. + */ + protected function get_wp_version() { + if ( function_exists( 'get_bloginfo' ) ) { + return get_bloginfo( 'version' ); + } + // Fallback to a recent stable version when WordPress is not loaded. + return self::FALLBACK_WP_VERSION; } /** @@ -1205,7 +1334,11 @@ private static function canonicalize_path( $path ) { */ private function get_theme_name( $theme ) { if ( true === $theme ) { - $theme = wp_get_theme()->template; + if ( function_exists( 'wp_get_theme' ) ) { + $theme = wp_get_theme()->template; + } else { + WP_CLI::error( 'Cannot determine active theme without WordPress being loaded. Please specify the theme explicitly.' ); + } } return strtolower( $theme ); } diff --git a/src/Scaffold_Filesystem_Fallback.php b/src/Scaffold_Filesystem_Fallback.php new file mode 100644 index 00000000..45aefd23 --- /dev/null +++ b/src/Scaffold_Filesystem_Fallback.php @@ -0,0 +1,85 @@ +mkdir( $dir ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy + return copy( $source, $destination ); + } + + /** + * Changes file permissions. + * + * @param string $file File path. + * @param int $mode Permission mode (octal). + * @return bool True on success, false on failure. + */ + public function chmod( $file, $mode ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod + return chmod( $file, $mode ); + } +}