mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
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:
parent
730b891e10
commit
e46135be0a
13
_config/cli.yml
Normal file
13
_config/cli.yml
Normal 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'
|
@ -22,10 +22,6 @@ 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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
15
bin/sake
Executable 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();
|
@ -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();
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
119
sake
@ -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" "${@}"
|
63
src/Cli/Command/NavigateCommand.php
Normal file
63
src/Cli/Command/NavigateCommand.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
48
src/Cli/Command/PolyCommandCliWrapper.php
Normal file
48
src/Cli/Command/PolyCommandCliWrapper.php
Normal 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());
|
||||
}
|
||||
}
|
85
src/Cli/Command/TasksCommand.php
Normal file
85
src/Cli/Command/TasksCommand.php
Normal 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);
|
||||
}
|
||||
}
|
55
src/Cli/CommandLoader/ArrayCommandLoader.php
Normal file
55
src/Cli/CommandLoader/ArrayCommandLoader.php
Normal 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);
|
||||
}
|
||||
}
|
16
src/Cli/CommandLoader/DevCommandLoader.php
Normal file
16
src/Cli/CommandLoader/DevCommandLoader.php
Normal 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();
|
||||
}
|
||||
}
|
25
src/Cli/CommandLoader/DevTaskLoader.php
Normal file
25
src/Cli/CommandLoader/DevTaskLoader.php
Normal 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;
|
||||
}
|
||||
}
|
73
src/Cli/CommandLoader/InjectorCommandLoader.php
Normal file
73
src/Cli/CommandLoader/InjectorCommandLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
82
src/Cli/CommandLoader/PolyCommandLoader.php
Normal file
82
src/Cli/CommandLoader/PolyCommandLoader.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
168
src/Cli/LegacyParamArgvInput.php
Normal file
168
src/Cli/LegacyParamArgvInput.php
Normal 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
282
src/Cli/Sake.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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']);
|
||||
@ -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, []);
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
*/
|
||||
|
65
src/Control/PolyCommandController.php
Normal file
65
src/Control/PolyCommandController.php
Normal 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();
|
||||
}
|
||||
}
|
@ -30,7 +30,6 @@ class CoreKernel extends BaseKernel
|
||||
}
|
||||
|
||||
/**
|
||||
* @param false $flush
|
||||
* @throws HTTPResponse_Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
109
src/Dev/Command/ConfigAudit.php
Normal file
109
src/Dev/Command/ConfigAudit.php
Normal 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;
|
||||
}
|
||||
}
|
58
src/Dev/Command/ConfigDump.php
Normal file
58
src/Dev/Command/ConfigDump.php
Normal 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
347
src/Dev/Command/DbBuild.php
Normal 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());
|
||||
}
|
||||
}
|
89
src/Dev/Command/DbCleanup.php
Normal file
89
src/Dev/Command/DbCleanup.php
Normal 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';
|
||||
}
|
||||
}
|
49
src/Dev/Command/DbDefaults.php
Normal file
49
src/Dev/Command/DbDefaults.php
Normal 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';
|
||||
}
|
||||
}
|
58
src/Dev/Command/DevCommand.php
Normal file
58
src/Dev/Command/DevCommand.php
Normal 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;
|
||||
}
|
55
src/Dev/Command/GenerateSecureToken.php
Normal file
55
src/Dev/Command/GenerateSecureToken.php
Normal 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()
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@
|
||||
namespace SilverStripe\Dev;
|
||||
|
||||
use SilverStripe\Control\Director;
|
||||
use SilverStripe\ORM\DatabaseAdmin;
|
||||
use SilverStripe\Security\Confirmation;
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
$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);
|
||||
}
|
||||
$data = [
|
||||
'Description' => $description,
|
||||
'Link' => "{$base}$path",
|
||||
'Path' => $path,
|
||||
'Parameters' => $parameters,
|
||||
'Help' => $help,
|
||||
];
|
||||
$list[] = $data;
|
||||
}
|
||||
|
||||
echo $renderer->renderFooter();
|
||||
$data = [
|
||||
'ArrayLinks' => $list,
|
||||
'Header' => $renderer->renderHeader(),
|
||||
'Footer' => $renderer->renderFooter(),
|
||||
'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()),
|
||||
];
|
||||
|
||||
// CLI mode
|
||||
} else {
|
||||
echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n";
|
||||
echo "You can execute any of the following commands:\n\n";
|
||||
foreach ($links as $action => $description) {
|
||||
echo " sake dev/$action: $description\n";
|
||||
return ModelData::create()->renderWith(static::class, $data);
|
||||
}
|
||||
echo "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
public function runRegisteredController(HTTPRequest $request)
|
||||
{
|
||||
$controllerClass = null;
|
||||
|
||||
$baseUrlPart = $request->param('Action');
|
||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
||||
if (isset($reg[$baseUrlPart])) {
|
||||
$controllerClass = $reg[$baseUrlPart]['controller'];
|
||||
}
|
||||
|
||||
if ($controllerClass && class_exists($controllerClass ?? '')) {
|
||||
return $controllerClass::create();
|
||||
}
|
||||
|
||||
$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 {
|
||||
$this->httpError(404, $msg);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Internal methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated 5.2.0 use getLinks() instead to include permission checks
|
||||
* @return array of url => description
|
||||
* Run the command, or hand execution to the controller.
|
||||
* Note this method is for execution from the web only. CLI takes a different path.
|
||||
*/
|
||||
protected static function get_links()
|
||||
public function runRegisteredAction(HTTPRequest $request)
|
||||
{
|
||||
Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks');
|
||||
$links = [];
|
||||
$returnUrl = $this->getBackURL();
|
||||
$fullPath = $request->getURL();
|
||||
$routes = $this->getRegisteredRoutes();
|
||||
$class = null;
|
||||
|
||||
$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;
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
protected function getLinks(): array
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$class) {
|
||||
$msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
|
||||
$this->httpError(404, $msg);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of all registered DevCommands.
|
||||
* The key is the route used for browser execution.
|
||||
*/
|
||||
public function getCommands(): array
|
||||
{
|
||||
$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 $commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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($registeredController['controller']);
|
||||
$controllerSingleton = Injector::inst()->get($controllerClass);
|
||||
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($registeredController['links'] as $url => $desc) {
|
||||
$links[$url] = $desc;
|
||||
$items['dev/' . $urlSegment] = $info;
|
||||
}
|
||||
|
||||
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'])
|
||||
);
|
||||
|
@ -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'],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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'))
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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 = [];
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
143
src/PolyExecution/AnsiToHtmlConverter.php
Normal file
143
src/PolyExecution/AnsiToHtmlConverter.php
Normal 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>';
|
||||
}
|
||||
}
|
30
src/PolyExecution/AnsiToHtmlTheme.php
Normal file
30
src/PolyExecution/AnsiToHtmlTheme.php
Normal 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;
|
||||
}
|
||||
}
|
58
src/PolyExecution/HtmlOutputFormatter.php
Normal file
58
src/PolyExecution/HtmlOutputFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
111
src/PolyExecution/HttpRequestInput.php
Normal file
111
src/PolyExecution/HttpRequestInput.php
Normal 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;
|
||||
}
|
||||
}
|
197
src/PolyExecution/PolyCommand.php
Normal file
197
src/PolyExecution/PolyCommand.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
268
src/PolyExecution/PolyOutput.php
Normal file
268
src/PolyExecution/PolyOutput.php
Normal 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;
|
||||
}
|
||||
}
|
30
src/PolyExecution/PolyOutputLogHandler.php
Normal file
30
src/PolyExecution/PolyOutputLogHandler.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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')
|
||||
|
@ -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>
|
||||
|
37
templates/SilverStripe/Dev/DevelopmentAdmin.ss
Normal file
37
templates/SilverStripe/Dev/DevelopmentAdmin.ss
Normal 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
|
||||
|
8
templates/SilverStripe/Dev/Parameters.ss
Normal file
8
templates/SilverStripe/Dev/Parameters.ss
Normal 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>
|
@ -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>
|
||||
|
@ -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);
|
||||
|
99
tests/php/Cli/Command/NavigateCommandTest.php
Normal file
99
tests/php/Cli/Command/NavigateCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
52
tests/php/Cli/Command/NavigateCommandTest/TestController.php
Normal file
52
tests/php/Cli/Command/NavigateCommandTest/TestController.php
Normal 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);
|
||||
}
|
||||
}
|
52
tests/php/Cli/Command/PolyCommandCliWrapperTest.php
Normal file
52
tests/php/Cli/Command/PolyCommandCliWrapperTest.php
Normal 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());
|
||||
}
|
||||
}
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
159
tests/php/Cli/LegacyParamArgvInputTest.php
Normal file
159
tests/php/Cli/LegacyParamArgvInputTest.php
Normal 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
307
tests/php/Cli/SakeTest.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
23
tests/php/Cli/SakeTest/TestBuildTask.php
Normal file
23
tests/php/Cli/SakeTest/TestBuildTask.php
Normal 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;
|
||||
}
|
||||
}
|
31
tests/php/Cli/SakeTest/TestCommandLoader.php
Normal file
31
tests/php/Cli/SakeTest/TestCommandLoader.php
Normal 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];
|
||||
}
|
||||
}
|
19
tests/php/Cli/SakeTest/TestConfigCommand.php
Normal file
19
tests/php/Cli/SakeTest/TestConfigCommand.php
Normal 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;
|
||||
}
|
||||
}
|
26
tests/php/Cli/SakeTest/TestConfigPolyCommand.php
Normal file
26
tests/php/Cli/SakeTest/TestConfigPolyCommand.php
Normal 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;
|
||||
}
|
||||
}
|
19
tests/php/Cli/SakeTest/TestLoaderCommand.php
Normal file
19
tests/php/Cli/SakeTest/TestLoaderCommand.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
26
tests/php/Control/DirectorTest/TestPolyCommand.php
Normal file
26
tests/php/Control/DirectorTest/TestPolyCommand.php
Normal 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;
|
||||
}
|
||||
}
|
90
tests/php/Control/PolyCommandControllerTest.php
Normal file
90
tests/php/Control/PolyCommandControllerTest.php
Normal 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());
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
27
tests/php/Dev/BuildTaskTest/TestBuildTask.php
Normal file
27
tests/php/Dev/BuildTaskTest/TestBuildTask.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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']],
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,6 @@ class Controller1 extends Controller
|
||||
|
||||
public function y1Action()
|
||||
{
|
||||
echo Controller1::OK_MSG;
|
||||
echo Controller1::OK_MSG . ' y1';
|
||||
}
|
||||
}
|
||||
|
32
tests/php/Dev/DevAdminControllerTest/TestCommand.php
Normal file
32
tests/php/Dev/DevAdminControllerTest/TestCommand.php
Normal 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';
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
57
tests/php/PolyExecution/AnsiToHtmlConverterTest.php
Normal file
57
tests/php/PolyExecution/AnsiToHtmlConverterTest.php
Normal 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));
|
||||
}
|
||||
}
|
141
tests/php/PolyExecution/HttpRequestInputTest.php
Normal file
141
tests/php/PolyExecution/HttpRequestInputTest.php
Normal 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());
|
||||
}
|
||||
}
|
220
tests/php/PolyExecution/PolyOutputTest.php
Normal file
220
tests/php/PolyExecution/PolyOutputTest.php
Normal 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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
<% loop $MyArray %>
|
||||
$Val
|
||||
<% end_loop %>
|
Loading…
Reference in New Issue
Block a user