From 942a93f63faba29c2a0a3083909a5dbd66009f78 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli 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/dev.yml | 14 +- _config/extensions.yml | 4 +- bin/sake | 21 + cli-script.php | 35 -- client/styles/debug.css | 9 +- composer.json | 5 +- sake | 119 ---- src/Cli/ArrayCommandLoader.php | 52 ++ src/Cli/DevCommandLoader.php | 96 ++++ src/Cli/InjectorCommandLoader.php | 32 ++ src/Cli/Sake.php | 73 +++ src/Core/Injector/Injector.php | 2 +- src/Dev/DevBuildController.php | 83 --- src/Dev/DevConfigController.php | 199 ------- src/Dev/DevConfirmationController.php | 1 - src/Dev/DevelopmentAdmin.php | 316 +++++----- .../HybridExecution/AnsiToHtmlConverter.php | 129 +++++ src/Dev/HybridExecution/Command/DevBuild.php | 339 +++++++++++ .../Command/DevBuildCleanup.php | 79 +++ .../Command/DevBuildDefaults.php | 68 +++ src/Dev/HybridExecution/Command/DevConfig.php | 64 +++ .../Command/DevConfigAudit.php | 90 +++ .../HybridExecution/Command/HybridCommand.php | 145 +++++ .../HybridExecution/HtmlOutputFormatter.php | 58 ++ src/Dev/HybridExecution/HttpRequestInput.php | 94 +++ src/Dev/HybridExecution/HybridOutput.php | 220 +++++++ src/Dev/State/ExtensionTestState.php | 2 +- ...minExtension.php => DevBuildExtension.php} | 13 +- src/ORM/ArrayLib.php | 26 + src/ORM/Connect/TempDatabase.php | 2 +- src/ORM/DataObject.php | 2 +- src/ORM/DatabaseAdmin.php | 538 ------------------ src/Security/Security.php | 2 +- 33 files changed, 1797 insertions(+), 1135 deletions(-) create mode 100755 bin/sake delete mode 100755 cli-script.php delete mode 100755 sake create mode 100644 src/Cli/ArrayCommandLoader.php create mode 100644 src/Cli/DevCommandLoader.php create mode 100644 src/Cli/InjectorCommandLoader.php create mode 100644 src/Cli/Sake.php delete mode 100644 src/Dev/DevBuildController.php delete mode 100644 src/Dev/DevConfigController.php create mode 100644 src/Dev/HybridExecution/AnsiToHtmlConverter.php create mode 100644 src/Dev/HybridExecution/Command/DevBuild.php create mode 100644 src/Dev/HybridExecution/Command/DevBuildCleanup.php create mode 100644 src/Dev/HybridExecution/Command/DevBuildDefaults.php create mode 100644 src/Dev/HybridExecution/Command/DevConfig.php create mode 100644 src/Dev/HybridExecution/Command/DevConfigAudit.php create mode 100644 src/Dev/HybridExecution/Command/HybridCommand.php create mode 100644 src/Dev/HybridExecution/HtmlOutputFormatter.php create mode 100644 src/Dev/HybridExecution/HttpRequestInput.php create mode 100644 src/Dev/HybridExecution/HybridOutput.php rename src/Dev/Validation/{DatabaseAdminExtension.php => DevBuildExtension.php} (55%) delete mode 100644 src/ORM/DatabaseAdmin.php diff --git a/_config/dev.yml b/_config/dev.yml index 4c1636bc4b5..2f088975a2e 100644 --- a/_config/dev.yml +++ b/_config/dev.yml @@ -2,21 +2,19 @@ Name: DevelopmentAdmin --- SilverStripe\Dev\DevelopmentAdmin: + commands: + build: 'SilverStripe\Dev\HybridExecution\Command\DevBuild' + 'build:cleanup': 'SilverStripe\Dev\HybridExecution\Command\DevBuildCleanup' + 'build:defaults': 'SilverStripe\Dev\HybridExecution\Command\DevBuildDefaults' + config: 'SilverStripe\Dev\HybridExecution\Command\DevConfig' + 'config:audit': 'SilverStripe\Dev\HybridExecution\Command\DevConfigAudit' registered_controllers: - build: - controller: SilverStripe\Dev\DevBuildController - links: - build: 'Build/rebuild this environment. Call this whenever you have updated your project sources' tasks: controller: SilverStripe\Dev\TaskRunner links: tasks: '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' SilverStripe\Dev\CSSContentParser: disable_xml_external_entities: true diff --git a/_config/extensions.yml b/_config/extensions.yml index 1d77a36dc12..0cae14148f6 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\HybridExecution\Command\DevBuild: extensions: - - SilverStripe\Dev\Validation\DatabaseAdminExtension + - SilverStripe\Dev\Validation\DevBuildExtension diff --git a/bin/sake b/bin/sake new file mode 100755 index 00000000000..c9a74a8f709 --- /dev/null +++ b/bin/sake @@ -0,0 +1,21 @@ +#!/usr/bin/env php +addCommands([ + // probably do this inside the sake app itself though + // TODO: + // - flush + // - navigate (use HTTPRequest and spin off a "web" request from CLI) +]); +$sake->run(); diff --git a/cli-script.php b/cli-script.php deleted file mode 100755 index 879b2de6546..00000000000 --- a/cli-script.php +++ /dev/null @@ -1,35 +0,0 @@ -handle($request); - -$response->output(); diff --git a/client/styles/debug.css b/client/styles/debug.css index bb3ac83912f..4fdd5c81831 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,19 +127,19 @@ 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; } diff --git a/composer.json b/composer.json index 8c31c99f12b..03f98562265 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": "^4.0", + "sensiolabs/ansi-to-html": "^1.2", "silverstripe/config": "^3", "silverstripe/assets": "^3", "silverstripe/vendor-plugin": "^2", "sminnee/callbacklist": "^0.1.1", "symfony/cache": "^6.1", "symfony/config": "^6.1", + "symfony/console": "^7.0", "symfony/dom-crawler": "^6.1", "symfony/filesystem": "^6.1", "symfony/http-foundation": "^6.1", @@ -85,6 +87,7 @@ }, "autoload": { "psr-4": { + "SilverStripe\\Cli\\": "src/Cli/", "SilverStripe\\Control\\": "src/Control/", "SilverStripe\\Control\\Tests\\": "tests/php/Control/", "SilverStripe\\Core\\": "src/Core/", 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/ArrayCommandLoader.php b/src/Cli/ArrayCommandLoader.php new file mode 100644 index 00000000000..ec518106d3d --- /dev/null +++ b/src/Cli/ArrayCommandLoader.php @@ -0,0 +1,52 @@ + + */ + 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("Can't find command $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/DevCommandLoader.php b/src/Cli/DevCommandLoader.php new file mode 100644 index 00000000000..32e2615692f --- /dev/null +++ b/src/Cli/DevCommandLoader.php @@ -0,0 +1,96 @@ +init(); + $name = $this->deAlias($name); + if (!$this->has($name)) { + throw new CommandNotFoundException("Can't find command $name"); + } + /** @var HybridCommand $commandClass */ + $commandClass = $this->commands[$name]; + $hybridCommand = $commandClass::create(); + // Use the name that was passed into the method instead of fetching from the hybrid command + // because it includes the full namespace. + // TODO move all this (plus getting $hybridCommand->getOptions()) into its own class to wrap hybridcommand with + // we'll be reusing it for dev/tasks after all. + $command = new Command($name); + $command->setAliases([$this->makeAlias($name)]); + $command->setDefinition(new InputDefinition($hybridCommand->getOptions())); + $command->setDescription($commandClass::getDescription()); + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($hybridCommand) { + $hybridOutput = HybridOutput::create( + HybridOutput::CONTEXT_CLI, + $output->getVerbosity(), + $output->isDecorated(), + $output + ); + // TODO make the subtitle look a lil nicer + if (ClassInfo::hasMethod($hybridCommand, 'subTitle')) { + $hybridOutput->writeln([$hybridCommand->getSubtitle(), '--------']); + } + return $hybridCommand->run($input, $hybridOutput); + }); + return $command; + } + + public function has(string $name): bool + { + $this->init(); + if (array_key_exists($name, $this->commands)) { + return true; + } + var_dump($name); + return array_key_exists($this->deAlias($name), $this->commands); + } + + public function getNames(): array + { + $this->init(); + return array_keys($this->commands); + } + + private function init(): void + { + if (!empty($this->commands)) { + return; + } + $commands = DevelopmentAdmin::singleton()->getCommands(); + foreach ($commands as $name => $class) { + if (!$class::canRunInBrowser()) { + unset($commands[$name]); + } + } + $this->commands = $commands; + } + + private function deAlias(string $name): string + { + return str_replace('/', ':', $name); + } + + private function makeAlias(string $name): string + { + return str_replace(':', '/', $name); + } +} diff --git a/src/Cli/InjectorCommandLoader.php b/src/Cli/InjectorCommandLoader.php new file mode 100644 index 00000000000..3bcb1a5f1ee --- /dev/null +++ b/src/Cli/InjectorCommandLoader.php @@ -0,0 +1,32 @@ +setCommandLoader(new ArrayCommandLoader([ + new InjectorCommandLoader(), + new DevCommandLoader(), + ])); + } + + public function getVersion(): string + { + return VersionProvider::singleton()->getVersion(); + } + + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int + { + $input ??= new ArgvInput(); + + $skipDatabase = $input->hasParameterOption('--no-database', true); + if ($skipDatabase) { + DB::set_conn(new NullDatabase()); + } + + // Instantiate the kernel + // TODO: Replace with a single CliKernel implementation + // or add a `skipDatabase` argument to the CoreKernel constructor + $this->kernel = $skipDatabase + ? new DatabaselessKernel(BASE_PATH) + : new CoreKernel(BASE_PATH); + + try { + $flush = $input->hasParameterOption('--flush', true); + $this->kernel->boot($flush); + return parent::run($input, $output); + } finally { + $this->kernel->shutdown(); + } + } + + 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; + } +} diff --git a/src/Core/Injector/Injector.php b/src/Core/Injector/Injector.php index afb909a5b84..a0deace9635 100644 --- a/src/Core/Injector/Injector.php +++ b/src/Core/Injector/Injector.php @@ -972,7 +972,7 @@ public function unregisterObjects($types) * @param bool $asSingleton If set to false a new instance will be returned. * If true a singleton will be returned unless the spec is type=prototype' * @param array $constructorArgs Args to pass in to the constructor. Note: Ignored for singletons - * @return T|mixed Instance of the specified object + * @return T Instance of the specified object */ public function get($name, $asSingleton = true, $constructorArgs = []) { diff --git a/src/Dev/DevBuildController.php b/src/Dev/DevBuildController.php deleted file mode 100644 index 6dc791d4294..00000000000 --- a/src/Dev/DevBuildController.php +++ /dev/null @@ -1,83 +0,0 @@ - 'build' - ]; - - private static $allowed_actions = [ - 'build' - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_BUILD', - ]; - - 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 "
"; - - $da = DatabaseAdmin::create(); - $response = $da->handleRequest($request); - - echo "
"; - 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 03c53281056..00000000000 --- a/src/Dev/DevConfigController.php +++ /dev/null @@ -1,199 +0,0 @@ - 'audit', - '' => 'index' - ]; - - /** - * @var array - */ - private static $allowed_actions = [ - 'index', - 'audit', - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_CONFIG', - ]; - - 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 .= "
"; - $body .= sprintf("

%s

", $subtitle); - $body .= "
";
-            $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
-            $body .= "
"; - $body .= "
"; - $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 .= "
"; - $body .= sprintf("

%s

", $subtitle); - $body .= sprintf("
%s
", $output); - $body .= "
"; - $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 752ae82dd64..633665143c6 100644 --- a/src/Dev/DevelopmentAdmin.php +++ b/src/Dev/DevelopmentAdmin.php @@ -2,7 +2,8 @@ namespace SilverStripe\Dev; -use Exception; +use HttpRequestInput; +use LogicException; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; @@ -10,8 +11,8 @@ 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\HybridExecution\Command\HybridCommand; +use SilverStripe\Dev\HybridExecution\HybridOutput; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\Security; @@ -20,47 +21,58 @@ /** * 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 = [ '' => 'index', - 'build/defaults' => 'buildDefaults', 'generatesecuretoken' => 'generatesecuretoken', - '$Action' => 'runRegisteredController', + '$Action' => 'runRegisteredAction', ]; private static $allowed_actions = [ 'index', - 'buildDefaults', - 'runRegisteredController', + 'runRegisteredAction', 'generatesecuretoken', ]; /** - * Controllers for dev admin views + * Commands for dev admin views. + * + * Register any HybridCommand classes that you want to be under the `/dev/*` HTTP + * route and in the `dev:*` CLI namespace. + * + * Any namespaced commands will be nested under the `dev:*` CLI namespace, e.g + * `dev:my-namespace:command-two` + * + * Namespaces are also converted to URL segments for HTTP requests, e.g + * `dev/my-namspace/command-two` * * e.g [ - * 'urlsegment' => [ - * 'controller' => 'SilverStripe\Dev\DevelopmentAdmin', - * 'links' => [ - * 'urlsegment' => 'description', - * ... - * ] - * ] + * 'command-one' => 'App\HybridExecution\CommandOne', + * 'my-namespace:command-two' => 'App\HybridExecution\MyNamespace\CommandTwo', * ] + */ + private static array $commands = []; + + /** + * Controllers for dev admin views. * - * @var array + * 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 hybrid command under $commands instead. + * + * e.g [ + * 'urlsegment' => 'App\Dev\MyHttpOnlyController', + * ] */ - 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) + * Applies to all development admin tasks (E.g. TaskRunner, DevBuild) * * @config * @var bool @@ -82,7 +94,7 @@ protected function init() if (static::config()->get('deny_non_cli') && !Director::is_cli()) { return $this->httpError(404); } - + if (!$this->canViewAll() && empty($this->getLinks())) { Security::permissionFailure($this); return; @@ -96,154 +108,195 @@ 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 '