NEW Refactor CLI interaction with Silverstripe app (#11353)

- Turn sake into a symfony/console app
- Avoid using HTTPRequest for CLI interaction
- Implement abstract hybrid execution path
This commit is contained in:
Guy Sartorelli 2024-09-26 17:16:47 +12:00 committed by GitHub
parent 730b891e10
commit e46135be0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 4869 additions and 1838 deletions

13
_config/cli.yml Normal file
View File

@ -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'

View File

@ -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
type: prototype

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

15
bin/sake Executable file
View File

@ -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();

View File

@ -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();

View File

@ -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;
}

View File

@ -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;

View File

@ -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/",
@ -100,6 +104,8 @@
"SilverStripe\\Model\\Tests\\": "tests/php/Model/",
"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/",

119
sake
View File

@ -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" "${@}"

View File

@ -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-vars</> 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-vars',
InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
'Optional GET variables or a query string'
);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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,
];
}
}
}
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace SilverStripe\Cli;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Core\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::withSuppressedNotice(
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 "notice" 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;
}
}

282
src/Cli/Sake.php Normal file
View File

@ -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;
}
}

View File

@ -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 @@ class CLIRequestBuilder extends HTTPRequestBuilder
'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 @@ class CLIRequestBuilder extends HTTPRequestBuilder
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 @@ class CLIRequestBuilder extends HTTPRequestBuilder
$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 @@ class CLIRequestBuilder extends HTTPRequestBuilder
* @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 @@ class CLIRequestBuilder extends HTTPRequestBuilder
return $request;
}
public static function createFromInput(InputInterface $input): HTTPRequest
{
$variables = [];
$variables['_SERVER']['argv'] = [
'sake',
$input->getArgument('path'),
...$input->getArgument('get-vars'),
];
$cleanVars = static::cleanEnvironment($variables);
Environment::setVariables($cleanVars);
return static::createFromVariables($cleanVars, []);
}
}

View File

@ -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()
{
}
}

View File

@ -12,6 +12,7 @@ use SilverStripe\Core\Injector\Injectable;
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 @@ class Director implements TemplateGlobalProvider
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();

View File

@ -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::withSuppressedNotice(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();
}
}

View File

@ -4,7 +4,6 @@ namespace SilverStripe\Control\Middleware;
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 @@ use SilverStripe\Security\Permission;
*/
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 @@ class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmation
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]);
}
}

View File

@ -22,7 +22,7 @@ use SilverStripe\Security\RandomGenerator;
* - 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}
*/

View File

@ -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();
}
}

View File

@ -30,7 +30,6 @@ class CoreKernel extends BaseKernel
}
/**
* @param false $flush
* @throws HTTPResponse_Exception
* @throws Exception
*/

View File

@ -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;
}
}

View File

@ -139,4 +139,9 @@ interface Kernel
* @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;
}

View File

@ -549,7 +549,7 @@ class ClassManifest
$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);

View File

@ -2,103 +2,107 @@
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;
/**
* Shown in the overview on the {@link TaskRunner}
* HTML or CLI interface. Should be short and concise.
* Do not use HTML markup.
*/
protected string $title;
/**
* 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 = true;
/**
* Describe the implications the task has, and the changes it makes.
* Do not use HTML markup.
*/
protected static string $description = 'No description available';
private static array $permissions_for_browser_execution = [
'ADMIN',
'ALL_DEV_ADMIN' => true,
'BUILDTASK_CAN_RUN' => true,
];
public function __construct()
{
}
/**
* Set a custom url segment (to follow dev/tasks/)
* The code for running this task.
*
* @config
* @var string
* @deprecated 5.4.0 Will be replaced with $commandName
*/
private static $segment = null;
/**
* Make this non-nullable and change this to `bool` in CMS6 with a value of `true`
* @var bool|null
*/
private static ?bool $is_enabled = null;
/**
* @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
*/
protected $enabled = true;
/**
* @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;
/**
* @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';
/**
* Implement this method in the task subclass to
* execute via the TaskRunner
* 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 writeForHtml() method).
*
* @param HTTPRequest $request
* @return void
* 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->writeForAnsi("<options=bold>Running task '{$this->getTitle()}'</>", true);
$output->writeForHtml("<h1>Running task '{$this->getTitle()}'</h1>", false);
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::withSuppressedNotice(
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;
}
}

View File

@ -0,0 +1,109 @@
<?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 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->writeForHtml('<pre>');
$output->write($body);
$output->writeForHtml('</pre>');
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;
}
}

View File

@ -0,0 +1,58 @@
<?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->writeForHtml('<pre>');
$output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
$output->writeForHtml('</pre>');
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
],
];
}
}

347
src/Dev/Command/DbBuild.php Normal file
View File

@ -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());
}
}

View File

@ -0,0 +1,89 @@
<?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';
}
}

View File

@ -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';
}
}

View File

@ -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',
'ALL_DEV_ADMIN' => true,
];
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->writeForAnsi(["<options=bold>{$heading}</>", $underline], true);
$output->writeForHtml("<h2>{$heading}</h2>");
} 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->writeForAnsi(["<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 writeForHtml() 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;
}

View File

@ -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->writeForHtml('<code>');
$output->writeln($token);
$output->writeForHtml('</code>');
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()
),
];
}
}

View File

@ -290,7 +290,7 @@ class Deprecation
$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,

View File

@ -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::withSuppressedNotice(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
],
];
}
}

View File

@ -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::withSuppressedNotice(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;
}
}

View File

@ -3,7 +3,6 @@
namespace SilverStripe\Dev;
use SilverStripe\Control\Director;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\Security\Confirmation;
/**

View File

@ -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\Model\ModelData;
/**
* 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 [
* '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.
*
* e.g [
* 'urlsegment' => [
* 'controller' => 'SilverStripe\Dev\DevelopmentAdmin',
* 'links' => [
* 'urlsegment' => 'description',
* ...
* ]
* ]
* 'class' => 'App\Dev\MyHttpOnlyController',
* 'description' => 'See a list of build tasks to run',
* ],
* ]
*
* @var array
* @deprecated 5.4.0 Will be replaced with "controllers" and "commands" configuration properties
*/
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 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
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,195 +99,223 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
}
}
/**
* 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();
$renderer = DebugView::create();
$base = Director::baseURL();
$formatter = HtmlOutputFormatter::create();
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";
$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);
}
echo $renderer->renderFooter();
// 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";
$data = [
'Description' => $description,
'Link' => "{$base}$path",
'Path' => $path,
'Parameters' => $parameters,
'Help' => $help,
];
$list[] = $data;
}
$data = [
'ArrayLinks' => $list,
'Header' => $renderer->renderHeader(),
'Footer' => $renderer->renderFooter(),
'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()),
];
return ModelData::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;
$returnUrl = $this->getBackURL();
$fullPath = $request->getURL();
$routes = $this->getRegisteredRoutes();
$class = null;
$baseUrlPart = $request->param('Action');
$reg = Config::inst()->get(static::class, 'registered_controllers');
if (isset($reg[$baseUrlPart])) {
$controllerClass = $reg[$baseUrlPart]['controller'];
// 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
*/
// Hand execution to the controller
if (is_a($class, RequestHandler::class, true)) {
return $class::create();
}
/** @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->writeForHtml($headerOutput);
// Run command
$command->run($input, $output);
// Output footer etc
$output->writeForHtml([
'</div>',
$renderer->renderFooter(),
]);
// Return to whence we came (e.g. if we had been redirected to dev/build)
if ($returnUrl) {
return $this->redirect($returnUrl);
}
}
/**
* @deprecated 5.2.0 use getLinks() instead to include permission checks
* @return array of url => description
* Get a map of all registered DevCommands.
* The key is the route used for browser execution.
*/
protected static function get_links()
public function getCommands(): array
{
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;
}
$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);
}
// Add to list of commands
$commands['dev/' . $name] = $class;
}
return $links;
return $commands;
}
protected function getLinks(): array
/**
* 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 getRegisteredRoutes(): 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'])) {
$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];
}
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 (!$canViewAll) {
// Check access to controller
$controllerSingleton = Injector::inst()->get($registeredController['controller']);
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
continue;
}
}
$items['dev/' . $urlSegment] = $info;
}
foreach ($registeredController['links'] as $url => $desc) {
$links[$url] = $desc;
}
return $items;
}
/**
* 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 getLinks(): array
{
$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;
}
/**
* @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
*/
protected function getRegisteredController($baseUrlPart)
{
Deprecation::notice('5.4.0', 'Will be removed without equivalent functionality to replace it');
$reg = Config::inst()->get(static::class, 'registered_controllers');
if (isset($reg[$baseUrlPart])) {
$controllerClass = $reg[$baseUrlPart]['controller'];
return $controllerClass;
}
return null;
}
/*
* 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
*/
public function buildDefaults()
{
Deprecation::withSuppressedNotice(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\">";
}
$da->buildDefaults();
if (!Director::is_cli()) {
echo "</div>";
echo $renderer->renderFooter();
}
}
/**
* 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
*/
public function generatesecuretoken()
{
Deprecation::withSuppressedNotice(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');
}
public function errors()
{
$this->redirect("Debug_");
@ -310,17 +340,17 @@ TXT;
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'])
);

View File

@ -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();
public function down()
/**
* Revert the migration (new to old)
*/
abstract 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'],
),
];
}
}

View File

@ -88,7 +88,7 @@ class ExtensionTestState implements TestState
}
// 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

View File

@ -12,6 +12,10 @@ use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ModuleResourceLoader;
use SilverStripe\Model\List\ArrayList;
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;
@ -20,7 +24,6 @@ use SilverStripe\Model\ModelData;
class TaskRunner extends Controller implements PermissionProvider
{
use Configurable;
private static $url_handlers = [
@ -59,25 +62,17 @@ class TaskRunner extends Controller implements PermissionProvider
{
$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 @@ class TaskRunner extends Controller implements PermissionProvider
$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 @@ class TaskRunner extends Controller implements PermissionProvider
}
/**
* @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 @@ class TaskRunner extends Controller implements PermissionProvider
return false;
}
/** @var BuildTask $task */
$task = Injector::inst()->get($class);
if (!$task->isEnabled()) {
return false;
@ -197,8 +200,7 @@ class TaskRunner extends Controller implements PermissionProvider
{
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'))
);

View File

@ -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 @@ use SilverStripe\Security\Security;
*/
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 array $permissions_for_browser_execution = [
'ALL_DEV_ADMIN' => false,
'BUILDTASK_CAN_RUN' => false,
];
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::withSuppressedNotice(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with canRunInBrowser()'
);
});
return Permission::check('ADMIN') || Director::is_cli();
return Command::SUCCESS;
}
}

View File

@ -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),
];
}
}

View File

@ -4,24 +4,21 @@ namespace SilverStripe\Dev\Validation;
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();

View File

@ -12,14 +12,11 @@ use SilverStripe\Control\HTTPResponse;
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
*/
@ -62,7 +59,7 @@ class HTTPOutputHandler extends AbstractProcessingHandler
* 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)
{
@ -97,7 +94,7 @@ class HTTPOutputHandler extends AbstractProcessingHandler
* 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)
{
@ -180,6 +177,11 @@ class HTTPOutputHandler extends AbstractProcessingHandler
}
}
if (Director::is_cli()) {
echo $record['formatted'];
return;
}
if (Controller::has_curr()) {
$response = Controller::curr()->getResponse();
} else {
@ -198,14 +200,4 @@ class HTTPOutputHandler extends AbstractProcessingHandler
$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();
}
}

View File

@ -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 = [];

View File

@ -233,7 +233,7 @@ class TempDatabase
{
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);

View File

@ -190,7 +190,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
/**
* 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;
@ -3785,7 +3785,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
* 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()
{

View File

@ -314,7 +314,7 @@ class DataObjectSchema
* 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
*

View File

@ -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::withSuppressedNotice(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::withSuppressedNotice(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);
}
}
}
}

View File

@ -56,7 +56,7 @@ trait DBClassNameTrait
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;

View File

@ -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>';
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,197 @@
<?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.
* Users must have at least one of these permissions to run the command in an HTTP request.
* If `can_run_in_browser` is false, these permissions are ignored.
* Must be defined in the subclass.
*
* If permissions are set as keys, the value must be boolean, indicating whether to check
* that permission or not. This is useful for allowing developers to turn off permission checks
* that aren't valid for their project or for their 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 writeForHtml() 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 (!empty($permissions)) {
$usePermissions = [];
// Only use permissions that aren't set to false
// Allow permissions to also be simply set as values, for simplicity
foreach ($permissions as $key => $value) {
if (is_string($value)) {
$usePermissions[] = $value;
} elseif ($value) {
$usePermissions[] = $key;
}
}
return Permission::check($usePermissions);
}
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.');
}
}
}
}

View File

@ -0,0 +1,268 @@
<?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 HTML format.
* Useful for when HTML and ANSI formatted output need to diverge.
*
* Note that this method uses RAW output by default, which allows you to add HTML markup
* directly into the message. If you're using symfony/console style formatting, set
* $options to use the OUTPUT_NORMAL constant.
*
* @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 writeForHtml(
string|iterable $messages,
bool $newline = false,
int $options = OutputInterface::OUTPUT_RAW
): void {
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$this->write($messages, $newline, $options);
}
}
/**
* Writes messages to the output - but only if we're using ANSI format.
* Useful for when HTML and ANSI formatted output need to diverge.
*
* @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 writeForAnsi(
string|iterable $messages,
bool $newline = false,
int $options = OutputInterface::OUTPUT_NORMAL
): void {
if ($this->outputFormat === PolyOutput::FORMAT_ANSI) {
$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;
}
}

View File

@ -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);
}
}

View File

@ -7,11 +7,6 @@ use SilverStripe\Control\Controller;
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

View File

@ -773,7 +773,7 @@ class Member extends DataObject
}
}
// 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')

View File

@ -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.

View File

@ -1067,7 +1067,7 @@ class Security extends Controller implements TemplateGlobalProvider
/**
* 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
*/

View File

@ -5,6 +5,7 @@ namespace SilverStripe\View;
use InvalidArgumentException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Model\ModelData;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\FieldType\DBField;
/**
@ -433,6 +434,11 @@ class SSViewer_DataPresenter extends SSViewer_Scope
return $value;
}
// Wrap list arrays in ModelData 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'])
? ModelData::config()->uninherited('default_cast')

View File

@ -6,7 +6,6 @@ use Exception;
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 @@ use SilverStripe\ORM\DataObject;
*
* 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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -25,9 +25,6 @@ $_SERVER = array_merge([
$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);

View File

@ -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-vars' => $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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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),
];
}
}

View File

@ -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));
}
}
}

307
tests/php/Cli/SakeTest.php Normal file
View File

@ -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;
});
}
}

View File

@ -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;
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -12,6 +12,7 @@ use SilverStripe\Control\Middleware\CanonicalURLMiddleware;
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 @@ class DirectorTest extends SapphireTest
$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());
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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 @@ class DevAdminControllerTest extends FunctionalTest
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 @@ class DevAdminControllerTest extends FunctionalTest
// 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 @@ class DevAdminControllerTest extends FunctionalTest
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']],
];
}

View File

@ -27,6 +27,6 @@ class Controller1 extends Controller
public function y1Action()
{
echo Controller1::OK_MSG;
echo Controller1::OK_MSG . ' y1';
}
}

View File

@ -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';
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -13,9 +13,10 @@ use SilverStripe\Dev\SapphireTest;
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 @@ class HTTPOutputHandlerTest extends SapphireTest
public function testGetFormatter()
{
$handler = new HTTPOutputHandler();
$handler = new ErrorOutputHandler();
$detailedFormatter = new DetailedErrorFormatter();
$friendlyFormatter = new DebugViewFriendlyErrorFormatter();
@ -49,9 +50,9 @@ class HTTPOutputHandlerTest extends SapphireTest
*/
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 @@ class HTTPOutputHandlerTest extends SapphireTest
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 @@ class HTTPOutputHandlerTest extends SapphireTest
$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);
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,220 @@
<?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 provideWriteForHtml(): array
{
return [
'html for html' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'messages' => ['one message', 'two message'],
'expected' => "one message<br>\ntwo message<br>\n",
],
'ansi for html' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'messages' => ['one message', 'two message'],
'expected' => '',
],
];
}
#[DataProvider('provideWriteForHtml')]
public function testWriteForHtml(
string $outputFormat,
string|iterable $messages,
string $expected
): void {
$buffer = new BufferedOutput();
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
$output->writeForHtml($messages, true);
$this->assertSame($expected, $buffer->fetch());
}
public static function provideWriteForAnsi(): array
{
return [
'html for ansi' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'messages' => ['one message', 'two message'],
'expected' => '',
],
'ansi for ansi' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'messages' => ['one message', 'two message'],
'expected' => "one message\ntwo message\n",
],
];
}
#[DataProvider('provideWriteForAnsi')]
public function testWriteForAnsi(
string $outputFormat,
string|iterable $messages,
string $expected
): void {
$buffer = new BufferedOutput();
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
$output->writeForAnsi($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();
}
}

View File

@ -2367,4 +2367,17 @@ EOC;
};
$this->render('$Me', $myArrayData);
}
public function testLoopingThroughArrayInOverlay(): void
{
$modelData = new ModelData();
$theArray = [
['Val' => 'one'],
['Val' => 'two'],
['Val' => 'red'],
['Val' => 'blue'],
];
$output = $modelData->renderWith('SSViewerTestLoopArray', ['MyArray' => $theArray]);
$this->assertEqualIgnoringWhitespace('one two red blue', $output);
}
}

View File

@ -0,0 +1,3 @@
<% loop $MyArray %>
$Val
<% end_loop %>