From 7cb613d146c204ad241d6ea52182d0132fc02e03 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli <guy.sartorelli@gmail.com> Date: Tue, 27 Aug 2024 18:13:45 +1200 Subject: [PATCH] NEW Refactor CLI interaction with Silverstripe app - Turn sake into a symfony/console app - Avoid using HTTPRequest for CLI interaction - Implement abstract hybrid execution path --- _config/cli.yml | 13 + _config/confirmation-middleware.yml | 6 +- _config/dev.yml | 25 +- _config/extensions.yml | 4 +- _config/logging.yml | 4 +- _config/requestprocessors.yml | 2 - bin/sake | 15 + cli-script.php | 35 -- client/styles/debug.css | 40 +- client/styles/task-runner.css | 6 + composer.json | 8 +- sake | 119 ---- src/Cli/Command/NavigateCommand.php | 63 ++ src/Cli/Command/PolyCommandCliWrapper.php | 48 ++ src/Cli/Command/TasksCommand.php | 85 +++ src/Cli/CommandLoader/ArrayCommandLoader.php | 55 ++ src/Cli/CommandLoader/DevCommandLoader.php | 16 + src/Cli/CommandLoader/DevTaskLoader.php | 25 + .../CommandLoader/InjectorCommandLoader.php | 73 +++ src/Cli/CommandLoader/PolyCommandLoader.php | 82 +++ src/Cli/LegacyParamArgvInput.php | 168 ++++++ src/Cli/Sake.php | 282 +++++++++ src/Control/CLIRequestBuilder.php | 25 +- src/Control/CliController.php | 58 -- src/Control/Director.php | 4 + .../ConfirmationMiddleware/CliBypass.php | 39 -- ...DevelopmentAdminConfirmationMiddleware.php | 23 +- .../Middleware/URLSpecialsMiddleware.php | 2 +- src/Control/PolyCommandController.php | 65 ++ src/Core/CoreKernel.php | 1 - src/Core/DatabaselessKernel.php | 70 --- src/Core/Kernel.php | 5 + src/Core/Manifest/ClassManifest.php | 2 +- src/Dev/BuildTask.php | 143 ++--- src/Dev/Command/ConfigAudit.php | 117 ++++ src/Dev/Command/ConfigDump.php | 66 ++ src/Dev/Command/DbBuild.php | 347 +++++++++++ src/Dev/Command/DbCleanup.php | 90 +++ src/Dev/Command/DbDefaults.php | 49 ++ src/Dev/Command/DevCommand.php | 58 ++ src/Dev/Command/GenerateSecureToken.php | 55 ++ src/Dev/Deprecation.php | 2 +- src/Dev/DevBuildController.php | 98 --- src/Dev/DevConfigController.php | 214 ------- src/Dev/DevConfirmationController.php | 1 - src/Dev/DevelopmentAdmin.php | 422 +++++++------ src/Dev/MigrationTask.php | 84 +-- src/Dev/State/ExtensionTestState.php | 2 +- src/Dev/TaskRunner.php | 94 +-- src/Dev/Tasks/CleanupTestDatabasesTask.php | 38 +- src/Dev/Tasks/i18nTextCollectorTask.php | 72 +-- ...dminExtension.php => DbBuildExtension.php} | 13 +- ...tputHandler.php => ErrorOutputHandler.php} | 28 +- src/ORM/Connect/DBSchemaManager.php | 8 +- src/ORM/Connect/TempDatabase.php | 2 +- src/ORM/DataObject.php | 4 +- src/ORM/DataObjectSchema.php | 2 +- src/ORM/DatabaseAdmin.php | 566 ------------------ src/ORM/FieldType/DBClassNameTrait.php | 2 +- src/PolyExecution/AnsiToHtmlConverter.php | 143 +++++ src/PolyExecution/AnsiToHtmlTheme.php | 30 + src/PolyExecution/HtmlOutputFormatter.php | 58 ++ src/PolyExecution/HttpRequestInput.php | 111 ++++ src/PolyExecution/PolyCommand.php | 182 ++++++ src/PolyExecution/PolyOutput.php | 250 ++++++++ src/PolyExecution/PolyOutputLogHandler.php | 30 + src/Security/Confirmation/Handler.php | 5 - src/Security/Member.php | 2 +- src/Security/RandomGenerator.php | 3 + src/Security/Security.php | 2 +- src/View/SSViewer_DataPresenter.php | 6 + src/i18n/TextCollection/i18nTextCollector.php | 5 +- .../SilverStripe/Dev/DevelopmentAdmin.ss | 37 ++ templates/SilverStripe/Dev/Parameters.ss | 8 + templates/SilverStripe/Dev/TaskRunner.ss | 14 +- tests/bootstrap/cli.php | 3 - tests/php/Cli/Command/NavigateCommandTest.php | 99 +++ .../NavigateCommandTest/TestController.php | 52 ++ .../Cli/Command/PolyCommandCliWrapperTest.php | 52 ++ .../TestPolyCommand.php | 43 ++ tests/php/Cli/LegacyParamArgvInputTest.php | 159 +++++ tests/php/Cli/SakeTest.php | 307 ++++++++++ tests/php/Cli/SakeTest/TestBuildTask.php | 23 + tests/php/Cli/SakeTest/TestCommandLoader.php | 31 + tests/php/Cli/SakeTest/TestConfigCommand.php | 19 + .../Cli/SakeTest/TestConfigPolyCommand.php | 26 + tests/php/Cli/SakeTest/TestLoaderCommand.php | 19 + tests/php/Control/DirectorTest.php | 15 + .../Control/DirectorTest/TestPolyCommand.php | 26 + .../php/Control/PolyCommandControllerTest.php | 90 +++ .../TestPolyCommand.php | 59 ++ tests/php/Dev/BuildTaskTest.php | 49 +- tests/php/Dev/BuildTaskTest/TestBuildTask.php | 27 + tests/php/Dev/DevAdminControllerTest.php | 121 ++-- .../DevAdminControllerTest/Controller1.php | 2 +- .../DevAdminControllerTest/TestCommand.php | 32 + .../TestHiddenController.php | 15 + .../TaskRunnerTest_AbstractTask.php | 6 +- .../TaskRunnerTest_ChildOfAbstractTask.php | 6 +- .../TaskRunnerTest_DisabledTask.php | 8 +- .../TaskRunnerTest_EnabledTask.php | 6 +- ...lerTest.php => ErrorOutputHandlerTest.php} | 16 +- .../PolyExecution/AnsiToHtmlConverterTest.php | 57 ++ .../PolyExecution/HttpRequestInputTest.php | 141 +++++ tests/php/PolyExecution/PolyOutputTest.php | 207 +++++++ tests/php/View/SSViewerTest.php | 13 + .../templates/SSViewerTestLoopArray.ss | 3 + 107 files changed, 4839 insertions(+), 1824 deletions(-) create mode 100644 _config/cli.yml create mode 100755 bin/sake delete mode 100755 cli-script.php delete mode 100755 sake create mode 100644 src/Cli/Command/NavigateCommand.php create mode 100644 src/Cli/Command/PolyCommandCliWrapper.php create mode 100644 src/Cli/Command/TasksCommand.php create mode 100644 src/Cli/CommandLoader/ArrayCommandLoader.php create mode 100644 src/Cli/CommandLoader/DevCommandLoader.php create mode 100644 src/Cli/CommandLoader/DevTaskLoader.php create mode 100644 src/Cli/CommandLoader/InjectorCommandLoader.php create mode 100644 src/Cli/CommandLoader/PolyCommandLoader.php create mode 100644 src/Cli/LegacyParamArgvInput.php create mode 100644 src/Cli/Sake.php delete mode 100644 src/Control/CliController.php delete mode 100644 src/Control/Middleware/ConfirmationMiddleware/CliBypass.php create mode 100644 src/Control/PolyCommandController.php delete mode 100644 src/Core/DatabaselessKernel.php create mode 100644 src/Dev/Command/ConfigAudit.php create mode 100644 src/Dev/Command/ConfigDump.php create mode 100644 src/Dev/Command/DbBuild.php create mode 100644 src/Dev/Command/DbCleanup.php create mode 100644 src/Dev/Command/DbDefaults.php create mode 100644 src/Dev/Command/DevCommand.php create mode 100644 src/Dev/Command/GenerateSecureToken.php delete mode 100644 src/Dev/DevBuildController.php delete mode 100644 src/Dev/DevConfigController.php rename src/Dev/Validation/{DatabaseAdminExtension.php => DbBuildExtension.php} (55%) rename src/Logging/{HTTPOutputHandler.php => ErrorOutputHandler.php} (86%) delete mode 100644 src/ORM/DatabaseAdmin.php create mode 100644 src/PolyExecution/AnsiToHtmlConverter.php create mode 100644 src/PolyExecution/AnsiToHtmlTheme.php create mode 100644 src/PolyExecution/HtmlOutputFormatter.php create mode 100644 src/PolyExecution/HttpRequestInput.php create mode 100644 src/PolyExecution/PolyCommand.php create mode 100644 src/PolyExecution/PolyOutput.php create mode 100644 src/PolyExecution/PolyOutputLogHandler.php create mode 100644 templates/SilverStripe/Dev/DevelopmentAdmin.ss create mode 100644 templates/SilverStripe/Dev/Parameters.ss create mode 100644 tests/php/Cli/Command/NavigateCommandTest.php create mode 100644 tests/php/Cli/Command/NavigateCommandTest/TestController.php create mode 100644 tests/php/Cli/Command/PolyCommandCliWrapperTest.php create mode 100644 tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php create mode 100644 tests/php/Cli/LegacyParamArgvInputTest.php create mode 100644 tests/php/Cli/SakeTest.php create mode 100644 tests/php/Cli/SakeTest/TestBuildTask.php create mode 100644 tests/php/Cli/SakeTest/TestCommandLoader.php create mode 100644 tests/php/Cli/SakeTest/TestConfigCommand.php create mode 100644 tests/php/Cli/SakeTest/TestConfigPolyCommand.php create mode 100644 tests/php/Cli/SakeTest/TestLoaderCommand.php create mode 100644 tests/php/Control/DirectorTest/TestPolyCommand.php create mode 100644 tests/php/Control/PolyCommandControllerTest.php create mode 100644 tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php create mode 100644 tests/php/Dev/BuildTaskTest/TestBuildTask.php create mode 100644 tests/php/Dev/DevAdminControllerTest/TestCommand.php create mode 100644 tests/php/Dev/DevAdminControllerTest/TestHiddenController.php rename tests/php/Logging/{HTTPOutputHandlerTest.php => ErrorOutputHandlerTest.php} (94%) create mode 100644 tests/php/PolyExecution/AnsiToHtmlConverterTest.php create mode 100644 tests/php/PolyExecution/HttpRequestInputTest.php create mode 100644 tests/php/PolyExecution/PolyOutputTest.php create mode 100644 tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss diff --git a/_config/cli.yml b/_config/cli.yml new file mode 100644 index 00000000000..bc1fb049263 --- /dev/null +++ b/_config/cli.yml @@ -0,0 +1,13 @@ +--- +Name: cli-config +--- +SilverStripe\Core\Injector\Injector: + Symfony\Contracts\EventDispatcher\EventDispatcherInterface.sake: + class: 'Symfony\Component\EventDispatcher\EventDispatcher' + Symfony\Component\Console\Formatter\OutputFormatterInterface: + class: 'Symfony\Component\Console\Formatter\OutputFormatter' + calls: + - ['setDecorated', [true]] + SilverStripe\PolyExecution\HtmlOutputFormatter: + constructor: + formatter: '%$Symfony\Component\Console\Formatter\OutputFormatterInterface' diff --git a/_config/confirmation-middleware.yml b/_config/confirmation-middleware.yml index 70089ed4d51..a5250a3c5a6 100644 --- a/_config/confirmation-middleware.yml +++ b/_config/confirmation-middleware.yml @@ -22,14 +22,10 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass type: prototype - SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass: - class: SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass - type: prototype - SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass: class: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass type: prototype SilverStripe\Control\Middleware\ConfirmationMiddleware\Url: class: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url - type: prototype \ No newline at end of file + type: prototype diff --git a/_config/dev.yml b/_config/dev.yml index 4c1636bc4b5..6667751f3c4 100644 --- a/_config/dev.yml +++ b/_config/dev.yml @@ -2,21 +2,20 @@ Name: DevelopmentAdmin --- SilverStripe\Dev\DevelopmentAdmin: - registered_controllers: - build: - controller: SilverStripe\Dev\DevBuildController - links: - build: 'Build/rebuild this environment. Call this whenever you have updated your project sources' + commands: + build: 'SilverStripe\Dev\Command\DbBuild' + 'build/cleanup': 'SilverStripe\Dev\Command\DbCleanup' + 'build/defaults': 'SilverStripe\Dev\Command\DbDefaults' + config: 'SilverStripe\Dev\Command\ConfigDump' + 'config/audit': 'SilverStripe\Dev\Command\ConfigAudit' + generatesecuretoken: 'SilverStripe\Dev\Command\GenerateSecureToken' + controllers: tasks: - controller: SilverStripe\Dev\TaskRunner - links: - tasks: 'See a list of build tasks to run' + class: 'SilverStripe\Dev\TaskRunner' + description: 'See a list of build tasks to run' confirm: - controller: SilverStripe\Dev\DevConfirmationController - config: - controller: Silverstripe\Dev\DevConfigController - links: - config: 'View the current config, useful for debugging' + class: 'SilverStripe\Dev\DevConfirmationController' + skipLink: true SilverStripe\Dev\CSSContentParser: disable_xml_external_entities: true diff --git a/_config/extensions.yml b/_config/extensions.yml index 1d77a36dc12..9d928d52879 100644 --- a/_config/extensions.yml +++ b/_config/extensions.yml @@ -7,6 +7,6 @@ SilverStripe\Security\Member: SilverStripe\Security\Group: extensions: - SilverStripe\Security\InheritedPermissionFlusher -SilverStripe\ORM\DatabaseAdmin: +SilverStripe\Dev\Command\DbBuild: extensions: - - SilverStripe\Dev\Validation\DatabaseAdminExtension + - SilverStripe\Dev\Validation\DbBuildExtension diff --git a/_config/logging.yml b/_config/logging.yml index b729fd33710..d49e30b14fc 100644 --- a/_config/logging.yml +++ b/_config/logging.yml @@ -52,7 +52,7 @@ Only: # Dev handler outputs detailed information including notices SilverStripe\Core\Injector\Injector: Monolog\Handler\HandlerInterface: - class: SilverStripe\Logging\HTTPOutputHandler + class: SilverStripe\Logging\ErrorOutputHandler constructor: - "notice" properties: @@ -66,7 +66,7 @@ Except: # CLI errors still show full details SilverStripe\Core\Injector\Injector: Monolog\Handler\HandlerInterface: - class: SilverStripe\Logging\HTTPOutputHandler + class: SilverStripe\Logging\ErrorOutputHandler constructor: - "error" properties: diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml index ad454e9fa13..abf0b6d5efa 100644 --- a/_config/requestprocessors.yml +++ b/_config/requestprocessors.yml @@ -60,7 +60,6 @@ SilverStripe\Core\Injector\Injector: ConfirmationStorageId: 'url-specials' ConfirmationFormUrl: '/dev/confirm' Bypasses: - - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")' EnforceAuthentication: true @@ -94,7 +93,6 @@ SilverStripe\Core\Injector\Injector: ConfirmationStorageId: 'dev-urls' ConfirmationFormUrl: '/dev/confirm' Bypasses: - - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")' EnforceAuthentication: false diff --git a/bin/sake b/bin/sake new file mode 100755 index 00000000000..713ab522689 --- /dev/null +++ b/bin/sake @@ -0,0 +1,15 @@ +#!/usr/bin/env php +<?php + +use SilverStripe\Cli\Sake; + +// Ensure that people can't access this from a web-server +if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) { + echo 'sake cannot be run from a web request, you have to run it on the command-line.'; + die(); +} + +require_once __DIR__ . '/../src/includes/autoload.php'; + +$sake = new Sake(); +$sake->run(); diff --git a/cli-script.php b/cli-script.php deleted file mode 100755 index c21778710f0..00000000000 --- a/cli-script.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php - -// CLI specific bootstrapping -use SilverStripe\Control\CLIRequestBuilder; -use SilverStripe\Control\HTTPApplication; -use SilverStripe\Core\CoreKernel; -use SilverStripe\ORM\DB; -use SilverStripe\ORM\Connect\NullDatabase; - -require __DIR__ . '/src/includes/autoload.php'; - -// Ensure that people can't access this from a web-server -if (!in_array(PHP_SAPI, ["cli", "cgi", "cgi-fcgi"])) { - echo "cli-script.php can't be run from a web request, you have to run it on the command-line."; - die(); -} - -// Build request and detect flush -$request = CLIRequestBuilder::createFromEnvironment(); - - -$skipDatabase = in_array('--no-database', $argv); -if ($skipDatabase) { - DB::set_conn(new NullDatabase()); -} -// Default application -$kernel = new CoreKernel(BASE_PATH); -if ($skipDatabase) { - $kernel->setBootDatabase(false); -} - -$app = new HTTPApplication($kernel); -$response = $app->handle($request); - -$response->output(); diff --git a/client/styles/debug.css b/client/styles/debug.css index bb3ac83912f..4c41f05e4b7 100644 --- a/client/styles/debug.css +++ b/client/styles/debug.css @@ -113,7 +113,6 @@ a:active { } /* Content types */ -.build, .options, .trace { position: relative; @@ -128,22 +127,28 @@ a:active { line-height: 1.3; } -.build .success { +.options .success { color: #2b6c2d; } -.build .error { +.options .error { color: #d30000; } -.build .warning { +.options .warning { color: #8a6d3b; } -.build .info { +.options .info { color: #0073c1; } +.options .more-details { + border: 1px dotted; + width: fit-content; + padding: 5px; +} + /* Backtrace styles */ pre { overflow: auto; @@ -162,3 +167,28 @@ pre span { pre .error { color: #d30000; } + +.params { + margin-top: 0; + margin-left: 10px; +} + +.param { + display: flex; + align-items: baseline; +} + +.param__name { + display: inline-block; + font-weight: 200; +} + +.param__name::after { + content: ": "; +} + +.param__description { + display: inline-block; + margin-left: 0.5em; + font-style: italic; +} diff --git a/client/styles/task-runner.css b/client/styles/task-runner.css index 33d1e0fa2c0..e44c39ea552 100644 --- a/client/styles/task-runner.css +++ b/client/styles/task-runner.css @@ -36,6 +36,12 @@ margin-bottom: 12px; } +.task__help { + border: 1px dotted; + width: fit-content; + padding: 5px; +} + .task__button { border: 1px solid #ced5e1; border-radius: 5px; diff --git a/composer.json b/composer.json index 4e8fed3e73c..0fdec4af13a 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "bin": [ - "sake" + "bin/sake" ], "require": { "php": "^8.3", @@ -36,12 +36,14 @@ "psr/container": "^1.1 || ^2.0", "psr/http-message": "^1", "sebastian/diff": "^6.0", + "sensiolabs/ansi-to-html": "^1.2", "silverstripe/config": "^3", "silverstripe/assets": "^3", "silverstripe/vendor-plugin": "^2", "sminnee/callbacklist": "^0.1.1", "symfony/cache": "^7.0", "symfony/config": "^7.0", + "symfony/console": "^7.0", "symfony/dom-crawler": "^7.0", "symfony/filesystem": "^7.0", "symfony/http-foundation": "^7.0", @@ -84,6 +86,8 @@ }, "autoload": { "psr-4": { + "SilverStripe\\Cli\\": "src/Cli/", + "SilverStripe\\Cli\\Tests\\": "tests/php/Cli/", "SilverStripe\\Control\\": "src/Control/", "SilverStripe\\Control\\Tests\\": "tests/php/Control/", "SilverStripe\\Core\\": "src/Core/", @@ -98,6 +102,8 @@ "SilverStripe\\Logging\\Tests\\": "tests/php/Logging/", "SilverStripe\\ORM\\": "src/ORM/", "SilverStripe\\ORM\\Tests\\": "tests/php/ORM/", + "SilverStripe\\PolyExecution\\": "src/PolyExecution/", + "SilverStripe\\PolyExecution\\Tests\\": "tests/php/PolyExecution/", "SilverStripe\\Security\\": "src/Security/", "SilverStripe\\Security\\Tests\\": "tests/php/Security/", "SilverStripe\\View\\": "src/View/", diff --git a/sake b/sake deleted file mode 100755 index 59103445b54..00000000000 --- a/sake +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash - -# Check for an argument -if [ ${1:-""} = "" ]; then - echo "SilverStripe Sake - -Usage: $0 (command-url) (params) -Executes a SilverStripe command" - exit 1 -fi - -command -v which >/dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "Error: sake requires the 'which' command to operate." >&2 - exit 1 -fi - -# find the silverstripe installation, looking first at sake -# bin location, but falling back to current directory -sakedir=`dirname $0` -directory="$PWD" -if [ -f "$sakedir/cli-script.php" ]; then - # Calling sake from vendor/silverstripe/framework/sake - framework="$sakedir" - base="$sakedir/../../.." -elif [ -f "$sakedir/../silverstripe/framework/cli-script.php" ]; then - # Calling sake from vendor/bin/sake - framework="$sakedir/../silverstripe/framework" - base="$sakedir/../.." -elif [ -f "$directory/vendor/silverstripe/framework/cli-script.php" ]; then - # Vendor framework (from base) if sake installed globally - framework="$directory/vendor/silverstripe/framework" - base=. -elif [ -f "$directory/framework/cli-script.php" ]; then - # Legacy directory (from base) if sake installed globally - framework="$directory/framework" - base=. -else - echo "Can't find cli-script.php in $sakedir" - exit 1 -fi - -# Find the PHP binary -for candidatephp in php php5; do - if [ "`which $candidatephp 2>/dev/null`" -a -f "`which $candidatephp 2>/dev/null`" ]; then - php=`which $candidatephp 2>/dev/null` - break - fi -done -if [ "$php" = "" ]; then - echo "Can't find any php binary" - exit 2 -fi - -################################################################################################ -## Installation to /usr/bin - -if [ "$1" = "installsake" ]; then - echo "Installing sake to /usr/local/bin..." - rm -rf /usr/local/bin/sake - cp $0 /usr/local/bin - exit 0 -fi - -################################################################################################ -## Process control - -if [ "$1" = "-start" ]; then - if [ "`which daemon`" = "" ]; then - echo "You need to install the 'daemon' tool. In debian, go 'sudo apt-get install daemon'" - exit 1 - fi - - if [ ! -f $base/$2.pid ]; then - echo "Starting service $2 $3" - touch $base/$2.pid - pidfile=`realpath $base/$2.pid` - - outlog=$base/$2.log - errlog=$base/$2.err - - echo "Logging to $outlog" - - sake=`realpath $0` - base=`realpath $base` - - # if third argument is not explicitly given, copy from second argument - if [ "$3" = "" ]; then - url=$2 - else - url=$3 - fi - - processname=$2 - - daemon -n $processname -r -D $base --pidfile=$pidfile --stdout=$outlog --stderr=$errlog $sake $url - else - echo "Service $2 seems to already be running" - fi - exit 0 -fi - -if [ "$1" = "-stop" ]; then - pidfile=$base/$2.pid - if [ -f $pidfile ]; then - echo "Stopping service $2" - - kill -KILL `cat $pidfile` - unlink $pidfile - else - echo "Service $2 doesn't seem to be running." - fi - exit 0 -fi - -################################################################################################ -## Basic execution - -"$php" "$framework/cli-script.php" "${@}" diff --git a/src/Cli/Command/NavigateCommand.php b/src/Cli/Command/NavigateCommand.php new file mode 100644 index 00000000000..738ec495bda --- /dev/null +++ b/src/Cli/Command/NavigateCommand.php @@ -0,0 +1,63 @@ +<?php + +namespace SilverStripe\Cli\Command; + +use SilverStripe\Control\CLIRequestBuilder; +use SilverStripe\Control\HTTPApplication; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Kernel; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Command that simulates an HTTP request to the Silverstripe App based on CLI input. + */ +#[AsCommand(name: 'navigate', description: 'Navigate to a URL on your site via a simulated HTTP request')] +class NavigateCommand extends Command +{ + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Convert input into HTTP request. + // Use the kernel we already booted for consistency and performance reasons + $app = new HTTPApplication(Injector::inst()->get(Kernel::class)); + $request = CLIRequestBuilder::createFromInput($input); + + // Handle request and output resonse body + $response = $app->handle($request); + $output->writeln($response->getBody(), OutputInterface::OUTPUT_RAW); + + // Transform HTTP status code into sensible exit code + $responseCode = $response->getStatusCode(); + $output->writeln("<options=bold>RESPONSE STATUS CODE WAS {$responseCode}</>", OutputInterface::VERBOSITY_VERBOSE); + // We can't use the response code for unsuccessful requests directly as the exit code + // because symfony gives us an exit code ceiling of 255. So just use the regular constants. + return match (true) { + ($responseCode >= 200 && $responseCode < 400) => Command::SUCCESS, + ($responseCode >= 400 && $responseCode < 500) => Command::INVALID, + default => Command::FAILURE, + }; + } + + protected function configure(): void + { + $this->setHelp(<<<HELP + Use verbose mode to see the HTTP response status code. + The <info>get-var</> arg can either be separated GET variables, or a full query string + e.g: <comment>sake navigate about-us/team q=test arrayval[]=value1 arrayval[]=value2</> + e.g: <comment>sake navigate about-us/team q=test<info>&</info>arrayval[]=value1<info>&</info>arrayval[]=value2</> + HELP); + $this->addArgument( + 'path', + InputArgument::REQUIRED, + 'Relative path to navigate to (e.g: <info>about-us/team</>). Can optionally start with a "/"' + ); + $this->addArgument( + 'get-var', + InputArgument::IS_ARRAY | InputArgument::OPTIONAL, + 'Optional GET variables or a query string' + ); + } +} diff --git a/src/Cli/Command/PolyCommandCliWrapper.php b/src/Cli/Command/PolyCommandCliWrapper.php new file mode 100644 index 00000000000..c5fd8587795 --- /dev/null +++ b/src/Cli/Command/PolyCommandCliWrapper.php @@ -0,0 +1,48 @@ +<?php + +namespace SilverStripe\Cli\Command; + +use SilverStripe\Core\Injector\Injectable; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Wraps a PolyCommand for use in CLI. + */ +class PolyCommandCliWrapper extends Command +{ + use Injectable; + + private PolyCommand $command; + + public function __construct(PolyCommand $command, string $alias = '') + { + $this->command = $command; + parent::__construct($command->getName()); + if ($alias) { + $this->setAliases([$alias]); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $polyOutput = PolyOutput::create( + PolyOutput::FORMAT_ANSI, + $output->getVerbosity(), + $output->isDecorated(), + $output + ); + return $this->command->run($input, $polyOutput); + } + + protected function configure(): void + { + $this->setDescription($this->command::getDescription()); + $this->setDefinition(new InputDefinition($this->command->getOptions())); + $this->setHelp($this->command->getHelp()); + } +} diff --git a/src/Cli/Command/TasksCommand.php b/src/Cli/Command/TasksCommand.php new file mode 100644 index 00000000000..8e5742490f7 --- /dev/null +++ b/src/Cli/Command/TasksCommand.php @@ -0,0 +1,85 @@ +<?php + +namespace SilverStripe\Cli\Command; + +use SilverStripe\Cli\CommandLoader\DevTaskLoader; +use SilverStripe\Cli\Sake; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\ListCommand; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Command that runs `sake list tasks` under the hood to list all of the available tasks. + * Useful when you have too many tasks to show in the main commands list. + * + * Note the description is blue so it stands out, to avoid developers missing it if they add a new + * task and suddenly they don't see the tasks in their main commands list anymore. + */ +#[AsCommand(name: 'tasks', description: '<fg=blue>See a list of build tasks to run</>')] +class TasksCommand extends Command +{ + private Command $listCommand; + + public function __construct() + { + parent::__construct(); + $this->listCommand = new ListCommand(); + $this->setDefinition($this->listCommand->getDefinition()); + } + + public function setApplication(?Application $application): void + { + $this->listCommand->setApplication($application); + parent::setApplication($application); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->getCompletionType() === CompletionInput::TYPE_ARGUMENT_VALUE) { + // Make this command transparent to completion, so we can `sake tasks<tab>` and see all tasks + if ($input->getCompletionValue() === $this->getName()) { + $taskLoader = DevTaskLoader::create(); + $suggestions->suggestValues($taskLoader->getNames()); + } + // Don't allow completion for the namespace argument, because we will override their value anyway + return; + } + // Still allow completion for options e.g. --format + parent::complete($input, $suggestions); + } + + public function isHidden(): bool + { + return !$this->getApplication()->shouldHideTasks(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Explicitly don't allow any namespace other than tasks + $input->setArgument('namespace', 'tasks'); + // We have to call execute() here instead of run(), because run() would re-bind + // the input which would throw away the namespace argument. + $this->getApplication()?->setIgnoreTaskLimit(true); + $exitCode = $this->listCommand->execute($input, $output); + $this->getApplication()?->setIgnoreTaskLimit(false); + return $exitCode; + } + + protected function configure() + { + $sakeClass = Sake::class; + $this->setHelp(<<<HELP + If you want to display the tasks in the main commands list, update the <info>$sakeClass.max_tasks_to_display</info> configuration. + <comment> + $sakeClass: + max_tasks_to_display: 50 + </> + Set the value to 0 to always display tasks in the main command list regardless of how many there are. + HELP); + } +} diff --git a/src/Cli/CommandLoader/ArrayCommandLoader.php b/src/Cli/CommandLoader/ArrayCommandLoader.php new file mode 100644 index 00000000000..9eff04bc5e9 --- /dev/null +++ b/src/Cli/CommandLoader/ArrayCommandLoader.php @@ -0,0 +1,55 @@ +<?php + +namespace SilverStripe\Cli\CommandLoader; + +use SilverStripe\Core\Injector\Injectable; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Exception\CommandNotFoundException; + +/** + * Command loader that holds more command loaders + */ +class ArrayCommandLoader implements CommandLoaderInterface +{ + use Injectable; + + /** + * @var array<CommandLoaderInterface> + */ + private array $loaders = []; + + public function __construct(array $loaders) + { + $this->loaders = $loaders; + } + + public function get(string $name): Command + { + foreach ($this->loaders as $loader) { + if ($loader->has($name)) { + return $loader->get($name); + } + } + throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); + } + + public function has(string $name): bool + { + foreach ($this->loaders as $loader) { + if ($loader->has($name)) { + return true; + } + } + return false; + } + + public function getNames(): array + { + $names = []; + foreach ($this->loaders as $loader) { + $names = array_merge($names, $loader->getNames()); + } + return array_unique($names); + } +} diff --git a/src/Cli/CommandLoader/DevCommandLoader.php b/src/Cli/CommandLoader/DevCommandLoader.php new file mode 100644 index 00000000000..bd67b3de3a8 --- /dev/null +++ b/src/Cli/CommandLoader/DevCommandLoader.php @@ -0,0 +1,16 @@ +<?php + +namespace SilverStripe\Cli\CommandLoader; + +use SilverStripe\Dev\DevelopmentAdmin; + +/** + * Get commands for the controllers registered in DevelopmentAdmin + */ +class DevCommandLoader extends PolyCommandLoader +{ + protected function getCommands(): array + { + return DevelopmentAdmin::singleton()->getCommands(); + } +} diff --git a/src/Cli/CommandLoader/DevTaskLoader.php b/src/Cli/CommandLoader/DevTaskLoader.php new file mode 100644 index 00000000000..cea0384e895 --- /dev/null +++ b/src/Cli/CommandLoader/DevTaskLoader.php @@ -0,0 +1,25 @@ +<?php + +namespace SilverStripe\Cli\CommandLoader; + +use SilverStripe\Dev\TaskRunner; + +/** + * Get commands for the dev:tasks namespace + */ +class DevTaskLoader extends PolyCommandLoader +{ + protected function getCommands(): array + { + $commands = []; + foreach (TaskRunner::singleton()->getTaskList() as $name => $class) { + $singleton = $class::singleton(); + // Don't add disabled tasks. + // No need to check canRunInCli() - the superclass will take care of that. + if ($singleton->isEnabled()) { + $commands['dev/' . str_replace('tasks:', 'tasks/', $name)] = $class; + } + }; + return $commands; + } +} diff --git a/src/Cli/CommandLoader/InjectorCommandLoader.php b/src/Cli/CommandLoader/InjectorCommandLoader.php new file mode 100644 index 00000000000..d9aff575a2b --- /dev/null +++ b/src/Cli/CommandLoader/InjectorCommandLoader.php @@ -0,0 +1,73 @@ +<?php + +namespace SilverStripe\Cli\CommandLoader; + +use LogicException; +use SilverStripe\Cli\Command\PolyCommandCliWrapper; +use SilverStripe\Cli\Sake; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\PolyExecution\PolyCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Exception\CommandNotFoundException; + +/** + * Command loader that loads commands from the injector if they were registered with Sake. + */ +class InjectorCommandLoader implements CommandLoaderInterface +{ + private array $commands = []; + private array $commandAliases = []; + + public function get(string $name): Command + { + if (!$this->has($name)) { + throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); + } + return $this->commands[$name] ?? $this->commandAliases[$name]; + } + + public function has(string $name): bool + { + $this->initCommands(); + return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases); + } + + public function getNames(): array + { + $this->initCommands(); + return array_keys($this->commands); + } + + private function initCommands(): void + { + if (empty($this->commands)) { + $commandClasses = Sake::config()->get('commands'); + foreach ($commandClasses as $class) { + if ($class === null) { + // Allow unsetting commands via yaml + continue; + } + $command = Injector::inst()->create($class); + // Wrap poly commands (if they're allowed to be run) + if ($command instanceof PolyCommand) { + if (!$command::canRunInCli()) { + continue; + } + $command = PolyCommandCliWrapper::create($command); + } + /** @var Command $command */ + if (!$command->getName()) { + throw new LogicException(sprintf( + 'The command defined in "%s" cannot have an empty name.', + get_debug_type($command) + )); + } + $this->commands[$command->getName()] = $command; + foreach ($command->getAliases() as $alias) { + $this->commandAliases[$alias] = $command; + } + } + } + } +} diff --git a/src/Cli/CommandLoader/PolyCommandLoader.php b/src/Cli/CommandLoader/PolyCommandLoader.php new file mode 100644 index 00000000000..78700665d87 --- /dev/null +++ b/src/Cli/CommandLoader/PolyCommandLoader.php @@ -0,0 +1,82 @@ +<?php + +namespace SilverStripe\Cli\CommandLoader; + +use SilverStripe\Cli\Command\PolyCommandCliWrapper; +use SilverStripe\Core\Injector\Injectable; +use SilverStripe\PolyExecution\PolyCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Exception\CommandNotFoundException; + +/** + * Get commands for PolyCommand classes + */ +abstract class PolyCommandLoader implements CommandLoaderInterface +{ + use Injectable; + + private array $commands = []; + private array $commandAliases = []; + + public function get(string $name): Command + { + if (!$this->has($name)) { + throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); + } + $info = $this->commands[$name] ?? $this->commandAliases[$name]; + /** @var PolyCommand $commandClass */ + $commandClass = $info['class']; + $polyCommand = $commandClass::create(); + return PolyCommandCliWrapper::create($polyCommand, $info['alias']); + } + + public function has(string $name): bool + { + $this->initCommands(); + return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases); + } + + public function getNames(): array + { + $this->initCommands(); + return array_keys($this->commands); + } + + /** + * Get the array of PolyCommand objects this loader is responsible for. + * Do not filter canRunInCli(). + * + * @return array<string, PolyCommand> Associative array of commands. + * The key is an alias, or if no alias exists, the name of the command. + */ + abstract protected function getCommands(): array; + + /** + * Limit to only the commands that are allowed to be run in CLI. + */ + private function initCommands(): void + { + if (empty($this->commands)) { + $commands = $this->getCommands(); + /** @var PolyCommand $class */ + foreach ($commands as $alias => $class) { + if (!$class::canRunInCli()) { + continue; + } + $commandName = $class::getName(); + $hasAlias = $alias !== $commandName; + $this->commands[$commandName] = [ + 'class' => $class, + 'alias' => $hasAlias ? $alias : null, + ]; + if ($hasAlias) { + $this->commandAliases[$alias] = [ + 'class' => $class, + 'alias' => $alias, + ]; + } + } + } + } +} diff --git a/src/Cli/LegacyParamArgvInput.php b/src/Cli/LegacyParamArgvInput.php new file mode 100644 index 00000000000..5d1cadc3115 --- /dev/null +++ b/src/Cli/LegacyParamArgvInput.php @@ -0,0 +1,168 @@ +<?php + +namespace SilverStripe\Cli; + +use SilverStripe\Dev\Deprecation; +use SilverStripe\ORM\ArrayLib; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputDefinition; + +/** + * Represents an input coming from the CLI arguments - but converts legacy arg-style parameters to flags. + * + * e.g. `ddev dev:build flush=1` is converted to `ddev dev:build --flush`. + * Doesn't convert anything that isn't explicitly an InputOption in the relevant InputDefinition. + * Removes the parameters from the input args (e.g. doesn't become `ddev dev:build flush=1 --flush`). + * + * @deprecated 6.0.0 Use Symfony\Component\Console\Input\ArgvInput instead. + */ +class LegacyParamArgvInput extends ArgvInput +{ + /** + * Input from the command line. + * + * We need a separate copy of this because the one held by the parent class is private + * and not exposed until symfony/console 7.1 + */ + private array $argv; + + public function __construct(?array $argv = null, ?InputDefinition $definition = null) + { + Deprecation::withNoReplacement( + fn() => Deprecation::notice('6.0.0', 'Use ' . ArgvInput::class . ' instead', Deprecation::SCOPE_CLASS) + ); + $argv ??= $_SERVER['argv'] ?? []; + parent::__construct($argv, $definition); + // Strip the application name, matching what the parent class did with its copy + array_shift($argv); + $this->argv = $argv; + } + + public function hasParameterOption(string|array $values, bool $onlyParams = false): bool + { + if (parent::hasParameterOption($values, $onlyParams)) { + return true; + } + return $this->hasLegacyParameterOption($values); + } + + public function getParameterOption(string|array $values, string|bool|int|float|array|null $default = false, bool $onlyParams = false): mixed + { + if (parent::hasParameterOption($values, $onlyParams)) { + return parent::getParameterOption($values, $default, $onlyParams); + } + return $this->getLegacyParameterOption($values, $default); + } + + /** + * Binds the current Input instance with the given arguments and options. + * + * Also converts any arg-style params into true flags, based on the options defined. + */ + public function bind(InputDefinition $definition): void + { + // Convert arg-style params into flags + $tokens = $this->argv; + $convertedFlags = []; + $hadLegacyParams = false; + foreach ($definition->getOptions() as $option) { + $flagName = '--' . $option->getName(); + // Check if there is a legacy param first. This saves us from accidentally getting + // values that come after the end of options (--) signal + if (!$this->hasLegacyParameterOption($flagName)) { + continue; + } + // Get the value from the legacy param + $value = $this->getLegacyParameterOption($flagName); + if ($value && !$this->hasLegacyParameterOption($flagName . '=' . $value)) { + // symfony/console will try to get the value from the next argument if the current argument ends with `=` + // We don't want to count that as the value, so double check it. + $value = null; + } elseif ($option->acceptValue()) { + if ($value === '' || $value === null) { + $convertedFlags[] = $flagName; + } else { + $convertedFlags[] = $flagName . '=' . $value; + } + } else { + // If the option doesn't accept a value, only add the flag if the value is true. + $valueAsBool = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + if ($valueAsBool) { + $convertedFlags[] = $flagName; + } + } + $hadLegacyParams = true; + // Remove the legacy param from the token set + foreach ($tokens as $i => $token) { + if (str_starts_with($token, $option->getName() . '=')) { + unset($tokens[$i]); + break; + } + } + } + if (!empty($convertedFlags)) { + // Make sure it's before the end of options (--) signal if there is one. + $tokens = ArrayLib::insertBefore($tokens, $convertedFlags, '--', true, true); + } + if ($hadLegacyParams) { + // We only want the warning once regardless of how many params there are. + Deprecation::notice( + '6.0.0', + 'Using `param=value` style flags is deprecated. Use `--flag=value` CLI flags instead.', + Deprecation::SCOPE_GLOBAL + ); + // Set the new tokens so the parent class can operate on them. + // Specifically skip setting $this->argv in case someone decides to bind to a different + // input definition afterwards for whatever reason. + parent::setTokens($tokens); + } + parent::bind($definition); + } + + protected function setTokens(array $tokens): void + { + $this->argv = $tokens; + parent::setTokens($tokens); + } + + private function hasLegacyParameterOption(string|array $values): bool + { + $values = $this->getLegacyParamsForFlags((array) $values); + if (empty($values)) { + return false; + } + return parent::hasParameterOption($values, true); + } + + public function getLegacyParameterOption(string|array $values, string|bool|int|float|array|null $default = false): mixed + { + $values = $this->getLegacyParamsForFlags((array) $values); + if (empty($values)) { + return $default; + } + return parent::getParameterOption($values, $default, true); + } + + /** + * Given a set of flag names, return what they would be called in the legacy format. + */ + private function getLegacyParamsForFlags(array $flags): array + { + $legacyParams = []; + foreach ($flags as $flag) { + // Only allow full flags e.g. `--flush`, not shortcuts like `-f` + if (!str_starts_with($flag, '--')) { + continue; + } + // Convert to legacy format, e.g. `--flush` becomes `flush=` + // but if there's already an equals e.g. `--flush=1` keep it (`flush=1`) + // because the developer is checking for a specific value set to the flag. + $flag = ltrim($flag, '-'); + if (!str_contains($flag, '=')) { + $flag .= '='; + } + $legacyParams[] = $flag; + } + return $legacyParams; + } +} diff --git a/src/Cli/Sake.php b/src/Cli/Sake.php new file mode 100644 index 00000000000..027b5fde039 --- /dev/null +++ b/src/Cli/Sake.php @@ -0,0 +1,282 @@ +<?php + +namespace SilverStripe\Cli; + +use SilverStripe\Cli\Command\NavigateCommand; +use SilverStripe\Cli\Command\TasksCommand; +use SilverStripe\Cli\CommandLoader\ArrayCommandLoader; +use SilverStripe\Cli\CommandLoader\DevCommandLoader; +use SilverStripe\Cli\CommandLoader\DevTaskLoader; +use SilverStripe\Cli\CommandLoader\InjectorCommandLoader; +use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\CoreKernel; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Kernel; +use SilverStripe\Core\Manifest\VersionProvider; +use SilverStripe\Dev\Deprecation; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\DumpCompletionCommand; +use Symfony\Component\Console\Command\HelpCommand; +use Symfony\Component\Console\Command\ListCommand; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * CLI application for running commands against a Silverstripe CMS project + * Boots up a full kernel, using the same configuration and database the web server uses. + */ +class Sake extends Application +{ + use Configurable; + + /** + * Commands that can be run. These commands will be instantiated via the Injector. + * Does not include commands in the dev/ namespace (see command_loaders). + * + * @var array<Command> + */ + private static array $commands = [ + 'navigate' => NavigateCommand::class, + ]; + + /** + * Command loaders for dynamically adding commands to sake. + * These loaders will be instantiated via the Injector. + * + * @var array<CommandLoaderInterface> + */ + private static array $command_loaders = [ + 'dev-commands' => DevCommandLoader::class, + 'dev-tasks' => DevTaskLoader::class, + 'injected' => InjectorCommandLoader::class, + ]; + + /** + * Maximum number of tasks to display in the main command list. + * + * If there are more tasks than this, they will be hidden from the main command list - running `sake tasks` will show them. + * Set to 0 to always show tasks in the main list. + */ + private static int $max_tasks_to_display = 20; + + /** + * Set this to true to hide the "completion" command. + * Useful if you never intend to set up shell completion, or if you've already done so. + */ + private static bool $hide_completion_command = false; + + private ?Kernel $kernel; + + private bool $ignoreTaskLimit = false; + + public function __construct(?Kernel $kernel = null) + { + $this->kernel = $kernel; + parent::__construct('Silverstripe Sake'); + } + + public function getVersion(): string + { + return VersionProvider::singleton()->getVersion(); + } + + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int + { + $input = $input ?? new LegacyParamArgvInput(); + $flush = $input->hasParameterOption('--flush', true) || $input->getFirstArgument() === 'flush'; + $bootDatabase = !$input->hasParameterOption('--no-database', true); + + $managingKernel = !$this->kernel; + if ($managingKernel) { + // Instantiate the kernel if we weren't given a pre-loaded one + $this->kernel = new CoreKernel(BASE_PATH); + } + try { + // Boot if not already booted + if (!$this->kernel->getBooted()) { + if ($this->kernel instanceof CoreKernel) { + $this->kernel->setBootDatabase($bootDatabase); + } + $this->kernel->boot($flush); + } + // Allow developers to hook into symfony/console events + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = Injector::inst()->get(EventDispatcherInterface::class . '.sake'); + $this->setDispatcher($dispatcher); + // Add commands and finally execute + $this->addCommandLoadersFromConfig(); + return parent::run($input, $output); + } finally { + // If we instantiated the kernel, we're also responsible for shutting it down. + if ($managingKernel) { + $this->kernel->shutdown(); + } + } + } + + public function all(?string $namespace = null): array + { + $commands = parent::all($namespace); + // If number of tasks is greater than the limit, hide them from the main comands list. + $maxTasks = Sake::config()->get('max_tasks_to_display'); + if (!$this->ignoreTaskLimit && $maxTasks > 0 && $namespace === null) { + $tasks = []; + // Find all commands in the tasks: namespace + foreach (array_keys($commands) as $name) { + if (str_starts_with($name, 'tasks:') || str_starts_with($name, 'dev/tasks/')) { + $tasks[] = $name; + } + } + if (count($tasks) > $maxTasks) { + // Hide the commands + foreach ($tasks as $name) { + unset($commands[$name]); + } + } + } + return $commands; + } + + /** + * Check whether tasks should currently be hidden from the main command list + */ + public function shouldHideTasks(): bool + { + $maxLimit = Sake::config()->get('max_tasks_to_display'); + return $maxLimit > 0 && count($this->all('tasks')) > $maxLimit; + } + + /** + * Set whether the task limit should be ignored. + * Used by the tasks command and completion to allow listing tasks when there's too many of them + * to list in the main command list. + */ + public function setIgnoreTaskLimit(bool $ignore): void + { + $this->ignoreTaskLimit = $ignore; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + // Make sure tasks can always be shown in completion even if there's too many of them to list + // in the main command list. + $this->setIgnoreTaskLimit(true); + + // Remove legacy dev/* aliases from completion suggestions, but only + // if the user isn't explicitly looking for them (i.e. hasn't typed anything yet) + if (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType() + && $input->getCompletionName() === 'command' + && $input->getCompletionValue() === '' + ) { + foreach ($this->all() as $name => $command) { + // skip hidden commands + // skip aliased commands as they get added below + if ($command->isHidden() || $command->getName() !== $name) { + continue; + } + $suggestions->suggestValue(new Suggestion($command->getName(), $command->getDescription())); + foreach ($command->getAliases() as $name) { + // Skip legacy dev aliases + if (str_starts_with($name, 'dev/')) { + continue; + } + $suggestions->suggestValue(new Suggestion($name, $command->getDescription())); + } + } + + return; + } else { + // For everything else, use the superclass + parent::complete($input, $suggestions); + } + $this->setIgnoreTaskLimit(false); + } + + protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int + { + $name = $command->getName() ?? ''; + $nameUsedAs = $input->getFirstArgument() ?? ''; + if (str_starts_with($nameUsedAs, 'dev/')) { + Deprecation::notice( + '6.0.0', + "Using the command with the name '$nameUsedAs' is deprecated. Use '$name' instead", + Deprecation::SCOPE_GLOBAL + ); + } + return parent::doRunCommand($command, $input, $output); + } + + protected function getDefaultInputDefinition(): InputDefinition + { + $definition = parent::getDefaultInputDefinition(); + $definition->addOptions([ + new InputOption('no-database', null, InputOption::VALUE_NONE, 'Run the command without connecting to the database'), + new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'), + ]); + return $definition; + } + + protected function getDefaultCommands(): array + { + $commands = parent::getDefaultCommands(); + + // Hide commands that are just cluttering up the list + $toHide = [ + // List is the default command, and you have to have used it to see it anyway. + ListCommand::class, + // The --help flag is more common and is already displayed. + HelpCommand::class, + ]; + // Completion is just clutter if you've already used it or aren't going to. + if (Sake::config()->get('hide_completion_command')) { + $toHide[] = DumpCompletionCommand::class; + } + foreach ($commands as $command) { + if (in_array(get_class($command), $toHide)) { + $command->setHidden(true); + } + } + + $commands[] = $this->createFlushCommand(); + $commands[] = new TasksCommand(); + + return $commands; + } + + private function addCommandLoadersFromConfig(): void + { + $loaderClasses = Sake::config()->get('command_loaders'); + $loaders = []; + foreach ($loaderClasses as $class) { + if ($class === null) { + // Allow unsetting loaders via yaml + continue; + } + $loaders[] = Injector::inst()->create($class); + } + $this->setCommandLoader(ArrayCommandLoader::create($loaders)); + } + + /** + * Creates a dummy "flush" command for when you just want to flush without running another command. + */ + private function createFlushCommand(): Command + { + $command = new Command('flush'); + $command->setDescription('Flush the cache (or use the <info>--flush</info> flag with any command)'); + $command->setCode(function (InputInterface $input, OutputInterface $ouput) { + // Actual flushing happens in `run()` when booting the kernel, so there's nothing to do here. + $ouput->writeln('Cache flushed.'); + return Command::SUCCESS; + }); + return $command; + } +} diff --git a/src/Control/CLIRequestBuilder.php b/src/Control/CLIRequestBuilder.php index e122288d5e2..2aa827c7632 100644 --- a/src/Control/CLIRequestBuilder.php +++ b/src/Control/CLIRequestBuilder.php @@ -3,6 +3,7 @@ namespace SilverStripe\Control; use SilverStripe\Core\Environment; +use Symfony\Component\Console\Input\InputInterface; /** * CLI specific request building logic @@ -33,7 +34,7 @@ public static function cleanEnvironment(array $variables) 'HTTP_USER_AGENT' => 'CLI', ], $variables['_SERVER']); - /** + /* * Process arguments and load them into the $_GET and $_REQUEST arrays * For example, * sake my/url somearg otherarg key=val --otherkey=val third=val&fourth=val @@ -48,12 +49,12 @@ public static function cleanEnvironment(array $variables) if (isset($variables['_SERVER']['argv'][2])) { $args = array_slice($variables['_SERVER']['argv'] ?? [], 2); foreach ($args as $arg) { - if (strpos($arg ?? '', '=') == false) { + if (strpos($arg ?? '', '=') === false) { $variables['_GET']['args'][] = $arg; } else { $newItems = []; parse_str((substr($arg ?? '', 0, 2) == '--') ? substr($arg, 2) : $arg, $newItems); - $variables['_GET'] = array_merge($variables['_GET'], $newItems); + $variables['_GET'] = array_merge_recursive($variables['_GET'], $newItems); } } $_REQUEST = array_merge($_REQUEST, $variables['_GET']); @@ -64,7 +65,7 @@ public static function cleanEnvironment(array $variables) $variables['_GET']['url'] = $variables['_SERVER']['argv'][1]; $variables['_SERVER']['REQUEST_URI'] = $variables['_SERVER']['argv'][1]; } - + // Set 'HTTPS' and 'SSL' flag for CLI depending on SS_BASE_URL scheme value. $scheme = parse_url(Environment::getEnv('SS_BASE_URL') ?? '', PHP_URL_SCHEME); if ($scheme == 'https') { @@ -80,9 +81,8 @@ public static function cleanEnvironment(array $variables) * @param array $variables * @param string $input * @param string|null $url - * @return HTTPRequest */ - public static function createFromVariables(array $variables, $input, $url = null) + public static function createFromVariables(array $variables, $input, $url = null): HTTPRequest { $request = parent::createFromVariables($variables, $input, $url); // unset scheme so that SS_BASE_URL can provide `is_https` information if required @@ -93,4 +93,17 @@ public static function createFromVariables(array $variables, $input, $url = null return $request; } + + public static function createFromInput(InputInterface $input): HTTPRequest + { + $variables = []; + $variables['_SERVER']['argv'] = [ + 'sake', + $input->getArgument('path'), + ...$input->getArgument('get-var'), + ]; + $cleanVars = static::cleanEnvironment($variables); + Environment::setVariables($cleanVars); + return static::createFromVariables($cleanVars, []); + } } diff --git a/src/Control/CliController.php b/src/Control/CliController.php deleted file mode 100644 index 30d66250b93..00000000000 --- a/src/Control/CliController.php +++ /dev/null @@ -1,58 +0,0 @@ -<?php - -namespace SilverStripe\Control; - -use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Deprecation; -use SilverStripe\Security\Permission; -use SilverStripe\Security\Security; - -/** - * Base class invoked from CLI rather than the webserver (Cron jobs, handling email bounces). - * You can call subclasses of CliController directly, which will trigger a - * call to {@link process()} on every sub-subclass. For instance, calling - * "sake DailyTask" from the commandline will call {@link process()} on every subclass - * of DailyTask. - * - * @deprecated 5.4.0 Will be replaced with symfony/console commands - */ -abstract class CliController extends Controller -{ - public function __construct() - { - parent::__construct(); - Deprecation::notice('5.4.0', 'Will be replaced with symfony/console commands', Deprecation::SCOPE_CLASS); - } - - private static $allowed_actions = [ - 'index' - ]; - - protected function init() - { - parent::init(); - // Unless called from the command line, all CliControllers need ADMIN privileges - if (!Director::is_cli() && !Permission::check("ADMIN")) { - Security::permissionFailure(); - } - } - - public function index() - { - foreach (ClassInfo::subclassesFor(static::class) as $subclass) { - echo $subclass . "\n"; - /** @var CliController $task */ - $task = Injector::inst()->create($subclass); - $task->doInit(); - $task->process(); - } - } - - /** - * Overload this method to contain the task logic. - */ - public function process() - { - } -} diff --git a/src/Control/Director.php b/src/Control/Director.php index 119d4e746d0..3ff9a456c7a 100644 --- a/src/Control/Director.php +++ b/src/Control/Director.php @@ -12,6 +12,7 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Kernel; use SilverStripe\Core\Path; +use SilverStripe\PolyExecution\PolyCommand; use SilverStripe\Versioned\Versioned; use SilverStripe\View\Requirements; use SilverStripe\View\Requirements_Backend; @@ -345,6 +346,9 @@ public function handleRequest(HTTPRequest $request) try { /** @var RequestHandler $controllerObj */ $controllerObj = Injector::inst()->create($arguments['Controller']); + if ($controllerObj instanceof PolyCommand) { + $controllerObj = PolyCommandController::create($controllerObj); + } return $controllerObj->handleRequest($request); } catch (HTTPResponse_Exception $responseException) { return $responseException->getResponse(); diff --git a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php b/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php deleted file mode 100644 index 1a16be0c695..00000000000 --- a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php - -namespace SilverStripe\Control\Middleware\ConfirmationMiddleware; - -use SilverStripe\Control\Director; -use SilverStripe\Control\HTTPRequest; -use SilverStripe\Core\Kernel; -use SilverStripe\Dev\Deprecation; - -/** - * Allows a bypass when the request has been run in CLI mode - * - * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it - */ -class CliBypass implements Bypass -{ - public function __construct() - { - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be removed without equivalent functionality to replace it', - Deprecation::SCOPE_CLASS - ); - }); - } - - /** - * Returns true if the current process is running in CLI mode - * - * @param HTTPRequest $request - * - * @return bool - */ - public function checkRequestForBypass(HTTPRequest $request) - { - return Director::is_cli(); - } -} diff --git a/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php b/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php index 25976e9ec67..d85fda587d8 100644 --- a/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php +++ b/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php @@ -4,7 +4,6 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\DevelopmentAdmin; use SilverStripe\Security\Permission; @@ -25,7 +24,6 @@ */ class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmationMiddleware { - /** * Check whether the user has permissions to perform the target operation * Otherwise we may want to skip the confirmation dialog. @@ -43,21 +41,10 @@ public function hasAccess(HTTPRequest $request) return false; } - $registeredRoutes = DevelopmentAdmin::config()->get('registered_controllers'); - while (!isset($registeredRoutes[$action]) && strpos($action, '/') !== false) { - // Check for the parent route if a specific route isn't found - $action = substr($action, 0, strrpos($action, '/')); - } - - if (isset($registeredRoutes[$action]['controller'])) { - $initPermissions = Config::forClass($registeredRoutes[$action]['controller'])->get('init_permissions'); - foreach ($initPermissions as $permission) { - if (Permission::check($permission)) { - return true; - } - } - } - - return false; + $url = rtrim($request->getURL(), '/'); + $registeredRoutes = DevelopmentAdmin::singleton()->getLinks(); + // Permissions were already checked when generating the links list, so if + // it's in the list the user has access. + return isset($registeredRoutes[$url]); } } diff --git a/src/Control/Middleware/URLSpecialsMiddleware.php b/src/Control/Middleware/URLSpecialsMiddleware.php index f32d779f544..ddbec1a2d0f 100644 --- a/src/Control/Middleware/URLSpecialsMiddleware.php +++ b/src/Control/Middleware/URLSpecialsMiddleware.php @@ -22,7 +22,7 @@ * - isTest GET parameter * - dev/build URL * - * @see https://docs.silverstripe.org/en/4/developer_guides/debugging/url_variable_tools/ special variables docs + * @see https://docs.silverstripe.org/en/developer_guides/debugging/url_variable_tools/ special variables docs * * {@inheritdoc} */ diff --git a/src/Control/PolyCommandController.php b/src/Control/PolyCommandController.php new file mode 100644 index 00000000000..0e4df84e0bd --- /dev/null +++ b/src/Control/PolyCommandController.php @@ -0,0 +1,65 @@ +<?php + +namespace SilverStripe\Control; + +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\HttpRequestInput; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Output\BufferedOutput; + +/** + * Controller that allows routing HTTP requests to PolyCommands + * + * This controller is automatically wrapped around any PolyCommand + * that is added to the regular routing configuration. + */ +class PolyCommandController extends Controller +{ + private PolyCommand $command; + + public function __construct(PolyCommand $polyCommand) + { + $this->command = $polyCommand; + parent::__construct(); + } + + protected function init() + { + parent::init(); + if (!$this->command::canRunInBrowser()) { + $this->httpError(404); + } + } + + public function index(HTTPRequest $request): HTTPResponse + { + $response = $this->getResponse(); + + try { + $input = HttpRequestInput::create($request, $this->command->getOptions()); + } catch (InvalidOptionException|InvalidArgumentException $e) { + $response->setBody($e->getMessage()); + $response->setStatusCode(400); + $this->afterHandleRequest(); + return $this->getResponse(); + } + + $buffer = new BufferedOutput(); + $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true, $buffer); + $exitCode = $this->command->run($input, $output); + $response->setBody($buffer->fetch()); + $responseCode = match (true) { + $exitCode === Command::SUCCESS => 200, + $exitCode === Command::FAILURE => 500, + $exitCode === Command::INVALID => 400, + // If someone's using an unexpected exit code, we shouldn't guess what they meant, + // just assume they intentionally set it to something meaningful. + default => $exitCode, + }; + $response->setStatusCode($responseCode); + return $this->getResponse(); + } +} diff --git a/src/Core/CoreKernel.php b/src/Core/CoreKernel.php index 750859d36dd..e3f92b8f7a7 100644 --- a/src/Core/CoreKernel.php +++ b/src/Core/CoreKernel.php @@ -30,7 +30,6 @@ public function setBootDatabase(bool $bool): static } /** - * @param false $flush * @throws HTTPResponse_Exception * @throws Exception */ diff --git a/src/Core/DatabaselessKernel.php b/src/Core/DatabaselessKernel.php deleted file mode 100644 index c3c809dc391..00000000000 --- a/src/Core/DatabaselessKernel.php +++ /dev/null @@ -1,70 +0,0 @@ -<?php - -namespace SilverStripe\Core; - -use Exception; -use SilverStripe\Dev\Deprecation; - -/** - * Boot a kernel without requiring a database connection. - * This is a workaround for the lack of composition in the boot stages - * of CoreKernel, as well as for the framework's misguided assumptions - * around the availability of a database for every execution path. - * - * @internal - * @deprecated 5.4.0 Use SilverStripe\Core\CoreKernel::setBootDatabase() instead - */ -class DatabaselessKernel extends BaseKernel -{ - /** - * Indicates whether the Kernel has been flushed on boot - * Null before boot - */ - private ?bool $flush = null; - - /** - * Allows disabling of the configured error handling. - * This can be useful to ensure the execution context (e.g. composer) - * can consistently use its own error handling. - * - * @var boolean - */ - protected $bootErrorHandling = true; - - public function __construct($basePath) - { - parent::__construct($basePath); - Deprecation::notice( - '5.4.0', - 'Use ' . CoreKernel::class . '::setBootDatabase() instead', - Deprecation::SCOPE_CLASS - ); - } - - public function setBootErrorHandling(bool $bool) - { - $this->bootErrorHandling = $bool; - return $this; - } - - /** - * @param false $flush - * @throws Exception - */ - public function boot($flush = false) - { - $this->flush = $flush; - - $this->bootPHP(); - $this->bootManifests($flush); - $this->bootErrorHandling(); - $this->bootConfigs(); - - $this->setBooted(true); - } - - public function isFlushed(): ?bool - { - return $this->flush; - } -} diff --git a/src/Core/Kernel.php b/src/Core/Kernel.php index a64590bcec7..0315ad5c97f 100644 --- a/src/Core/Kernel.php +++ b/src/Core/Kernel.php @@ -139,4 +139,9 @@ public function setEnvironment($environment); * @return bool|null null if the kernel hasn't been booted yet */ public function isFlushed(): ?bool; + + /** + * Returns whether the kernel has been booted + */ + public function getBooted(): bool; } diff --git a/src/Core/Manifest/ClassManifest.php b/src/Core/Manifest/ClassManifest.php index a89a38f75f6..9b8b32b3618 100644 --- a/src/Core/Manifest/ClassManifest.php +++ b/src/Core/Manifest/ClassManifest.php @@ -549,7 +549,7 @@ public function regenerate($includeTests) $finder = new ManifestFileFinder(); $finder->setOptions([ 'name_regex' => '/^[^_].*\\.php$/', - 'ignore_files' => ['index.php', 'cli-script.php'], + 'ignore_files' => ['index.php', 'bin/sake.php'], 'ignore_tests' => !$includeTests, 'file_callback' => function ($basename, $pathname, $depth) use ($includeTests) { $this->handleFile($basename, $pathname, $includeTests); diff --git a/src/Dev/BuildTask.php b/src/Dev/BuildTask.php index 5497cc3f0f0..16e1d2466b3 100644 --- a/src/Dev/BuildTask.php +++ b/src/Dev/BuildTask.php @@ -2,103 +2,112 @@ namespace SilverStripe\Dev; -use SilverStripe\Control\HTTPRequest; -use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Config\Configurable; +use LogicException; use SilverStripe\Core\Extensible; -use SilverStripe\Core\Injector\Injectable; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\FieldType\DBDatetime; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; /** - * Interface for a generic build task. Does not support dependencies. This will simply - * run a chunk of code when called. - * - * To disable the task (in the case of potentially destructive updates or deletes), declare - * the $Disabled property on the subclass. + * A task that can be run either from the CLI or via an HTTP request. + * This is often used for post-deployment tasks, e.g. migrating data to fit a new schema. */ -abstract class BuildTask +abstract class BuildTask extends PolyCommand { - use Injectable; - use Configurable; use Extensible; - public function __construct() - { - } - /** - * Set a custom url segment (to follow dev/tasks/) - * - * @config - * @var string - * @deprecated 5.4.0 Will be replaced with $commandName + * Shown in the overview on the {@link TaskRunner} + * HTML or CLI interface. Should be short and concise. + * Do not use HTML markup. */ - private static $segment = null; + protected string $title; /** - * Make this non-nullable and change this to `bool` in CMS6 with a value of `true` - * @var bool|null + * Whether the task is allowed to be run or not. + * This property overrides `can_run_in_cli` and `can_run_in_browser` if set to false. */ - private static ?bool $is_enabled = null; + private static bool $is_enabled = true; /** - * @var bool $enabled If set to FALSE, keep it from showing in the list - * and from being executable through URL or CLI. - * @deprecated - remove in CMS 6 and rely on $is_enabled instead + * Describe the implications the task has, and the changes it makes. + * Do not use HTML markup. */ - protected $enabled = true; + protected static string $description = 'No description available'; - /** - * @var string $title Shown in the overview on the {@link TaskRunner} - * HTML or CLI interface. Should be short and concise, no HTML allowed. - */ - protected $title; + private static array $permissions_for_browser_execution = [ + 'ADMIN', + 'anyone_with_dev_admin_permissions' => 'ALL_DEV_ADMIN', + 'anyone_with_task_permissions' => 'BUILDTASK_CAN_RUN', + ]; - /** - * @var string $description Describe the implications the task has, - * and the changes it makes. Accepts HTML formatting. - * @deprecated 5.4.0 Will be replaced with a static property with the same name - */ - protected $description = 'No description available'; + public function __construct() + { + } /** - * Implement this method in the task subclass to - * execute via the TaskRunner + * The code for running this task. * - * @param HTTPRequest $request - * @return void + * Output should be agnostic - do not include explicit HTML in the output unless there is no API + * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method). + * + * Use symfony/console ANSI formatting to style the output. + * See https://symfony.com/doc/current/console/coloring.html + * + * @return int 0 if everything went fine, or an exit code */ - abstract public function run($request); + abstract protected function execute(InputInterface $input, PolyOutput $output): int; - /** - * @return bool - */ - public function isEnabled() + public function run(InputInterface $input, PolyOutput $output): int { - $isEnabled = $this->config()->get('is_enabled'); + $output->writeForFormat(PolyOutput::FORMAT_ANSI, "<options=bold>Running task '{$this->getTitle()}'</>", true); + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + "<h1>Running task '{$this->getTitle()}'</h1>", + false, + PolyOutput::OUTPUT_RAW + ); - if ($isEnabled === null) { - return $this->enabled; + $before = DBDatetime::now(); + $exitCode = $this->execute($input, $output); + $after = DBDatetime::now(); + + $message = "Task '{$this->getTitle()}' "; + if ($exitCode === Command::SUCCESS) { + $message .= 'completed successfully'; + } else { + $message .= 'failed'; } - return $isEnabled; + $timeTaken = DBDatetime::getTimeBetween($before, $after); + $message .= " in $timeTaken"; + $output->writeln(['', "<options=bold>{$message}</>"]); + return $exitCode; } - /** - * @return string - */ - public function getTitle() + public function isEnabled(): bool { - return $this->title ?: static::class; + return $this->config()->get('is_enabled'); } - /** - * @return string HTML formatted description - * @deprecated 5.4.0 Will be replaced with a static method with the same name - */ - public function getDescription() + public function getTitle(): string { - Deprecation::withNoReplacement( - fn() => Deprecation::notice('5.4.0', 'Will be replaced with a static method with the same name') - ); - return $this->description; + return $this->title ?? static::class; + } + + public static function getName(): string + { + return 'tasks:' . static::getNameWithoutNamespace(); + } + + public static function getNameWithoutNamespace(): string + { + $name = parent::getName() ?: str_replace('\\', '-', static::class); + // Don't allow `:` or `/` because it would affect routing and CLI namespacing + if (str_contains($name, ':') || str_contains($name, '/')) { + throw new LogicException('commandName must not contain `:` or `/`. Got ' . $name); + } + return $name; } } diff --git a/src/Dev/Command/ConfigAudit.php b/src/Dev/Command/ConfigAudit.php new file mode 100644 index 00000000000..8c823ec62f8 --- /dev/null +++ b/src/Dev/Command/ConfigAudit.php @@ -0,0 +1,117 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; + +/** + * Command to audit the configuration. + * Can be run either via an HTTP request or the CLI. + */ +class ConfigAudit extends DevCommand +{ + protected static string $commandName = 'config:audit'; + + protected static string $description = 'Find configuration properties that are not defined (or inherited) by their respective classes'; + + private static string|array $permissions_for_browser_execution = [ + 'CAN_DEV_CONFIG', + ]; + + public function getTitle(): string + { + return 'Configuration'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $body = ''; + $missing = []; + + foreach ($this->arrayKeysRecursive(Config::inst()->getAll(), 2) as $className => $props) { + $props = array_keys($props ?? []); + + if (!count($props ?? [])) { + // We can skip this entry + continue; + } + + if ($className == strtolower(Injector::class)) { + // We don't want to check the injector config + continue; + } + + foreach ($props as $prop) { + $defined = false; + // Check ancestry (private properties don't inherit natively) + foreach (ClassInfo::ancestry($className) as $cn) { + if (property_exists($cn, $prop ?? '')) { + $defined = true; + break; + } + } + + if ($defined) { + // No need to record this property + continue; + } + + $missing[] = sprintf("%s::$%s\n", $className, $prop); + } + } + + $body = count($missing ?? []) + ? implode("\n", $missing) + : "All configured properties are defined\n"; + + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + '<pre>', + options: PolyOutput::OUTPUT_RAW + ); + $output->write($body); + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + '</pre>', + options: PolyOutput::OUTPUT_RAW + ); + + return Command::SUCCESS; + } + + protected function getHeading(): string + { + return 'Missing configuration property definitions'; + } + + /** + * Returns all the keys of a multi-dimensional array while maintining any nested structure. + * Does not include keys where the values are not arrays, so not suitable as a generic method. + */ + private function arrayKeysRecursive( + array $array, + int $maxdepth = 20, + int $depth = 0, + array $arrayKeys = [] + ): array { + if ($depth < $maxdepth) { + $depth++; + $keys = array_keys($array ?? []); + + foreach ($keys as $key) { + if (!is_array($array[$key])) { + continue; + } + + $arrayKeys[$key] = $this->arrayKeysRecursive($array[$key], $maxdepth, $depth); + } + } + + return $arrayKeys; + } +} diff --git a/src/Dev/Command/ConfigDump.php b/src/Dev/Command/ConfigDump.php new file mode 100644 index 00000000000..64f2f7d4d4a --- /dev/null +++ b/src/Dev/Command/ConfigDump.php @@ -0,0 +1,66 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\Core\Config\Config; +use SilverStripe\Dev\DevelopmentAdmin; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\Security\PermissionProvider; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Command to dump the configuration. + * Can be run either via an HTTP request or the CLI. + */ +class ConfigDump extends DevCommand implements PermissionProvider +{ + protected static string $commandName = 'config:dump'; + + protected static string $description = 'View the current config, useful for debugging'; + + private static array $permissions_for_browser_execution = [ + 'CAN_DEV_CONFIG', + ]; + + public function getTitle(): string + { + return 'Configuration'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + '<pre>', + options: PolyOutput::OUTPUT_RAW + ); + + $output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE)); + + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + '</pre>', + options: PolyOutput::OUTPUT_RAW + ); + return Command::SUCCESS; + } + + protected function getHeading(): string + { + return 'Config manifest'; + } + + public function providePermissions(): array + { + return [ + 'CAN_DEV_CONFIG' => [ + 'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'), + 'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'), + 'category' => DevelopmentAdmin::permissionsCategory(), + 'sort' => 100 + ], + ]; + } +} diff --git a/src/Dev/Command/DbBuild.php b/src/Dev/Command/DbBuild.php new file mode 100644 index 00000000000..10e452a97a5 --- /dev/null +++ b/src/Dev/Command/DbBuild.php @@ -0,0 +1,347 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use BadMethodCallException; +use SilverStripe\Control\Director; +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Environment; +use SilverStripe\Core\Extensible; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Manifest\ClassLoader; +use SilverStripe\Dev\Deprecation; +use SilverStripe\Dev\DevelopmentAdmin; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\Connect\TableBuilder; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DB; +use SilverStripe\ORM\FieldType\DBClassName; +use SilverStripe\Security\PermissionProvider; +use SilverStripe\Security\Security; +use SilverStripe\Versioned\Versioned; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +/** + * Command to build the database. + * Can be run either via an HTTP request or the CLI. + */ +class DbBuild extends DevCommand implements PermissionProvider +{ + use Extensible; + + protected static string $commandName = 'db:build'; + + protected static string $description = 'Build/rebuild this environment. Run this whenever you have updated your project sources'; + + private static array $permissions_for_browser_execution = [ + 'CAN_DEV_BUILD', + ]; + + /** + * Obsolete classname values that should be remapped while building the database. + * Map old FQCN to new FQCN, e.g + * 'App\\OldNamespace\\MyClass' => 'App\\NewNamespace\\MyClass' + */ + private static array $classname_value_remapping = []; + + /** + * Config setting to enabled/disable the display of record counts on the build output + */ + private static bool $show_record_counts = true; + + public function getTitle(): string + { + return 'Environment Builder'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + // The default time limit of 30 seconds is normally not enough + Environment::increaseTimeLimitTo(600); + + // If this code is being run without a flush, we need to at least flush the class manifest + if (!$input->getOption('flush')) { + ClassLoader::inst()->getManifest()->regenerate(false); + } + + $populate = !$input->getOption('no-populate'); + if ($input->getOption('dont_populate')) { + $populate = false; + Deprecation::notice( + '6.0.0', + '`dont_populate` is deprecated. Use `no-populate` instead', + Deprecation::SCOPE_GLOBAL + ); + } + $this->doBuild($output, $populate); + return Command::SUCCESS; + } + + protected function getHeading(): string + { + $conn = DB::get_conn(); + // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database") + $dbType = substr(get_class($conn), 0, -8); + $dbVersion = $conn->getVersion(); + $databaseName = $conn->getSelectedDatabase(); + return sprintf('Building database %s using %s %s', $databaseName, $dbType, $dbVersion); + } + + /** + * Updates the database schema, creating tables & fields as necessary. + * + * @param bool $populate Populate the database, as well as setting up its schema + */ + public function doBuild(PolyOutput $output, bool $populate = true, bool $testMode = false): void + { + $this->extend('onBeforeBuild', $output, $populate, $testMode); + + if ($output->isQuiet()) { + DB::quiet(); + } + + // Set up the initial database + if (!DB::is_active()) { + $output->writeln(['<options=bold>Creating database</>', '']); + + // Load parameters from existing configuration + $databaseConfig = DB::getConfig(); + if (empty($databaseConfig)) { + throw new BadMethodCallException("No database configuration available"); + } + + // Check database name is given + if (empty($databaseConfig['database'])) { + throw new BadMethodCallException( + "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME" + ); + } + $database = $databaseConfig['database']; + + // Establish connection + unset($databaseConfig['database']); + DB::connect($databaseConfig); + + // Create database + DB::create_database($database); + } + + // Build the database. Most of the hard work is handled by DataObject + $dataClasses = ClassInfo::subclassesFor(DataObject::class); + array_shift($dataClasses); + + $output->writeln(['<options=bold>Creating database tables</>', '']); + $output->startList(PolyOutput::LIST_UNORDERED); + + $showRecordCounts = (bool) static::config()->get('show_record_counts'); + + // Initiate schema update + $dbSchema = DB::get_schema(); + $tableBuilder = TableBuilder::singleton(); + $tableBuilder->buildTables($dbSchema, $dataClasses, [], $output->isQuiet(), $testMode, $showRecordCounts); + ClassInfo::reset_db_cache(); + + $output->stopList(); + + if ($populate) { + $output->writeln(['<options=bold>Creating database records</>', '']); + $output->startList(PolyOutput::LIST_UNORDERED); + + // Remap obsolete class names + $this->migrateClassNames(); + + // Require all default records + foreach ($dataClasses as $dataClass) { + // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness + // Test_ indicates that it's the data class is part of testing system + if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) { + $output->writeListItem($dataClass); + DataObject::singleton($dataClass)->requireDefaultRecords(); + } + } + + $output->stopList(); + } + + touch(static::getLastGeneratedFilePath()); + + $output->writeln(['<options=bold>Database build completed!</>', '']); + + foreach ($dataClasses as $dataClass) { + DataObject::singleton($dataClass)->onAfterBuild(); + } + + ClassInfo::reset_db_cache(); + + $this->extend('onAfterBuild', $output, $populate, $testMode); + } + + public function getOptions(): array + { + return [ + new InputOption( + 'no-populate', + null, + InputOption::VALUE_NONE, + 'Don\'t run <info>requireDefaultRecords()</info> on the models when building.' + . 'This will build the table but not insert any records' + ), + new InputOption( + 'dont_populate', + null, + InputOption::VALUE_NONE, + 'Deprecated - use <info>no-populate</info> instead' + ) + ]; + } + + public function providePermissions(): array + { + return [ + 'CAN_DEV_BUILD' => [ + 'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'), + 'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'), + 'category' => DevelopmentAdmin::permissionsCategory(), + 'sort' => 100 + ], + ]; + } + + /** + * Given a base data class, a field name and a mapping of class replacements, look for obsolete + * values in the $dataClass's $fieldName column and replace it with $mapping + * + * @param string $dataClass The data class to look up + * @param string $fieldName The field name to look in for obsolete class names + * @param string[] $mapping Map of old to new classnames + */ + protected function updateLegacyClassNameField(string $dataClass, string $fieldName, array $mapping): void + { + $schema = DataObject::getSchema(); + // Check first to ensure that the class has the specified field to update + if (!$schema->databaseField($dataClass, $fieldName, false)) { + return; + } + + // Load a list of any records that have obsolete class names + $table = $schema->tableName($dataClass); + $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column(); + + // Get all invalid classes for this field + $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? [])); + if (!$invalidClasses) { + return; + } + + $numberClasses = count($invalidClasses ?? []); + DB::alteration_message( + "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types", + 'obsolete' + ); + + // Build case assignment based on all intersected legacy classnames + $cases = []; + $params = []; + foreach ($invalidClasses as $invalidClass) { + $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?"; + $params[] = $invalidClass; + $params[] = $mapping[$invalidClass]; + } + + foreach ($this->getClassTables($dataClass) as $table) { + $casesSQL = implode(' ', $cases); + $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END"; + DB::prepared_query($sql, $params); + } + } + + /** + * Get tables to update for this class + */ + protected function getClassTables(string $dataClass): iterable + { + $schema = DataObject::getSchema(); + $table = $schema->tableName($dataClass); + + // Base table + yield $table; + + // Remap versioned table class name values as well + /** @var Versioned|DataObject $dataClass */ + $dataClass = DataObject::singleton($dataClass); + if ($dataClass->hasExtension(Versioned::class)) { + if ($dataClass->hasStages()) { + yield "{$table}_Live"; + } + yield "{$table}_Versions"; + } + } + + /** + * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes + * `ClassName` fields as well as polymorphic class name fields. + * + * @return array[] + */ + protected function getClassNameRemappingFields(): array + { + $dataClasses = ClassInfo::getValidSubClasses(DataObject::class); + $schema = DataObject::getSchema(); + $remapping = []; + + foreach ($dataClasses as $className) { + $fieldSpecs = $schema->fieldSpecs($className); + foreach ($fieldSpecs as $fieldName => $fieldSpec) { + if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) { + $remapping[$className][] = $fieldName; + } + } + } + + return $remapping; + } + + /** + * Migrate all class names + */ + protected function migrateClassNames(): void + { + $remappingConfig = static::config()->get('classname_value_remapping'); + $remappingFields = $this->getClassNameRemappingFields(); + foreach ($remappingFields as $className => $fieldNames) { + foreach ($fieldNames as $fieldName) { + $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig); + } + } + } + + /** + * Returns the timestamp of the time that the database was last built + * or an empty string if we can't find that information. + */ + public static function lastBuilt(): string + { + $file = static::getLastGeneratedFilePath(); + if (file_exists($file)) { + return filemtime($file); + } + return ''; + } + + public static function canRunInBrowser(): bool + { + // Must allow running in browser if DB hasn't been built yet or is broken + // or the permission checks will throw an error + return !Security::database_is_ready() || parent::canRunInBrowser(); + } + + private static function getLastGeneratedFilePath(): string + { + return TEMP_PATH + . DIRECTORY_SEPARATOR + . 'database-last-generated-' + . str_replace(['\\', '/', ':'], '.', Director::baseFolder()); + } +} diff --git a/src/Dev/Command/DbCleanup.php b/src/Dev/Command/DbCleanup.php new file mode 100644 index 00000000000..6442d88e891 --- /dev/null +++ b/src/Dev/Command/DbCleanup.php @@ -0,0 +1,90 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\Core\ClassInfo; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DB; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; + +/** + * Command to clean up the database. + * Can be run either via an HTTP request or the CLI. + */ +class DbCleanup extends DevCommand +{ + protected static string $commandName = 'db:cleanup'; + + protected static string $description = 'Remove records that don\'t have corresponding rows in their parent class tables'; + + private static array $permissions_for_browser_execution = [ + 'CAN_DEV_BUILD', + ]; + + public function getTitle(): string + { + return 'Database Cleanup'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $schema = DataObject::getSchema(); + $baseClasses = []; + foreach (ClassInfo::subclassesFor(DataObject::class) as $class) { + if (get_parent_class($class ?? '') == DataObject::class) { + $baseClasses[] = $class; + } + } + + $countDeleted = 0; + $output->startList(PolyOutput::LIST_UNORDERED); + foreach ($baseClasses as $baseClass) { + // Get data classes + $baseTable = $schema->baseDataTable($baseClass); + $subclasses = ClassInfo::subclassesFor($baseClass); + unset($subclasses[0]); + foreach ($subclasses as $k => $subclass) { + if (!DataObject::getSchema()->classHasTable($subclass)) { + unset($subclasses[$k]); + } + } + + if ($subclasses) { + $records = DB::query("SELECT * FROM \"$baseTable\""); + + + foreach ($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); + $recordExists[$subclass] = + DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn(); + } + + foreach ($records as $record) { + foreach ($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); + $id = $record['ID']; + if (($record['ClassName'] != $subclass) + && (!is_subclass_of($record['ClassName'], $subclass ?? '')) + && isset($recordExists[$subclass][$id]) + ) { + $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?"; + $output->writeListItem("$sql [{$id}]"); + DB::prepared_query($sql, [$id]); + $countDeleted++; + } + } + } + } + } + $output->stopList(); + $output->writeln("Deleted {$countDeleted} rows"); + return Command::SUCCESS; + } + + protected function getHeading(): string + { + return 'Deleting records with no corresponding row in their parent class tables'; + } +} diff --git a/src/Dev/Command/DbDefaults.php b/src/Dev/Command/DbDefaults.php new file mode 100644 index 00000000000..c387e55eddb --- /dev/null +++ b/src/Dev/Command/DbDefaults.php @@ -0,0 +1,49 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\Core\ClassInfo; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\DataObject; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; + +/** + * Command to build default records in the database. + * Can be run either via an HTTP request or the CLI. + */ +class DbDefaults extends DevCommand +{ + protected static string $commandName = 'db:defaults'; + + protected static string $description = 'Build the default data, calling requireDefaultRecords on all DataObject classes'; + + private static array $permissions_for_browser_execution = [ + 'CAN_DEV_BUILD', + ]; + + public function getTitle(): string + { + return 'Defaults Builder'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $dataClasses = ClassInfo::subclassesFor(DataObject::class); + array_shift($dataClasses); + + $output->startList(PolyOutput::LIST_UNORDERED); + foreach ($dataClasses as $dataClass) { + singleton($dataClass)->requireDefaultRecords(); + $output->writeListItem("Defaults loaded for $dataClass"); + } + $output->stopList(); + + return Command::SUCCESS; + } + + protected function getHeading(): string + { + return 'Building default data for all DataObject classes'; + } +} diff --git a/src/Dev/Command/DevCommand.php b/src/Dev/Command/DevCommand.php new file mode 100644 index 00000000000..d3677f09c2b --- /dev/null +++ b/src/Dev/Command/DevCommand.php @@ -0,0 +1,58 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Terminal; + +/** + * A command that can be run from CLI or via an HTTP request in a dev/* route + */ +abstract class DevCommand extends PolyCommand +{ + private static array $permissions_for_browser_execution = [ + 'ADMIN', + 'anyone_with_dev_admin_permissions' => 'ALL_DEV_ADMIN', + ]; + + public function run(InputInterface $input, PolyOutput $output): int + { + $terminal = new Terminal(); + $heading = $this->getHeading(); + if ($heading) { + // Output heading + $underline = str_repeat('-', min($terminal->getWidth(), strlen($heading))); + $output->writeForFormat(PolyOutput::FORMAT_ANSI, ["<options=bold>{$heading}</>", $underline], true); + $output->writeForFormat(PolyOutput::FORMAT_HTML, "<h2>{$heading}</h2>", false, PolyOutput::OUTPUT_RAW); + } else { + // Only print the title in CLI (and only if there's no heading) + // The DevAdminController outputs the title already for HTTP stuff. + $title = $this->getTitle(); + $underline = str_repeat('-', min($terminal->getWidth(), strlen($title))); + $output->writeForFormat(PolyOutput::FORMAT_ANSI, ["<options=bold>{$title}</>", $underline], true); + } + + return $this->execute($input, $output); + } + + /** + * The code for running this command. + * + * Output should be agnostic - do not include explicit HTML in the output unless there is no API + * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method). + * + * Use symfony/console ANSI formatting to style the output. + * See https://symfony.com/doc/current/console/coloring.html + * + * @return int 0 if everything went fine, or an exit code + */ + abstract protected function execute(InputInterface $input, PolyOutput $output): int; + + /** + * Content to output before command is executed. + * In HTML format this will be an h2. + */ + abstract protected function getHeading(): string; +} diff --git a/src/Dev/Command/GenerateSecureToken.php b/src/Dev/Command/GenerateSecureToken.php new file mode 100644 index 00000000000..65237f8055d --- /dev/null +++ b/src/Dev/Command/GenerateSecureToken.php @@ -0,0 +1,55 @@ +<?php + +namespace SilverStripe\Dev\Command; + +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\Security\RandomGenerator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +/** + * Command to generate a secure token. + * Can be run either via an HTTP request or the CLI. + */ +class GenerateSecureToken extends DevCommand +{ + protected static string $commandName = 'generatesecuretoken'; + + protected static string $description = 'Generate a secure token'; + + public function getTitle(): string + { + return 'Secure token'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $token = RandomGenerator::create()->randomToken($input->getOption('algorithm')); + + $output->writeForFormat(PolyOutput::FORMAT_HTML, '<code>', options: PolyOutput::OUTPUT_RAW); + $output->writeln($token); + $output->writeForFormat(PolyOutput::FORMAT_HTML, '</code>', options: PolyOutput::OUTPUT_RAW); + + return Command::SUCCESS; + } + + protected function getHeading(): string + { + return 'Generating new token'; + } + + public function getOptions(): array + { + return [ + new InputOption( + 'algorithm', + null, + InputOption::VALUE_REQUIRED, + 'The hashing algorithm used to generate the token. Can be any identifier listed in <href=https://www.php.net/manual/en/function.hash-algos.php>hash_algos()</>', + 'sha1', + hash_algos() + ), + ]; + } +} diff --git a/src/Dev/Deprecation.php b/src/Dev/Deprecation.php index 9c7d4f5dab1..ab4d22a34fa 100644 --- a/src/Dev/Deprecation.php +++ b/src/Dev/Deprecation.php @@ -280,7 +280,7 @@ public static function notice($atVersion, $string = '', $scope = Deprecation::SC $data = null; if ($scope === Deprecation::SCOPE_CONFIG) { // Deprecated config set via yaml will only be shown in the browser when using ?flush=1 - // It will not show in CLI when running dev/build flush=1 + // It will not show in CLI when running db:build --flush $data = [ 'key' => sha1($string), 'message' => $string, diff --git a/src/Dev/DevBuildController.php b/src/Dev/DevBuildController.php deleted file mode 100644 index 1559296865a..00000000000 --- a/src/Dev/DevBuildController.php +++ /dev/null @@ -1,98 +0,0 @@ -<?php - -namespace SilverStripe\Dev; - -use SilverStripe\Control\Controller; -use SilverStripe\Control\Director; -use SilverStripe\Control\HTTPRequest; -use SilverStripe\Control\HTTPResponse; -use SilverStripe\ORM\DatabaseAdmin; -use SilverStripe\Security\Permission; -use SilverStripe\Security\PermissionProvider; -use SilverStripe\Security\Security; - -/** - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild - */ -class DevBuildController extends Controller implements PermissionProvider -{ - - private static $url_handlers = [ - '' => 'build' - ]; - - private static $allowed_actions = [ - 'build' - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_BUILD', - ]; - - public function __construct() - { - parent::__construct(); - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\DbBuild', - Deprecation::SCOPE_CLASS - ); - }); - } - - protected function init(): void - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure($this); - } - } - - public function build(HTTPRequest $request): HTTPResponse - { - if (Director::is_cli()) { - $da = DatabaseAdmin::create(); - return $da->handleRequest($request); - } else { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("Environment Builder", Director::absoluteBaseURL()); - echo "<div class=\"build\">"; - - $da = DatabaseAdmin::create(); - $response = $da->handleRequest($request); - - echo "</div>"; - echo $renderer->renderFooter(); - - return $response; - } - } - - public function canInit(): bool - { - return ( - Director::isDev() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tasks" from CLI. - || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) - || Permission::check(static::config()->get('init_permissions')) - ); - } - - public function providePermissions(): array - { - return [ - 'CAN_DEV_BUILD' => [ - 'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'), - 'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'), - 'category' => DevelopmentAdmin::permissionsCategory(), - 'sort' => 100 - ], - ]; - } -} diff --git a/src/Dev/DevConfigController.php b/src/Dev/DevConfigController.php deleted file mode 100644 index 056f0ee04fe..00000000000 --- a/src/Dev/DevConfigController.php +++ /dev/null @@ -1,214 +0,0 @@ -<?php - -namespace SilverStripe\Dev; - -use SilverStripe\Control\Controller; -use SilverStripe\Control\Director; -use SilverStripe\Control\HTTPResponse; -use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\Security\Permission; -use SilverStripe\Security\PermissionProvider; -use SilverStripe\Security\Security; -use Symfony\Component\Yaml\Yaml; - -/** - * Outputs the full configuration. - * - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\ConfigDump - */ -class DevConfigController extends Controller implements PermissionProvider -{ - - /** - * @var array - */ - private static $url_handlers = [ - 'audit' => 'audit', - '' => 'index' - ]; - - /** - * @var array - */ - private static $allowed_actions = [ - 'index', - 'audit', - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_CONFIG', - ]; - - - public function __construct() - { - parent::__construct(); - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\ConfigDump', - Deprecation::SCOPE_CLASS - ); - }); - } - - protected function init(): void - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure($this); - } - } - - /** - * Note: config() method is already defined, so let's just use index() - * - * @return string|HTTPResponse - */ - public function index() - { - $body = ''; - $subtitle = "Config manifest"; - - if (Director::is_cli()) { - $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? '')); - $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - } else { - $renderer = DebugView::create(); - $body .= $renderer->renderHeader(); - $body .= $renderer->renderInfo("Configuration", Director::absoluteBaseURL()); - $body .= "<div class=\"options\">"; - $body .= sprintf("<h2>%s</h2>", $subtitle); - $body .= "<pre>"; - $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - $body .= "</pre>"; - $body .= "</div>"; - $body .= $renderer->renderFooter(); - } - - return $this->getResponse()->setBody($body); - } - - /** - * Output the extraneous config properties which are defined in .yaml but not in a corresponding class - * - * @return string|HTTPResponse - */ - public function audit() - { - $body = ''; - $missing = []; - $subtitle = "Missing Config property definitions"; - - foreach ($this->array_keys_recursive(Config::inst()->getAll(), 2) as $className => $props) { - $props = array_keys($props ?? []); - - if (!count($props ?? [])) { - // We can skip this entry - continue; - } - - if ($className == strtolower(Injector::class)) { - // We don't want to check the injector config - continue; - } - - foreach ($props as $prop) { - $defined = false; - // Check ancestry (private properties don't inherit natively) - foreach (ClassInfo::ancestry($className) as $cn) { - if (property_exists($cn, $prop ?? '')) { - $defined = true; - break; - } - } - - if ($defined) { - // No need to record this property - continue; - } - - $missing[] = sprintf("%s::$%s\n", $className, $prop); - } - } - - $output = count($missing ?? []) - ? implode("\n", $missing) - : "All configured properties are defined\n"; - - if (Director::is_cli()) { - $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? '')); - $body .= $output; - } else { - $renderer = DebugView::create(); - $body .= $renderer->renderHeader(); - $body .= $renderer->renderInfo( - "Configuration", - Director::absoluteBaseURL(), - "Config properties that are not defined (or inherited) by their respective classes" - ); - $body .= "<div class=\"options\">"; - $body .= sprintf("<h2>%s</h2>", $subtitle); - $body .= sprintf("<pre>%s</pre>", $output); - $body .= "</div>"; - $body .= $renderer->renderFooter(); - } - - return $this->getResponse()->setBody($body); - } - - public function canInit(): bool - { - return ( - Director::isDev() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tasks" from CLI. - || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) - || Permission::check(static::config()->get('init_permissions')) - ); - } - - public function providePermissions(): array - { - return [ - 'CAN_DEV_CONFIG' => [ - 'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'), - 'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'), - 'category' => DevelopmentAdmin::permissionsCategory(), - 'sort' => 100 - ], - ]; - } - - /** - * Returns all the keys of a multi-dimensional array while maintining any nested structure - * - * @param array $array - * @param int $maxdepth - * @param int $depth - * @param array $arrayKeys - * @return array - */ - private function array_keys_recursive($array, $maxdepth = 20, $depth = 0, $arrayKeys = []) - { - if ($depth < $maxdepth) { - $depth++; - $keys = array_keys($array ?? []); - - foreach ($keys as $key) { - if (!is_array($array[$key])) { - continue; - } - - $arrayKeys[$key] = $this->array_keys_recursive($array[$key], $maxdepth, $depth); - } - } - - return $arrayKeys; - } -} diff --git a/src/Dev/DevConfirmationController.php b/src/Dev/DevConfirmationController.php index 2a64b4b4c22..7cf2e05ce34 100644 --- a/src/Dev/DevConfirmationController.php +++ b/src/Dev/DevConfirmationController.php @@ -3,7 +3,6 @@ namespace SilverStripe\Dev; use SilverStripe\Control\Director; -use SilverStripe\ORM\DatabaseAdmin; use SilverStripe\Security\Confirmation; /** diff --git a/src/Dev/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php index ccf279c309e..0fc196058f9 100644 --- a/src/Dev/DevelopmentAdmin.php +++ b/src/Dev/DevelopmentAdmin.php @@ -2,79 +2,81 @@ namespace SilverStripe\Dev; -use Exception; +use LogicException; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\RequestHandler; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Deprecation; -use SilverStripe\ORM\DatabaseAdmin; +use SilverStripe\Dev\Command\DevCommand; +use SilverStripe\PolyExecution\HtmlOutputFormatter; +use SilverStripe\PolyExecution\HttpRequestInput; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\FieldType\DBField; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\Security; use SilverStripe\Versioned\Versioned; +use SilverStripe\View\ViewableData; /** * Base class for development tools. * - * Configured in framework/_config/dev.yml, with the config key registeredControllers being - * used to generate the list of links for /dev. + * Configured via the `commands` and `controllers` configuration properties */ class DevelopmentAdmin extends Controller implements PermissionProvider { - - private static $url_handlers = [ + private static array $url_handlers = [ '' => 'index', - 'build/defaults' => 'buildDefaults', - 'generatesecuretoken' => 'generatesecuretoken', - '$Action' => 'runRegisteredController', + '$Action' => 'runRegisteredAction', ]; - private static $allowed_actions = [ + private static array $allowed_actions = [ 'index', - 'buildDefaults', - 'runRegisteredController', - 'generatesecuretoken', + 'runRegisteredAction', ]; /** - * Controllers for dev admin views + * Commands for dev admin views. + * + * Register any DevCommand classes that you want to be under the `/dev/*` HTTP + * route and also accessible by CLI. * * e.g [ - * 'urlsegment' => [ - * 'controller' => 'SilverStripe\Dev\DevelopmentAdmin', - * 'links' => [ - * 'urlsegment' => 'description', - * ... - * ] - * ] + * 'command-one' => 'App\Dev\CommandOne', * ] + */ + private static array $commands = []; + + /** + * Controllers for dev admin views. + * + * This is for HTTP-only controllers routed under `/dev/*` which + * cannot be managed via CLI (e.g. an interactive GraphQL IDE). + * For most purposes, register a PolyCommand under $commands instead. * - * @var array - * @deprecated 5.4.0 Will be replaced with "controllers" and "commands" configuration properties + * e.g [ + * 'urlsegment' => [ + * 'class' => 'App\Dev\MyHttpOnlyController', + * 'description' => 'See a list of build tasks to run', + * ], + * ] */ - private static $registered_controllers = []; + private static array $controllers = []; /** * Assume that CLI equals admin permissions * If set to false, normal permission model will apply even in CLI mode - * Applies to all development admin tasks (E.g. TaskRunner, DatabaseAdmin) - * - * @config - * @var bool + * Applies to all development admin tasks (E.g. TaskRunner, DbBuild) */ - private static $allow_all_cli = true; + private static bool $allow_all_cli = true; /** * Deny all non-cli requests (browser based ones) to dev admin - * - * @config - * @var bool */ - private static $deny_non_cli = false; + private static bool $deny_non_cli = false; protected function init() { @@ -89,7 +91,7 @@ protected function init() return; } - // Backwards compat: Default to "draft" stage, which is important + // Default to "draft" stage, which is important // for tasks like dev/build which call DataObject->requireDefaultRecords(), // but also for other administrative tasks which have assumptions about the default stage. if (class_exists(Versioned::class)) { @@ -97,193 +99,229 @@ protected function init() } } + /** + * Renders the main /dev menu in the browser + */ public function index() { - $links = $this->getLinks(); - // Web mode - if (!Director::is_cli()) { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()); - $base = Director::baseURL(); - - echo '<div class="options"><ul>'; - $evenOdd = "odd"; - foreach ($links as $action => $description) { - echo "<li class=\"$evenOdd\"><a href=\"{$base}dev/$action\"><b>/dev/$action:</b>" - . " $description</a></li>\n"; - $evenOdd = ($evenOdd == "odd") ? "even" : "odd"; + $renderer = DebugView::create(); + $base = Director::baseURL(); + $formatter = HtmlOutputFormatter::create(); + + $list = []; + + foreach ($this->getLinks() as $path => $info) { + $class = $info['class']; + $description = $info['description'] ?? ''; + $parameters = null; + $help = null; + if (is_a($class, DevCommand::class, true)) { + $parameters = $class::singleton()->getOptionsForTemplate(); + $description = DBField::create_field('HTMLText', $formatter->format($class::getDescription())); + $help = DBField::create_field('HTMLText', nl2br($formatter->format($class::getHelp())), false); } + $data = [ + 'Description' => $description, + 'Link' => "{$base}$path", + 'Path' => $path, + 'Parameters' => $parameters, + 'Help' => $help, + ]; + $list[] = $data; + } - echo $renderer->renderFooter(); + $data = [ + 'ArrayLinks' => $list, + 'Header' => $renderer->renderHeader(), + 'Footer' => $renderer->renderFooter(), + 'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()), + ]; - // CLI mode - } else { - echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n"; - echo "You can execute any of the following commands:\n\n"; - foreach ($links as $action => $description) { - echo " sake dev/$action: $description\n"; - } - echo "\n\n"; - } + return ViewableData::create()->renderWith(static::class, $data); } - public function runRegisteredController(HTTPRequest $request) + /** + * Run the command, or hand execution to the controller. + * Note this method is for execution from the web only. CLI takes a different path. + */ + public function runRegisteredAction(HTTPRequest $request) { - $controllerClass = null; - - $baseUrlPart = $request->param('Action'); - $reg = Config::inst()->get(static::class, 'registered_controllers'); - if (isset($reg[$baseUrlPart])) { - $controllerClass = $reg[$baseUrlPart]['controller']; + $returnUrl = $this->getBackURL(); + $fullPath = $request->getURL(); + $routes = $this->getRegisteredRoutes(); + $class = null; + + // If full path directly matches, use that class. + if (isset($routes[$fullPath])) { + $class = $routes[$fullPath]['class']; + if (is_a($class, DevCommand::class, true)) { + // Tell the request we've matched the full URL + $request->shift($request->remaining()); + } } - if ($controllerClass && class_exists($controllerClass ?? '')) { - return $controllerClass::create(); + // The full path doesn't directly match any registered command or controller. + // Look for a controller that can handle the request. We reject commands at this stage. + // The full path will be for an action on the controller and may include nested actions, + // so we need to check all urlsegment sections within the request URL. + if (!$class) { + $parts = explode('/', $fullPath); + array_pop($parts); + while (count($parts) > 0) { + $newPath = implode('/', $parts); + // Don't check dev itself - that's the controller we're currently in. + if ($newPath === 'dev') { + break; + } + // Check for a controller that matches this partial path. + $class = $routes[$newPath]['class'] ?? null; + if ($class !== null && is_a($class, RequestHandler::class, true)) { + break; + } + array_pop($parts); + } } - $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action'); - if (Director::is_cli()) { - // in CLI we cant use httpError because of a bug with stuff being in the output already, see DevAdminControllerTest - throw new Exception($msg); - } else { + if (!$class) { + $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action'); $this->httpError(404, $msg); } - } - - /* - * Internal methods - */ - /** - * @deprecated 5.2.0 use getLinks() instead to include permission checks - * @return array of url => description - */ - protected static function get_links() - { - Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks'); - $links = []; - - $reg = Config::inst()->get(static::class, 'registered_controllers'); - foreach ($reg as $registeredController) { - if (isset($registeredController['links'])) { - foreach ($registeredController['links'] as $url => $desc) { - $links[$url] = $desc; - } - } + // Hand execution to the controller + if (is_a($class, RequestHandler::class, true)) { + return $class::create(); } - return $links; - } - protected function getLinks(): array - { - $canViewAll = $this->canViewAll(); - $links = []; - $reg = Config::inst()->get(static::class, 'registered_controllers'); - foreach ($reg as $registeredController) { - if (isset($registeredController['links'])) { - if (!ClassInfo::exists($registeredController['controller'])) { - continue; - } + /** @var DevCommand $command */ + $command = $class::create(); + $input = HttpRequestInput::create($request, $command->getOptions()); + // DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed + // to the client as its available, so that if there's an error the client gets all of the output + // available until the error occurs. + $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true); + $renderer = DebugView::create(); + + // Output header etc + $headerOutput = [ + $renderer->renderHeader(), + $renderer->renderInfo( + $command->getTitle(), + Director::absoluteBaseURL() + ), + '<div class="options">', + ]; + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + $headerOutput, + options: PolyOutput::OUTPUT_RAW + ); - if (!$canViewAll) { - // Check access to controller - $controllerSingleton = Injector::inst()->get($registeredController['controller']); - if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) { - continue; - } - } + // Run command + $command->run($input, $output); - foreach ($registeredController['links'] as $url => $desc) { - $links[$url] = $desc; - } - } + // Output footer etc + $output->writeForFormat( + PolyOutput::FORMAT_HTML, + [ + '</div>', + $renderer->renderFooter(), + ], + options: PolyOutput::OUTPUT_RAW + ); + + // Return to whence we came (e.g. if we had been redirected to dev/build) + if ($returnUrl) { + return $this->redirect($returnUrl); } - return $links; } /** - * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it + * Get a map of all registered DevCommands. + * The key is the route used for browser execution. */ - protected function getRegisteredController($baseUrlPart) + public function getCommands(): array { - Deprecation::notice('5.4.0', 'Will be removed without equivalent functionality to replace it'); - $reg = Config::inst()->get(static::class, 'registered_controllers'); + $commands = []; + foreach (Config::inst()->get(static::class, 'commands') as $name => $class) { + // Allow unsetting a command via YAML + if ($class === null) { + continue; + } + // Check that the class exists and is a DevCommand + if (!ClassInfo::exists($class)) { + throw new LogicException("Class '$class' doesn't exist"); + } + if (!is_a($class, DevCommand::class, true)) { + throw new LogicException("Class '$class' must be a subclass of " . DevCommand::class); + } - if (isset($reg[$baseUrlPart])) { - $controllerClass = $reg[$baseUrlPart]['controller']; - return $controllerClass; + // Add to list of commands + $commands['dev/' . $name] = $class; } - - return null; + return $commands; } - - /* - * Unregistered (hidden) actions - */ - /** - * Build the default data, calling requireDefaultRecords on all - * DataObject classes - * Should match the $url_handlers rule: - * 'build/defaults' => 'buildDefaults', - * - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\DbDefaults + * Get a map of routes that can be run via this controller in an HTTP request. + * The key is the URI path, and the value is an associative array of information about the route. */ - public function buildDefaults() + public function getRegisteredRoutes(): array { - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\DbDefaults' - ); - }); - - $da = DatabaseAdmin::create(); - - $renderer = null; - if (!Director::is_cli()) { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL()); - echo "<div class=\"build\">"; + $canViewAll = $this->canViewAll(); + $items = []; + + foreach ($this->getCommands() as $urlSegment => $commandClass) { + // Note we've already checked if command classes exist and are DevCommand + // Check command can run in current context + if (!$canViewAll && !$commandClass::canRunInBrowser()) { + continue; + } + + $items[$urlSegment] = ['class' => $commandClass]; } - $da->buildDefaults(); + foreach (static::config()->get('controllers') as $urlSegment => $info) { + // Allow unsetting a controller via YAML + if ($info === null) { + continue; + } + $controllerClass = $info['class']; + // Check that the class exists and is a RequestHandler + if (!ClassInfo::exists($controllerClass)) { + throw new LogicException("Class '$controllerClass' doesn't exist"); + } + if (!is_a($controllerClass, RequestHandler::class, true)) { + throw new LogicException("Class '$controllerClass' must be a subclass of " . RequestHandler::class); + } + + if (!$canViewAll) { + // Check access to controller + $controllerSingleton = Injector::inst()->get($controllerClass); + if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) { + continue; + } + } - if (!Director::is_cli()) { - echo "</div>"; - echo $renderer->renderFooter(); + $items['dev/' . $urlSegment] = $info; } + + return $items; } /** - * Generate a secure token which can be used as a crypto key. - * Returns the token and suggests PHP configuration to set it. - * - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\GenerateSecureToken + * Get a map of links to be displayed in the /dev route. + * The key is the URI path, and the value is an associative array of information about the route. */ - public function generatesecuretoken() + public function getLinks(): array { - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\GenerateSecureToken' - ); - }); - - $generator = Injector::inst()->create('SilverStripe\\Security\\RandomGenerator'); - $token = $generator->randomToken('sha1'); - $body = <<<TXT -Generated new token. Please add the following code to your YAML configuration: - -Security: - token: $token - -TXT; - $response = new HTTPResponse($body); - return $response->addHeader('Content-Type', 'text/plain'); + $links = $this->getRegisteredRoutes(); + foreach ($links as $i => $info) { + // Allow a controller without a link, e.g. DevConfirmationController + if ($info['skipLink'] ?? false) { + unset($links[$i]); + } + } + return $links; } public function errors() @@ -310,17 +348,17 @@ public static function permissionsCategory(): string protected function canViewAll(): bool { - // Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957) - $requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0) - && (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false); - - // We allow access to this controller regardless of live-status or ADMIN permission only - // if on CLI. Access to this controller is always allowed in "dev-mode", or of the user is ADMIN. - $allowAllCLI = static::config()->get('allow_all_cli'); + // If dev/build was requested, we must defer to DbBuild permission checks explicitly + // because otherwise the permission checks may result in an error + $url = rtrim($this->getRequest()->getURL(), '/'); + if ($url === 'dev/build') { + return false; + } + // We allow access to this controller regardless of live-status or ADMIN permission only if on CLI. + // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN. return ( - $requestedDevBuild - || Director::isDev() - || (Director::is_cli() && $allowAllCLI) + Director::isDev() + || (Director::is_cli() && static::config()->get('allow_all_cli')) // Its important that we don't run this check if dev/build was requested || Permission::check(['ADMIN', 'ALL_DEV_ADMIN']) ); diff --git a/src/Dev/MigrationTask.php b/src/Dev/MigrationTask.php index 58981ffdaab..49bdcfb925e 100644 --- a/src/Dev/MigrationTask.php +++ b/src/Dev/MigrationTask.php @@ -2,77 +2,49 @@ namespace SilverStripe\Dev; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + /** * A migration task is a build task that is reversible. * - * <b>Creating Migration Tasks</b> - * * To create your own migration task, you need to define your own subclass of MigrationTask - * and implement the following methods - * - * <i>app/src/MyMigrationTask.php</i> - * - * <code> - * class MyMigrationTask extends MigrationTask { - * - * private static $segment = 'MyMigrationTask'; // segment in the dev/tasks/ namespace for URL access - * protected $title = "My Database Migrations"; // title of the script - * protected $description = "My Description"; // description of what it does - * - * public function run($request) { - * if ($request->getVar('Direction') == 'down') { - * $this->down(); - * } else { - * $this->up(); - * } - * } - * - * public function up() { - * // do something when going from old -> new - * } - * - * public function down() { - * // do something when going from new -> old - * } - * } - * </code> - * - * <b>Running Migration Tasks</b> - * You can find all tasks under the dev/tasks/ namespace. - * To run the above script you would need to run the following and note - Either the site has to be - * in [devmode](debugging) or you need to add ?isDev=1 to the URL. - * - * <code> - * // url to visit if in dev mode. - * https://www.yoursite.com/dev/tasks/MyMigrationTask - * - * // url to visit if you are in live mode but need to run this - * https://www.yoursite.com/dev/tasks/MyMigrationTask?isDev=1 - * </code> + * and implement the abstract methods. */ abstract class MigrationTask extends BuildTask { - - private static $segment = 'MigrationTask'; - - protected $title = "Database Migrations"; - - protected $description = "Provide atomic database changes (subclass this and implement yourself)"; - - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { - if ($request->param('Direction') == 'down') { + if ($input->getOption('direction') === 'down') { $this->down(); } else { $this->up(); } + return Command::SUCCESS; } - public function up() - { - } + /** + * Migrate from old to new + */ + abstract public function up(); + + /** + * Revert the migration (new to old) + */ + abstract public function down(); - public function down() + public function getOptions(): array { + return [ + new InputOption( + 'direction', + null, + InputOption::VALUE_REQUIRED, + '"up" if migrating from old to new, "down" to revert a migration', + suggestedValues: ['up', 'down'], + ), + ]; } } diff --git a/src/Dev/State/ExtensionTestState.php b/src/Dev/State/ExtensionTestState.php index 0cf274367a7..e0ffae7c197 100644 --- a/src/Dev/State/ExtensionTestState.php +++ b/src/Dev/State/ExtensionTestState.php @@ -88,7 +88,7 @@ public function setUpOnce($class) } // clear singletons, they're caching old extension info - // which is used in DatabaseAdmin->doBuild() + // which is used in DbBuild->doBuild() Injector::inst()->unregisterObjects([ DataObject::class, Extension::class diff --git a/src/Dev/TaskRunner.php b/src/Dev/TaskRunner.php index ecd87c1a26b..bb4ee65ea9b 100644 --- a/src/Dev/TaskRunner.php +++ b/src/Dev/TaskRunner.php @@ -11,7 +11,11 @@ use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Manifest\ModuleResourceLoader; +use SilverStripe\PolyExecution\HtmlOutputFormatter; +use SilverStripe\PolyExecution\HttpRequestInput; +use SilverStripe\PolyExecution\PolyOutput; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\FieldType\DBField; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\Security; @@ -20,7 +24,6 @@ class TaskRunner extends Controller implements PermissionProvider { - use Configurable; private static $url_handlers = [ @@ -59,25 +62,17 @@ public function index() { $baseUrl = Director::absoluteBaseURL(); $tasks = $this->getTasks(); - - if (Director::is_cli()) { - // CLI mode - $output = 'SILVERSTRIPE DEVELOPMENT TOOLS: Tasks' . PHP_EOL . '--------------------------' . PHP_EOL . PHP_EOL; - - foreach ($tasks as $task) { - $output .= sprintf(' * %s: sake dev/tasks/%s%s', $task['title'], $task['segment'], PHP_EOL); - } - - return $output; - } - $list = ArrayList::create(); - foreach ($tasks as $task) { + if (!$task['class']::canRunInBrowser()) { + continue; + } $list->push(ArrayData::create([ 'TaskLink' => Controller::join_links($baseUrl, 'dev/tasks/', $task['segment']), 'Title' => $task['title'], 'Description' => $task['description'], + 'Parameters' => $task['parameters'], + 'Help' => $task['help'], ])); } @@ -104,26 +99,26 @@ public function runTask($request) $name = $request->param('TaskName'); $tasks = $this->getTasks(); - $title = function ($content) { - printf(Director::is_cli() ? "%s\n\n" : '<h1>%s</h1>', $content); - }; - $message = function ($content) { - printf(Director::is_cli() ? "%s\n" : '<p>%s</p>', $content); + printf('<p>%s</p>', $content); }; foreach ($tasks as $task) { if ($task['segment'] == $name) { /** @var BuildTask $inst */ $inst = Injector::inst()->create($task['class']); - $title(sprintf('Running Task %s', $inst->getTitle())); - if (!$this->taskEnabled($task['class'])) { + if (!$this->taskEnabled($task['class']) || !$task['class']::canRunInBrowser()) { $message('The task is disabled or you do not have sufficient permission to run it'); return; } - $inst->run($request); + $input = HttpRequestInput::create($request, $inst->getOptions()); + // DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed + // to the client as its available, so that if there's an error the client gets all of the output + // available until the error occurs. + $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true); + $inst->run($input, $output); return; } } @@ -132,44 +127,51 @@ public function runTask($request) } /** - * @return array Array of associative arrays for each task (Keys: 'class', 'title', 'description') + * Get an associative array of task names to classes for all enabled BuildTasks */ - protected function getTasks() + public function getTaskList(): array + { + $taskList = []; + $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false); + foreach ($taskClasses as $taskClass) { + if ($this->taskEnabled($taskClass)) { + $taskList[$taskClass::getName()] = $taskClass; + } + } + return $taskList; + } + + /** + * Get the class names of all build tasks for use in HTTP requests + */ + protected function getTasks(): array { $availableTasks = []; + $formatter = HtmlOutputFormatter::create(); + /** @var BuildTask $class */ foreach ($this->getTaskList() as $class) { - $singleton = BuildTask::singleton($class); - $description = $singleton->getDescription(); - $description = trim($description ?? ''); + if (!$class::canRunInBrowser()) { + continue; + } - $desc = (Director::is_cli()) - ? Convert::html2raw($description) - : $description; + $singleton = BuildTask::singleton($class); + $description = DBField::create_field('HTMLText', $formatter->format($class::getDescription())); + $help = DBField::create_field('HTMLText', nl2br($formatter->format($class::getHelp())), false); $availableTasks[] = [ 'class' => $class, 'title' => $singleton->getTitle(), - 'segment' => $singleton->config()->segment ?: str_replace('\\', '-', $class ?? ''), - 'description' => $desc, + 'segment' => $class::getNameWithoutNamespace(), + 'description' => $description, + 'parameters' => $singleton->getOptionsForTemplate(), + 'help' => $help, ]; } return $availableTasks; } - protected function getTaskList(): array - { - $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false); - foreach ($taskClasses as $index => $task) { - if (!$this->taskEnabled($task)) { - unset($taskClasses[$index]); - } - } - - return $taskClasses; - } - /** * @param string $class * @return boolean @@ -181,6 +183,7 @@ protected function taskEnabled($class) return false; } + /** @var BuildTask $task */ $task = Injector::inst()->get($class); if (!$task->isEnabled()) { return false; @@ -197,8 +200,7 @@ protected function canViewAllTasks(): bool { return ( Director::isDev() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tasks" from CLI. + // We need to ensure that unit tests can simulate permission failures when navigating to "dev/tasks" || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) || Permission::check(static::config()->get('init_permissions')) ); diff --git a/src/Dev/Tasks/CleanupTestDatabasesTask.php b/src/Dev/Tasks/CleanupTestDatabasesTask.php index 10c399afbd3..70c30a29bba 100644 --- a/src/Dev/Tasks/CleanupTestDatabasesTask.php +++ b/src/Dev/Tasks/CleanupTestDatabasesTask.php @@ -2,12 +2,11 @@ namespace SilverStripe\Dev\Tasks; -use SilverStripe\Control\Director; use SilverStripe\Dev\BuildTask; -use SilverStripe\Dev\Deprecation; +use SilverStripe\PolyExecution\PolyOutput; use SilverStripe\ORM\Connect\TempDatabase; -use SilverStripe\Security\Permission; -use SilverStripe\Security\Security; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; /** * Cleans up leftover databases from aborted test executions (starting with ss_tmpdb) @@ -15,33 +14,20 @@ */ class CleanupTestDatabasesTask extends BuildTask { + protected static string $commandName = 'CleanupTestDatabasesTask'; - private static $segment = 'CleanupTestDatabasesTask'; + protected string $title = 'Deletes all temporary test databases'; - protected $title = 'Deletes all temporary test databases'; + protected static string $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)'; - protected $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)'; + private static string|array|null $permissions_for_browser_execution = [ + 'anyone_with_dev_admin_permissions' => null, + 'anyone_with_task_permissions' => null, + ]; - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { - if (!$this->canView()) { - $response = Security::permissionFailure(); - if ($response) { - $response->output(); - } - die; - } TempDatabase::create()->deleteAll(); - } - - public function canView(): bool - { - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with canRunInBrowser()' - ); - }); - return Permission::check('ADMIN') || Director::is_cli(); + return Command::SUCCESS; } } diff --git a/src/Dev/Tasks/i18nTextCollectorTask.php b/src/Dev/Tasks/i18nTextCollectorTask.php index 8ecd4b279b7..39a84593b67 100644 --- a/src/Dev/Tasks/i18nTextCollectorTask.php +++ b/src/Dev/Tasks/i18nTextCollectorTask.php @@ -2,83 +2,71 @@ namespace SilverStripe\Dev\Tasks; -use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Debug; use SilverStripe\Dev\BuildTask; +use SilverStripe\PolyExecution\PolyOutput; use SilverStripe\i18n\TextCollection\i18nTextCollector; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * Collects i18n strings + * + * It will search for existent modules that use the i18n feature, parse the _t() calls + * and write the resultant files in the lang folder of each module. */ class i18nTextCollectorTask extends BuildTask { + protected static string $commandName = 'i18nTextCollectorTask'; - private static $segment = 'i18nTextCollectorTask'; + protected string $title = "i18n Textcollector Task"; - protected $title = "i18n Textcollector Task"; + protected static string $description = 'Traverses through files in order to collect the ' + . '"entity master tables" stored in each module.'; - protected $description = " - Traverses through files in order to collect the 'entity master tables' - stored in each module. - - Parameters: - - locale: Sets default locale - - writer: Custom writer class (defaults to i18nTextCollector_Writer_RailsYaml) - - module: One or more modules to limit collection (comma-separated) - - merge: Merge new strings with existing ones already defined in language files (default: TRUE) - "; - - /** - * This is the main method to build the master string tables with the original strings. - * It will search for existent modules that use the i18n feature, parse the _t() calls - * and write the resultant files in the lang folder of each module. - * - * @uses DataObject::collectI18nStatics() - * - * @param HTTPRequest $request - */ - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { Environment::increaseTimeLimitTo(); - $collector = i18nTextCollector::create($request->getVar('locale')); + $collector = i18nTextCollector::create($input->getOption('locale')); - $merge = $this->getIsMerge($request); + $merge = $this->getIsMerge($input); // Custom writer - $writerName = $request->getVar('writer'); + $writerName = $input->getOption('writer'); if ($writerName) { $writer = Injector::inst()->get($writerName); $collector->setWriter($writer); } // Get restrictions - $restrictModules = ($request->getVar('module')) - ? explode(',', $request->getVar('module')) + $restrictModules = ($input->getOption('module')) + ? explode(',', $input->getOption('module')) : null; $collector->run($restrictModules, $merge); - Debug::message(__CLASS__ . " completed!", false); + return Command::SUCCESS; } /** * Check if we should merge - * - * @param HTTPRequest $request - * @return bool */ - protected function getIsMerge($request) + protected function getIsMerge(InputInterface $input): bool { - $merge = $request->getVar('merge'); - - // Default to true if not given - if (!isset($merge)) { - return true; - } - + $merge = $input->getOption('merge'); // merge=0 or merge=false will disable merge return !in_array($merge, ['0', 'false']); } + + public function getOptions(): array + { + return [ + new InputOption('locale', null, InputOption::VALUE_REQUIRED, 'Sets default locale'), + new InputOption('writer', null, InputOption::VALUE_REQUIRED, 'Custom writer class (must implement the <info>SilverStripe\i18n\Messages\Writer</> interface)'), + new InputOption('module', null, InputOption::VALUE_REQUIRED, 'One or more modules to limit collection (comma-separated)'), + new InputOption('merge', null, InputOption::VALUE_NEGATABLE, 'Merge new strings with existing ones already defined in language files', true), + ]; + } } diff --git a/src/Dev/Validation/DatabaseAdminExtension.php b/src/Dev/Validation/DbBuildExtension.php similarity index 55% rename from src/Dev/Validation/DatabaseAdminExtension.php rename to src/Dev/Validation/DbBuildExtension.php index db0c83351d8..c245c660cf2 100644 --- a/src/Dev/Validation/DatabaseAdminExtension.php +++ b/src/Dev/Validation/DbBuildExtension.php @@ -4,24 +4,21 @@ use ReflectionException; use SilverStripe\Core\Extension; -use SilverStripe\ORM\DatabaseAdmin; +use SilverStripe\Dev\Command\DbBuild; /** * Hook up static validation to the deb/build process * - * @extends Extension<DatabaseAdmin> + * @extends Extension<DbBuild> */ -class DatabaseAdminExtension extends Extension +class DbBuildExtension extends Extension { /** - * Extension point in @see DatabaseAdmin::doBuild() + * Extension point in @see DbBuild::doBuild() * - * @param bool $quiet - * @param bool $populate - * @param bool $testMode * @throws ReflectionException */ - protected function onAfterBuild(bool $quiet, bool $populate, bool $testMode): void + protected function onAfterBuild(): void { $service = RelationValidationService::singleton(); diff --git a/src/Logging/HTTPOutputHandler.php b/src/Logging/ErrorOutputHandler.php similarity index 86% rename from src/Logging/HTTPOutputHandler.php rename to src/Logging/ErrorOutputHandler.php index 39df450f131..2b18ba79ad2 100644 --- a/src/Logging/HTTPOutputHandler.php +++ b/src/Logging/ErrorOutputHandler.php @@ -11,14 +11,11 @@ use SilverStripe\Dev\Deprecation; /** - * Output the error to the browser, with the given HTTP status code. - * We recommend that you use a formatter that generates HTML with this. - * - * @deprecated 5.4.0 Will be renamed to ErrorOutputHandler + * Output the error to either the browser or the terminal, depending on + * the context we're running in. */ -class HTTPOutputHandler extends AbstractProcessingHandler +class ErrorOutputHandler extends AbstractProcessingHandler { - /** * @var string */ @@ -61,7 +58,7 @@ public function getContentType() * Default text/html * * @param string $contentType - * @return HTTPOutputHandler Return $this to allow chainable calls + * @return ErrorOutputHandler Return $this to allow chainable calls */ public function setContentType($contentType) { @@ -96,7 +93,7 @@ public function setStatusCode($statusCode) * Set a formatter to use if Director::is_cli() is true * * @param FormatterInterface $cliFormatter - * @return HTTPOutputHandler Return $this to allow chainable calls + * @return ErrorOutputHandler Return $this to allow chainable calls */ public function setCLIFormatter(FormatterInterface $cliFormatter) { @@ -179,6 +176,11 @@ protected function write(LogRecord $record): void } } + if (Director::is_cli()) { + echo $record['formatted']; + return; + } + if (Controller::has_curr()) { $response = Controller::curr()->getResponse(); } else { @@ -197,14 +199,4 @@ protected function write(LogRecord $record): void $response->setBody($record['formatted']); $response->output(); } - - /** - * This method used to be used for unit testing but is no longer required. - * @deprecated 5.4.0 Use SilverStripe\Control\Director::is_cli() instead - */ - protected function isCli(): bool - { - Deprecation::notice('5.4.0', 'Use ' . Director::class . '::is_cli() instead'); - return Director::is_cli(); - } } diff --git a/src/ORM/Connect/DBSchemaManager.php b/src/ORM/Connect/DBSchemaManager.php index c889bb66918..15049ec252e 100644 --- a/src/ORM/Connect/DBSchemaManager.php +++ b/src/ORM/Connect/DBSchemaManager.php @@ -19,9 +19,7 @@ abstract class DBSchemaManager { /** - * - * @config - * Check tables when running /dev/build, and repair them if necessary. + * Check tables when building the db, and repair them if necessary. * In case of large databases or more fine-grained control on how to handle * data corruption in tables, you can disable this behaviour and handle it * outside of this class, e.g. through a nightly system task with extended logging capabilities. @@ -32,11 +30,11 @@ abstract class DBSchemaManager /** * For large databases you can declare a list of DataObject classes which will be excluded from - * CHECK TABLE and REPAIR TABLE queries during dev/build. Note that the entire inheritance chain + * CHECK TABLE and REPAIR TABLE queries when building the db. Note that the entire inheritance chain * for that class will be excluded, including both ancestors and descendants. * * Only use this configuration if you know what you are doing and have identified specific models - * as being problematic during your dev/build process. + * as being problematic when building the db. */ private static array $exclude_models_from_db_checks = []; diff --git a/src/ORM/Connect/TempDatabase.php b/src/ORM/Connect/TempDatabase.php index 52c87c61075..598339ea22e 100644 --- a/src/ORM/Connect/TempDatabase.php +++ b/src/ORM/Connect/TempDatabase.php @@ -233,7 +233,7 @@ protected function rebuildTables($extraDataObjects = []) { DataObject::reset(); - // clear singletons, they're caching old extension info which is used in DatabaseAdmin->doBuild() + // clear singletons, they're caching old extension info which is used in DbBuild->doBuild() Injector::inst()->unregisterObjects(DataObject::class); $dataClasses = ClassInfo::subclassesFor(DataObject::class); diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index da89460b85e..8e77e4fa997 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -186,7 +186,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type, - * e.g. to call requireTable() in dev/build + * e.g. to call requireTable() when building the db * Defaults will not be populated and data passed will be ignored */ const CREATE_SINGLETON = 1; @@ -3781,7 +3781,7 @@ public function requireDefaultRecords() * Invoked after every database build is complete (including after table creation and * default record population). * - * See {@link DatabaseAdmin::doBuild()} for context. + * See {@link DbBuild::doBuild()} for context. */ public function onAfterBuild() { diff --git a/src/ORM/DataObjectSchema.php b/src/ORM/DataObjectSchema.php index 9fc2ca6646e..e0f1ee5f579 100644 --- a/src/ORM/DataObjectSchema.php +++ b/src/ORM/DataObjectSchema.php @@ -313,7 +313,7 @@ protected function cacheTableNames() * Generate table name for a class. * * Note: some DB schema have a hard limit on table name length. This is not enforced by this method. - * See dev/build errors for details in case of table name violation. + * See build errors for details in case of table name violation. * * @param string $class * diff --git a/src/ORM/DatabaseAdmin.php b/src/ORM/DatabaseAdmin.php deleted file mode 100644 index 29f56474a23..00000000000 --- a/src/ORM/DatabaseAdmin.php +++ /dev/null @@ -1,566 +0,0 @@ -<?php - -namespace SilverStripe\ORM; - -use BadMethodCallException; -use Generator; -use SilverStripe\Control\Controller; -use SilverStripe\Control\Director; -use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Environment; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\Core\Manifest\ClassLoader; -use SilverStripe\Dev\Deprecation; -use SilverStripe\Dev\DevBuildController; -use SilverStripe\Dev\DevelopmentAdmin; -use SilverStripe\ORM\Connect\DatabaseException; -use SilverStripe\ORM\Connect\TableBuilder; -use SilverStripe\ORM\FieldType\DBClassName; -use SilverStripe\ORM\FieldType\DBClassNameVarchar; -use SilverStripe\Security\Permission; -use SilverStripe\Security\Security; -use SilverStripe\Versioned\Versioned; - -/** - * DatabaseAdmin class - * - * Utility functions for administrating the database. These can be accessed - * via URL, e.g. http://www.yourdomain.com/db/build. - * - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild - */ -class DatabaseAdmin extends Controller -{ - - /// SECURITY /// - private static $allowed_actions = [ - 'index', - 'build', - 'cleanup', - 'import' - ]; - - /** - * Obsolete classname values that should be remapped in dev/build - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.classname_value_remapping - */ - private static $classname_value_remapping = [ - 'File' => 'SilverStripe\\Assets\\File', - 'Image' => 'SilverStripe\\Assets\\Image', - 'Folder' => 'SilverStripe\\Assets\\Folder', - 'Group' => 'SilverStripe\\Security\\Group', - 'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt', - 'Member' => 'SilverStripe\\Security\\Member', - 'MemberPassword' => 'SilverStripe\\Security\\MemberPassword', - 'Permission' => 'SilverStripe\\Security\\Permission', - 'PermissionRole' => 'SilverStripe\\Security\\PermissionRole', - 'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode', - 'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash', - ]; - - /** - * Config setting to enabled/disable the display of record counts on the dev/build output - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.show_record_counts - */ - private static $show_record_counts = true; - - public function __construct() - { - parent::__construct(); - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\DbBuild', - Deprecation::SCOPE_CLASS - ); - }); - } - - protected function init() - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure( - $this, - "This page is secured and you need elevated permissions to access it. " . - "Enter your credentials below and we will send you right along." - ); - } - } - - /** - * Get the data classes, grouped by their root class - * - * @return array Array of data classes, grouped by their root class - */ - public function groupedDataClasses() - { - // Get all root data objects - $allClasses = get_declared_classes(); - $rootClasses = []; - foreach ($allClasses as $class) { - if (get_parent_class($class ?? '') == DataObject::class) { - $rootClasses[$class] = []; - } - } - - // Assign every other data object one of those - foreach ($allClasses as $class) { - if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) { - foreach ($rootClasses as $rootClass => $dummy) { - if (is_subclass_of($class, $rootClass ?? '')) { - $rootClasses[$rootClass][] = $class; - break; - } - } - } - } - return $rootClasses; - } - - - /** - * When we're called as /dev/build, that's actually the index. Do the same - * as /dev/build/build. - */ - public function index() - { - return $this->build(); - } - - /** - * Updates the database schema, creating tables & fields as necessary. - */ - public function build() - { - // The default time limit of 30 seconds is normally not enough - Environment::increaseTimeLimitTo(600); - - // If this code is being run outside of a dev/build or without a ?flush query string param, - // the class manifest hasn't been flushed, so do it here - $request = $this->getRequest(); - if (!array_key_exists('flush', $request->getVars() ?? []) && strpos($request->getURL() ?? '', 'dev/build') !== 0) { - ClassLoader::inst()->getManifest()->regenerate(false); - } - - $url = $this->getReturnURL(); - if ($url) { - echo "<p>Setting up the database; you will be returned to your site shortly....</p>"; - $this->doBuild(true); - echo "<p>Done!</p>"; - $this->redirect($url); - } else { - $quiet = $this->request->requestVar('quiet') !== null; - $fromInstaller = $this->request->requestVar('from_installer') !== null; - $populate = $this->request->requestVar('dont_populate') === null; - $this->doBuild($quiet || $fromInstaller, $populate); - } - } - - /** - * Gets the url to return to after build - * - * @return string|null - */ - protected function getReturnURL() - { - $url = $this->request->getVar('returnURL'); - - // Check that this url is a site url - if (empty($url) || !Director::is_site_url($url)) { - return null; - } - - // Convert to absolute URL - return Director::absoluteURL((string) $url, true); - } - - /** - * Build the default data, calling requireDefaultRecords on all - * DataObject classes - */ - public function buildDefaults() - { - $dataClasses = ClassInfo::subclassesFor(DataObject::class); - array_shift($dataClasses); - - if (!Director::is_cli()) { - echo "<ul>"; - } - - foreach ($dataClasses as $dataClass) { - singleton($dataClass)->requireDefaultRecords(); - if (Director::is_cli()) { - echo "Defaults loaded for $dataClass\n"; - } else { - echo "<li>Defaults loaded for $dataClass</li>\n"; - } - } - - if (!Director::is_cli()) { - echo "</ul>"; - } - } - - /** - * Returns the timestamp of the time that the database was last built - * - * @return string Returns the timestamp of the time that the database was - * last built - * - * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt() - */ - public static function lastBuilt() - { - Deprecation::withNoReplacement(function () { - Deprecation::notice( - '5.4.0', - 'Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()' - ); - }); - - $file = TEMP_PATH - . DIRECTORY_SEPARATOR - . 'database-last-generated-' - . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? ''); - - if (file_exists($file ?? '')) { - return filemtime($file ?? ''); - } - return null; - } - - - /** - * Updates the database schema, creating tables & fields as necessary. - * - * @param boolean $quiet Don't show messages - * @param boolean $populate Populate the database, as well as setting up its schema - * @param bool $testMode - */ - public function doBuild($quiet = false, $populate = true, $testMode = false) - { - $this->extend('onBeforeBuild', $quiet, $populate, $testMode); - - if ($quiet) { - DB::quiet(); - } else { - $conn = DB::get_conn(); - // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database") - $dbType = substr(get_class($conn), 0, -8); - $dbVersion = $conn->getVersion(); - $databaseName = $conn->getSelectedDatabase(); - - if (Director::is_cli()) { - echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion); - } else { - echo sprintf("<h2>Building database %s using %s %s</h2>", $databaseName, $dbType, $dbVersion); - } - } - - // Set up the initial database - if (!DB::is_active()) { - if (!$quiet) { - echo '<p><b>Creating database</b></p>'; - } - - // Load parameters from existing configuration - $databaseConfig = DB::getConfig(); - if (empty($databaseConfig) && empty($_REQUEST['db'])) { - throw new BadMethodCallException("No database configuration available"); - } - $parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db']; - - // Check database name is given - if (empty($parameters['database'])) { - throw new BadMethodCallException( - "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME" - ); - } - $database = $parameters['database']; - - // Establish connection - unset($parameters['database']); - DB::connect($parameters); - - // Check to ensure that the re-instated SS_DATABASE_SUFFIX functionality won't unexpectedly - // rename the database. To be removed for SS5 - if ($suffix = Environment::getEnv('SS_DATABASE_SUFFIX')) { - $previousName = preg_replace("/{$suffix}$/", '', $database ?? ''); - - if (!isset($_GET['force_suffix_rename']) && DB::get_conn()->databaseExists($previousName)) { - throw new DatabaseException( - "SS_DATABASE_SUFFIX was previously broken, but has now been fixed. This will result in your " - . "database being named \"{$database}\" instead of \"{$previousName}\" from now on. If this " - . "change is intentional, please visit dev/build?force_suffix_rename=1 to continue" - ); - } - } - - // Create database - DB::create_database($database); - } - - // Build the database. Most of the hard work is handled by DataObject - $dataClasses = ClassInfo::subclassesFor(DataObject::class); - array_shift($dataClasses); - - if (!$quiet) { - if (Director::is_cli()) { - echo "\nCREATING DATABASE TABLES\n\n"; - } else { - echo "\n<p><b>Creating database tables</b></p><ul>\n\n"; - } - } - - $showRecordCounts = (boolean)$this->config()->show_record_counts; - - // Initiate schema update - $dbSchema = DB::get_schema(); - $tableBuilder = TableBuilder::singleton(); - $tableBuilder->buildTables($dbSchema, $dataClasses, [], $quiet, $testMode, $showRecordCounts); - ClassInfo::reset_db_cache(); - - if (!$quiet && !Director::is_cli()) { - echo "</ul>"; - } - - if ($populate) { - if (!$quiet) { - if (Director::is_cli()) { - echo "\nCREATING DATABASE RECORDS\n\n"; - } else { - echo "\n<p><b>Creating database records</b></p><ul>\n\n"; - } - } - - // Remap obsolete class names - $this->migrateClassNames(); - - // Require all default records - foreach ($dataClasses as $dataClass) { - // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness - // Test_ indicates that it's the data class is part of testing system - if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) { - if (!$quiet) { - if (Director::is_cli()) { - echo " * $dataClass\n"; - } else { - echo "<li>$dataClass</li>\n"; - } - } - - DataObject::singleton($dataClass)->requireDefaultRecords(); - } - } - - if (!$quiet && !Director::is_cli()) { - echo "</ul>"; - } - } - - touch(TEMP_PATH - . DIRECTORY_SEPARATOR - . 'database-last-generated-' - . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? '')); - - if (isset($_REQUEST['from_installer'])) { - echo "OK"; - } - - if (!$quiet) { - echo (Director::is_cli()) ? "\n Database build completed!\n\n" : "<p>Database build completed!</p>"; - } - - foreach ($dataClasses as $dataClass) { - DataObject::singleton($dataClass)->onAfterBuild(); - } - - ClassInfo::reset_db_cache(); - - $this->extend('onAfterBuild', $quiet, $populate, $testMode); - } - - public function canInit(): bool - { - // We allow access to this controller regardless of live-status or ADMIN permission only - // if on CLI or with the database not ready. The latter makes it less error-prone to do an - // initial schema build without requiring a default-admin login. - // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN. - $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli'); - return ( - Director::isDev() - || !Security::database_is_ready() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tests" from CLI. - || (Director::is_cli() && $allowAllCLI) - || Permission::check(DevBuildController::config()->get('init_permissions')) - ); - } - - /** - * Given a base data class, a field name and a mapping of class replacements, look for obsolete - * values in the $dataClass's $fieldName column and replace it with $mapping - * - * @param string $dataClass The data class to look up - * @param string $fieldName The field name to look in for obsolete class names - * @param string[] $mapping Map of old to new classnames - */ - protected function updateLegacyClassNameField($dataClass, $fieldName, $mapping) - { - $schema = DataObject::getSchema(); - // Check first to ensure that the class has the specified field to update - if (!$schema->databaseField($dataClass, $fieldName, false)) { - return; - } - - // Load a list of any records that have obsolete class names - $table = $schema->tableName($dataClass); - $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column(); - - // Get all invalid classes for this field - $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? [])); - if (!$invalidClasses) { - return; - } - - $numberClasses = count($invalidClasses ?? []); - DB::alteration_message( - "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types", - 'obsolete' - ); - - // Build case assignment based on all intersected legacy classnames - $cases = []; - $params = []; - foreach ($invalidClasses as $invalidClass) { - $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?"; - $params[] = $invalidClass; - $params[] = $mapping[$invalidClass]; - } - - foreach ($this->getClassTables($dataClass) as $table) { - $casesSQL = implode(' ', $cases); - $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END"; - DB::prepared_query($sql, $params); - } - } - - /** - * Get tables to update for this class - * - * @param string $dataClass - * @return Generator|string[] - */ - protected function getClassTables($dataClass) - { - $schema = DataObject::getSchema(); - $table = $schema->tableName($dataClass); - - // Base table - yield $table; - - // Remap versioned table class name values as well - /** @var Versioned|DataObject $dataClass */ - $dataClass = DataObject::singleton($dataClass); - if ($dataClass->hasExtension(Versioned::class)) { - if ($dataClass->hasStages()) { - yield "{$table}_Live"; - } - yield "{$table}_Versions"; - } - } - - /** - * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes - * `ClassName` fields as well as polymorphic class name fields. - * - * @return array[] - */ - protected function getClassNameRemappingFields() - { - $dataClasses = ClassInfo::getValidSubClasses(DataObject::class); - $schema = DataObject::getSchema(); - $remapping = []; - - foreach ($dataClasses as $className) { - $fieldSpecs = $schema->fieldSpecs($className); - foreach ($fieldSpecs as $fieldName => $fieldSpec) { - $dummy = Injector::inst()->create($fieldSpec, 'Dummy'); - if ($dummy instanceof DBClassName || $dummy instanceof DBClassNameVarchar) { - $remapping[$className][] = $fieldName; - } - } - } - - return $remapping; - } - - /** - * Remove invalid records from tables - that is, records that don't have - * corresponding records in their parent class tables. - */ - public function cleanup() - { - $baseClasses = []; - foreach (ClassInfo::subclassesFor(DataObject::class) as $class) { - if (get_parent_class($class ?? '') == DataObject::class) { - $baseClasses[] = $class; - } - } - - $schema = DataObject::getSchema(); - foreach ($baseClasses as $baseClass) { - // Get data classes - $baseTable = $schema->baseDataTable($baseClass); - $subclasses = ClassInfo::subclassesFor($baseClass); - unset($subclasses[0]); - foreach ($subclasses as $k => $subclass) { - if (!DataObject::getSchema()->classHasTable($subclass)) { - unset($subclasses[$k]); - } - } - - if ($subclasses) { - $records = DB::query("SELECT * FROM \"$baseTable\""); - - - foreach ($subclasses as $subclass) { - $subclassTable = $schema->tableName($subclass); - $recordExists[$subclass] = - DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn(); - } - - foreach ($records as $record) { - foreach ($subclasses as $subclass) { - $subclassTable = $schema->tableName($subclass); - $id = $record['ID']; - if (($record['ClassName'] != $subclass) - && (!is_subclass_of($record['ClassName'], $subclass ?? '')) - && isset($recordExists[$subclass][$id]) - ) { - $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?"; - echo "<li>$sql [{$id}]</li>"; - DB::prepared_query($sql, [$id]); - } - } - } - } - } - } - - /** - * Migrate all class names - */ - protected function migrateClassNames() - { - $remappingConfig = $this->config()->get('classname_value_remapping'); - $remappingFields = $this->getClassNameRemappingFields(); - foreach ($remappingFields as $className => $fieldNames) { - foreach ($fieldNames as $fieldName) { - $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig); - } - } - } -} diff --git a/src/ORM/FieldType/DBClassNameTrait.php b/src/ORM/FieldType/DBClassNameTrait.php index 6a892b59aaf..132074054ff 100644 --- a/src/ORM/FieldType/DBClassNameTrait.php +++ b/src/ORM/FieldType/DBClassNameTrait.php @@ -56,7 +56,7 @@ public function getBaseClass(): string if ($this->record) { return $schema->baseDataClass($this->record); } - // During dev/build only the table is assigned + // When building the db only the table is assigned $tableClass = $schema->tableClass($this->getTable()); if ($tableClass && ($baseClass = $schema->baseDataClass($tableClass))) { return $baseClass; diff --git a/src/PolyExecution/AnsiToHtmlConverter.php b/src/PolyExecution/AnsiToHtmlConverter.php new file mode 100644 index 00000000000..9d3bbb35a50 --- /dev/null +++ b/src/PolyExecution/AnsiToHtmlConverter.php @@ -0,0 +1,143 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use SensioLabs\AnsiConverter\AnsiToHtmlConverter as BaseAnsiConverter; +use SensioLabs\AnsiConverter\Theme\Theme; +use SilverStripe\Core\Injector\Injectable; + +/** + * Converts an ANSI text to HTML5 but doesn't give an opinionated default colour that isn't specified in the ANSI. + */ +class AnsiToHtmlConverter extends BaseAnsiConverter +{ + use Injectable; + + public function __construct(Theme $theme = null, $inlineStyles = true, $charset = 'UTF-8') + { + $theme ??= AnsiToHtmlTheme::create(); + parent::__construct($theme, $inlineStyles, $charset); + } + + public function convert($text) + { + // remove cursor movement sequences + $text = preg_replace('#\e\[(K|s|u|2J|2K|\d+(A|B|C|D|E|F|G|J|K|S|T)|\d+;\d+(H|f))#', '', $text); + // remove character set sequences + $text = preg_replace('#\e(\(|\))(A|B|[0-2])#', '', $text); + + $text = htmlspecialchars($text, PHP_VERSION_ID >= 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset); + + // convert hyperlinks to `<a>` tags (this is new to this subclass) + $text = preg_replace('#\033]8;;(?<href>[^\033]*)\033\\\(?<text>[^\033]*)\033]8;;\033\\\#', '<a href="$1">$2</a>', $text); + + // carriage return + $text = preg_replace('#^.*\r(?!\n)#m', '', $text); + + $tokens = $this->tokenize($text); + + // a backspace remove the previous character but only from a text token + foreach ($tokens as $i => $token) { + if ('backspace' == $token[0]) { + $j = $i; + while (--$j >= 0) { + if ('text' == $tokens[$j][0] && strlen($tokens[$j][1]) > 0) { + $tokens[$j][1] = substr($tokens[$j][1], 0, -1); + + break; + } + } + } + } + + $html = ''; + foreach ($tokens as $token) { + if ('text' == $token[0]) { + $html .= $token[1]; + } elseif ('color' == $token[0]) { + $html .= $this->convertAnsiToColor($token[1]); + } + } + + // These lines commented out from the parent class implementation. + // We don't want this opinionated default colouring - it doesn't appear in the ANSI format so it doesn't belong in the output. + // if ($this->inlineStyles) { + // $html = sprintf('<span style="background-color: %s; color: %s">%s</span>', $this->inlineColors['black'], $this->inlineColors['white'], $html); + // } else { + // $html = sprintf('<span class="ansi_color_bg_black ansi_color_fg_white">%s</span>', $html); + // } + // We do need an opening and closing span though, or the HTML markup is broken + $html = '<span>' . $html . '</span>'; + + // remove empty span + $html = preg_replace('#<span[^>]*></span>#', '', $html); + // remove unnecessary span + $html = preg_replace('#<span>(.*?(?!</span>)[^<]*)</span>#', '$1', $html); + + return $html; + } + + protected function convertAnsiToColor($ansi) + { + // Set $bg and $fg to null so we don't have a default opinionated colouring + $bg = null; + $fg = null; + $style = []; + $classes = []; + if ('0' != $ansi && '' != $ansi) { + $options = explode(';', $ansi); + + foreach ($options as $option) { + if ($option >= 30 && $option < 38) { + $fg = $option - 30; + } elseif ($option >= 40 && $option < 48) { + $bg = $option - 40; + } elseif (39 == $option) { + $fg = null; // reset to default + } elseif (49 == $option) { + $bg = null; // reset to default + } + } + + // options: bold => 1, underscore => 4, blink => 5, reverse => 7, conceal => 8 + if (in_array(1, $options)) { + $style[] = 'font-weight: bold'; + $classes[] = 'ansi_bold'; + } + + if (in_array(4, $options)) { + $style[] = 'text-decoration: underline'; + $classes[] = 'ansi_underline'; + } + + if (in_array(7, $options)) { + $tmp = $fg; + $fg = $bg; + $bg = $tmp; + } + } + + // Biggest changes start here and go to the end of the method. + // We're explicitly only setting the styling that was included in the ANSI formatting. The original applies + // default colours regardless. + if ($bg !== null) { + $style[] = sprintf('background-color: %s', $this->inlineColors[$this->colorNames[$bg]]); + $classes[] = sprintf('ansi_color_bg_%s', $this->colorNames[$bg]); + } + if ($fg !== null) { + $style[] = sprintf('color: %s', $this->inlineColors[$this->colorNames[$fg]]); + $classes[] = sprintf('ansi_color_fg_%s', $this->colorNames[$fg]); + } + + if ($this->inlineStyles && !empty($style)) { + return sprintf('</span><span style="%s">', implode('; ', $style)); + } + if (!$this->inlineStyles && !empty($classes)) { + return sprintf('</span><span class="%s">', implode('; ', $classes)); + } + + // Because of the way the parent class is implemented, we need to stop the old span and start a new one + // even if we don't have any styling to apply. + return '</span><span>'; + } +} diff --git a/src/PolyExecution/AnsiToHtmlTheme.php b/src/PolyExecution/AnsiToHtmlTheme.php new file mode 100644 index 00000000000..69e363f2cc6 --- /dev/null +++ b/src/PolyExecution/AnsiToHtmlTheme.php @@ -0,0 +1,30 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use SensioLabs\AnsiConverter\Theme\Theme; +use SilverStripe\Core\Injector\Injectable; + +/** + * Theme for converting ANSI colours to something suitable in a browser against a white background + */ +class AnsiToHtmlTheme extends Theme +{ + use Injectable; + + public function asArray() + { + $colourMap = parent::asArray(); + $colourMap['cyan'] = 'royalblue'; + $colourMap['yellow'] = 'goldenrod'; + return $colourMap; + } + + public function asArrayBackground() + { + $colourMap = parent::asArrayBackground(); + $colourMap['cyan'] = 'royalblue'; + $colourMap['yellow'] = 'goldenrod'; + return $colourMap; + } +} diff --git a/src/PolyExecution/HtmlOutputFormatter.php b/src/PolyExecution/HtmlOutputFormatter.php new file mode 100644 index 00000000000..51694014416 --- /dev/null +++ b/src/PolyExecution/HtmlOutputFormatter.php @@ -0,0 +1,58 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use SilverStripe\Core\Injector\Injectable; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use Symfony\Component\Console\Formatter\OutputFormatterStyleInterface; + +/** + * Wraps an ANSI formatter and converts the ANSI formatting to styled HTML. + */ +class HtmlOutputFormatter implements OutputFormatterInterface +{ + use Injectable; + + private OutputFormatterInterface $ansiFormatter; + private AnsiToHtmlConverter $ansiConverter; + + public function __construct(OutputFormatterInterface $formatter) + { + $this->ansiFormatter = $formatter; + $this->ansiConverter = AnsiToHtmlConverter::create(); + } + + public function setDecorated(bool $decorated): void + { + $this->ansiFormatter->setDecorated($decorated); + } + + public function isDecorated(): bool + { + return $this->ansiFormatter->isDecorated(); + } + + public function setStyle(string $name, OutputFormatterStyleInterface $style): void + { + $this->ansiFormatter->setStyle($name, $style); + } + + public function hasStyle(string $name): bool + { + return $this->ansiFormatter->hasStyle($name); + } + + public function getStyle(string $name): OutputFormatterStyleInterface + { + return $this->ansiFormatter->getStyle($name); + } + + public function format(?string $message): ?string + { + $formatted = $this->ansiFormatter->format($message); + if ($this->isDecorated()) { + return $this->ansiConverter->convert($formatted); + } + return $formatted; + } +} diff --git a/src/PolyExecution/HttpRequestInput.php b/src/PolyExecution/HttpRequestInput.php new file mode 100644 index 00000000000..f1b17c3ea80 --- /dev/null +++ b/src/PolyExecution/HttpRequestInput.php @@ -0,0 +1,111 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Core\Injector\Injectable; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Input that populates options from an HTTPRequest + * + * Use this for inputs to PolyCommand when called from a web request. + */ +class HttpRequestInput extends ArrayInput +{ + use Injectable; + + protected bool $interactive = false; + + /** + * @param array<InputOption> $commandOptions Any options that apply for the command itself. + * Do not include global options (e.g. flush) - they are added explicitly in the constructor. + */ + public function __construct(HTTPRequest $request, array $commandOptions = []) + { + $definition = new InputDefinition([ + // Also add global options that are applicable for HTTP requests + new InputOption('quiet', null, InputOption::VALUE_NONE, 'Do not output any message'), + new InputOption('verbose', null, InputOption::VALUE_OPTIONAL, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'), + // The actual flushing already happened before this point, but we still need + // to declare the option in case someone's checking against it + new InputOption('flush', null, InputOption::VALUE_NONE, 'Flush the cache before running the command'), + ...$commandOptions + ]); + $optionValues = $this->getOptionValuesFromRequest($request, $definition); + parent::__construct($optionValues, $definition); + } + + /** + * Get the verbosity that should be used based on the request vars. + * This is used to set the verbosity for PolyOutput. + */ + public function getVerbosity(): int + { + if ($this->getOption('quiet')) { + return OutputInterface::VERBOSITY_QUIET; + } + $verbose = $this->getOption('verbose'); + if ($verbose === '1' || $verbose === 1 || $verbose === true) { + return OutputInterface::VERBOSITY_VERBOSE; + } + if ($verbose === '2' || $verbose === 2) { + return OutputInterface::VERBOSITY_VERY_VERBOSE; + } + if ($verbose === '3' || $verbose === 3) { + return OutputInterface::VERBOSITY_DEBUG; + } + return OutputInterface::VERBOSITY_NORMAL; + } + + private function getOptionValuesFromRequest(HTTPRequest $request, InputDefinition $definition): array + { + $options = []; + foreach ($definition->getOptions() as $option) { + // We'll check for the long name and all shortcuts. + // Note the `--` and `-` prefixes are already stripped at this point. + $candidateParams = [$option->getName()]; + $shortcutString = $option->getShortcut(); + if ($shortcutString !== null) { + $shortcuts = explode('|', $shortcutString); + foreach ($shortcuts as $shortcut) { + $candidateParams[] = $shortcut; + } + } + // Get a value if there is one + $value = null; + foreach ($candidateParams as $candidateParam) { + $value = $request->requestVar($candidateParam); + } + $default = $option->getDefault(); + // Set correct default value + if ($value === null && $default !== null) { + $value = $default; + } + // Ignore missing values if values aren't required + if (($value === null || $value === []) && $option->isValueRequired()) { + continue; + } + // Convert value to array if it should be one + if ($value !== null && $option->isArray() && !is_array($value)) { + $value = [$value]; + } + // If there's a value (or the option accepts one and didn't get one), set the option. + if ($value !== null || $option->acceptValue()) { + // If the option doesn't accept a value, determine the correct boolean state for it. + // If we weren't able to determine if the value's boolean-ness, default to truthy=true + // because that's what you'd end up with with `if ($request->requestVar('myVar'))` + if (!$option->acceptValue()) { + $value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + } + // We need to prefix with `--` so the superclass knows it's an + // option rather than an argument. + $options['--' . $option->getName()] = $value; + } + } + return $options; + } +} diff --git a/src/PolyExecution/PolyCommand.php b/src/PolyExecution/PolyCommand.php new file mode 100644 index 00000000000..4774994184f --- /dev/null +++ b/src/PolyExecution/PolyCommand.php @@ -0,0 +1,182 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use RuntimeException; +use SilverStripe\Control\Director; +use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\Injector\Injectable; +use SilverStripe\Dev\DevelopmentAdmin; +use SilverStripe\PolyExecution\HtmlOutputFormatter; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\Security\Permission; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +/** + * Abstract class for commands which can be run either via an HTTP request or the CLI. + */ +abstract class PolyCommand +{ + use Configurable; + use Injectable; + + /** + * Defines whether this command can be run in the CLI via sake. + * Overridden if DevelopmentAdmin sets allow_all_cli to true. + * + * Note that in dev mode the command can always be run. + */ + private static bool $can_run_in_cli = true; + + /** + * Defines whether this command can be run in the browser via a web request. + * If true, user must have the requisite permissions. + * + * Note that in dev mode the command can always be run. + */ + private static bool $can_run_in_browser = true; + + /** + * Permissions required for users to execute this command via the browser. + * If `can_run_in_browser` is false, these permissions are ignored. + * Must be defined in the subclass. + */ + private static array $permissions_for_browser_execution = []; + + /** + * Name of the command. Also used as the end of the URL segment for browser execution. + * Must be defined in the subclass. + */ + protected static string $commandName = ''; + + /** + * Description of what the command does. Can use symfony console styling. + * See https://symfony.com/doc/current/console/coloring.html. + * Must be defined in the subclass. + */ + protected static string $description = ''; + + /** + * Get the title for this command. + */ + abstract public function getTitle(): string; + + /** + * Execute this command. + * + * Output should be agnostic - do not include explicit HTML in the output unless there is no API + * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method). + * + * Use symfony/console ANSI formatting to style the output. + * See https://symfony.com/doc/current/console/coloring.html + * + * @return int 0 if everything went fine, or an exit code + */ + abstract public function run(InputInterface $input, PolyOutput $output): int; + + /** + * Get the name of this command. + */ + public static function getName(): string + { + return static::$commandName; + } + + /** + * Get the description of this command. Includes unparsed symfony/console styling. + */ + public static function getDescription(): string + { + return _t(static::class . '.description', static::$description); + } + + /** + * Return additional help context to avoid an overly long description. + */ + public static function getHelp(): string + { + return ''; + } + + /** + * Get input options that can be passed into the command. + * + * In CLI execution these will be passed as flags. + * In HTTP execution these will be passed in the query string. + * + * @return array<InputOption> + */ + public function getOptions(): array + { + return []; + } + + public function getOptionsForTemplate(): array + { + $formatter = HtmlOutputFormatter::create(); + $forTemplate = []; + foreach ($this->getOptions() as $option) { + $default = $option->getDefault(); + if (is_bool($default)) { + // Use 1/0 for boolean, since that's what you'd pass in the query string + $default = $default ? '1' : '0'; + } + if (is_array($default)) { + $default = implode(',', $default); + } + $forTemplate[] = [ + 'Name' => $option->getName(), + 'Description' => DBField::create_field('HTMLText', $formatter->format($option->getDescription())), + 'Default' => $default, + ]; + } + return $forTemplate; + } + + /** + * Check whether this command can be run in CLI via sake + */ + public static function canRunInCli(): bool + { + static::checkPrerequisites(); + return Director::isDev() + || static::config()->get('can_run_in_cli') + || DevelopmentAdmin::config()->get('allow_all_cli'); + } + + /** + * Check whether this command can be run in the browser via a web request + */ + public static function canRunInBrowser(): bool + { + static::checkPrerequisites(); + // Can always run in browser in dev mode + if (Director::isDev()) { + return true; + } + if (!static::config()->get('can_run_in_browser')) { + return false; + } + // Check permissions if there are any + $permissions = static::config()->get('permissions_for_browser_execution'); + if ($permissions) { + return Permission::check($permissions); + } + return true; + } + + private static function checkPrerequisites(): void + { + $mandatoryMethods = [ + 'getName' => 'commandName', + 'getDescription' => 'description', + ]; + foreach ($mandatoryMethods as $getter => $property) { + if (!static::$getter()) { + throw new RuntimeException($property . ' property needs to be set.'); + } + } + } +} diff --git a/src/PolyExecution/PolyOutput.php b/src/PolyExecution/PolyOutput.php new file mode 100644 index 00000000000..36b070412a8 --- /dev/null +++ b/src/PolyExecution/PolyOutput.php @@ -0,0 +1,250 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use InvalidArgumentException; +use LogicException; +use SensioLabs\AnsiConverter\AnsiToHtmlConverter; +use SilverStripe\Core\Injector\Injectable; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Output that correctly formats for HTML or for the terminal, depending on the output type. + * Used for functionality that can be used both via CLI and via the browser. + */ +class PolyOutput extends Output +{ + use Injectable; + + public const LIST_UNORDERED = 'ul'; + public const LIST_ORDERED = 'ol'; + + /** Use this if you want HTML markup in the output */ + public const FORMAT_HTML = 'Html'; + /** Use this for outputing to a terminal, or for plain text output */ + public const FORMAT_ANSI = 'Ansi'; + + private string $outputFormat; + + private ?OutputInterface $wrappedOutput = null; + + private ?AnsiToHtmlConverter $ansiConverter = null; + + /** + * Array of list types that are opened, and the options that were used to open them. + */ + private array $listTypeStack = []; + + /** + * @param string $outputFormat The format to use for the output (one of the FORMAT_* constants) + * @param int The verbosity level (one of the VERBOSITY_* constants in OutputInterface) + * @param boolean $decorated Whether to decorate messages (if false, decoration tags will simply be removed) + * @param OutputInterface|null $wrappedOutput An optional output pipe messages through. + * Useful for capturing output instead of echoing directly to the client, for example. + */ + public function __construct( + string $outputFormat, + int $verbosity = OutputInterface::VERBOSITY_NORMAL, + bool $decorated = false, + ?OutputInterface $wrappedOutput = null + ) { + $this->setOutputFormat($outputFormat); + // Intentionally don't call parent constructor, because it doesn't use the setter methods. + if ($wrappedOutput) { + $this->setWrappedOutput($wrappedOutput); + } else { + $this->setFormatter(new OutputFormatter()); + } + $this->setDecorated($decorated); + $this->setVerbosity($verbosity); + } + + /** + * Writes messages to the output - but only if we're using the given output format. + * Useful for adding explicit HTML markup (divs, etc) to wrap the main output. + * If including HTML markup, use OUTPUT_RAW in the options. + * + * @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + */ + public function writeForFormat( + string $outputFormat, + string|iterable $messages, + bool $newline = false, + int $options = OutputInterface::OUTPUT_NORMAL + ): void { + if ($this->outputFormat === $outputFormat) { + $this->write($messages, $newline, $options); + } + } + + /** + * Start a list. + * In HTML format this will write the opening `<ul>` or `<ol>` tag. + * In ANSI format this will set up information for rendering list items. + * + * Call writeListItem() to add items to the list, then call stopList() when you're done. + * + * @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + */ + public function startList(string $listType = PolyOutput::LIST_UNORDERED, int $options = OutputInterface::OUTPUT_NORMAL): void + { + $this->listTypeStack[] = ['type' => $listType, 'options' => $options]; + if ($this->outputFormat === PolyOutput::FORMAT_HTML) { + $this->write("<{$listType}>", options: $this->forceRawOutput($options)); + } + } + + /** + * Stop a list. + * In HTML format this will write the closing `</ul>` or `</ol>` tag. + * In ANSI format this will mark the list as closed (useful when nesting lists) + */ + public function stopList(): void + { + if (empty($this->listTypeStack)) { + throw new LogicException('No list to close.'); + } + $info = array_pop($this->listTypeStack); + if ($this->outputFormat === PolyOutput::FORMAT_HTML) { + $this->write("</{$info['type']}>", options: $this->forceRawOutput($info['options'])); + } + } + + /** + * Writes messages formatted as a list. + * Make sure to call startList() before writing list items, and call stopList() when you're done. + * + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * by default this will inherit the options used to start the list. + */ + public function writeListItem(string|iterable $items, ?int $options = null): void + { + if (empty($this->listTypeStack)) { + throw new LogicException('No lists started. Call startList() first.'); + } + if (is_string($items)) { + $items = [$items]; + } + $method = "writeListItem{$this->outputFormat}"; + $this->$method($items, $options); + } + + public function setFormatter(OutputFormatterInterface $formatter): void + { + if ($this->outputFormat === PolyOutput::FORMAT_HTML) { + $formatter = HtmlOutputFormatter::create($formatter); + } + parent::setFormatter($formatter); + } + + /** + * Set whether this will output in HTML or ANSI format. + * + * @throws InvalidArgumentException if the format isn't one of the FORMAT_* constants + */ + public function setOutputFormat(string $outputFormat): void + { + if (!in_array($outputFormat, [PolyOutput::FORMAT_ANSI, PolyOutput::FORMAT_HTML])) { + throw new InvalidArgumentException("Unexpected format - got '$outputFormat'."); + } + $this->outputFormat = $outputFormat; + } + + /** + * Get the format used for output. + */ + public function getOutputFormat(): string + { + return $this->outputFormat; + } + + /** + * Set an output to wrap inside this one. Useful for capturing output in a buffer. + */ + public function setWrappedOutput(OutputInterface $wrappedOutput): void + { + $this->wrappedOutput = $wrappedOutput; + $this->setFormatter($this->wrappedOutput->getFormatter()); + // Give wrapped output a debug verbosity - that way it'll output everything we tell it to. + // Actual verbosity is handled by PolyOutput's parent Output class. + $this->wrappedOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + } + + protected function doWrite(string $message, bool $newline): void + { + if ($this->outputFormat === PolyOutput::FORMAT_HTML) { + $output = $message . ($newline ? '<br>' . PHP_EOL : ''); + } else { + $output = $message . ($newline ? PHP_EOL : ''); + } + if ($this->wrappedOutput) { + $this->wrappedOutput->write($output, options: OutputInterface::OUTPUT_RAW); + } else { + echo $output; + } + } + + private function writeListItemHtml(iterable $items, ?int $options): void + { + if ($options === null) { + $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)]; + $options = $listInfo['options']; + } + foreach ($items as $item) { + $this->write('<li>', options: $this->forceRawOutput($options)); + $this->write($item, options: $options); + $this->write('</li>', options: $this->forceRawOutput($options)); + } + } + + private function writeListItemAnsi(iterable $items, ?int $options): void + { + $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)]; + $listType = $listInfo['type']; + if ($listType === PolyOutput::LIST_ORDERED) { + echo ''; + } + if ($options === null) { + $options = $listInfo['options']; + } + foreach ($items as $i => $item) { + switch ($listType) { + case PolyOutput::LIST_UNORDERED: + $bullet = '*'; + break; + case PolyOutput::LIST_ORDERED: + // Start at 1 + $numberOffset = $listInfo['offset'] ?? 1; + $bullet = ($i + $numberOffset) . '.'; + break; + default: + throw new InvalidArgumentException("Unexpected list type - got '$listType'."); + } + $indent = str_repeat(' ', count($this->listTypeStack)); + $this->writeln("{$indent}{$bullet} {$item}", $options); + } + // Update the number offset so the next item in the list has the correct number + if ($listType === PolyOutput::LIST_ORDERED) { + $this->listTypeStack[array_key_last($this->listTypeStack)]['offset'] = $numberOffset + $i + 1; + } + } + + private function getVerbosityOption(int $options): int + { + // Logic copied from Output::write() - uses bitwise operations to separate verbosity from output type. + $verbosities = OutputInterface::VERBOSITY_QUIET | OutputInterface::VERBOSITY_NORMAL | OutputInterface::VERBOSITY_VERBOSE | OutputInterface::VERBOSITY_VERY_VERBOSE | OutputInterface::VERBOSITY_DEBUG; + return $verbosities & $options ?: OutputInterface::VERBOSITY_NORMAL; + } + + private function forceRawOutput(int $options): int + { + return $this->getVerbosityOption($options) | OutputInterface::OUTPUT_RAW; + } +} diff --git a/src/PolyExecution/PolyOutputLogHandler.php b/src/PolyExecution/PolyOutputLogHandler.php new file mode 100644 index 00000000000..2caa07dc536 --- /dev/null +++ b/src/PolyExecution/PolyOutputLogHandler.php @@ -0,0 +1,30 @@ +<?php + +namespace SilverStripe\PolyExecution; + +use Monolog\Handler\AbstractProcessingHandler; +use Monolog\Level; +use Monolog\LogRecord; +use SilverStripe\Core\Injector\Injectable; + +/** + * Log handler that uses a PolyOutput to output log entries to the browser or CLI. + */ +class PolyOutputLogHandler extends AbstractProcessingHandler +{ + use Injectable; + + private PolyOutput $output; + + public function __construct(PolyOutput $output, int|string|Level $level = Level::Debug, bool $bubble = true) + { + $this->output = $output; + parent::__construct($level, $bubble); + } + + protected function write(LogRecord $record): void + { + $message = rtrim($record->formatted, PHP_EOL); + $this->output->write($message, true, PolyOutput::OUTPUT_RAW); + } +} diff --git a/src/Security/Confirmation/Handler.php b/src/Security/Confirmation/Handler.php index 2c61912403c..dcc7162ac30 100644 --- a/src/Security/Confirmation/Handler.php +++ b/src/Security/Confirmation/Handler.php @@ -7,11 +7,6 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\RequestHandler; -use SilverStripe\Forms\Form as BaseForm; -use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\TextField; -use SilverStripe\Forms\FormAction; -use SilverStripe\Forms\RequiredFields; /** * Confirmation form handler implementation diff --git a/src/Security/Member.php b/src/Security/Member.php index 1b87e2169fb..c6103015042 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -772,7 +772,7 @@ protected function onBeforeWrite() } } - // We don't send emails out on dev/tests sites to prevent accidentally spamming users. + // We don't send emails out during tests to prevent accidentally spamming users. // However, if TestMailer is in use this isn't a risk. if ((Director::isLive() || Injector::inst()->get(MailerInterface::class) instanceof TestMailer) && $this->isChanged('Password') diff --git a/src/Security/RandomGenerator.php b/src/Security/RandomGenerator.php index b0fc390cf6a..71e58b73463 100644 --- a/src/Security/RandomGenerator.php +++ b/src/Security/RandomGenerator.php @@ -3,12 +3,15 @@ namespace SilverStripe\Security; use Exception; +use SilverStripe\Core\Injector\Injectable; /** * Convenience class for generating cryptographically secure pseudo-random strings/tokens */ class RandomGenerator { + use Injectable; + /** * Generates a random token that can be used for session IDs, CSRF tokens etc., based on * hash algorithms. diff --git a/src/Security/Security.php b/src/Security/Security.php index 80d6fe8d4bb..b2e96d449d9 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -1067,7 +1067,7 @@ public static function encrypt_password($password, $salt = null, $algorithm = nu /** * Checks the database is in a state to perform security checks. - * See {@link DatabaseAdmin->init()} for more information. + * See DbBuild permission checks for more information. * * @return bool */ diff --git a/src/View/SSViewer_DataPresenter.php b/src/View/SSViewer_DataPresenter.php index 7c5b6e5ecd0..c2032d3940c 100644 --- a/src/View/SSViewer_DataPresenter.php +++ b/src/View/SSViewer_DataPresenter.php @@ -4,6 +4,7 @@ use InvalidArgumentException; use SilverStripe\Core\ClassInfo; +use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\FieldType\DBField; /** @@ -432,6 +433,11 @@ protected function castValue($value, $source) return $value; } + // Wrap list arrays in ViewableData so templates can handle them + if (is_array($value) && array_is_list($value)) { + return ArrayList::create($value); + } + // Get provided or default cast $casting = empty($source['casting']) ? ViewableData::config()->uninherited('default_cast') diff --git a/src/i18n/TextCollection/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php index 3d3d6fa6f8f..53b095888cb 100644 --- a/src/i18n/TextCollection/i18nTextCollector.php +++ b/src/i18n/TextCollection/i18nTextCollector.php @@ -6,7 +6,6 @@ use LogicException; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Extension; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Manifest\ClassLoader; @@ -39,8 +38,8 @@ * * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule - * Usage on CLI: sake dev/tasks/i18nTextCollectorTask - * Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule + * Usage on CLI: sake tasks:i18nTextCollectorTask + * Usage on CLI (module-specific): sake tasks:i18nTextCollectorTask --module=mymodule * * @author Bernat Foj Capell <bernat@silverstripe.com> * @author Ingo Schommer <FIRSTNAME@silverstripe.com> diff --git a/templates/SilverStripe/Dev/DevelopmentAdmin.ss b/templates/SilverStripe/Dev/DevelopmentAdmin.ss new file mode 100644 index 00000000000..f833bf0fcbf --- /dev/null +++ b/templates/SilverStripe/Dev/DevelopmentAdmin.ss @@ -0,0 +1,37 @@ +$Header.RAW +$Info.RAW + +<% if $Title %> + <div class="info"> + <h1>$Title</h1> + </div> +<% end_if %> + +<div class="options"> + <% if $Form %> + <%-- confirmation handler --%> + $Form + <% else %> + <ul> + <% loop $ArrayLinks %> + <li class="$EvenOdd"> + <a href="$Link"><b>/$Path:</b> $Description</a> + <% if $Help %> + <details class="more-details"> + <summary>Display additional information</summary> + $Help + </details> + <% end_if %> + <% if $Parameters %> + <div>Parameters: + <% include SilverStripe/Dev/Parameters %> + </div> + <% end_if %> + </li> + <% end_loop %> + </ul> + <% end_if %> +</div> + +$Footer.RAW + diff --git a/templates/SilverStripe/Dev/Parameters.ss b/templates/SilverStripe/Dev/Parameters.ss new file mode 100644 index 00000000000..6fafb1dffeb --- /dev/null +++ b/templates/SilverStripe/Dev/Parameters.ss @@ -0,0 +1,8 @@ +<dl class="params"> + <% loop $Parameters %> + <div class="param"> + <dt class="param__name">$Name</dt> + <dd class="param__description">$Description<% if $Default %> [default: $Default]<% end_if %></dd> + </div> + <% end_loop %> +</dl> diff --git a/templates/SilverStripe/Dev/TaskRunner.ss b/templates/SilverStripe/Dev/TaskRunner.ss index 10020f74d2c..e14dda4a2da 100644 --- a/templates/SilverStripe/Dev/TaskRunner.ss +++ b/templates/SilverStripe/Dev/TaskRunner.ss @@ -9,7 +9,19 @@ $Info.RAW <div class="task__item"> <div> <h3 class="task__title">$Title</h3> - <div class="task__description">$Description</div> + <div class="task__description"> + $Description + <% if $Help %> + <details class="task__help"> + <summary>Display additional information</summary> + $Help + </details> + <% end_if %> + </div> + <% if $Parameters %> + Parameters: + <% include SilverStripe/Dev/Parameters %> + <% end_if %> </div> <div> <a href="{$TaskLink.ATT}" class="task__button">Run task</a> diff --git a/tests/bootstrap/cli.php b/tests/bootstrap/cli.php index c7bb21504a5..d5317a123d9 100644 --- a/tests/bootstrap/cli.php +++ b/tests/bootstrap/cli.php @@ -25,9 +25,6 @@ $frameworkPath = dirname(dirname(__FILE__)); $frameworkDir = basename($frameworkPath ?? ''); -$_SERVER['SCRIPT_FILENAME'] = $frameworkPath . DIRECTORY_SEPARATOR . 'cli-script.php'; -$_SERVER['SCRIPT_NAME'] = '.' . DIRECTORY_SEPARATOR . $frameworkDir . DIRECTORY_SEPARATOR . 'cli-script.php'; - // Copied from cli-script.php, to enable same behaviour through phpunit runner. if (isset($_SERVER['argv'][2])) { $args = array_slice($_SERVER['argv'] ?? [], 2); diff --git a/tests/php/Cli/Command/NavigateCommandTest.php b/tests/php/Cli/Command/NavigateCommandTest.php new file mode 100644 index 00000000000..3dd9851f2a9 --- /dev/null +++ b/tests/php/Cli/Command/NavigateCommandTest.php @@ -0,0 +1,99 @@ +<?php + +namespace SilverStripe\Cli\Tests\Command; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Cli\Command\NavigateCommand; +use SilverStripe\Cli\Tests\Command\NavigateCommandTest\TestController; +use SilverStripe\Control\Director; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class NavigateCommandTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideExecute(): array + { + return [ + [ + 'path' => 'missing-route', + 'getVars' => [], + 'expectedExitCode' => 2, + 'expectedOutput' => '', + ], + [ + 'path' => 'test-controller', + 'getVars' => [], + 'expectedExitCode' => 0, + 'expectedOutput' => 'This is the index for TestController.' . PHP_EOL, + ], + [ + 'path' => 'test-controller/actionOne', + 'getVars' => [], + 'expectedExitCode' => 0, + 'expectedOutput' => 'This is action one!' . PHP_EOL, + ], + [ + 'path' => 'test-controller/errorResponse', + 'getVars' => [], + 'expectedExitCode' => 1, + 'expectedOutput' => '', + ], + [ + 'path' => 'test-controller/missing-action', + 'getVars' => [], + 'expectedExitCode' => 2, + 'expectedOutput' => '', + ], + [ + 'path' => 'test-controller', + 'getVars' => [ + 'var1=1', + 'var2=abcd', + 'var3=', + 'var4[]=a', + 'var4[]=b', + 'var4[]=c', + ], + 'expectedExitCode' => 0, + 'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL, + ], + [ + 'path' => 'test-controller', + 'getVars' => [ + 'var1=1&var2=abcd&var3=&var4[]=a&var4[]=b&var4[]=c', + ], + 'expectedExitCode' => 0, + 'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL, + ], + ]; + } + + #[DataProvider('provideExecute')] + public function testExecute(string $path, array $getVars, int $expectedExitCode, string $expectedOutput): void + { + // Intentionally override existing rules + Director::config()->set('rules', ['test-controller' => TestController::class]); + $navigateCommand = new NavigateCommand(); + $inputParams = [ + 'path' => $path, + 'get-var' => $getVars, + ]; + $input = new ArrayInput($inputParams, $navigateCommand->getDefinition()); + $input->setInteractive(false); + $buffer = new BufferedOutput(); + $output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer); + + $exitCode = $navigateCommand->run($input, $output); + + // Don't asset specific output for failed or invalid responses + // The response body for those is handled outside of the navigate command's control + if ($expectedExitCode === 0) { + $this->assertSame($expectedOutput, $buffer->fetch()); + } + $this->assertSame($expectedExitCode, $exitCode); + } +} diff --git a/tests/php/Cli/Command/NavigateCommandTest/TestController.php b/tests/php/Cli/Command/NavigateCommandTest/TestController.php new file mode 100644 index 00000000000..d7bc07c42c3 --- /dev/null +++ b/tests/php/Cli/Command/NavigateCommandTest/TestController.php @@ -0,0 +1,52 @@ +<?php + +namespace SilverStripe\Cli\Tests\Command\NavigateCommandTest; + +use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse; + +class TestController extends Controller +{ + private static $allowed_actions = [ + 'actionOne', + 'errorResponse', + ]; + + public function index(HTTPRequest $request): HTTPResponse + { + $var1 = $request->getVar('var1'); + $var2 = $request->getVar('var2'); + $var3 = $request->getVar('var3'); + $var4 = $request->getVar('var4'); + + $output = 'This is the index for TestController.'; + + if ($var1) { + $output .= ' var1=' . $var1; + } + if ($var2) { + $output .= ' var2=' . $var2; + } + if ($var3) { + $output .= ' var3=' . $var3; + } + if ($var4) { + $output .= ' var4=' . implode(',', $var4); + } + + $this->response->setBody($output); + return $this->response; + } + + public function actionOne(HTTPRequest $request): HTTPResponse + { + $this->response->setBody('This is action one!'); + return $this->response; + } + + public function errorResponse(HTTPRequest $request): HTTPResponse + { + $this->httpError(500); + } +} diff --git a/tests/php/Cli/Command/PolyCommandCliWrapperTest.php b/tests/php/Cli/Command/PolyCommandCliWrapperTest.php new file mode 100644 index 00000000000..b8b1855c5f0 --- /dev/null +++ b/tests/php/Cli/Command/PolyCommandCliWrapperTest.php @@ -0,0 +1,52 @@ +<?php + +namespace SilverStripe\Cli\Tests\Command; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Cli\Command\PolyCommandCliWrapper; +use SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest\TestPolyCommand; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class PolyCommandCliWrapperTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideExecute(): array + { + return [ + 'no-params' => [ + 'exitCode' => 0, + 'params' => [], + 'expectedOutput' => 'Has option 1: false' . PHP_EOL + . 'option 2 value: ' . PHP_EOL, + ], + 'with-params' => [ + 'exitCode' => 1, + 'params' => [ + '--option1' => true, + '--option2' => 'abc', + ], + 'expectedOutput' => 'Has option 1: true' . PHP_EOL + . 'option 2 value: abc' . PHP_EOL, + ], + ]; + } + + #[DataProvider('provideExecute')] + public function testExecute(int $exitCode, array $params, string $expectedOutput): void + { + $polyCommand = new TestPolyCommand(); + $polyCommand->setExitCode($exitCode); + $wrapper = new PolyCommandCliWrapper($polyCommand); + $input = new ArrayInput($params, $wrapper->getDefinition()); + $input->setInteractive(false); + $buffer = new BufferedOutput(); + $output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer); + + $this->assertSame($exitCode, $wrapper->run($input, $output)); + $this->assertSame($expectedOutput, $buffer->fetch()); + } +} diff --git a/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php b/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php new file mode 100644 index 00000000000..42afb678449 --- /dev/null +++ b/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php @@ -0,0 +1,43 @@ +<?php + +namespace SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest; + +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +class TestPolyCommand extends PolyCommand implements TestOnly +{ + protected static string $commandName = 'test:poly'; + + protected static string $description = 'simple command for testing CLI wrapper'; + + private int $exitCode = 0; + + public function getTitle(): string + { + return 'This is the title!'; + } + + public function run(InputInterface $input, PolyOutput $output): int + { + $output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false')); + $output->writeln('option 2 value: ' . $input->getOption('option2')); + return $this->exitCode; + } + + public function setExitCode(int $code): void + { + $this->exitCode = $code; + } + + public function getOptions(): array + { + return [ + new InputOption('option1', null, InputOption::VALUE_NONE), + new InputOption('option2', null, InputOption::VALUE_REQUIRED), + ]; + } +} diff --git a/tests/php/Cli/LegacyParamArgvInputTest.php b/tests/php/Cli/LegacyParamArgvInputTest.php new file mode 100644 index 00000000000..d7b7229eb77 --- /dev/null +++ b/tests/php/Cli/LegacyParamArgvInputTest.php @@ -0,0 +1,159 @@ +<?php + +namespace SilverStripe\Cli\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Cli\LegacyParamArgvInput; +use SilverStripe\Dev\SapphireTest; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputOption; + +class LegacyParamArgvInputTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideHasParameterOption(): array + { + return [ + 'sake flush=1' => [ + 'argv' => [ + 'sake', + 'flush=1' + ], + 'checkFor' => '--flush', + 'expected' => true, + ], + 'sake flush=0' => [ + 'argv' => [ + 'sake', + 'flush=0' + ], + 'checkFor' => '--flush', + 'expected' => true, + ], + 'sake flush=1 --' => [ + 'argv' => [ + 'sake', + 'flush=1', + '--' + ], + 'checkFor' => '--flush', + 'expected' => true, + ], + 'sake -- flush=1' => [ + 'argv' => [ + 'sake', + '--', + 'flush=1' + ], + 'checkFor' => '--flush', + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideHasParameterOption')] + public function testHasParameterOption(array $argv, string $checkFor, bool $expected): void + { + $input = new LegacyParamArgvInput($argv); + $this->assertSame($expected, $input->hasParameterOption($checkFor)); + } + + public static function provideGetParameterOption(): array + { + $scenarios = static::provideHasParameterOption(); + $scenarios['sake flush=1']['expected'] = '1'; + $scenarios['sake flush=0']['expected'] = '0'; + $scenarios['sake flush=1 --']['expected'] = '1'; + $scenarios['sake -- flush=1']['expected'] = false; + return $scenarios; + } + + #[DataProvider('provideGetParameterOption')] + public function testGetParameterOption(array $argv, string $checkFor, false|string $expected): void + { + $input = new LegacyParamArgvInput($argv); + $this->assertSame($expected, $input->getParameterOption($checkFor)); + } + + public static function provideBind(): array + { + return [ + 'sake flush=1 arg=value' => [ + 'argv' => [ + 'sake', + 'flush=1', + 'arg=value', + ], + 'options' => [ + new InputOption('--flush', null, InputOption::VALUE_NONE), + new InputOption('--arg', null, InputOption::VALUE_REQUIRED), + ], + 'expected' => [ + 'flush' => true, + 'arg' => 'value', + ], + ], + 'sake flush=yes arg=abc' => [ + 'argv' => [ + 'sake', + 'flush=yes', + 'arg=abc', + ], + 'options' => [ + new InputOption('flush', null, InputOption::VALUE_NONE), + new InputOption('arg', null, InputOption::VALUE_OPTIONAL), + ], + 'expected' => [ + 'flush' => true, + 'arg' => 'abc', + ], + ], + 'sake flush=0 arg=' => [ + 'argv' => [ + 'sake', + 'flush=0', + 'arg=', + ], + 'options' => [ + new InputOption('flush', null, InputOption::VALUE_NONE), + new InputOption('arg', null, InputOption::VALUE_OPTIONAL), + ], + 'expected' => [ + 'flush' => false, + 'arg' => null, + ], + ], + 'sake flush=1 -- arg=abc' => [ + 'argv' => [ + 'sake', + 'flush=1', + '--', + 'arg=abc', + ], + 'options' => [ + new InputOption('flush', null, InputOption::VALUE_NONE), + new InputOption('arg', null, InputOption::VALUE_OPTIONAL), + // Since arg=abc is now included as an argument, we need to allow an argument. + new InputArgument('needed-to-avoid-error', InputArgument::REQUIRED), + ], + 'expected' => [ + 'flush' => true, + 'arg' => null, + ], + ], + ]; + } + + #[DataProvider('provideBind')] + public function testBind(array $argv, array $options, array $expected): void + { + $input = new LegacyParamArgvInput($argv); + $definition = new InputDefinition($options); + $input->bind($definition); + foreach ($expected as $option => $value) { + $this->assertSame($value, $input->getOption($option)); + } + } +} diff --git a/tests/php/Cli/SakeTest.php b/tests/php/Cli/SakeTest.php new file mode 100644 index 00000000000..8434484c2ba --- /dev/null +++ b/tests/php/Cli/SakeTest.php @@ -0,0 +1,307 @@ +<?php + +namespace SilverStripe\Cli\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Error\Deprecated; +use ReflectionClass; +use SilverStripe\Cli\Sake; +use SilverStripe\Cli\Tests\SakeTest\TestBuildTask; +use SilverStripe\Cli\Tests\SakeTest\TestCommandLoader; +use SilverStripe\Cli\Tests\SakeTest\TestConfigCommand; +use SilverStripe\Cli\Tests\SakeTest\TestConfigPolyCommand; +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Kernel; +use SilverStripe\Core\Manifest\VersionProvider; +use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\Deprecation; +use SilverStripe\Dev\DevelopmentAdmin; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestException; +use Symfony\Component\Console\Command\DumpCompletionCommand; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class SakeTest extends SapphireTest +{ + protected $usesDatabase = false; + + private $oldErrorHandler = null; + + public static function provideList(): array + { + return [ + 'display all' => [ + 'addExtra' => true, + 'hideCompletion' => true, + ], + 'display none' => [ + 'addExtra' => false, + 'hideCompletion' => false, + ], + ]; + } + + /** + * Test adding commands and command loaders to Sake via configuration API + */ + #[DataProvider('provideList')] + public function testList(bool $addExtra, bool $hideCompletion): void + { + $sake = new Sake(Injector::inst()->get(Kernel::class)); + $sake->setAutoExit(false); + $input = new ArrayInput(['list']); + $input->setInteractive(false); + $output = new BufferedOutput(); + + if ($addExtra) { + Sake::config()->merge('commands', [ + TestConfigPolyCommand::class, + TestConfigCommand::class, + ]); + Sake::config()->merge('command_loaders', [ + TestCommandLoader::class, + ]); + } + Sake::config()->set('hide_completion_command', $hideCompletion); + // Make sure all tasks are displayed - we'll test hiding them in testHideTasks + Sake::config()->set('max_tasks_to_display', 0); + + $sake->run($input, $output); + + $commandNames = [ + 'loader:test-command', + 'test:from-config:standard', + 'test:from-config:poly', + ]; + $commandDescriptions = [ + 'command for testing adding custom command loaders', + 'command for testing adding standard commands via config', + 'command for testing adding poly commands via config', + ]; + + $listOutput = $output->fetch(); + + // Check if the extra commands are there or not + if ($addExtra) { + foreach ($commandNames as $name) { + $this->assertStringContainsString($name, $listOutput); + } + foreach ($commandDescriptions as $description) { + $this->assertStringContainsString($description, $listOutput); + } + } else { + foreach ($commandNames as $name) { + $this->assertStringNotContainsString($name, $listOutput); + } + foreach ($commandDescriptions as $description) { + $this->assertStringNotContainsString($description, $listOutput); + } + } + + // Build task could display automagically as a matter of class inheritance. + $task = new TestBuildTask(); + $this->assertStringContainsString($task->getName(), $listOutput); + $this->assertStringContainsString(TestBuildTask::getDescription(), $listOutput); + + // Check if the completion command is there or not + $command = new DumpCompletionCommand(); + $completionRegex = "/{$command->getName()}\s+{$command->getDescription()}/"; + if ($hideCompletion) { + $this->assertDoesNotMatchRegularExpression($completionRegex, $listOutput); + } else { + $this->assertMatchesRegularExpression($completionRegex, $listOutput); + } + + // Make sure the "help" and "list" commands aren't shown + $this->assertStringNotContainsString($listOutput, 'List commands', 'the list command should not display'); + $this->assertStringNotContainsString($listOutput, 'Display help for a command', 'the help command should not display'); + } + + public function testPolyCommandCanRunInCli(): void + { + $kernel = Injector::inst()->get(Kernel::class); + $sake = new Sake($kernel); + $sake->setAutoExit(false); + $input = new ArrayInput(['list']); + $input->setInteractive(false); + $output = new BufferedOutput(); + + // Add test commands + Sake::config()->merge('commands', [ + TestConfigPolyCommand::class, + ]); + + // Disallow these to run in CLI. + // Note the scenario where all are allowed is in testList(). + TestConfigPolyCommand::config()->set('can_run_in_cli', false); + TestBuildTask::config()->set('can_run_in_cli', false); + DevelopmentAdmin::config()->set('allow_all_cli', false); + + // Must not be in dev mode to test permissions, because all PolyCommand can be run in dev mode. + $origEnvironment = $kernel->getEnvironment(); + $kernel->setEnvironment('live'); + try { + $sake->run($input, $output); + } finally { + $kernel->setEnvironment($origEnvironment); + } + $listOutput = $output->fetch(); + + $allCommands = [ + TestConfigPolyCommand::class, + TestBuildTask::class, + ]; + foreach ($allCommands as $commandClass) { + $command = new $commandClass(); + $this->assertStringNotContainsString($command->getName(), $listOutput); + $this->assertStringNotContainsString($commandClass::getDescription(), $listOutput); + } + } + + public static function provideHideTasks(): array + { + return [ + 'task count matches limit' => [ + 'taskLimit' => 'same', + 'shouldShow' => true, + ], + 'task count lower than limit' => [ + 'taskLimit' => 'more', + 'shouldShow' => true, + ], + 'task count greater than limit' => [ + 'taskLimit' => 'less', + 'shouldShow' => false, + ], + 'unlimited tasks allowed' => [ + 'taskLimit' => 'all', + 'shouldShow' => true, + ], + ]; + } + + #[DataProvider('provideHideTasks')] + public function testHideTasks(string $taskLimit, bool $shouldShow): void + { + $sake = new Sake(Injector::inst()->get(Kernel::class)); + $sake->setAutoExit(false); + $input = new ArrayInput(['list']); + $input->setInteractive(false); + $output = new BufferedOutput(); + + // Determine max tasks config value + $taskInfo = []; + foreach (ClassInfo::subclassesFor(BuildTask::class, false) as $class) { + $reflectionClass = new ReflectionClass($class); + if ($reflectionClass->isAbstract()) { + continue; + } + $singleton = $class::singleton(); + if ($class::canRunInCli() && $singleton->isEnabled()) { + $taskInfo[$singleton->getName()] = $class::getDescription(); + } + } + $maxTasks = match ($taskLimit) { + 'same' => count($taskInfo), + 'more' => count($taskInfo) + 1, + 'less' => count($taskInfo) - 1, + 'all' => 0, + }; + + Sake::config()->set('max_tasks_to_display', $maxTasks); + $sake->run($input, $output); + $listOutput = $output->fetch(); + + // Check the tasks are showing/hidden as appropriate + if ($shouldShow) { + foreach ($taskInfo as $name => $description) { + $this->assertStringContainsString($name, $listOutput); + $this->assertStringContainsString($description, $listOutput); + } + // Shouldn't display the task command + $this->assertStringNotContainsString('See a list of build tasks to run', $listOutput); + } else { + foreach ($taskInfo as $name => $description) { + $this->assertStringNotContainsString($name, $listOutput); + $this->assertStringNotContainsString($description, $listOutput); + } + // Should display the task command + $this->assertStringContainsString('See a list of build tasks to run', $listOutput); + } + + // Check `sake tasks` ALWAYS shows the tasks + $input = new ArrayInput(['tasks']); + $sake->run($input, $output); + $listOutput = $output->fetch(); + foreach ($taskInfo as $name => $description) { + $this->assertStringContainsString($name, $listOutput); + $this->assertStringContainsString($description, $listOutput); + } + } + + public function testVersion(): void + { + $sake = new Sake(Injector::inst()->get(Kernel::class)); + $sake->setAutoExit(false); + $versionProvider = new VersionProvider(); + $this->assertSame($versionProvider->getVersion(), $sake->getVersion()); + } + + public function testLegacyDevCommands(): void + { + $sake = new Sake(Injector::inst()->get(Kernel::class)); + $sake->setAutoExit(false); + $input = new ArrayInput(['dev/config']); + $input->setInteractive(false); + $output = new BufferedOutput(); + + $deprecationsWereEnabled = Deprecation::isEnabled(); + Deprecation::enable(); + $this->expectException(DeprecationTestException::class); + $expectedErrorString = 'Using the command with the name \'dev/config\' is deprecated. Use \'config:dump\' instead'; + $this->expectExceptionMessage($expectedErrorString); + + $exitCode = $sake->run($input, $output); + $this->assertSame(0, $exitCode, 'command should run successfully'); + // $this->assertStringContainsString('abababa', $output->fetch()); + + $this->allowCatchingDeprecations($expectedErrorString); + try { + // call outputNotices() directly because the regular shutdown function that emits + // the notices within Deprecation won't be called until after this unit-test has finished + Deprecation::outputNotices(); + } finally { + restore_error_handler(); + $this->oldErrorHandler = null; + // Disable if they weren't enabled before. + if (!$deprecationsWereEnabled) { + Deprecation::disable(); + } + } + } + + private function allowCatchingDeprecations(string $expectedErrorString): void + { + // Use custom error handler for two reasons: + // - Filter out errors for deprecations unrelated to this test class + // - Allow the use of expectDeprecation(), which doesn't work with E_USER_DEPRECATION by default + // https://github.com/laminas/laminas-di/pull/30#issuecomment-927585210 + $this->oldErrorHandler = set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($expectedErrorString) { + if ($errno === E_USER_DEPRECATED) { + if (str_contains($errstr, $expectedErrorString)) { + throw new DeprecationTestException($errstr); + } else { + // Suppress any E_USER_DEPRECATED unrelated to this test class + return true; + } + } + if (is_callable($this->oldErrorHandler)) { + return call_user_func($this->oldErrorHandler, $errno, $errstr, $errfile, $errline); + } + // Fallback to default PHP error handler + return false; + }); + } +} diff --git a/tests/php/Cli/SakeTest/TestBuildTask.php b/tests/php/Cli/SakeTest/TestBuildTask.php new file mode 100644 index 00000000000..105f03635dc --- /dev/null +++ b/tests/php/Cli/SakeTest/TestBuildTask.php @@ -0,0 +1,23 @@ +<?php + +namespace SilverStripe\Cli\Tests\SakeTest; + +use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; + +class TestBuildTask extends BuildTask implements TestOnly +{ + protected static string $commandName = 'test-build-task'; + + protected string $title = 'my title'; + + protected static string $description = 'command for testing build tasks display as expected'; + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $output->writeln('This output is coming from a build task'); + return 0; + } +} diff --git a/tests/php/Cli/SakeTest/TestCommandLoader.php b/tests/php/Cli/SakeTest/TestCommandLoader.php new file mode 100644 index 00000000000..0cbbdf1c124 --- /dev/null +++ b/tests/php/Cli/SakeTest/TestCommandLoader.php @@ -0,0 +1,31 @@ +<?php + +namespace SilverStripe\Cli\Tests\SakeTest; + +use SilverStripe\Dev\TestOnly; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Exception\CommandNotFoundException; + +class TestCommandLoader implements CommandLoaderInterface, TestOnly +{ + private string $commandName = 'loader:test-command'; + + public function get(string $name): Command + { + if ($name !== $this->commandName) { + throw new CommandNotFoundException("Wrong command fetched. Expected '$this->commandName' - got '$name'"); + } + return new TestLoaderCommand(); + } + + public function has(string $name): bool + { + return $name === $this->commandName; + } + + public function getNames(): array + { + return [$this->commandName]; + } +} diff --git a/tests/php/Cli/SakeTest/TestConfigCommand.php b/tests/php/Cli/SakeTest/TestConfigCommand.php new file mode 100644 index 00000000000..040d6f058f0 --- /dev/null +++ b/tests/php/Cli/SakeTest/TestConfigCommand.php @@ -0,0 +1,19 @@ +<?php + +namespace SilverStripe\Cli\Tests\SakeTest; + +use SilverStripe\Dev\TestOnly; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand('test:from-config:standard', 'command for testing adding standard commands via config')] +class TestConfigCommand extends Command implements TestOnly +{ + protected function execute(InputInterface $input, OutputInterface $output): int + { + return 'This is a standard command'; + return 0; + } +} diff --git a/tests/php/Cli/SakeTest/TestConfigPolyCommand.php b/tests/php/Cli/SakeTest/TestConfigPolyCommand.php new file mode 100644 index 00000000000..dd981288555 --- /dev/null +++ b/tests/php/Cli/SakeTest/TestConfigPolyCommand.php @@ -0,0 +1,26 @@ +<?php + +namespace SilverStripe\Cli\Tests\SakeTest; + +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; + +class TestConfigPolyCommand extends PolyCommand implements TestOnly +{ + protected static string $commandName = 'test:from-config:poly'; + + protected static string $description = 'command for testing adding poly commands via config'; + + public function getTitle(): string + { + return 'This is a poly command'; + } + + public function run(InputInterface $input, PolyOutput $output): int + { + $output->writeln('This output is coming from a poly command'); + return 0; + } +} diff --git a/tests/php/Cli/SakeTest/TestLoaderCommand.php b/tests/php/Cli/SakeTest/TestLoaderCommand.php new file mode 100644 index 00000000000..ec8891a9798 --- /dev/null +++ b/tests/php/Cli/SakeTest/TestLoaderCommand.php @@ -0,0 +1,19 @@ +<?php + +namespace SilverStripe\Cli\Tests\SakeTest; + +use SilverStripe\Dev\TestOnly; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand('loader:test-command', 'command for testing adding custom command loaders')] +class TestLoaderCommand extends Command implements TestOnly +{ + protected function execute(InputInterface $input, OutputInterface $output): int + { + return 'This is a standard command'; + return 0; + } +} diff --git a/tests/php/Control/DirectorTest.php b/tests/php/Control/DirectorTest.php index 5ee9c7b5ab1..35a4cb196d8 100644 --- a/tests/php/Control/DirectorTest.php +++ b/tests/php/Control/DirectorTest.php @@ -12,6 +12,7 @@ use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter; use SilverStripe\Control\Middleware\TrustedProxyMiddleware; use SilverStripe\Control\Tests\DirectorTest\TestController; +use SilverStripe\Control\Tests\DirectorTest\TestPolyCommand; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Environment; @@ -997,4 +998,18 @@ public function testMockRequest() $this->assertEquals('/some-subdir/some-page/nested', $_SERVER['REQUEST_URI']); }, 'some-page/nested?query=1'); } + + public function testPolyCommandRoute(): void + { + Director::config()->set('rules', [ + 'test-route' => TestPolyCommand::class, + ]); + $response = Director::test('test-route'); + $this->assertSame('Successful poly command request!', $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + + // Arguments aren't available for PolyCommand yet so URLs with additional params should result in 404 + $response = Director::test('test-route/more/params'); + $this->assertSame(404, $response->getStatusCode()); + } } diff --git a/tests/php/Control/DirectorTest/TestPolyCommand.php b/tests/php/Control/DirectorTest/TestPolyCommand.php new file mode 100644 index 00000000000..41508ac8f97 --- /dev/null +++ b/tests/php/Control/DirectorTest/TestPolyCommand.php @@ -0,0 +1,26 @@ +<?php + +namespace SilverStripe\Control\Tests\DirectorTest; + +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; + +class TestPolyCommand extends PolyCommand implements TestOnly +{ + protected static string $commandName = 'test:poly'; + + protected static string $description = 'simple command for testing Director routing to PolyCommand'; + + public function getTitle(): string + { + return 'This is the title!'; + } + + public function run(InputInterface $input, PolyOutput $output): int + { + $output->write('Successful poly command request!'); + return 0; + } +} diff --git a/tests/php/Control/PolyCommandControllerTest.php b/tests/php/Control/PolyCommandControllerTest.php new file mode 100644 index 00000000000..c2eaebc1ae3 --- /dev/null +++ b/tests/php/Control/PolyCommandControllerTest.php @@ -0,0 +1,90 @@ +<?php + +namespace SilverStripe\Control\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse_Exception; +use SilverStripe\Control\PolyCommandController; +use SilverStripe\Control\Session; +use SilverStripe\Control\Tests\PolyCommandControllerTest\TestPolyCommand; +use SilverStripe\Dev\SapphireTest; + +class PolyCommandControllerTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideHandleRequest(): array + { + return [ + 'no params' => [ + 'exitCode' => 0, + 'params' => [], + 'allowed' => true, + 'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n", + ], + 'with params' => [ + 'exitCode' => 1, + 'params' => [ + 'option1' => true, + 'option2' => 'abc', + 'option3' => [ + 'val1', + 'val2', + ], + ], + 'allowed' => true, + 'expectedOutput' => "Has option 1: true<br>\noption 2 value: abc<br>\noption 3 value: val1<br>\noption 3 value: val2<br>\n", + ], + 'explicit exit code' => [ + 'exitCode' => 418, + 'params' => [], + 'allowed' => true, + 'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n", + ], + 'not allowed to run' => [ + 'exitCode' => 404, + 'params' => [], + 'allowed' => false, + 'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n", + ], + ]; + } + + #[DataProvider('provideHandleRequest')] + public function testHandleRequest(int $exitCode, array $params, bool $allowed, string $expectedOutput): void + { + $polyCommand = new TestPolyCommand(); + TestPolyCommand::setCanRunInBrowser($allowed); + if ($allowed) { + // Don't set the exit code if not allowed to run - we want to test that it's correctly forced to 404 + $polyCommand->setExitCode($exitCode); + } else { + $this->expectException(HTTPResponse_Exception::class); + $this->expectExceptionCode(404); + } + $controller = new PolyCommandController($polyCommand); + + $request = new HTTPRequest('GET', '', $params); + $request->setSession(new Session([])); + $response = $controller->handleRequest($request); + + if ($exitCode === 0) { + $statusCode = 200; + } elseif ($exitCode === 1) { + $statusCode = 500; + } elseif ($exitCode === 2) { + $statusCode = 400; + } else { + $statusCode = $exitCode; + } + + if ($allowed) { + $this->assertSame($expectedOutput, $response->getBody()); + } else { + // The 404 response will NOT contain any output from the command, because the command didn't run. + $this->assertNotSame($expectedOutput, $response->getBody()); + } + $this->assertSame($statusCode, $response->getStatusCode()); + } +} diff --git a/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php b/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php new file mode 100644 index 00000000000..fbcc5adc936 --- /dev/null +++ b/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php @@ -0,0 +1,59 @@ +<?php + +namespace SilverStripe\Control\Tests\PolyCommandControllerTest; + +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +class TestPolyCommand extends PolyCommand implements TestOnly +{ + protected static string $commandName = 'test:poly'; + + protected static string $description = 'simple command for testing controller wrapper'; + + protected static bool $canRunInBrowser = true; + + private int $exitCode = 0; + + public function getTitle(): string + { + return 'This is the title!'; + } + + public function run(InputInterface $input, PolyOutput $output): int + { + $output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false')); + $output->writeln('option 2 value: ' . $input->getOption('option2')); + foreach ($input->getOption('option3') ?? [] as $value) { + $output->writeln('option 3 value: ' . $value); + } + return $this->exitCode; + } + + public function setExitCode(int $code): void + { + $this->exitCode = $code; + } + + public function getOptions(): array + { + return [ + new InputOption('option1', null, InputOption::VALUE_NONE), + new InputOption('option2', null, InputOption::VALUE_REQUIRED), + new InputOption('option3', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), + ]; + } + + public static function canRunInBrowser(): bool + { + return static::$canRunInBrowser; + } + + public static function setCanRunInBrowser(bool $canRun): void + { + static::$canRunInBrowser = $canRun; + } +} diff --git a/tests/php/Dev/BuildTaskTest.php b/tests/php/Dev/BuildTaskTest.php index 8f8f5420949..afa6239d46e 100644 --- a/tests/php/Dev/BuildTaskTest.php +++ b/tests/php/Dev/BuildTaskTest.php @@ -3,41 +3,26 @@ namespace SilverStripe\Dev\Tests; use SilverStripe\Dev\SapphireTest; -use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\Tests\BuildTaskTest\TestBuildTask; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\FieldType\DBDatetime; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; class BuildTaskTest extends SapphireTest { - /** - * Test that the default `$enabled` property is used when the new `is_enabled` config is not used - * Test that the `is_enabled` config overrides `$enabled` property - * - * This test should be removed in CMS 6 as the default $enabled property is now deprecated - */ - public function testIsEnabled(): void + public function testRunOutput(): void { - // enabledTask - $enabledTask = new class extends BuildTask - { - protected $enabled = true; - public function run($request) - { - // noop - } - }; - $this->assertTrue($enabledTask->isEnabled()); - $enabledTask->config()->set('is_enabled', false); - $this->assertFalse($enabledTask->isEnabled()); - // disabledTask - $disabledTask = new class extends BuildTask - { - protected $enabled = false; - public function run($request) - { - // noop - } - }; - $this->assertFalse($disabledTask->isEnabled()); - $disabledTask->config()->set('is_enabled', true); - $this->assertTrue($disabledTask->isEnabled()); + DBDatetime::set_mock_now('2024-01-01 12:00:00'); + $task = new TestBuildTask(); + $task->setTimeTo = '2024-01-01 12:00:15'; + $buffer = new BufferedOutput(); + $output = new PolyOutput(PolyOutput::FORMAT_ANSI, wrappedOutput: $buffer); + $input = new ArrayInput([]); + $input->setInteractive(false); + + $task->run($input, $output); + + $this->assertSame("Running task 'my title'\nThis output is coming from a build task\n\nTask 'my title' completed successfully in 15 seconds\n", $buffer->fetch()); } } diff --git a/tests/php/Dev/BuildTaskTest/TestBuildTask.php b/tests/php/Dev/BuildTaskTest/TestBuildTask.php new file mode 100644 index 00000000000..16c26fa33e3 --- /dev/null +++ b/tests/php/Dev/BuildTaskTest/TestBuildTask.php @@ -0,0 +1,27 @@ +<?php + +namespace SilverStripe\Dev\Tests\BuildTaskTest; + +use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\TestOnly; +use SilverStripe\PolyExecution\PolyOutput; +use SilverStripe\ORM\FieldType\DBDatetime; +use Symfony\Component\Console\Input\InputInterface; + +class TestBuildTask extends BuildTask implements TestOnly +{ + protected static string $commandName = 'test-build-task'; + + protected string $title = 'my title'; + + protected static string $description = 'command for testing build tasks display as expected'; + + public string $setTimeTo; + + protected function execute(InputInterface $input, PolyOutput $output): int + { + DBDatetime::set_mock_now($this->setTimeTo); + $output->writeln('This output is coming from a build task'); + return 0; + } +} diff --git a/tests/php/Dev/DevAdminControllerTest.php b/tests/php/Dev/DevAdminControllerTest.php index a644a47e4dd..0a6d9aa08ca 100644 --- a/tests/php/Dev/DevAdminControllerTest.php +++ b/tests/php/Dev/DevAdminControllerTest.php @@ -3,47 +3,47 @@ namespace SilverStripe\Dev\Tests; use Exception; -use ReflectionMethod; +use LogicException; use SilverStripe\Control\Director; +use SilverStripe\Control\RequestHandler; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Kernel; +use SilverStripe\Dev\Command\DevCommand; use SilverStripe\Dev\DevelopmentAdmin; use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\Tests\DevAdminControllerTest\Controller1; use SilverStripe\Dev\Tests\DevAdminControllerTest\ControllerWithPermissions; +use SilverStripe\Dev\Tests\DevAdminControllerTest\TestCommand; +use SilverStripe\Dev\Tests\DevAdminControllerTest\TestHiddenController; use PHPUnit\Framework\Attributes\DataProvider; -/** - * Note: the running of this test is handled by the thing it's testing (DevelopmentAdmin controller). - */ class DevAdminControllerTest extends FunctionalTest { - protected function setUp(): void { parent::setUp(); DevelopmentAdmin::config()->merge( - 'registered_controllers', + 'commands', + [ + 'c1' => TestCommand::class, + ] + ); + + DevelopmentAdmin::config()->merge( + 'controllers', [ 'x1' => [ - 'controller' => Controller1::class, - 'links' => [ - 'x1' => 'x1 link description', - 'x1/y1' => 'x1/y1 link description' - ] - ], - 'x2' => [ - 'controller' => 'DevAdminControllerTest_Controller2', // intentionally not a class that exists - 'links' => [ - 'x2' => 'x2 link description' - ] + 'class' => Controller1::class, + 'description' => 'controller1 description', ], 'x3' => [ - 'controller' => ControllerWithPermissions::class, - 'links' => [ - 'x3' => 'x3 link description' - ] + 'class' => ControllerWithPermissions::class, + 'description' => 'permission controller description', + ], + 'x4' => [ + 'class' => TestHiddenController::class, + 'skipLink' => true, ], ] ); @@ -51,10 +51,12 @@ protected function setUp(): void public function testGoodRegisteredControllerOutput() { - // Check for the controller running from the registered url above - // (we use contains rather than equals because sometimes you get a warning) + // Check for the controller or command running from the registered url above + // Use string contains string because there's a lot of extra HTML markup around the output $this->assertStringContainsString(Controller1::OK_MSG, $this->getCapture('/dev/x1')); - $this->assertStringContainsString(Controller1::OK_MSG, $this->getCapture('/dev/x1/y1')); + $this->assertStringContainsString(Controller1::OK_MSG . ' y1', $this->getCapture('/dev/x1/y1')); + $this->assertStringContainsString(TestHiddenController::OK_MSG, $this->getCapture('/dev/x4')); + $this->assertStringContainsString('<h2>This is a test command</h2>' . TestCommand::OK_MSG, $this->getCapture('/dev/c1')); } public function testGoodRegisteredControllerStatus() @@ -62,9 +64,8 @@ public function testGoodRegisteredControllerStatus() // Check response code is 200/OK $this->assertEquals(false, $this->getAndCheckForError('/dev/x1')); $this->assertEquals(false, $this->getAndCheckForError('/dev/x1/y1')); - - // Check response code is 500/ some sort of error - $this->assertEquals(true, $this->getAndCheckForError('/dev/x2')); + $this->assertEquals(false, $this->getAndCheckForError('/dev/x4')); + $this->assertEquals(false, $this->getAndCheckForError('/dev/xc1')); } #[DataProvider('getLinksPermissionsProvider')] @@ -77,29 +78,77 @@ public function testGetLinks(string $permission, array $present, array $absent): try { $this->logInWithPermission($permission); $controller = new DevelopmentAdmin(); - $method = new ReflectionMethod($controller, 'getLinks'); - $method->setAccessible(true); - $links = $method->invoke($controller); + $links = $controller->getLinks(); foreach ($present as $expected) { - $this->assertArrayHasKey($expected, $links, sprintf('Expected link %s not found in %s', $expected, json_encode($links))); + $this->assertArrayHasKey('dev/' . $expected, $links, sprintf('Expected link %s not found in %s', 'dev/' . $expected, json_encode($links))); } foreach ($absent as $unexpected) { - $this->assertArrayNotHasKey($unexpected, $links, sprintf('Unexpected link %s found in %s', $unexpected, json_encode($links))); + $this->assertArrayNotHasKey('dev/' . $unexpected, $links, sprintf('Unexpected link %s found in %s', 'dev/' . $unexpected, json_encode($links))); } } finally { $kernel->setEnvironment($env); } } + public static function provideMissingClasses(): array + { + return [ + 'missing command' => [ + 'configKey' => 'commands', + 'configToMerge' => [ + 'c2' => 'DevAdminControllerTest_NonExistentCommand', + ], + 'expectedMessage' => 'Class \'DevAdminControllerTest_NonExistentCommand\' doesn\'t exist', + ], + 'missing controller' => [ + 'configKey' => 'controllers', + 'configToMerge' => [ + 'x2' => [ + 'class' => 'DevAdminControllerTest_NonExistentController', + 'description' => 'controller2 description', + ], + ], + 'expectedMessage' => 'Class \'DevAdminControllerTest_NonExistentController\' doesn\'t exist', + ], + 'wrong class command' => [ + 'configKey' => 'commands', + 'configToMerge' => [ + 'c2' => static::class, + ], + 'expectedMessage' => 'Class \'' . static::class . '\' must be a subclass of ' . DevCommand::class, + ], + 'wrong class controller' => [ + 'configKey' => 'controllers', + 'configToMerge' => [ + 'x2' => [ + 'class' => static::class, + 'description' => 'controller2 description', + ], + ], + 'expectedMessage' => 'Class \'' . static::class . '\' must be a subclass of ' . RequestHandler::class, + ], + ]; + } + + #[DataProvider('provideMissingClasses')] + public function testMissingClasses(string $configKey, array $configToMerge, string $expectedMessage): void + { + DevelopmentAdmin::config()->merge($configKey, $configToMerge); + $controller = new DevelopmentAdmin(); + $this->expectException(LogicException::class); + $this->expectExceptionMessage($expectedMessage); + $controller->getLinks(); + } + public static function getLinksPermissionsProvider() : array { return [ - ['ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']], - ['ALL_DEV_ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']], - ['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['x1', 'x1/y1', 'x2']], - ['NOTHING', [], ['x1', 'x1/y1', 'x2', 'x3']], + 'admin access' => ['ADMIN', ['c1', 'x1', 'x3'], ['x4']], + 'all dev access' => ['ALL_DEV_ADMIN', ['c1', 'x1', 'x3'], ['x4']], + 'dev test access' => ['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['c1', 'x1', 'x4']], + 'no access' => ['NOTHING', [], ['c1', 'x1', 'x3', 'x4']], ]; } diff --git a/tests/php/Dev/DevAdminControllerTest/Controller1.php b/tests/php/Dev/DevAdminControllerTest/Controller1.php index e73ee27ccfd..6e16c113154 100644 --- a/tests/php/Dev/DevAdminControllerTest/Controller1.php +++ b/tests/php/Dev/DevAdminControllerTest/Controller1.php @@ -27,6 +27,6 @@ public function index() public function y1Action() { - echo Controller1::OK_MSG; + echo Controller1::OK_MSG . ' y1'; } } diff --git a/tests/php/Dev/DevAdminControllerTest/TestCommand.php b/tests/php/Dev/DevAdminControllerTest/TestCommand.php new file mode 100644 index 00000000000..94d8310fb38 --- /dev/null +++ b/tests/php/Dev/DevAdminControllerTest/TestCommand.php @@ -0,0 +1,32 @@ +<?php + +namespace SilverStripe\Dev\Tests\DevAdminControllerTest; + +use SilverStripe\Dev\Command\DevCommand; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; + +class TestCommand extends DevCommand +{ + const OK_MSG = 'DevAdminControllerTest_TestCommand TEST OK'; + + protected static string $commandName = 'my-test-command'; + + protected static string $description = 'my test command'; + + public function getTitle(): string + { + return 'Test command'; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + $output->write(TestCommand::OK_MSG); + return 0; + } + + protected function getHeading(): string + { + return 'This is a test command'; + } +} diff --git a/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php b/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php new file mode 100644 index 00000000000..50f8908b5be --- /dev/null +++ b/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php @@ -0,0 +1,15 @@ +<?php + +namespace SilverStripe\Dev\Tests\DevAdminControllerTest; + +use SilverStripe\Control\Controller; + +class TestHiddenController extends Controller +{ + const OK_MSG = 'DevAdminControllerTest_TestHiddenController TEST OK'; + + public function index() + { + echo TestHiddenController::OK_MSG; + } +} diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php index eeabc78a090..825313ea22a 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; abstract class TaskRunnerTest_AbstractTask extends BuildTask { protected $enabled = true; - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php index c45bdbc2248..cb42a1e5834 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\Tests\TaskRunnerTest\TaskRunnerTest_AbstractTask; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; class TaskRunnerTest_ChildOfAbstractTask extends TaskRunnerTest_AbstractTask { protected $enabled = true; - public function run($request) + protected function doRun(InputInterface $input, PolyOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php index e0748a8607b..4b01ad50ade 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; class TaskRunnerTest_DisabledTask extends BuildTask { - protected $enabled = false; + private static bool $is_enabled = false; - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php index c76bd16f0c6..b37d0d7ce7a 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Input\InputInterface; class TaskRunnerTest_EnabledTask extends BuildTask { protected $enabled = true; - public function run($request) + protected function execute(InputInterface $input, PolyOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Logging/HTTPOutputHandlerTest.php b/tests/php/Logging/ErrorOutputHandlerTest.php similarity index 94% rename from tests/php/Logging/HTTPOutputHandlerTest.php rename to tests/php/Logging/ErrorOutputHandlerTest.php index dc43867b8bc..9e1cfffba0f 100644 --- a/tests/php/Logging/HTTPOutputHandlerTest.php +++ b/tests/php/Logging/ErrorOutputHandlerTest.php @@ -13,9 +13,10 @@ use SilverStripe\Logging\DebugViewFriendlyErrorFormatter; use SilverStripe\Logging\DetailedErrorFormatter; use SilverStripe\Logging\HTTPOutputHandler; +use SilverStripe\Logging\ErrorOutputHandler; use PHPUnit\Framework\Attributes\DataProvider; -class HTTPOutputHandlerTest extends SapphireTest +class ErrorOutputHandlerTest extends SapphireTest { protected function setUp(): void { @@ -28,7 +29,7 @@ protected function setUp(): void public function testGetFormatter() { - $handler = new HTTPOutputHandler(); + $handler = new ErrorOutputHandler(); $detailedFormatter = new DetailedErrorFormatter(); $friendlyFormatter = new DebugViewFriendlyErrorFormatter(); @@ -49,9 +50,9 @@ public function testGetFormatter() */ public function testDevConfig() { - /** @var HTTPOutputHandler $handler */ + /** @var ErrorOutputHandler $handler */ $handler = Injector::inst()->get(HandlerInterface::class); - $this->assertInstanceOf(HTTPOutputHandler::class, $handler); + $this->assertInstanceOf(ErrorOutputHandler::class, $handler); // Test only default formatter is set, but CLI specific formatter is left out $this->assertNull($handler->getCLIFormatter()); @@ -154,7 +155,7 @@ public function testShouldShowError( bool $shouldShow, bool $expected ) { - $reflectionShouldShow = new ReflectionMethod(HTTPOutputHandler::class, 'shouldShowError'); + $reflectionShouldShow = new ReflectionMethod(ErrorOutputHandler::class, 'shouldShowError'); $reflectionShouldShow->setAccessible(true); $reflectionDeprecation = new ReflectionClass(Deprecation::class); @@ -175,16 +176,15 @@ public function testShouldShowError( $reflectionDirector = new ReflectionClass(Environment::class); $origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride'); $reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli); - try { - $handler = new HTTPOutputHandler(); + $handler = new ErrorOutputHandler(); $result = $reflectionShouldShow->invoke($handler, $errorCode); $this->assertSame($expected, $result); Deprecation::setShouldShowForCli($cliShouldShowOrig); Deprecation::setShouldShowForHttp($httpShouldShowOrig); - $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig); } finally { + $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig); $reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli); } } diff --git a/tests/php/PolyExecution/AnsiToHtmlConverterTest.php b/tests/php/PolyExecution/AnsiToHtmlConverterTest.php new file mode 100644 index 00000000000..563b80148b1 --- /dev/null +++ b/tests/php/PolyExecution/AnsiToHtmlConverterTest.php @@ -0,0 +1,57 @@ +<?php + +namespace SilverStripe\PolyExecution\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\PolyExecution\AnsiToHtmlConverter; +use Symfony\Component\Console\Formatter\OutputFormatter; + +class AnsiToHtmlConverterTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideConvert(): array + { + return [ + 'no text, no result' => [ + 'unformatted' => '', + 'expected' => '', + ], + 'no empty span' => [ + 'unformatted' => 'This text <info></info> is unformatted', + 'expected' => 'This text is unformatted', + ], + 'named formats are converted' => [ + 'unformatted' => 'This text <info>has some</info> formatting', + 'expected' => 'This text <span style="color: green">has some</span> formatting', + ], + 'fg and bg are converted' => [ + 'unformatted' => 'This text <fg=red;bg=blue>has some</> formatting', + 'expected' => 'This text <span style="background-color: blue; color: darkred">has some</span> formatting', + ], + 'bold and underscore are converted' => [ + 'unformatted' => 'This text <options=bold;options=underscore>has some</> formatting', + 'expected' => 'This text <span style="font-weight: bold; text-decoration: underline">has some</span> formatting', + ], + 'multiple styles are converted' => [ + 'unformatted' => 'This text <options=bold;fg=green>has some</> <comment>formatting</comment>', + 'expected' => 'This text <span style="font-weight: bold; color: green">has some</span> <span style="color: goldenrod">formatting</span>', + ], + 'hyperlinks are converted' => [ + 'unformatted' => 'This text <href=https://www.example.com/>has a</> link', + 'expected' => 'This text <a href="https://www.example.com/">has a</a> link', + ], + ]; + } + + #[DataProvider('provideConvert')] + public function testConvert(string $unformatted, string $expected): void + { + $converter = new AnsiToHtmlConverter(); + $ansiFormatter = new OutputFormatter(true); + $formatted = $ansiFormatter->format($unformatted); + + $this->assertSame($expected, $converter->convert($formatted)); + } +} diff --git a/tests/php/PolyExecution/HttpRequestInputTest.php b/tests/php/PolyExecution/HttpRequestInputTest.php new file mode 100644 index 00000000000..ad41daaef8d --- /dev/null +++ b/tests/php/PolyExecution/HttpRequestInputTest.php @@ -0,0 +1,141 @@ +<?php + +namespace SilverStripe\PolyExecution\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\PolyExecution\HttpRequestInput; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class HttpRequestInputTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideInputOptions(): array + { + return [ + 'no vars, no options' => [ + 'requestVars' => [], + 'commandOptions' => [], + 'expected' => [], + ], + 'some vars, no options' => [ + 'requestVars' => [ + 'var1' => '1', + 'var2' => 'abcd', + 'var3' => null, + 'var4' => ['a', 'b', 'c'], + ], + 'commandOptions' => [], + 'expected' => [], + ], + 'no vars, some options' => [ + 'requestVars' => [], + 'commandOptions' => [ + new InputOption('var1', null, InputOption::VALUE_NEGATABLE), + new InputOption('var2', null, InputOption::VALUE_REQUIRED), + new InputOption('var3', null, InputOption::VALUE_OPTIONAL), + new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), + ], + 'expected' => [ + 'var1' => null, + 'var2' => null, + 'var3' => null, + 'var4' => [], + ], + ], + 'no vars, some options (with default values)' => [ + 'requestVars' => [], + 'commandOptions' => [ + new InputOption('var1', null, InputOption::VALUE_NEGATABLE, default: true), + new InputOption('var2', null, InputOption::VALUE_REQUIRED, default: 'def'), + new InputOption('var3', null, InputOption::VALUE_OPTIONAL, default: false), + new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, default: [1, 2, 'banana']), + ], + 'expected' => [ + 'var1' => true, + 'var2' => 'def', + 'var3' => false, + 'var4' => [1, 2, 'banana'], + ], + ], + 'some vars and options' => [ + 'requestVars' => [ + 'var1' => '1', + 'var2' => 'abcd', + 'var3' => 2, + 'var4' => ['a', 'b', 'c'], + ], + 'commandOptions' => [ + new InputOption('var1', null, InputOption::VALUE_NEGATABLE), + new InputOption('var2', null, InputOption::VALUE_REQUIRED), + new InputOption('var3', null, InputOption::VALUE_OPTIONAL), + new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), + ], + 'expected' => [ + 'var1' => true, + 'var2' => 'abcd', + 'var3' => 2, + 'var4' => ['a', 'b', 'c'], + ], + ], + ]; + } + + #[DataProvider('provideInputOptions')] + public function testInputOptions(array $requestVars, array $commandOptions, array $expected): void + { + $request = new HTTPRequest('GET', 'arbitrary-url', $requestVars); + $input = new HttpRequestInput($request, $commandOptions); + + foreach ($expected as $option => $value) { + $this->assertSame($value, $input->getOption($option), 'checking value for ' . $option); + } + + // If there's no expected values, the success metric is that we didn't throw any exceptions. + if (empty($expected)) { + $this->expectNotToPerformAssertions(); + } + } + + public static function provideGetVerbosity(): array + { + return [ + 'default to normal' => [ + 'requestVars' => [], + 'expected' => OutputInterface::VERBOSITY_NORMAL, + ], + 'shortcuts are ignored' => [ + 'requestVars' => ['v' => 1], + 'expected' => OutputInterface::VERBOSITY_NORMAL, + ], + '?verbose=1 is verbose' => [ + 'requestVars' => ['verbose' => 1], + 'expected' => OutputInterface::VERBOSITY_VERBOSE, + ], + '?verbose=2 is very verbose' => [ + 'requestVars' => ['verbose' => 2], + 'expected' => OutputInterface::VERBOSITY_VERY_VERBOSE, + ], + '?verbose=3 is debug' => [ + // Check string works as well as int + 'requestVars' => ['verbose' => '3'], + 'expected' => OutputInterface::VERBOSITY_DEBUG, + ], + '?quiet=1 is quiet' => [ + 'requestVars' => ['quiet' => 1], + 'expected' => OutputInterface::VERBOSITY_QUIET, + ], + ]; + } + + #[DataProvider('provideGetVerbosity')] + public function testGetVerbosity(array $requestVars, int $expected): void + { + $request = new HTTPRequest('GET', 'arbitrary-url', $requestVars); + $input = new HttpRequestInput($request); + $this->assertSame($expected, $input->getVerbosity()); + } +} diff --git a/tests/php/PolyExecution/PolyOutputTest.php b/tests/php/PolyExecution/PolyOutputTest.php new file mode 100644 index 00000000000..e60825b28c3 --- /dev/null +++ b/tests/php/PolyExecution/PolyOutputTest.php @@ -0,0 +1,207 @@ +<?php + +namespace SilverStripe\PolyExecution\Tests; + +use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\PolyExecution\PolyOutput; +use Symfony\Component\Console\Output\BufferedOutput; + +class PolyOutputTest extends SapphireTest +{ + protected $usesDatabase = false; + + public static function provideWriteForFormat(): array + { + return [ + 'html for html' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'forFormat' => PolyOutput::FORMAT_HTML, + 'messages' => ['one message', 'two message'], + 'expected' => "one message<br>\ntwo message<br>\n", + ], + 'html for ansi' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'forFormat' => PolyOutput::FORMAT_ANSI, + 'messages' => ['one message', 'two message'], + 'expected' => '', + ], + 'ansi for html' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'forFormat' => PolyOutput::FORMAT_HTML, + 'messages' => ['one message', 'two message'], + 'expected' => '', + ], + 'ansi for ansi' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'forFormat' => PolyOutput::FORMAT_ANSI, + 'messages' => ['one message', 'two message'], + 'expected' => "one message\ntwo message\n", + ], + ]; + } + + #[DataProvider('provideWriteForFormat')] + public function testWriteForFormat( + string $outputFormat, + string $forFormat, + string|iterable $messages, + string $expected + ): void { + $buffer = new BufferedOutput(); + $output = new PolyOutput($outputFormat, wrappedOutput: $buffer); + $output->writeForFormat($forFormat, $messages, true); + $this->assertSame($expected, $buffer->fetch()); + } + + public static function provideList(): array + { + return [ + 'empty list ANSI' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => [] + ], + 'expected' => '', + ], + 'empty list HTML' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => [] + ], + 'expected' => '<ul></ul>', + ], + 'single list UL ANSI' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => ['item 1', 'item 2'] + ], + 'expected' => <<< EOL + * item 1 + * item 2 + + EOL, + ], + 'single list OL ANSI' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'list' => [ + 'type' => PolyOutput::LIST_ORDERED, + 'items' => ['item 1', 'item 2'] + ], + 'expected' => <<< EOL + 1. item 1 + 2. item 2 + + EOL, + ], + 'single list UL HTML' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => ['item 1', 'item 2'] + ], + 'expected' => '<ul><li>item 1</li><li>item 2</li></ul>', + ], + 'single list OL HTML' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'list' => [ + 'type' => PolyOutput::LIST_ORDERED, + 'items' => ['item 1', 'item 2'] + ], + 'expected' => '<ol><li>item 1</li><li>item 2</li></ol>', + ], + 'nested list ANSI' => [ + 'outputFormat' => PolyOutput::FORMAT_ANSI, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => [ + 'item 1', + 'item 2', + [ + 'type' => PolyOutput::LIST_ORDERED, + 'items' => [ + 'item 2a', + ['item 2b','item 2c'], + 'item 2d', + ] + ], + 'item 3', + ] + ], + 'expected' => <<< EOL + * item 1 + * item 2 + 1. item 2a + 2. item 2b + 3. item 2c + 4. item 2d + * item 3 + + EOL, + ], + 'nested list HTML' => [ + 'outputFormat' => PolyOutput::FORMAT_HTML, + 'list' => [ + 'type' => PolyOutput::LIST_UNORDERED, + 'items' => [ + 'item 1', + 'item 2', + 'list' => [ + 'type' => PolyOutput::LIST_ORDERED, + 'items' => [ + 'item 2a', + ['item 2b','item 2c'], + 'item 2d', + ] + ], + 'item 3', + ] + ], + 'expected' => '<ul><li>item 1</li><li>item 2</li><ol><li>item 2a</li><li>item 2b</li><li>item 2c</li><li>item 2d</li></ol><li>item 3</li></ul>', + ], + ]; + } + + #[DataProvider('provideList')] + public function testList(string $outputFormat, array $list, string $expected): void + { + $buffer = new BufferedOutput(); + $output = new PolyOutput($outputFormat, wrappedOutput: $buffer); + $this->makeListRecursive($output, $list); + $this->assertSame($expected, $buffer->fetch()); + } + + public static function provideListMustBeStarted(): array + { + return [ + [PolyOutput::FORMAT_ANSI], + [PolyOutput::FORMAT_HTML], + ]; + } + + #[DataProvider('provideListMustBeStarted')] + public function testListMustBeStarted(string $outputFormat): void + { + $output = new PolyOutput($outputFormat); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No lists started. Call startList() first.'); + $output->writeListItem(''); + } + + private function makeListRecursive(PolyOutput $output, array $list): void + { + $output->startList($list['type']); + foreach ($list['items'] as $item) { + if (isset($item['type'])) { + $this->makeListRecursive($output, $item); + continue; + } + $output->writeListItem($item); + } + $output->stopList(); + } +} diff --git a/tests/php/View/SSViewerTest.php b/tests/php/View/SSViewerTest.php index 45af82fbd72..f39dce9cc95 100644 --- a/tests/php/View/SSViewerTest.php +++ b/tests/php/View/SSViewerTest.php @@ -2367,4 +2367,17 @@ public function forTemplate() }; $this->render('$Me', $myArrayData); } + + public function testLoopingThroughArrayInOverlay(): void + { + $viewableData = new ViewableData(); + $theArray = [ + ['Val' => 'one'], + ['Val' => 'two'], + ['Val' => 'red'], + ['Val' => 'blue'], + ]; + $output = $viewableData->renderWith('SSViewerTestLoopArray', ['MyArray' => $theArray]); + $this->assertEqualIgnoringWhitespace('one two red blue', $output); + } } diff --git a/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss b/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss new file mode 100644 index 00000000000..f9a20f36eb5 --- /dev/null +++ b/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss @@ -0,0 +1,3 @@ +<% loop $MyArray %> + $Val +<% end_loop %>