diff --git a/_config/dev.yml b/_config/dev.yml
index 4c1636bc4b5..ba535253a6f 100644
--- a/_config/dev.yml
+++ b/_config/dev.yml
@@ -2,6 +2,9 @@
Name: DevelopmentAdmin
---
SilverStripe\Dev\DevelopmentAdmin:
+ commands:
+ config: 'SilverStripe\Dev\HybridExecution\Command\DevConfig'
+ 'config:audit': 'SilverStripe\Dev\HybridExecution\Command\DevConfigAudit'
registered_controllers:
build:
controller: SilverStripe\Dev\DevBuildController
@@ -13,10 +16,6 @@ SilverStripe\Dev\DevelopmentAdmin:
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/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/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..ff11e39fbc8
--- /dev/null
+++ b/src/Cli/DevCommandLoader.php
@@ -0,0 +1,89 @@
+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.
+ $command = new Command($name);
+ $command->setAliases([$this->makeAlias($name)]);
+ $command->setDescription($hybridCommand->getDescription());
+ $command->setCode(function (InputInterface $input, OutputInterface $output) use ($hybridCommand) {
+ $hybridOutput = HybridOutput::create(
+ HybridOutput::CONTEXT_CONSOLE,
+ $output->getVerbosity(),
+ $output->isDecorated(),
+ $output
+ );
+ // TODO make the title look a lil nicer
+ $hybridOutput->writeln([$hybridCommand->getTitle(), '--------']);
+ 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());
+ }
+
+ // // Build request and detect flush
+ // $request = CLIRequestBuilder::createFromEnvironment();
+
+ // Instantiate the kernel
+ // TODO: Replace with a single CliKernel implementation
+ // TODO: Figure out how to get this earlier, so we can use config/injection to build commands list
+ $this->kernel = $skipDatabase
+ ? new DatabaselessKernel(BASE_PATH)
+ : new CoreKernel(BASE_PATH);
+ // $app = new HTTPApplication($kernel);
+ // $response = $app->handle($request);
+
+ // $response->output();
+
+ try {
+ $flush = $input->hasParameterOption('--flush', true);
+ // TODO boot the kernel earlier so we can use config/injection to build commands list
+ // Probably means figuring out a way to flush post-boot??
+ // How does symfony itself handle that?
+ $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', null, 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/DevConfigController.php b/src/Dev/DevConfigController.php
index 03c53281056..2539a1de7a3 100644
--- a/src/Dev/DevConfigController.php
+++ b/src/Dev/DevConfigController.php
@@ -18,7 +18,6 @@
*/
class DevConfigController extends Controller implements PermissionProvider
{
-
/**
* @var array
*/
@@ -157,7 +156,7 @@ public function canInit(): bool
|| Permission::check(static::config()->get('init_permissions'))
);
}
-
+
public function providePermissions(): array
{
return [
@@ -169,31 +168,4 @@ public function providePermissions(): array
],
];
}
-
- /**
- * 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/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php
index 752ae82dd64..bb4dec34f74 100644
--- a/src/Dev/DevelopmentAdmin.php
+++ b/src/Dev/DevelopmentAdmin.php
@@ -3,6 +3,7 @@
namespace SilverStripe\Dev;
use Exception;
+use LogicException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
@@ -10,52 +11,67 @@
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Dev\Deprecation;
+use SilverStripe\Dev\HybridExecution\Command\HybridCommand;
+use SilverStripe\Dev\HybridExecution\HybridOutput;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security;
use SilverStripe\Versioned\Versioned;
+use Symfony\Component\Console\Input\ArrayInput;
/**
* 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.
+ *
+ * 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.
*
- * @var array
+ * e.g [
+ * 'urlsegment' => 'App\Dev\MyHttpOnlyController',
+ * ]
*/
- private static $registered_controllers = [];
+ private static array $controllers = [];
/**
* Assume that CLI equals admin permissions
@@ -82,7 +98,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,123 +112,170 @@ 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 '
';
- $evenOdd = "odd";
- foreach ($links as $action => $description) {
- echo "- /dev/$action:"
- . " $description
\n";
- $evenOdd = ($evenOdd == "odd") ? "even" : "odd";
- }
-
- echo $renderer->renderFooter();
+ $renderer = DebugView::create();
+ echo $renderer->renderHeader();
+ echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL());
+ $base = Director::baseURL();
- // 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";
+ echo '';
+ $evenOdd = "odd";
+ $links = $this->getLinks();
+ foreach ($links as $path => $class) {
+ // TODO: Allow for controllers
+ $description = $class::singleton()->getDescription();
+ echo "- /$path:"
+ . " $description
\n";
+ $evenOdd = ($evenOdd == "odd") ? "even" : "odd";
}
+
+ echo $renderer->renderFooter();
}
- 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 = 'dev/' . $request->param('Action');
+ $fullPath = $request->getURL();
- $baseUrlPart = $request->param('Action');
- $reg = Config::inst()->get(static::class, 'registered_controllers');
- if (isset($reg[$baseUrlPart])) {
- $controllerClass = $reg[$baseUrlPart]['controller'];
- }
+ $links = $this->getLinks();
- if ($controllerClass && class_exists($controllerClass ?? '')) {
- return $controllerClass::create();
+ $class = $links[$fullPath] ?? null;
+ if ($class) {
+ // Tell the request we've matched the full URL
+ $request->shift($request->remaining());
+ } else {
+ $parts = explode('/', $fullPath);
+ // TODO: Loop through each set of parts - if there's a controller, use it.
+ // Don't accept commands at this stage - we're really looking for a controller that can handle the routing now,
+ // e.g. check `/dev/graphql/ide/woogie` then `dev/grahpql/ide` then `dev/graphql` don't check `dev` obviously.
+ // Check for a registered controller that can handle the request
+ $class = $links[$baseUrlPart] ?? null;
}
- $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
- */
+ // Hand execution to the controller
+ if (is_a($class, Controller::class, true)) {
+ return $class::create();
+ }
+
+ /** @var HybridCommand $command */
+ $command = $class::create();
+ // TODO: Get verbosity based on HTTPRequest (e.g. ?verbosity=verbose vs ?verbosity=debug or ?verbose=1 vs ?debug=1 vs ?quiet=1)
+ $output = HybridOutput::create(HybridOutput::CONTEXT_HTML, HybridOutput::VERBOSITY_NORMAL, true);
+
+ $renderer = DebugView::create();
+ echo $renderer->renderHeader();
+ echo $renderer->renderInfo("Configuration", Director::absoluteBaseURL()); // TODO "Configuration" should only be for config. So we have a title and a sub-title.
+ echo "";
+ echo "
{$command->getTitle()}
";
+
+ // TODO: Get an input based on the HTTPRequest.
+ $command->run(new ArrayInput([]), $output);
+
+ echo '';
+ echo $renderer->renderFooter();
+ }
/**
- * @deprecated 5.2.0 use getLinks() instead to include permission checks
- * @return array of url => description
+ * Get all registered HybridCommands
*/
- protected static function get_links()
+ public function getCommands(): array
{
- Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks');
- $links = [];
+ $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 HybridCommand
+ if (!ClassInfo::exists($class)) {
+ throw new LogicException("Class '$class' doesn't exist");
+ }
+ if (!is_a($class, HybridCommand::class, true)) {
+ throw new LogicException("Class '$class' must be a subclass of " . HybridCommand::class);
+ }
- $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;
- }
+ // Check that the command name (without namespace) matches what the command thinks its name is
+ $parts = explode(':', $name);
+ $nameLeaf = end($parts);
+ $realName = $class::config()->get('command_name');
+ if ($nameLeaf !== $realName) {
+ throw new LogicException(
+ "Class '$class' has a command_name of '$realName'."
+ . " DevelopmentAdmin.commands configuration thinks it should be '$nameLeaf'."
+ );
}
+
+ // Add to list of commands
+ $commands['dev:' . $name] = $class;
}
- return $links;
+ return $commands;
}
- protected function getLinks(): array
+ /**
+ * Get a map of paths to classes for all registered commands and controllers for this context.
+ * Should usually only be used for browser-based execution but accounts for unusual usages of CLI as well.
+ */
+ public function getLinks(): array
{
+ // TODO make sure canViewAll is correct for CLI vs non-CLI
$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;
- }
- if (!$canViewAll) {
- // Check access to controller
- $controllerSingleton = Injector::inst()->get($registeredController['controller']);
- if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
+ foreach ($this->getCommands() as $name => $command) {
+ // Check command can run in current context
+ if (!$canViewAll) {
+ if (Director::is_cli()) {
+ if (!$command::canRunInCli()) {
continue;
}
- }
-
- foreach ($registeredController['links'] as $url => $desc) {
- $links[$url] = $desc;
+ } elseif (!$command::canRunInBrowser()) {
+ continue;
}
}
- }
- return $links;
- }
-
- protected function getRegisteredController($baseUrlPart)
- {
- $reg = Config::inst()->get(static::class, 'registered_controllers');
- if (isset($reg[$baseUrlPart])) {
- $controllerClass = $reg[$baseUrlPart]['controller'];
- return $controllerClass;
+ $path = str_replace(':', '/', $name);
+ $links[$path] = $command;
}
- return null;
- }
+ // TODO add back controllers
+ // $reg = Config::inst()->get(static::class, 'registered_controllers');
+ // foreach ($reg as $registeredController) {
+ // if (isset($registeredController['links'])) {
+ // if (!ClassInfo::exists($registeredController['controller'])) {
+ // continue;
+ // }
+
+ // if (!$canViewAll) {
+ // // Check access to controller
+ // $controllerSingleton = Injector::inst()->get($registeredController['controller']);
+ // if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
+ // continue;
+ // }
+ // }
+
+ // foreach ($registeredController['links'] as $url => $desc) {
+ // $links[$url] = $desc;
+ // }
+ // }
+ // }
+
+ // TODO make sure there's no duplicate routes between commands and controllers.
+ return $links;
+ }
/*
* Unregistered (hidden) actions
diff --git a/src/Dev/HybridExecution/AnsiToHtmlConverter.php b/src/Dev/HybridExecution/AnsiToHtmlConverter.php
new file mode 100644
index 00000000000..7946915ea03
--- /dev/null
+++ b/src/Dev/HybridExecution/AnsiToHtmlConverter.php
@@ -0,0 +1,130 @@
+= 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset);
+
+ // 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('%s', $this->inlineColors['black'], $this->inlineColors['white'], $html);
+ // } else {
+ // $html = sprintf('%s', $html);
+ // }
+
+ // remove empty span
+ $html = preg_replace('#]*>#', '', $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;
+ $as = '';
+ $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 = 7;
+ } elseif (49 == $option) {
+ $bg = 0;
+ }
+ }
+
+ // 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('', implode('; ', $style));
+ }
+ if (!$this->inlineStyles && !empty($classes)) {
+ return sprintf('', 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 '';
+ }
+}
diff --git a/src/Dev/HybridExecution/Command/DevConfig.php b/src/Dev/HybridExecution/Command/DevConfig.php
new file mode 100644
index 00000000000..e7ccbc2a0b9
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/DevConfig.php
@@ -0,0 +1,54 @@
+writeUndecorated('');
+ }
+ $output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
+ if (!Director::is_cli()) {
+ $output->writeUndecorated('
');
+ }
+ return Command::SUCCESS;
+ }
+
+ 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/HybridExecution/Command/DevConfigAudit.php b/src/Dev/HybridExecution/Command/DevConfigAudit.php
new file mode 100644
index 00000000000..1f76666cbe3
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/DevConfigAudit.php
@@ -0,0 +1,100 @@
+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";
+
+ // 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();
+ // }
+
+ if (!Director::is_cli()) {
+ $output->writeUndecorated('');
+ }
+ $output->write($body);
+ if (!Director::is_cli()) {
+ $output->writeUndecorated('
');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Dev/HybridExecution/Command/HybridCommand.php b/src/Dev/HybridExecution/Command/HybridCommand.php
new file mode 100644
index 00000000000..284c8f33841
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/HybridCommand.php
@@ -0,0 +1,124 @@
+get('command_name');
+ }
+
+ /**
+ * Get the description of this command.
+ */
+ public function getDescription(): string
+ {
+ return _t(static::class . '.description', static::config()->get('description'));
+ }
+
+ /**
+ * 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 allowed to be run in browser, check user has correct permissions
+ return static::config()->get('can_run_in_browser')
+ && Permission::check(static::config()->get('permissions_for_browser_execution'));
+ }
+
+ private static function checkPrerequisites(): void
+ {
+ $mandatoryConfig = [
+ 'command_name',
+ 'description',
+ 'permissions_for_browser_execution',
+ ];
+ foreach ($mandatoryConfig as $config) {
+ if (!static::config()->get($config)) {
+ throw new RuntimeException($config . ' configuration property needs to be set.');
+ }
+ }
+ }
+}
diff --git a/src/Dev/HybridExecution/HtmlOutputFormatter.php b/src/Dev/HybridExecution/HtmlOutputFormatter.php
new file mode 100644
index 00000000000..07eb277313f
--- /dev/null
+++ b/src/Dev/HybridExecution/HtmlOutputFormatter.php
@@ -0,0 +1,58 @@
+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/Dev/HybridExecution/HybridOutput.php b/src/Dev/HybridExecution/HybridOutput.php
new file mode 100644
index 00000000000..0e0110460d6
--- /dev/null
+++ b/src/Dev/HybridExecution/HybridOutput.php
@@ -0,0 +1,162 @@
+context = $context;
+ switch ($context) {
+ case HybridOutput::CONTEXT_CONSOLE:
+ $this->consoleOutput = $consoleOutput ?? Injector::inst()->create(ConsoleOutput::class);
+ // Give console output a debug verbosity - that way it'll output everything we tell it to.
+ // Actual verbosity is handled by HybridOutput's parent Output class.
+ $this->consoleOutput->setVerbosity(Output::VERBOSITY_DEBUG);
+ $this->setFormatter(new OutputFormatter());
+ break;
+ case HybridOutput::CONTEXT_HTML:
+ if ($consoleOutput) {
+ throw new InvalidArgumentException('Cannot use consoleOutput in HTML context');
+ }
+ $this->setFormatter(HtmlOutputFormatter::create(new OutputFormatter()));
+ break;
+ default:
+ throw new InvalidArgumentException("Unexpected context - got '$context'.");
+ }
+ // Intentionally don't call parent constructor, because it doesn't use the setter methods.
+ $this->setDecorated($decorated);
+ $this->setVerbosity($verbosity);
+ }
+
+ /**
+ * Writes a message to the output without decorating it.
+ * Useful if you want to output explicit HTML without the ANSI decorator escaping it.
+ *
+ * @param bool $newline Whether to add a newline
+ * @param int $verbosity One of the VERBOSITY constants
+ */
+ public function writeUndecorated(
+ string|iterable $messages,
+ bool $newline = false,
+ int $verbosity = Output::VERBOSITY_NORMAL
+ ): void {
+ $this->write($messages, $newline, $verbosity | Output::OUTPUT_RAW);
+ }
+
+ /**
+ * Writes messages formatted as a list
+ * TODO: Nested lists (probably build a List object? Or pass nested arrays?)
+ *
+ * @param string $listType One of the LIST_* consts, e.g. HybridOutput::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 writeList(iterable $items, string $listType = HybridOutput::LIST_UNORDERED, int $options = 0): void
+ {
+ $method = "writeList{$this->context}";
+ $this->$method($items, $listType, $options);
+ }
+
+ private function writeListHtml(iterable $items, string $listType, int $options): void
+ {
+ $this->writeln("<{$listType}>", $options);
+ foreach ($items as $item) {
+ $this->writeln("- {$item}
", $options);
+ }
+ $this->writeln("{$listType}>", $options);
+ }
+
+ private function writeListConsole(iterable $items, string $listType, int $options): void
+ {
+ foreach ($items as $i => $item) {
+ switch ($listType) {
+ case HybridOutput::LIST_UNORDERED:
+ $bullet = '-';
+ break;
+ case HybridOutput::LIST_UNORDERED:
+ // Start at 1
+ $bullet = $i + 1 . '.';
+ break;
+ default:
+ throw new InvalidArgumentException("Unexpected list type - got '$listType'.");
+ }
+ $this->writeln("{$bullet} {$item}", $options);
+ }
+ }
+
+ public function write(string|iterable $messages, bool $newline = false, int $options = Output::OUTPUT_NORMAL): void
+ {
+ if ($this->context === HybridOutput::CONTEXT_CONSOLE) {
+ $this->consoleOutput->write($messages, $newline, $options);
+ return;
+ }
+ // This will do some pre-processing before handing off to doWrite()
+ parent::write($messages, $newline, $options);
+ }
+
+ protected function doWrite(string $message, bool $newline): void
+ {
+ if ($this->context !== HybridOutput::CONTEXT_HTML) {
+ throw new BadMethodCallException('Something went wrong - doWrite should only be called in HTML context');
+ }
+ echo $message . ($newline ? "
\n" : '');
+ }
+
+ public function setDecorated(bool $decorated): void
+ {
+ parent::setDecorated($decorated);
+ $this->consoleOutput?->setDecorated($decorated);
+ }
+
+ public function setFormatter(OutputFormatterInterface $formatter): void
+ {
+ parent::setFormatter($formatter);
+ $this->consoleOutput?->setFormatter($formatter);
+ }
+
+ protected function getOutputOption(int $options): int
+ {
+ $types = Output::OUTPUT_NORMAL | Output::OUTPUT_RAW | Output::OUTPUT_PLAIN;
+ return $types & $options ?: Output::OUTPUT_NORMAL;
+ }
+
+ protected function getVerbosityOption(int $options): int
+ {
+ $verbosities = Output::VERBOSITY_QUIET | Output::VERBOSITY_NORMAL | Output::VERBOSITY_VERBOSE | Output::VERBOSITY_VERY_VERBOSE | Output::VERBOSITY_DEBUG;
+ return $verbosities & $options ?: Output::VERBOSITY_NORMAL;
+ }
+}
diff --git a/src/ORM/ArrayLib.php b/src/ORM/ArrayLib.php
index 371d0f6dfca..b5dbc8ed684 100644
--- a/src/ORM/ArrayLib.php
+++ b/src/ORM/ArrayLib.php
@@ -85,6 +85,32 @@ public static function array_values_recursive($array)
return ArrayLib::flatten($array, false);
}
+
+ /**
+ * Returns all the keys of a multi-dimensional array while maintining any nested structure
+ */
+ public static 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] = static::arrayKeysRecursive($array[$key], $maxdepth, $depth);
+ }
+ }
+
+ return $arrayKeys;
+ }
+
/**
* Filter an array by keys (useful for only allowing certain form-input to
* be saved).