Compare commits

...

8 Commits

Author SHA1 Message Date
Guy Sartorelli
01ec7c7be3
Merge 88a1f0362f465bbd5ff4d8d55242b3b69ae7e81e into c523022cb9d52cb8c2567127844f7e16be63cb77 2024-09-25 00:25:35 +00:00
Guy Sartorelli
88a1f0362f
NEW Refactor CLI interaction with Silverstripe app
- Turn sake into a symfony/console app
- Avoid using HTTPRequest for CLI interaction
- Implement abstract hybrid execution path
2024-09-25 12:25:24 +12:00
Guy Sartorelli
c523022cb9
Merge branch '5' into 6 2024-09-24 14:07:57 +12:00
Guy Sartorelli
a81b7855de
Merge pull request #11393 from creative-commoners/pulls/5/fix-constructor
FIX Use correct contructor for HTTPOutputHandler
2024-09-24 09:35:35 +12:00
Steve Boyd
e93dafb2fe FIX Use correct contructor for HTTPOutputHandler 2024-09-19 17:21:12 +12:00
Guy Sartorelli
6287b6ebeb
API Rename Deprecation::withNoReplacement (#11390) 2024-09-19 11:27:08 +12:00
Guy Sartorelli
c7ba8d19c5
Merge pull request #11383 from wilr/features/9394-mysqli-flags
feat: support defining MySQLi flags
2024-09-19 09:38:13 +12:00
Will Rossiter
5fa88663b0
feat: support defining MySQLi flags 2024-09-19 07:24:19 +12:00
117 changed files with 4936 additions and 1885 deletions

13
_config/cli.yml Normal file
View File

@ -0,0 +1,13 @@
---
Name: cli-config
---
SilverStripe\Core\Injector\Injector:
Symfony\Contracts\EventDispatcher\EventDispatcherInterface.sake:
class: 'Symfony\Component\EventDispatcher\EventDispatcher'
Symfony\Component\Console\Formatter\OutputFormatterInterface:
class: 'Symfony\Component\Console\Formatter\OutputFormatter'
calls:
- ['setDecorated', [true]]
SilverStripe\PolyExecution\HtmlOutputFormatter:
constructor:
formatter: '%$Symfony\Component\Console\Formatter\OutputFormatterInterface'

View File

@ -22,14 +22,10 @@ SilverStripe\Core\Injector\Injector:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass
type: prototype type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass
type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass class: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass
type: prototype type: prototype
SilverStripe\Control\Middleware\ConfirmationMiddleware\Url: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url:
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url class: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url
type: prototype type: prototype

View File

@ -2,21 +2,20 @@
Name: DevelopmentAdmin Name: DevelopmentAdmin
--- ---
SilverStripe\Dev\DevelopmentAdmin: SilverStripe\Dev\DevelopmentAdmin:
registered_controllers: commands:
build: build: 'SilverStripe\Dev\Command\DbBuild'
controller: SilverStripe\Dev\DevBuildController 'build/cleanup': 'SilverStripe\Dev\Command\DbCleanup'
links: 'build/defaults': 'SilverStripe\Dev\Command\DbDefaults'
build: 'Build/rebuild this environment. Call this whenever you have updated your project sources' config: 'SilverStripe\Dev\Command\ConfigDump'
'config/audit': 'SilverStripe\Dev\Command\ConfigAudit'
generatesecuretoken: 'SilverStripe\Dev\Command\GenerateSecureToken'
controllers:
tasks: tasks:
controller: SilverStripe\Dev\TaskRunner class: 'SilverStripe\Dev\TaskRunner'
links: description: 'See a list of build tasks to run'
tasks: 'See a list of build tasks to run'
confirm: confirm:
controller: SilverStripe\Dev\DevConfirmationController class: 'SilverStripe\Dev\DevConfirmationController'
config: skipLink: true
controller: Silverstripe\Dev\DevConfigController
links:
config: 'View the current config, useful for debugging'
SilverStripe\Dev\CSSContentParser: SilverStripe\Dev\CSSContentParser:
disable_xml_external_entities: true disable_xml_external_entities: true

View File

@ -7,6 +7,6 @@ SilverStripe\Security\Member:
SilverStripe\Security\Group: SilverStripe\Security\Group:
extensions: extensions:
- SilverStripe\Security\InheritedPermissionFlusher - SilverStripe\Security\InheritedPermissionFlusher
SilverStripe\ORM\DatabaseAdmin: SilverStripe\Dev\Command\DbBuild:
extensions: extensions:
- SilverStripe\Dev\Validation\DatabaseAdminExtension - SilverStripe\Dev\Validation\DbBuildExtension

View File

@ -52,7 +52,7 @@ Only:
# Dev handler outputs detailed information including notices # Dev handler outputs detailed information including notices
SilverStripe\Core\Injector\Injector: SilverStripe\Core\Injector\Injector:
Monolog\Handler\HandlerInterface: Monolog\Handler\HandlerInterface:
class: SilverStripe\Logging\HTTPOutputHandler class: SilverStripe\Logging\ErrorOutputHandler
constructor: constructor:
- "notice" - "notice"
properties: properties:
@ -66,7 +66,7 @@ Except:
# CLI errors still show full details # CLI errors still show full details
SilverStripe\Core\Injector\Injector: SilverStripe\Core\Injector\Injector:
Monolog\Handler\HandlerInterface: Monolog\Handler\HandlerInterface:
class: SilverStripe\Logging\HTTPOutputHandler class: SilverStripe\Logging\ErrorOutputHandler
constructor: constructor:
- "error" - "error"
properties: properties:

View File

@ -60,7 +60,6 @@ SilverStripe\Core\Injector\Injector:
ConfirmationStorageId: 'url-specials' ConfirmationStorageId: 'url-specials'
ConfirmationFormUrl: '/dev/confirm' ConfirmationFormUrl: '/dev/confirm'
Bypasses: Bypasses:
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")'
EnforceAuthentication: true EnforceAuthentication: true
@ -94,7 +93,6 @@ SilverStripe\Core\Injector\Injector:
ConfirmationStorageId: 'dev-urls' ConfirmationStorageId: 'dev-urls'
ConfirmationFormUrl: '/dev/confirm' ConfirmationFormUrl: '/dev/confirm'
Bypasses: Bypasses:
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")' - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
EnforceAuthentication: false EnforceAuthentication: false

15
bin/sake Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env php
<?php
use SilverStripe\Cli\Sake;
// Ensure that people can't access this from a web-server
if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
echo 'sake cannot be run from a web request, you have to run it on the command-line.';
die();
}
require_once __DIR__ . '/../src/includes/autoload.php';
$sake = new Sake();
$sake->run();

View File

@ -1,35 +0,0 @@
<?php
// CLI specific bootstrapping
use SilverStripe\Control\CLIRequestBuilder;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Core\CoreKernel;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Connect\NullDatabase;
require __DIR__ . '/src/includes/autoload.php';
// Ensure that people can't access this from a web-server
if (!in_array(PHP_SAPI, ["cli", "cgi", "cgi-fcgi"])) {
echo "cli-script.php can't be run from a web request, you have to run it on the command-line.";
die();
}
// Build request and detect flush
$request = CLIRequestBuilder::createFromEnvironment();
$skipDatabase = in_array('--no-database', $argv);
if ($skipDatabase) {
DB::set_conn(new NullDatabase());
}
// Default application
$kernel = new CoreKernel(BASE_PATH);
if ($skipDatabase) {
$kernel->setBootDatabase(false);
}
$app = new HTTPApplication($kernel);
$response = $app->handle($request);
$response->output();

View File

@ -113,7 +113,6 @@ a:active {
} }
/* Content types */ /* Content types */
.build,
.options, .options,
.trace { .trace {
position: relative; position: relative;
@ -128,22 +127,28 @@ a:active {
line-height: 1.3; line-height: 1.3;
} }
.build .success { .options .success {
color: #2b6c2d; color: #2b6c2d;
} }
.build .error { .options .error {
color: #d30000; color: #d30000;
} }
.build .warning { .options .warning {
color: #8a6d3b; color: #8a6d3b;
} }
.build .info { .options .info {
color: #0073c1; color: #0073c1;
} }
.options .more-details {
border: 1px dotted;
width: fit-content;
padding: 5px;
}
/* Backtrace styles */ /* Backtrace styles */
pre { pre {
overflow: auto; overflow: auto;
@ -162,3 +167,28 @@ pre span {
pre .error { pre .error {
color: #d30000; color: #d30000;
} }
.params {
margin-top: 0;
margin-left: 10px;
}
.param {
display: flex;
align-items: baseline;
}
.param__name {
display: inline-block;
font-weight: 200;
}
.param__name::after {
content: ": ";
}
.param__description {
display: inline-block;
margin-left: 0.5em;
font-style: italic;
}

View File

@ -36,6 +36,12 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.task__help {
border: 1px dotted;
width: fit-content;
padding: 5px;
}
.task__button { .task__button {
border: 1px solid #ced5e1; border: 1px solid #ced5e1;
border-radius: 5px; border-radius: 5px;

View File

@ -19,7 +19,7 @@
} }
], ],
"bin": [ "bin": [
"sake" "bin/sake"
], ],
"require": { "require": {
"php": "^8.3", "php": "^8.3",
@ -36,12 +36,14 @@
"psr/container": "^1.1 || ^2.0", "psr/container": "^1.1 || ^2.0",
"psr/http-message": "^1", "psr/http-message": "^1",
"sebastian/diff": "^6.0", "sebastian/diff": "^6.0",
"sensiolabs/ansi-to-html": "^1.2",
"silverstripe/config": "^3", "silverstripe/config": "^3",
"silverstripe/assets": "^3", "silverstripe/assets": "^3",
"silverstripe/vendor-plugin": "^2", "silverstripe/vendor-plugin": "^2",
"sminnee/callbacklist": "^0.1.1", "sminnee/callbacklist": "^0.1.1",
"symfony/cache": "^7.0", "symfony/cache": "^7.0",
"symfony/config": "^7.0", "symfony/config": "^7.0",
"symfony/console": "^7.0",
"symfony/dom-crawler": "^7.0", "symfony/dom-crawler": "^7.0",
"symfony/filesystem": "^7.0", "symfony/filesystem": "^7.0",
"symfony/http-foundation": "^7.0", "symfony/http-foundation": "^7.0",
@ -84,6 +86,8 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"SilverStripe\\Cli\\": "src/Cli/",
"SilverStripe\\Cli\\Tests\\": "tests/php/Cli/",
"SilverStripe\\Control\\": "src/Control/", "SilverStripe\\Control\\": "src/Control/",
"SilverStripe\\Control\\Tests\\": "tests/php/Control/", "SilverStripe\\Control\\Tests\\": "tests/php/Control/",
"SilverStripe\\Core\\": "src/Core/", "SilverStripe\\Core\\": "src/Core/",
@ -100,6 +104,8 @@
"SilverStripe\\Model\\Tests\\": "tests/php/Model/", "SilverStripe\\Model\\Tests\\": "tests/php/Model/",
"SilverStripe\\ORM\\": "src/ORM/", "SilverStripe\\ORM\\": "src/ORM/",
"SilverStripe\\ORM\\Tests\\": "tests/php/ORM/", "SilverStripe\\ORM\\Tests\\": "tests/php/ORM/",
"SilverStripe\\PolyExecution\\": "src/PolyExecution/",
"SilverStripe\\PolyExecution\\Tests\\": "tests/php/PolyExecution/",
"SilverStripe\\Security\\": "src/Security/", "SilverStripe\\Security\\": "src/Security/",
"SilverStripe\\Security\\Tests\\": "tests/php/Security/", "SilverStripe\\Security\\Tests\\": "tests/php/Security/",
"SilverStripe\\View\\": "src/View/", "SilverStripe\\View\\": "src/View/",

119
sake
View File

@ -1,119 +0,0 @@
#!/usr/bin/env bash
# Check for an argument
if [ ${1:-""} = "" ]; then
echo "SilverStripe Sake
Usage: $0 (command-url) (params)
Executes a SilverStripe command"
exit 1
fi
command -v which >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Error: sake requires the 'which' command to operate." >&2
exit 1
fi
# find the silverstripe installation, looking first at sake
# bin location, but falling back to current directory
sakedir=`dirname $0`
directory="$PWD"
if [ -f "$sakedir/cli-script.php" ]; then
# Calling sake from vendor/silverstripe/framework/sake
framework="$sakedir"
base="$sakedir/../../.."
elif [ -f "$sakedir/../silverstripe/framework/cli-script.php" ]; then
# Calling sake from vendor/bin/sake
framework="$sakedir/../silverstripe/framework"
base="$sakedir/../.."
elif [ -f "$directory/vendor/silverstripe/framework/cli-script.php" ]; then
# Vendor framework (from base) if sake installed globally
framework="$directory/vendor/silverstripe/framework"
base=.
elif [ -f "$directory/framework/cli-script.php" ]; then
# Legacy directory (from base) if sake installed globally
framework="$directory/framework"
base=.
else
echo "Can't find cli-script.php in $sakedir"
exit 1
fi
# Find the PHP binary
for candidatephp in php php5; do
if [ "`which $candidatephp 2>/dev/null`" -a -f "`which $candidatephp 2>/dev/null`" ]; then
php=`which $candidatephp 2>/dev/null`
break
fi
done
if [ "$php" = "" ]; then
echo "Can't find any php binary"
exit 2
fi
################################################################################################
## Installation to /usr/bin
if [ "$1" = "installsake" ]; then
echo "Installing sake to /usr/local/bin..."
rm -rf /usr/local/bin/sake
cp $0 /usr/local/bin
exit 0
fi
################################################################################################
## Process control
if [ "$1" = "-start" ]; then
if [ "`which daemon`" = "" ]; then
echo "You need to install the 'daemon' tool. In debian, go 'sudo apt-get install daemon'"
exit 1
fi
if [ ! -f $base/$2.pid ]; then
echo "Starting service $2 $3"
touch $base/$2.pid
pidfile=`realpath $base/$2.pid`
outlog=$base/$2.log
errlog=$base/$2.err
echo "Logging to $outlog"
sake=`realpath $0`
base=`realpath $base`
# if third argument is not explicitly given, copy from second argument
if [ "$3" = "" ]; then
url=$2
else
url=$3
fi
processname=$2
daemon -n $processname -r -D $base --pidfile=$pidfile --stdout=$outlog --stderr=$errlog $sake $url
else
echo "Service $2 seems to already be running"
fi
exit 0
fi
if [ "$1" = "-stop" ]; then
pidfile=$base/$2.pid
if [ -f $pidfile ]; then
echo "Stopping service $2"
kill -KILL `cat $pidfile`
unlink $pidfile
else
echo "Service $2 doesn't seem to be running."
fi
exit 0
fi
################################################################################################
## Basic execution
"$php" "$framework/cli-script.php" "${@}"

View File

@ -0,0 +1,63 @@
<?php
namespace SilverStripe\Cli\Command;
use SilverStripe\Control\CLIRequestBuilder;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Command that simulates an HTTP request to the Silverstripe App based on CLI input.
*/
#[AsCommand(name: 'navigate', description: 'Navigate to a URL on your site via a simulated HTTP request')]
class NavigateCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Convert input into HTTP request.
// Use the kernel we already booted for consistency and performance reasons
$app = new HTTPApplication(Injector::inst()->get(Kernel::class));
$request = CLIRequestBuilder::createFromInput($input);
// Handle request and output resonse body
$response = $app->handle($request);
$output->writeln($response->getBody(), OutputInterface::OUTPUT_RAW);
// Transform HTTP status code into sensible exit code
$responseCode = $response->getStatusCode();
$output->writeln("<options=bold>RESPONSE STATUS CODE WAS {$responseCode}</>", OutputInterface::VERBOSITY_VERBOSE);
// We can't use the response code for unsuccessful requests directly as the exit code
// because symfony gives us an exit code ceiling of 255. So just use the regular constants.
return match (true) {
($responseCode >= 200 && $responseCode < 400) => Command::SUCCESS,
($responseCode >= 400 && $responseCode < 500) => Command::INVALID,
default => Command::FAILURE,
};
}
protected function configure(): void
{
$this->setHelp(<<<HELP
Use verbose mode to see the HTTP response status code.
The <info>get-vars</> arg can either be separated GET variables, or a full query string
e.g: <comment>sake navigate about-us/team q=test arrayval[]=value1 arrayval[]=value2</>
e.g: <comment>sake navigate about-us/team q=test<info>&</info>arrayval[]=value1<info>&</info>arrayval[]=value2</>
HELP);
$this->addArgument(
'path',
InputArgument::REQUIRED,
'Relative path to navigate to (e.g: <info>about-us/team</>). Can optionally start with a "/"'
);
$this->addArgument(
'get-vars',
InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
'Optional GET variables or a query string'
);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace SilverStripe\Cli\Command;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Wraps a PolyCommand for use in CLI.
*/
class PolyCommandCliWrapper extends Command
{
use Injectable;
private PolyCommand $command;
public function __construct(PolyCommand $command, string $alias = '')
{
$this->command = $command;
parent::__construct($command->getName());
if ($alias) {
$this->setAliases([$alias]);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$polyOutput = PolyOutput::create(
PolyOutput::FORMAT_ANSI,
$output->getVerbosity(),
$output->isDecorated(),
$output
);
return $this->command->run($input, $polyOutput);
}
protected function configure(): void
{
$this->setDescription($this->command::getDescription());
$this->setDefinition(new InputDefinition($this->command->getOptions()));
$this->setHelp($this->command->getHelp());
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace SilverStripe\Cli\Command;
use SilverStripe\Cli\CommandLoader\DevTaskLoader;
use SilverStripe\Cli\Sake;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\ListCommand;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Command that runs `sake list tasks` under the hood to list all of the available tasks.
* Useful when you have too many tasks to show in the main commands list.
*
* Note the description is blue so it stands out, to avoid developers missing it if they add a new
* task and suddenly they don't see the tasks in their main commands list anymore.
*/
#[AsCommand(name: 'tasks', description: '<fg=blue>See a list of build tasks to run</>')]
class TasksCommand extends Command
{
private Command $listCommand;
public function __construct()
{
parent::__construct();
$this->listCommand = new ListCommand();
$this->setDefinition($this->listCommand->getDefinition());
}
public function setApplication(?Application $application): void
{
$this->listCommand->setApplication($application);
parent::setApplication($application);
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->getCompletionType() === CompletionInput::TYPE_ARGUMENT_VALUE) {
// Make this command transparent to completion, so we can `sake tasks<tab>` and see all tasks
if ($input->getCompletionValue() === $this->getName()) {
$taskLoader = DevTaskLoader::create();
$suggestions->suggestValues($taskLoader->getNames());
}
// Don't allow completion for the namespace argument, because we will override their value anyway
return;
}
// Still allow completion for options e.g. --format
parent::complete($input, $suggestions);
}
public function isHidden(): bool
{
return !$this->getApplication()->shouldHideTasks();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Explicitly don't allow any namespace other than tasks
$input->setArgument('namespace', 'tasks');
// We have to call execute() here instead of run(), because run() would re-bind
// the input which would throw away the namespace argument.
$this->getApplication()?->setIgnoreTaskLimit(true);
$exitCode = $this->listCommand->execute($input, $output);
$this->getApplication()?->setIgnoreTaskLimit(false);
return $exitCode;
}
protected function configure()
{
$sakeClass = Sake::class;
$this->setHelp(<<<HELP
If you want to display the tasks in the main commands list, update the <info>$sakeClass.max_tasks_to_display</info> configuration.
<comment>
$sakeClass:
max_tasks_to_display: 50
</>
Set the value to 0 to always display tasks in the main command list regardless of how many there are.
HELP);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace SilverStripe\Cli\CommandLoader;
use SilverStripe\Core\Injector\Injectable;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Exception\CommandNotFoundException;
/**
* Command loader that holds more command loaders
*/
class ArrayCommandLoader implements CommandLoaderInterface
{
use Injectable;
/**
* @var array<CommandLoaderInterface>
*/
private array $loaders = [];
public function __construct(array $loaders)
{
$this->loaders = $loaders;
}
public function get(string $name): Command
{
foreach ($this->loaders as $loader) {
if ($loader->has($name)) {
return $loader->get($name);
}
}
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
}
public function has(string $name): bool
{
foreach ($this->loaders as $loader) {
if ($loader->has($name)) {
return true;
}
}
return false;
}
public function getNames(): array
{
$names = [];
foreach ($this->loaders as $loader) {
$names = array_merge($names, $loader->getNames());
}
return array_unique($names);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace SilverStripe\Cli\CommandLoader;
use SilverStripe\Dev\DevelopmentAdmin;
/**
* Get commands for the controllers registered in DevelopmentAdmin
*/
class DevCommandLoader extends PolyCommandLoader
{
protected function getCommands(): array
{
return DevelopmentAdmin::singleton()->getCommands();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace SilverStripe\Cli\CommandLoader;
use SilverStripe\Dev\TaskRunner;
/**
* Get commands for the dev:tasks namespace
*/
class DevTaskLoader extends PolyCommandLoader
{
protected function getCommands(): array
{
$commands = [];
foreach (TaskRunner::singleton()->getTaskList() as $name => $class) {
$singleton = $class::singleton();
// Don't add disabled tasks.
// No need to check canRunInCli() - the superclass will take care of that.
if ($singleton->isEnabled()) {
$commands['dev/' . str_replace('tasks:', 'tasks/', $name)] = $class;
}
};
return $commands;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace SilverStripe\Cli\CommandLoader;
use LogicException;
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
use SilverStripe\Cli\Sake;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\PolyExecution\PolyCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Exception\CommandNotFoundException;
/**
* Command loader that loads commands from the injector if they were registered with Sake.
*/
class InjectorCommandLoader implements CommandLoaderInterface
{
private array $commands = [];
private array $commandAliases = [];
public function get(string $name): Command
{
if (!$this->has($name)) {
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
}
return $this->commands[$name] ?? $this->commandAliases[$name];
}
public function has(string $name): bool
{
$this->initCommands();
return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
}
public function getNames(): array
{
$this->initCommands();
return array_keys($this->commands);
}
private function initCommands(): void
{
if (empty($this->commands)) {
$commandClasses = Sake::config()->get('commands');
foreach ($commandClasses as $class) {
if ($class === null) {
// Allow unsetting commands via yaml
continue;
}
$command = Injector::inst()->create($class);
// Wrap poly commands (if they're allowed to be run)
if ($command instanceof PolyCommand) {
if (!$command::canRunInCli()) {
continue;
}
$command = PolyCommandCliWrapper::create($command);
}
/** @var Command $command */
if (!$command->getName()) {
throw new LogicException(sprintf(
'The command defined in "%s" cannot have an empty name.',
get_debug_type($command)
));
}
$this->commands[$command->getName()] = $command;
foreach ($command->getAliases() as $alias) {
$this->commandAliases[$alias] = $command;
}
}
}
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace SilverStripe\Cli\CommandLoader;
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\PolyExecution\PolyCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Exception\CommandNotFoundException;
/**
* Get commands for PolyCommand classes
*/
abstract class PolyCommandLoader implements CommandLoaderInterface
{
use Injectable;
private array $commands = [];
private array $commandAliases = [];
public function get(string $name): Command
{
if (!$this->has($name)) {
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
}
$info = $this->commands[$name] ?? $this->commandAliases[$name];
/** @var PolyCommand $commandClass */
$commandClass = $info['class'];
$polyCommand = $commandClass::create();
return PolyCommandCliWrapper::create($polyCommand, $info['alias']);
}
public function has(string $name): bool
{
$this->initCommands();
return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
}
public function getNames(): array
{
$this->initCommands();
return array_keys($this->commands);
}
/**
* Get the array of PolyCommand objects this loader is responsible for.
* Do not filter canRunInCli().
*
* @return array<string, PolyCommand> Associative array of commands.
* The key is an alias, or if no alias exists, the name of the command.
*/
abstract protected function getCommands(): array;
/**
* Limit to only the commands that are allowed to be run in CLI.
*/
private function initCommands(): void
{
if (empty($this->commands)) {
$commands = $this->getCommands();
/** @var PolyCommand $class */
foreach ($commands as $alias => $class) {
if (!$class::canRunInCli()) {
continue;
}
$commandName = $class::getName();
$hasAlias = $alias !== $commandName;
$this->commands[$commandName] = [
'class' => $class,
'alias' => $hasAlias ? $alias : null,
];
if ($hasAlias) {
$this->commandAliases[$alias] = [
'class' => $class,
'alias' => $alias,
];
}
}
}
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace SilverStripe\Cli;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Core\ArrayLib;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
/**
* Represents an input coming from the CLI arguments - but converts legacy arg-style parameters to flags.
*
* e.g. `ddev dev:build flush=1` is converted to `ddev dev:build --flush`.
* Doesn't convert anything that isn't explicitly an InputOption in the relevant InputDefinition.
* Removes the parameters from the input args (e.g. doesn't become `ddev dev:build flush=1 --flush`).
*
* @deprecated 6.0.0 Use Symfony\Component\Console\Input\ArgvInput instead.
*/
class LegacyParamArgvInput extends ArgvInput
{
/**
* Input from the command line.
*
* We need a separate copy of this because the one held by the parent class is private
* and not exposed until symfony/console 7.1
*/
private array $argv;
public function __construct(?array $argv = null, ?InputDefinition $definition = null)
{
Deprecation::withNoReplacement(
fn() => Deprecation::notice('6.0.0', 'Use ' . ArgvInput::class . ' instead', Deprecation::SCOPE_CLASS)
);
$argv ??= $_SERVER['argv'] ?? [];
parent::__construct($argv, $definition);
// Strip the application name, matching what the parent class did with its copy
array_shift($argv);
$this->argv = $argv;
}
public function hasParameterOption(string|array $values, bool $onlyParams = false): bool
{
if (parent::hasParameterOption($values, $onlyParams)) {
return true;
}
return $this->hasLegacyParameterOption($values);
}
public function getParameterOption(string|array $values, string|bool|int|float|array|null $default = false, bool $onlyParams = false): mixed
{
if (parent::hasParameterOption($values, $onlyParams)) {
return parent::getParameterOption($values, $default, $onlyParams);
}
return $this->getLegacyParameterOption($values, $default);
}
/**
* Binds the current Input instance with the given arguments and options.
*
* Also converts any arg-style params into true flags, based on the options defined.
*/
public function bind(InputDefinition $definition): void
{
// Convert arg-style params into flags
$tokens = $this->argv;
$convertedFlags = [];
$hadLegacyParams = false;
foreach ($definition->getOptions() as $option) {
$flagName = '--' . $option->getName();
// Check if there is a legacy param first. This saves us from accidentally getting
// values that come after the end of options (--) signal
if (!$this->hasLegacyParameterOption($flagName)) {
continue;
}
// Get the value from the legacy param
$value = $this->getLegacyParameterOption($flagName);
if ($value && !$this->hasLegacyParameterOption($flagName . '=' . $value)) {
// symfony/console will try to get the value from the next argument if the current argument ends with `=`
// We don't want to count that as the value, so double check it.
$value = null;
} elseif ($option->acceptValue()) {
if ($value === '' || $value === null) {
$convertedFlags[] = $flagName;
} else {
$convertedFlags[] = $flagName . '=' . $value;
}
} else {
// If the option doesn't accept a value, only add the flag if the value is true.
$valueAsBool = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
if ($valueAsBool) {
$convertedFlags[] = $flagName;
}
}
$hadLegacyParams = true;
// Remove the legacy param from the token set
foreach ($tokens as $i => $token) {
if (str_starts_with($token, $option->getName() . '=')) {
unset($tokens[$i]);
break;
}
}
}
if (!empty($convertedFlags)) {
// Make sure it's before the end of options (--) signal if there is one.
$tokens = ArrayLib::insertBefore($tokens, $convertedFlags, '--', true, true);
}
if ($hadLegacyParams) {
// We only want the warning once regardless of how many params there are.
Deprecation::notice(
'6.0.0',
'Using `param=value` style flags is deprecated. Use `--flag=value` CLI flags instead.',
Deprecation::SCOPE_GLOBAL
);
// Set the new tokens so the parent class can operate on them.
// Specifically skip setting $this->argv in case someone decides to bind to a different
// input definition afterwards for whatever reason.
parent::setTokens($tokens);
}
parent::bind($definition);
}
protected function setTokens(array $tokens): void
{
$this->argv = $tokens;
parent::setTokens($tokens);
}
private function hasLegacyParameterOption(string|array $values): bool
{
$values = $this->getLegacyParamsForFlags((array) $values);
if (empty($values)) {
return false;
}
return parent::hasParameterOption($values, true);
}
public function getLegacyParameterOption(string|array $values, string|bool|int|float|array|null $default = false): mixed
{
$values = $this->getLegacyParamsForFlags((array) $values);
if (empty($values)) {
return $default;
}
return parent::getParameterOption($values, $default, true);
}
/**
* Given a set of flag names, return what they would be called in the legacy format.
*/
private function getLegacyParamsForFlags(array $flags): array
{
$legacyParams = [];
foreach ($flags as $flag) {
// Only allow full flags e.g. `--flush`, not shortcuts like `-f`
if (!str_starts_with($flag, '--')) {
continue;
}
// Convert to legacy format, e.g. `--flush` becomes `flush=`
// but if there's already an equals e.g. `--flush=1` keep it (`flush=1`)
// because the developer is checking for a specific value set to the flag.
$flag = ltrim($flag, '-');
if (!str_contains($flag, '=')) {
$flag .= '=';
}
$legacyParams[] = $flag;
}
return $legacyParams;
}
}

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

@ -0,0 +1,282 @@
<?php
namespace SilverStripe\Cli;
use SilverStripe\Cli\Command\NavigateCommand;
use SilverStripe\Cli\Command\TasksCommand;
use SilverStripe\Cli\CommandLoader\ArrayCommandLoader;
use SilverStripe\Cli\CommandLoader\DevCommandLoader;
use SilverStripe\Cli\CommandLoader\DevTaskLoader;
use SilverStripe\Cli\CommandLoader\InjectorCommandLoader;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\CoreKernel;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Manifest\VersionProvider;
use SilverStripe\Dev\Deprecation;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\DumpCompletionCommand;
use Symfony\Component\Console\Command\HelpCommand;
use Symfony\Component\Console\Command\ListCommand;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* CLI application for running commands against a Silverstripe CMS project
* Boots up a full kernel, using the same configuration and database the web server uses.
*/
class Sake extends Application
{
use Configurable;
/**
* Commands that can be run. These commands will be instantiated via the Injector.
* Does not include commands in the dev/ namespace (see command_loaders).
*
* @var array<Command>
*/
private static array $commands = [
'navigate' => NavigateCommand::class,
];
/**
* Command loaders for dynamically adding commands to sake.
* These loaders will be instantiated via the Injector.
*
* @var array<CommandLoaderInterface>
*/
private static array $command_loaders = [
'dev-commands' => DevCommandLoader::class,
'dev-tasks' => DevTaskLoader::class,
'injected' => InjectorCommandLoader::class,
];
/**
* Maximum number of tasks to display in the main command list.
*
* If there are more tasks than this, they will be hidden from the main command list - running `sake tasks` will show them.
* Set to 0 to always show tasks in the main list.
*/
private static int $max_tasks_to_display = 20;
/**
* Set this to true to hide the "completion" command.
* Useful if you never intend to set up shell completion, or if you've already done so.
*/
private static bool $hide_completion_command = false;
private ?Kernel $kernel;
private bool $ignoreTaskLimit = false;
public function __construct(?Kernel $kernel = null)
{
$this->kernel = $kernel;
parent::__construct('Silverstripe Sake');
}
public function getVersion(): string
{
return VersionProvider::singleton()->getVersion();
}
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
$input = $input ?? new LegacyParamArgvInput();
$flush = $input->hasParameterOption('--flush', true) || $input->getFirstArgument() === 'flush';
$bootDatabase = !$input->hasParameterOption('--no-database', true);
$managingKernel = !$this->kernel;
if ($managingKernel) {
// Instantiate the kernel if we weren't given a pre-loaded one
$this->kernel = new CoreKernel(BASE_PATH);
}
try {
// Boot if not already booted
if (!$this->kernel->getBooted()) {
if ($this->kernel instanceof CoreKernel) {
$this->kernel->setBootDatabase($bootDatabase);
}
$this->kernel->boot($flush);
}
// Allow developers to hook into symfony/console events
/** @var EventDispatcherInterface $dispatcher */
$dispatcher = Injector::inst()->get(EventDispatcherInterface::class . '.sake');
$this->setDispatcher($dispatcher);
// Add commands and finally execute
$this->addCommandLoadersFromConfig();
return parent::run($input, $output);
} finally {
// If we instantiated the kernel, we're also responsible for shutting it down.
if ($managingKernel) {
$this->kernel->shutdown();
}
}
}
public function all(?string $namespace = null): array
{
$commands = parent::all($namespace);
// If number of tasks is greater than the limit, hide them from the main comands list.
$maxTasks = Sake::config()->get('max_tasks_to_display');
if (!$this->ignoreTaskLimit && $maxTasks > 0 && $namespace === null) {
$tasks = [];
// Find all commands in the tasks: namespace
foreach (array_keys($commands) as $name) {
if (str_starts_with($name, 'tasks:') || str_starts_with($name, 'dev/tasks/')) {
$tasks[] = $name;
}
}
if (count($tasks) > $maxTasks) {
// Hide the commands
foreach ($tasks as $name) {
unset($commands[$name]);
}
}
}
return $commands;
}
/**
* Check whether tasks should currently be hidden from the main command list
*/
public function shouldHideTasks(): bool
{
$maxLimit = Sake::config()->get('max_tasks_to_display');
return $maxLimit > 0 && count($this->all('tasks')) > $maxLimit;
}
/**
* Set whether the task limit should be ignored.
* Used by the tasks command and completion to allow listing tasks when there's too many of them
* to list in the main command list.
*/
public function setIgnoreTaskLimit(bool $ignore): void
{
$this->ignoreTaskLimit = $ignore;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
// Make sure tasks can always be shown in completion even if there's too many of them to list
// in the main command list.
$this->setIgnoreTaskLimit(true);
// Remove legacy dev/* aliases from completion suggestions, but only
// if the user isn't explicitly looking for them (i.e. hasn't typed anything yet)
if (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
&& $input->getCompletionName() === 'command'
&& $input->getCompletionValue() === ''
) {
foreach ($this->all() as $name => $command) {
// skip hidden commands
// skip aliased commands as they get added below
if ($command->isHidden() || $command->getName() !== $name) {
continue;
}
$suggestions->suggestValue(new Suggestion($command->getName(), $command->getDescription()));
foreach ($command->getAliases() as $name) {
// Skip legacy dev aliases
if (str_starts_with($name, 'dev/')) {
continue;
}
$suggestions->suggestValue(new Suggestion($name, $command->getDescription()));
}
}
return;
} else {
// For everything else, use the superclass
parent::complete($input, $suggestions);
}
$this->setIgnoreTaskLimit(false);
}
protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int
{
$name = $command->getName() ?? '';
$nameUsedAs = $input->getFirstArgument() ?? '';
if (str_starts_with($nameUsedAs, 'dev/')) {
Deprecation::notice(
'6.0.0',
"Using the command with the name '$nameUsedAs' is deprecated. Use '$name' instead",
Deprecation::SCOPE_GLOBAL
);
}
return parent::doRunCommand($command, $input, $output);
}
protected function getDefaultInputDefinition(): InputDefinition
{
$definition = parent::getDefaultInputDefinition();
$definition->addOptions([
new InputOption('no-database', null, InputOption::VALUE_NONE, 'Run the command without connecting to the database'),
new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'),
]);
return $definition;
}
protected function getDefaultCommands(): array
{
$commands = parent::getDefaultCommands();
// Hide commands that are just cluttering up the list
$toHide = [
// List is the default command, and you have to have used it to see it anyway.
ListCommand::class,
// The --help flag is more common and is already displayed.
HelpCommand::class,
];
// Completion is just clutter if you've already used it or aren't going to.
if (Sake::config()->get('hide_completion_command')) {
$toHide[] = DumpCompletionCommand::class;
}
foreach ($commands as $command) {
if (in_array(get_class($command), $toHide)) {
$command->setHidden(true);
}
}
$commands[] = $this->createFlushCommand();
$commands[] = new TasksCommand();
return $commands;
}
private function addCommandLoadersFromConfig(): void
{
$loaderClasses = Sake::config()->get('command_loaders');
$loaders = [];
foreach ($loaderClasses as $class) {
if ($class === null) {
// Allow unsetting loaders via yaml
continue;
}
$loaders[] = Injector::inst()->create($class);
}
$this->setCommandLoader(ArrayCommandLoader::create($loaders));
}
/**
* Creates a dummy "flush" command for when you just want to flush without running another command.
*/
private function createFlushCommand(): Command
{
$command = new Command('flush');
$command->setDescription('Flush the cache (or use the <info>--flush</info> flag with any command)');
$command->setCode(function (InputInterface $input, OutputInterface $ouput) {
// Actual flushing happens in `run()` when booting the kernel, so there's nothing to do here.
$ouput->writeln('Cache flushed.');
return Command::SUCCESS;
});
return $command;
}
}

View File

@ -3,6 +3,7 @@
namespace SilverStripe\Control; namespace SilverStripe\Control;
use SilverStripe\Core\Environment; use SilverStripe\Core\Environment;
use Symfony\Component\Console\Input\InputInterface;
/** /**
* CLI specific request building logic * CLI specific request building logic
@ -33,7 +34,7 @@ class CLIRequestBuilder extends HTTPRequestBuilder
'HTTP_USER_AGENT' => 'CLI', 'HTTP_USER_AGENT' => 'CLI',
], $variables['_SERVER']); ], $variables['_SERVER']);
/** /*
* Process arguments and load them into the $_GET and $_REQUEST arrays * Process arguments and load them into the $_GET and $_REQUEST arrays
* For example, * For example,
* sake my/url somearg otherarg key=val --otherkey=val third=val&fourth=val * 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])) { if (isset($variables['_SERVER']['argv'][2])) {
$args = array_slice($variables['_SERVER']['argv'] ?? [], 2); $args = array_slice($variables['_SERVER']['argv'] ?? [], 2);
foreach ($args as $arg) { foreach ($args as $arg) {
if (strpos($arg ?? '', '=') == false) { if (strpos($arg ?? '', '=') === false) {
$variables['_GET']['args'][] = $arg; $variables['_GET']['args'][] = $arg;
} else { } else {
$newItems = []; $newItems = [];
parse_str((substr($arg ?? '', 0, 2) == '--') ? substr($arg, 2) : $arg, $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']); $_REQUEST = array_merge($_REQUEST, $variables['_GET']);
@ -64,7 +65,7 @@ class CLIRequestBuilder extends HTTPRequestBuilder
$variables['_GET']['url'] = $variables['_SERVER']['argv'][1]; $variables['_GET']['url'] = $variables['_SERVER']['argv'][1];
$variables['_SERVER']['REQUEST_URI'] = $variables['_SERVER']['argv'][1]; $variables['_SERVER']['REQUEST_URI'] = $variables['_SERVER']['argv'][1];
} }
// Set 'HTTPS' and 'SSL' flag for CLI depending on SS_BASE_URL scheme value. // Set 'HTTPS' and 'SSL' flag for CLI depending on SS_BASE_URL scheme value.
$scheme = parse_url(Environment::getEnv('SS_BASE_URL') ?? '', PHP_URL_SCHEME); $scheme = parse_url(Environment::getEnv('SS_BASE_URL') ?? '', PHP_URL_SCHEME);
if ($scheme == 'https') { if ($scheme == 'https') {
@ -80,9 +81,8 @@ class CLIRequestBuilder extends HTTPRequestBuilder
* @param array $variables * @param array $variables
* @param string $input * @param string $input
* @param string|null $url * @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); $request = parent::createFromVariables($variables, $input, $url);
// unset scheme so that SS_BASE_URL can provide `is_https` information if required // unset scheme so that SS_BASE_URL can provide `is_https` information if required
@ -93,4 +93,17 @@ class CLIRequestBuilder extends HTTPRequestBuilder
return $request; return $request;
} }
public static function createFromInput(InputInterface $input): HTTPRequest
{
$variables = [];
$variables['_SERVER']['argv'] = [
'sake',
$input->getArgument('path'),
...$input->getArgument('get-vars'),
];
$cleanVars = static::cleanEnvironment($variables);
Environment::setVariables($cleanVars);
return static::createFromVariables($cleanVars, []);
}
} }

View File

@ -1,58 +0,0 @@
<?php
namespace SilverStripe\Control;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
/**
* Base class invoked from CLI rather than the webserver (Cron jobs, handling email bounces).
* You can call subclasses of CliController directly, which will trigger a
* call to {@link process()} on every sub-subclass. For instance, calling
* "sake DailyTask" from the commandline will call {@link process()} on every subclass
* of DailyTask.
*
* @deprecated 5.4.0 Will be replaced with symfony/console commands
*/
abstract class CliController extends Controller
{
public function __construct()
{
parent::__construct();
Deprecation::notice('5.4.0', 'Will be replaced with symfony/console commands', Deprecation::SCOPE_CLASS);
}
private static $allowed_actions = [
'index'
];
protected function init()
{
parent::init();
// Unless called from the command line, all CliControllers need ADMIN privileges
if (!Director::is_cli() && !Permission::check("ADMIN")) {
Security::permissionFailure();
}
}
public function index()
{
foreach (ClassInfo::subclassesFor(static::class) as $subclass) {
echo $subclass . "\n";
/** @var CliController $task */
$task = Injector::inst()->create($subclass);
$task->doInit();
$task->process();
}
}
/**
* Overload this method to contain the task logic.
*/
public function process()
{
}
}

View File

@ -12,6 +12,7 @@ use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel; use SilverStripe\Core\Kernel;
use SilverStripe\Core\Path; use SilverStripe\Core\Path;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\Versioned\Versioned; use SilverStripe\Versioned\Versioned;
use SilverStripe\View\Requirements; use SilverStripe\View\Requirements;
use SilverStripe\View\Requirements_Backend; use SilverStripe\View\Requirements_Backend;
@ -345,6 +346,9 @@ class Director implements TemplateGlobalProvider
try { try {
/** @var RequestHandler $controllerObj */ /** @var RequestHandler $controllerObj */
$controllerObj = Injector::inst()->create($arguments['Controller']); $controllerObj = Injector::inst()->create($arguments['Controller']);
if ($controllerObj instanceof PolyCommand) {
$controllerObj = PolyCommandController::create($controllerObj);
}
return $controllerObj->handleRequest($request); return $controllerObj->handleRequest($request);
} catch (HTTPResponse_Exception $responseException) { } catch (HTTPResponse_Exception $responseException) {
return $responseException->getResponse(); return $responseException->getResponse();

View File

@ -1,39 +0,0 @@
<?php
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Kernel;
use SilverStripe\Dev\Deprecation;
/**
* Allows a bypass when the request has been run in CLI mode
*
* @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
*/
class CliBypass implements Bypass
{
public function __construct()
{
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be removed without equivalent functionality to replace it',
Deprecation::SCOPE_CLASS
);
});
}
/**
* Returns true if the current process is running in CLI mode
*
* @param HTTPRequest $request
*
* @return bool
*/
public function checkRequestForBypass(HTTPRequest $request)
{
return Director::is_cli();
}
}

View File

@ -4,7 +4,6 @@ namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\DevelopmentAdmin; use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\Security\Permission; use SilverStripe\Security\Permission;
@ -25,7 +24,6 @@ use SilverStripe\Security\Permission;
*/ */
class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmationMiddleware class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmationMiddleware
{ {
/** /**
* Check whether the user has permissions to perform the target operation * Check whether the user has permissions to perform the target operation
* Otherwise we may want to skip the confirmation dialog. * Otherwise we may want to skip the confirmation dialog.
@ -43,21 +41,10 @@ class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmation
return false; return false;
} }
$registeredRoutes = DevelopmentAdmin::config()->get('registered_controllers'); $url = rtrim($request->getURL(), '/');
while (!isset($registeredRoutes[$action]) && strpos($action, '/') !== false) { $registeredRoutes = DevelopmentAdmin::singleton()->getLinks();
// Check for the parent route if a specific route isn't found // Permissions were already checked when generating the links list, so if
$action = substr($action, 0, strrpos($action, '/')); // it's in the list the user has access.
} return isset($registeredRoutes[$url]);
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;
} }
} }

View File

@ -22,7 +22,7 @@ use SilverStripe\Security\RandomGenerator;
* - isTest GET parameter * - isTest GET parameter
* - dev/build URL * - 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} * {@inheritdoc}
*/ */

View File

@ -0,0 +1,65 @@
<?php
namespace SilverStripe\Control;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\HttpRequestInput;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* Controller that allows routing HTTP requests to PolyCommands
*
* This controller is automatically wrapped around any PolyCommand
* that is added to the regular routing configuration.
*/
class PolyCommandController extends Controller
{
private PolyCommand $command;
public function __construct(PolyCommand $polyCommand)
{
$this->command = $polyCommand;
parent::__construct();
}
protected function init()
{
parent::init();
if (!$this->command::canRunInBrowser()) {
$this->httpError(404);
}
}
public function index(HTTPRequest $request): HTTPResponse
{
$response = $this->getResponse();
try {
$input = HttpRequestInput::create($request, $this->command->getOptions());
} catch (InvalidOptionException|InvalidArgumentException $e) {
$response->setBody($e->getMessage());
$response->setStatusCode(400);
$this->afterHandleRequest();
return $this->getResponse();
}
$buffer = new BufferedOutput();
$output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true, $buffer);
$exitCode = $this->command->run($input, $output);
$response->setBody($buffer->fetch());
$responseCode = match (true) {
$exitCode === Command::SUCCESS => 200,
$exitCode === Command::FAILURE => 500,
$exitCode === Command::INVALID => 400,
// If someone's using an unexpected exit code, we shouldn't guess what they meant,
// just assume they intentionally set it to something meaningful.
default => $exitCode,
};
$response->setStatusCode($responseCode);
return $this->getResponse();
}
}

View File

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

View File

@ -1,70 +0,0 @@
<?php
namespace SilverStripe\Core;
use Exception;
use SilverStripe\Dev\Deprecation;
/**
* Boot a kernel without requiring a database connection.
* This is a workaround for the lack of composition in the boot stages
* of CoreKernel, as well as for the framework's misguided assumptions
* around the availability of a database for every execution path.
*
* @internal
* @deprecated 5.4.0 Use SilverStripe\Core\CoreKernel::setBootDatabase() instead
*/
class DatabaselessKernel extends BaseKernel
{
/**
* Indicates whether the Kernel has been flushed on boot
* Null before boot
*/
private ?bool $flush = null;
/**
* Allows disabling of the configured error handling.
* This can be useful to ensure the execution context (e.g. composer)
* can consistently use its own error handling.
*
* @var boolean
*/
protected $bootErrorHandling = true;
public function __construct($basePath)
{
parent::__construct($basePath);
Deprecation::notice(
'5.4.0',
'Use ' . CoreKernel::class . '::setBootDatabase() instead',
Deprecation::SCOPE_CLASS
);
}
public function setBootErrorHandling(bool $bool)
{
$this->bootErrorHandling = $bool;
return $this;
}
/**
* @param false $flush
* @throws Exception
*/
public function boot($flush = false)
{
$this->flush = $flush;
$this->bootPHP();
$this->bootManifests($flush);
$this->bootErrorHandling();
$this->bootConfigs();
$this->setBooted(true);
}
public function isFlushed(): ?bool
{
return $this->flush;
}
}

View File

@ -139,4 +139,9 @@ interface Kernel
* @return bool|null null if the kernel hasn't been booted yet * @return bool|null null if the kernel hasn't been booted yet
*/ */
public function isFlushed(): ?bool; public function isFlushed(): ?bool;
/**
* Returns whether the kernel has been booted
*/
public function getBooted(): bool;
} }

View File

@ -549,7 +549,7 @@ class ClassManifest
$finder = new ManifestFileFinder(); $finder = new ManifestFileFinder();
$finder->setOptions([ $finder->setOptions([
'name_regex' => '/^[^_].*\\.php$/', 'name_regex' => '/^[^_].*\\.php$/',
'ignore_files' => ['index.php', 'cli-script.php'], 'ignore_files' => ['index.php', 'bin/sake.php'],
'ignore_tests' => !$includeTests, 'ignore_tests' => !$includeTests,
'file_callback' => function ($basename, $pathname, $depth) use ($includeTests) { 'file_callback' => function ($basename, $pathname, $depth) use ($includeTests) {
$this->handleFile($basename, $pathname, $includeTests); $this->handleFile($basename, $pathname, $includeTests);

View File

@ -2,103 +2,107 @@
namespace SilverStripe\Dev; namespace SilverStripe\Dev;
use SilverStripe\Control\HTTPRequest; use LogicException;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extensible; 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 * A task that can be run either from the CLI or via an HTTP request.
* run a chunk of code when called. * This is often used for post-deployment tasks, e.g. migrating data to fit a new schema.
*
* To disable the task (in the case of potentially destructive updates or deletes), declare
* the $Disabled property on the subclass.
*/ */
abstract class BuildTask abstract class BuildTask extends PolyCommand
{ {
use Injectable;
use Configurable;
use Extensible; 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() public function __construct()
{ {
} }
/** /**
* Set a custom url segment (to follow dev/tasks/) * The code for running this task.
* *
* @config * Output should be agnostic - do not include explicit HTML in the output unless there is no API
* @var string * on `PolyOutput` for what you want to do (in which case use the writeForHtml() method).
* @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
* *
* @param HTTPRequest $request * Use symfony/console ANSI formatting to style the output.
* @return void * 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;
/** public function run(InputInterface $input, PolyOutput $output): int
* @return bool
*/
public function isEnabled()
{ {
$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) { $before = DBDatetime::now();
return $this->enabled; $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;
} }
/** public function isEnabled(): bool
* @return string
*/
public function getTitle()
{ {
return $this->title ?: static::class; return $this->config()->get('is_enabled');
} }
/** public function getTitle(): string
* @return string HTML formatted description
* @deprecated 5.4.0 Will be replaced with a static method with the same name
*/
public function getDescription()
{ {
Deprecation::withNoReplacement( return $this->title ?? static::class;
fn() => Deprecation::notice('5.4.0', 'Will be replaced with a static method with the same name') }
);
return $this->description; public static function getName(): string
{
return 'tasks:' . static::getNameWithoutNamespace();
}
public static function getNameWithoutNamespace(): string
{
$name = parent::getName() ?: str_replace('\\', '-', static::class);
// Don't allow `:` or `/` because it would affect routing and CLI namespacing
if (str_contains($name, ':') || str_contains($name, '/')) {
throw new LogicException('commandName must not contain `:` or `/`. Got ' . $name);
}
return $name;
} }
} }

View File

@ -0,0 +1,109 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
/**
* Command to audit the configuration.
* Can be run either via an HTTP request or the CLI.
*/
class ConfigAudit extends DevCommand
{
protected static string $commandName = 'config:audit';
protected static string $description = 'Find configuration properties that are not defined (or inherited) by their respective classes';
private static array $permissions_for_browser_execution = [
'CAN_DEV_CONFIG',
];
public function getTitle(): string
{
return 'Configuration';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$body = '';
$missing = [];
foreach ($this->arrayKeysRecursive(Config::inst()->getAll(), 2) as $className => $props) {
$props = array_keys($props ?? []);
if (!count($props ?? [])) {
// We can skip this entry
continue;
}
if ($className == strtolower(Injector::class)) {
// We don't want to check the injector config
continue;
}
foreach ($props as $prop) {
$defined = false;
// Check ancestry (private properties don't inherit natively)
foreach (ClassInfo::ancestry($className) as $cn) {
if (property_exists($cn, $prop ?? '')) {
$defined = true;
break;
}
}
if ($defined) {
// No need to record this property
continue;
}
$missing[] = sprintf("%s::$%s\n", $className, $prop);
}
}
$body = count($missing ?? [])
? implode("\n", $missing)
: "All configured properties are defined\n";
$output->writeForHtml('<pre>');
$output->write($body);
$output->writeForHtml('</pre>');
return Command::SUCCESS;
}
protected function getHeading(): string
{
return 'Missing configuration property definitions';
}
/**
* Returns all the keys of a multi-dimensional array while maintining any nested structure.
* Does not include keys where the values are not arrays, so not suitable as a generic method.
*/
private function arrayKeysRecursive(
array $array,
int $maxdepth = 20,
int $depth = 0,
array $arrayKeys = []
): array {
if ($depth < $maxdepth) {
$depth++;
$keys = array_keys($array ?? []);
foreach ($keys as $key) {
if (!is_array($array[$key])) {
continue;
}
$arrayKeys[$key] = $this->arrayKeysRecursive($array[$key], $maxdepth, $depth);
}
}
return $arrayKeys;
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\Security\PermissionProvider;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Yaml\Yaml;
/**
* Command to dump the configuration.
* Can be run either via an HTTP request or the CLI.
*/
class ConfigDump extends DevCommand implements PermissionProvider
{
protected static string $commandName = 'config:dump';
protected static string $description = 'View the current config, useful for debugging';
private static array $permissions_for_browser_execution = [
'CAN_DEV_CONFIG',
];
public function getTitle(): string
{
return 'Configuration';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$output->writeForHtml('<pre>');
$output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
$output->writeForHtml('</pre>');
return Command::SUCCESS;
}
protected function getHeading(): string
{
return 'Config manifest';
}
public function providePermissions(): array
{
return [
'CAN_DEV_CONFIG' => [
'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
'category' => DevelopmentAdmin::permissionsCategory(),
'sort' => 100
],
];
}
}

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

@ -0,0 +1,347 @@
<?php
namespace SilverStripe\Dev\Command;
use BadMethodCallException;
use SilverStripe\Control\Director;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ClassLoader;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\Connect\TableBuilder;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBClassName;
use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security;
use SilverStripe\Versioned\Versioned;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* Command to build the database.
* Can be run either via an HTTP request or the CLI.
*/
class DbBuild extends DevCommand implements PermissionProvider
{
use Extensible;
protected static string $commandName = 'db:build';
protected static string $description = 'Build/rebuild this environment. Run this whenever you have updated your project sources';
private static array $permissions_for_browser_execution = [
'CAN_DEV_BUILD',
];
/**
* Obsolete classname values that should be remapped while building the database.
* Map old FQCN to new FQCN, e.g
* 'App\\OldNamespace\\MyClass' => 'App\\NewNamespace\\MyClass'
*/
private static array $classname_value_remapping = [];
/**
* Config setting to enabled/disable the display of record counts on the build output
*/
private static bool $show_record_counts = true;
public function getTitle(): string
{
return 'Environment Builder';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
// The default time limit of 30 seconds is normally not enough
Environment::increaseTimeLimitTo(600);
// If this code is being run without a flush, we need to at least flush the class manifest
if (!$input->getOption('flush')) {
ClassLoader::inst()->getManifest()->regenerate(false);
}
$populate = !$input->getOption('no-populate');
if ($input->getOption('dont_populate')) {
$populate = false;
Deprecation::notice(
'6.0.0',
'`dont_populate` is deprecated. Use `no-populate` instead',
Deprecation::SCOPE_GLOBAL
);
}
$this->doBuild($output, $populate);
return Command::SUCCESS;
}
protected function getHeading(): string
{
$conn = DB::get_conn();
// Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
$dbType = substr(get_class($conn), 0, -8);
$dbVersion = $conn->getVersion();
$databaseName = $conn->getSelectedDatabase();
return sprintf('Building database %s using %s %s', $databaseName, $dbType, $dbVersion);
}
/**
* Updates the database schema, creating tables & fields as necessary.
*
* @param bool $populate Populate the database, as well as setting up its schema
*/
public function doBuild(PolyOutput $output, bool $populate = true, bool $testMode = false): void
{
$this->extend('onBeforeBuild', $output, $populate, $testMode);
if ($output->isQuiet()) {
DB::quiet();
}
// Set up the initial database
if (!DB::is_active()) {
$output->writeln(['<options=bold>Creating database</>', '']);
// Load parameters from existing configuration
$databaseConfig = DB::getConfig();
if (empty($databaseConfig)) {
throw new BadMethodCallException("No database configuration available");
}
// Check database name is given
if (empty($databaseConfig['database'])) {
throw new BadMethodCallException(
"No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
);
}
$database = $databaseConfig['database'];
// Establish connection
unset($databaseConfig['database']);
DB::connect($databaseConfig);
// Create database
DB::create_database($database);
}
// Build the database. Most of the hard work is handled by DataObject
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
array_shift($dataClasses);
$output->writeln(['<options=bold>Creating database tables</>', '']);
$output->startList(PolyOutput::LIST_UNORDERED);
$showRecordCounts = (bool) static::config()->get('show_record_counts');
// Initiate schema update
$dbSchema = DB::get_schema();
$tableBuilder = TableBuilder::singleton();
$tableBuilder->buildTables($dbSchema, $dataClasses, [], $output->isQuiet(), $testMode, $showRecordCounts);
ClassInfo::reset_db_cache();
$output->stopList();
if ($populate) {
$output->writeln(['<options=bold>Creating database records</>', '']);
$output->startList(PolyOutput::LIST_UNORDERED);
// Remap obsolete class names
$this->migrateClassNames();
// Require all default records
foreach ($dataClasses as $dataClass) {
// Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
// Test_ indicates that it's the data class is part of testing system
if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
$output->writeListItem($dataClass);
DataObject::singleton($dataClass)->requireDefaultRecords();
}
}
$output->stopList();
}
touch(static::getLastGeneratedFilePath());
$output->writeln(['<options=bold>Database build completed!</>', '']);
foreach ($dataClasses as $dataClass) {
DataObject::singleton($dataClass)->onAfterBuild();
}
ClassInfo::reset_db_cache();
$this->extend('onAfterBuild', $output, $populate, $testMode);
}
public function getOptions(): array
{
return [
new InputOption(
'no-populate',
null,
InputOption::VALUE_NONE,
'Don\'t run <info>requireDefaultRecords()</info> on the models when building.'
. 'This will build the table but not insert any records'
),
new InputOption(
'dont_populate',
null,
InputOption::VALUE_NONE,
'Deprecated - use <info>no-populate</info> instead'
)
];
}
public function providePermissions(): array
{
return [
'CAN_DEV_BUILD' => [
'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
'category' => DevelopmentAdmin::permissionsCategory(),
'sort' => 100
],
];
}
/**
* Given a base data class, a field name and a mapping of class replacements, look for obsolete
* values in the $dataClass's $fieldName column and replace it with $mapping
*
* @param string $dataClass The data class to look up
* @param string $fieldName The field name to look in for obsolete class names
* @param string[] $mapping Map of old to new classnames
*/
protected function updateLegacyClassNameField(string $dataClass, string $fieldName, array $mapping): void
{
$schema = DataObject::getSchema();
// Check first to ensure that the class has the specified field to update
if (!$schema->databaseField($dataClass, $fieldName, false)) {
return;
}
// Load a list of any records that have obsolete class names
$table = $schema->tableName($dataClass);
$currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
// Get all invalid classes for this field
$invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
if (!$invalidClasses) {
return;
}
$numberClasses = count($invalidClasses ?? []);
DB::alteration_message(
"Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
'obsolete'
);
// Build case assignment based on all intersected legacy classnames
$cases = [];
$params = [];
foreach ($invalidClasses as $invalidClass) {
$cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
$params[] = $invalidClass;
$params[] = $mapping[$invalidClass];
}
foreach ($this->getClassTables($dataClass) as $table) {
$casesSQL = implode(' ', $cases);
$sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
DB::prepared_query($sql, $params);
}
}
/**
* Get tables to update for this class
*/
protected function getClassTables(string $dataClass): iterable
{
$schema = DataObject::getSchema();
$table = $schema->tableName($dataClass);
// Base table
yield $table;
// Remap versioned table class name values as well
/** @var Versioned|DataObject $dataClass */
$dataClass = DataObject::singleton($dataClass);
if ($dataClass->hasExtension(Versioned::class)) {
if ($dataClass->hasStages()) {
yield "{$table}_Live";
}
yield "{$table}_Versions";
}
}
/**
* Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
* `ClassName` fields as well as polymorphic class name fields.
*
* @return array[]
*/
protected function getClassNameRemappingFields(): array
{
$dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
$schema = DataObject::getSchema();
$remapping = [];
foreach ($dataClasses as $className) {
$fieldSpecs = $schema->fieldSpecs($className);
foreach ($fieldSpecs as $fieldName => $fieldSpec) {
if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) {
$remapping[$className][] = $fieldName;
}
}
}
return $remapping;
}
/**
* Migrate all class names
*/
protected function migrateClassNames(): void
{
$remappingConfig = static::config()->get('classname_value_remapping');
$remappingFields = $this->getClassNameRemappingFields();
foreach ($remappingFields as $className => $fieldNames) {
foreach ($fieldNames as $fieldName) {
$this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
}
}
}
/**
* Returns the timestamp of the time that the database was last built
* or an empty string if we can't find that information.
*/
public static function lastBuilt(): string
{
$file = static::getLastGeneratedFilePath();
if (file_exists($file)) {
return filemtime($file);
}
return '';
}
public static function canRunInBrowser(): bool
{
// Must allow running in browser if DB hasn't been built yet or is broken
// or the permission checks will throw an error
return !Security::database_is_ready() || parent::canRunInBrowser();
}
private static function getLastGeneratedFilePath(): string
{
return TEMP_PATH
. DIRECTORY_SEPARATOR
. 'database-last-generated-'
. str_replace(['\\', '/', ':'], '.', Director::baseFolder());
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\Core\ClassInfo;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
/**
* Command to clean up the database.
* Can be run either via an HTTP request or the CLI.
*/
class DbCleanup extends DevCommand
{
protected static string $commandName = 'db:cleanup';
protected static string $description = 'Remove records that don\'t have corresponding rows in their parent class tables';
private static array $permissions_for_browser_execution = [
'CAN_DEV_BUILD',
];
public function getTitle(): string
{
return 'Database Cleanup';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$schema = DataObject::getSchema();
$baseClasses = [];
foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
if (get_parent_class($class ?? '') == DataObject::class) {
$baseClasses[] = $class;
}
}
$countDeleted = 0;
$output->startList(PolyOutput::LIST_UNORDERED);
foreach ($baseClasses as $baseClass) {
// Get data classes
$baseTable = $schema->baseDataTable($baseClass);
$subclasses = ClassInfo::subclassesFor($baseClass);
unset($subclasses[0]);
foreach ($subclasses as $k => $subclass) {
if (!DataObject::getSchema()->classHasTable($subclass)) {
unset($subclasses[$k]);
}
}
if ($subclasses) {
$records = DB::query("SELECT * FROM \"$baseTable\"");
foreach ($subclasses as $subclass) {
$subclassTable = $schema->tableName($subclass);
$recordExists[$subclass] =
DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
}
foreach ($records as $record) {
foreach ($subclasses as $subclass) {
$subclassTable = $schema->tableName($subclass);
$id = $record['ID'];
if (($record['ClassName'] != $subclass)
&& (!is_subclass_of($record['ClassName'], $subclass ?? ''))
&& isset($recordExists[$subclass][$id])
) {
$sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
$output->writeListItem("$sql [{$id}]");
DB::prepared_query($sql, [$id]);
$countDeleted++;
}
}
}
}
}
$output->stopList();
$output->writeln("Deleted {$countDeleted} rows");
return Command::SUCCESS;
}
protected function getHeading(): string
{
return 'Deleting records with no corresponding row in their parent class tables';
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\Core\ClassInfo;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\DataObject;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
/**
* Command to build default records in the database.
* Can be run either via an HTTP request or the CLI.
*/
class DbDefaults extends DevCommand
{
protected static string $commandName = 'db:defaults';
protected static string $description = 'Build the default data, calling requireDefaultRecords on all DataObject classes';
private static array $permissions_for_browser_execution = [
'CAN_DEV_BUILD',
];
public function getTitle(): string
{
return 'Defaults Builder';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
array_shift($dataClasses);
$output->startList(PolyOutput::LIST_UNORDERED);
foreach ($dataClasses as $dataClass) {
singleton($dataClass)->requireDefaultRecords();
$output->writeListItem("Defaults loaded for $dataClass");
}
$output->stopList();
return Command::SUCCESS;
}
protected function getHeading(): string
{
return 'Building default data for all DataObject classes';
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Terminal;
/**
* A command that can be run from CLI or via an HTTP request in a dev/* route
*/
abstract class DevCommand extends PolyCommand
{
private static array $permissions_for_browser_execution = [
'ADMIN',
'ALL_DEV_ADMIN' => true,
];
public function run(InputInterface $input, PolyOutput $output): int
{
$terminal = new Terminal();
$heading = $this->getHeading();
if ($heading) {
// Output heading
$underline = str_repeat('-', min($terminal->getWidth(), strlen($heading)));
$output->writeForAnsi(["<options=bold>{$heading}</>", $underline], true);
$output->writeForHtml("<h2>{$heading}</h2>");
} else {
// Only print the title in CLI (and only if there's no heading)
// The DevAdminController outputs the title already for HTTP stuff.
$title = $this->getTitle();
$underline = str_repeat('-', min($terminal->getWidth(), strlen($title)));
$output->writeForAnsi(["<options=bold>{$title}</>", $underline], true);
}
return $this->execute($input, $output);
}
/**
* The code for running this command.
*
* Output should be agnostic - do not include explicit HTML in the output unless there is no API
* on `PolyOutput` for what you want to do (in which case use the writeForHtml() method).
*
* Use symfony/console ANSI formatting to style the output.
* See https://symfony.com/doc/current/console/coloring.html
*
* @return int 0 if everything went fine, or an exit code
*/
abstract protected function execute(InputInterface $input, PolyOutput $output): int;
/**
* Content to output before command is executed.
* In HTML format this will be an h2.
*/
abstract protected function getHeading(): string;
}

View File

@ -0,0 +1,55 @@
<?php
namespace SilverStripe\Dev\Command;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\Security\RandomGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* Command to generate a secure token.
* Can be run either via an HTTP request or the CLI.
*/
class GenerateSecureToken extends DevCommand
{
protected static string $commandName = 'generatesecuretoken';
protected static string $description = 'Generate a secure token';
public function getTitle(): string
{
return 'Secure token';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$token = RandomGenerator::create()->randomToken($input->getOption('algorithm'));
$output->writeForHtml('<code>');
$output->writeln($token);
$output->writeForHtml('</code>');
return Command::SUCCESS;
}
protected function getHeading(): string
{
return 'Generating new token';
}
public function getOptions(): array
{
return [
new InputOption(
'algorithm',
null,
InputOption::VALUE_REQUIRED,
'The hashing algorithm used to generate the token. Can be any identifier listed in <href=https://www.php.net/manual/en/function.hash-algos.php>hash_algos()</>',
'sha1',
hash_algos()
),
];
}
}

View File

@ -53,7 +53,7 @@ class Deprecation
/** /**
* @internal * @internal
*/ */
private static bool $insideWithNoReplacement = false; private static bool $insideNoticeSuppression = false;
/** /**
* @internal * @internal
@ -103,22 +103,32 @@ class Deprecation
} }
/** /**
* Used to wrap deprecated methods and deprecated config get()/set() that will be removed * Used to wrap deprecated methods and deprecated config get()/set() called from the vendor
* in the next major version with no replacement. This is done to surpress deprecation notices * dir that projects have no ability to change.
* by for calls from the vendor dir to deprecated code that projects have no ability to change
* *
* @return mixed * @return mixed
* @deprecated 5.4.0 Use withSuppressedNotice() instead
*/ */
public static function withNoReplacement(callable $func) public static function withNoReplacement(callable $func)
{ {
if (Deprecation::$insideWithNoReplacement) { Deprecation::notice('5.4.0', 'Use withSuppressedNotice() instead');
return Deprecation::withSuppressedNotice($func);
}
/**
* Used to wrap deprecated methods and deprecated config get()/set() called from the vendor
* dir that projects have no ability to change.
*/
public static function withSuppressedNotice(callable $func): mixed
{
if (Deprecation::$insideNoticeSuppression) {
return $func(); return $func();
} }
Deprecation::$insideWithNoReplacement = true; Deprecation::$insideNoticeSuppression = true;
try { try {
return $func(); return $func();
} finally { } finally {
Deprecation::$insideWithNoReplacement = false; Deprecation::$insideNoticeSuppression = false;
} }
} }
@ -137,8 +147,8 @@ class Deprecation
$level = 1; $level = 1;
} }
$newLevel = $level; $newLevel = $level;
// handle closures inside withNoReplacement() // handle closures inside withSuppressedNotice()
if (Deprecation::$insideWithNoReplacement if (Deprecation::$insideNoticeSuppression
&& substr($backtrace[$newLevel]['function'], -strlen('{closure}')) === '{closure}' && substr($backtrace[$newLevel]['function'], -strlen('{closure}')) === '{closure}'
) { ) {
$newLevel = $newLevel + 2; $newLevel = $newLevel + 2;
@ -247,8 +257,8 @@ class Deprecation
$count++; $count++;
$arr = array_shift(Deprecation::$userErrorMessageBuffer); $arr = array_shift(Deprecation::$userErrorMessageBuffer);
$message = $arr['message']; $message = $arr['message'];
$calledInsideWithNoReplacement = $arr['calledInsideWithNoReplacement']; $calledWithNoticeSuppression = $arr['calledWithNoticeSuppression'];
if ($calledInsideWithNoReplacement && !Deprecation::$showNoReplacementNotices) { if ($calledWithNoticeSuppression && !Deprecation::$showNoReplacementNotices) {
continue; continue;
} }
Deprecation::$isTriggeringError = true; Deprecation::$isTriggeringError = true;
@ -280,11 +290,11 @@ class Deprecation
$data = null; $data = null;
if ($scope === Deprecation::SCOPE_CONFIG) { if ($scope === Deprecation::SCOPE_CONFIG) {
// Deprecated config set via yaml will only be shown in the browser when using ?flush=1 // 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 = [ $data = [
'key' => sha1($string), 'key' => sha1($string),
'message' => $string, 'message' => $string,
'calledInsideWithNoReplacement' => Deprecation::$insideWithNoReplacement 'calledWithNoticeSuppression' => Deprecation::$insideNoticeSuppression
]; ];
} else { } else {
if (!Deprecation::isEnabled()) { if (!Deprecation::isEnabled()) {
@ -310,7 +320,7 @@ class Deprecation
$string .= "."; $string .= ".";
} }
$level = Deprecation::$insideWithNoReplacement ? 4 : 2; $level = Deprecation::$insideNoticeSuppression ? 4 : 2;
$string .= " Called from " . Deprecation::get_called_method_from_trace($backtrace, $level) . '.'; $string .= " Called from " . Deprecation::get_called_method_from_trace($backtrace, $level) . '.';
if ($caller) { if ($caller) {
@ -319,7 +329,7 @@ class Deprecation
$data = [ $data = [
'key' => sha1($string), 'key' => sha1($string),
'message' => $string, 'message' => $string,
'calledInsideWithNoReplacement' => Deprecation::$insideWithNoReplacement 'calledWithNoticeSuppression' => Deprecation::$insideNoticeSuppression
]; ];
} }
if ($data && !array_key_exists($data['key'], Deprecation::$userErrorMessageBuffer)) { if ($data && !array_key_exists($data['key'], Deprecation::$userErrorMessageBuffer)) {

View File

@ -1,98 +0,0 @@
<?php
namespace SilverStripe\Dev;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security;
/**
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
*/
class DevBuildController extends Controller implements PermissionProvider
{
private static $url_handlers = [
'' => 'build'
];
private static $allowed_actions = [
'build'
];
private static $init_permissions = [
'ADMIN',
'ALL_DEV_ADMIN',
'CAN_DEV_BUILD',
];
public function __construct()
{
parent::__construct();
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\DbBuild',
Deprecation::SCOPE_CLASS
);
});
}
protected function init(): void
{
parent::init();
if (!$this->canInit()) {
Security::permissionFailure($this);
}
}
public function build(HTTPRequest $request): HTTPResponse
{
if (Director::is_cli()) {
$da = DatabaseAdmin::create();
return $da->handleRequest($request);
} else {
$renderer = DebugView::create();
echo $renderer->renderHeader();
echo $renderer->renderInfo("Environment Builder", Director::absoluteBaseURL());
echo "<div class=\"build\">";
$da = DatabaseAdmin::create();
$response = $da->handleRequest($request);
echo "</div>";
echo $renderer->renderFooter();
return $response;
}
}
public function canInit(): bool
{
return (
Director::isDev()
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
// "dev/tasks" from CLI.
|| (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
|| Permission::check(static::config()->get('init_permissions'))
);
}
public function providePermissions(): array
{
return [
'CAN_DEV_BUILD' => [
'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
'category' => DevelopmentAdmin::permissionsCategory(),
'sort' => 100
],
];
}
}

View File

@ -1,214 +0,0 @@
<?php
namespace SilverStripe\Dev;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security;
use Symfony\Component\Yaml\Yaml;
/**
* Outputs the full configuration.
*
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\ConfigDump
*/
class DevConfigController extends Controller implements PermissionProvider
{
/**
* @var array
*/
private static $url_handlers = [
'audit' => 'audit',
'' => 'index'
];
/**
* @var array
*/
private static $allowed_actions = [
'index',
'audit',
];
private static $init_permissions = [
'ADMIN',
'ALL_DEV_ADMIN',
'CAN_DEV_CONFIG',
];
public function __construct()
{
parent::__construct();
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\ConfigDump',
Deprecation::SCOPE_CLASS
);
});
}
protected function init(): void
{
parent::init();
if (!$this->canInit()) {
Security::permissionFailure($this);
}
}
/**
* Note: config() method is already defined, so let's just use index()
*
* @return string|HTTPResponse
*/
public function index()
{
$body = '';
$subtitle = "Config manifest";
if (Director::is_cli()) {
$body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
$body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
} else {
$renderer = DebugView::create();
$body .= $renderer->renderHeader();
$body .= $renderer->renderInfo("Configuration", Director::absoluteBaseURL());
$body .= "<div class=\"options\">";
$body .= sprintf("<h2>%s</h2>", $subtitle);
$body .= "<pre>";
$body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
$body .= "</pre>";
$body .= "</div>";
$body .= $renderer->renderFooter();
}
return $this->getResponse()->setBody($body);
}
/**
* Output the extraneous config properties which are defined in .yaml but not in a corresponding class
*
* @return string|HTTPResponse
*/
public function audit()
{
$body = '';
$missing = [];
$subtitle = "Missing Config property definitions";
foreach ($this->array_keys_recursive(Config::inst()->getAll(), 2) as $className => $props) {
$props = array_keys($props ?? []);
if (!count($props ?? [])) {
// We can skip this entry
continue;
}
if ($className == strtolower(Injector::class)) {
// We don't want to check the injector config
continue;
}
foreach ($props as $prop) {
$defined = false;
// Check ancestry (private properties don't inherit natively)
foreach (ClassInfo::ancestry($className) as $cn) {
if (property_exists($cn, $prop ?? '')) {
$defined = true;
break;
}
}
if ($defined) {
// No need to record this property
continue;
}
$missing[] = sprintf("%s::$%s\n", $className, $prop);
}
}
$output = count($missing ?? [])
? implode("\n", $missing)
: "All configured properties are defined\n";
if (Director::is_cli()) {
$body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
$body .= $output;
} else {
$renderer = DebugView::create();
$body .= $renderer->renderHeader();
$body .= $renderer->renderInfo(
"Configuration",
Director::absoluteBaseURL(),
"Config properties that are not defined (or inherited) by their respective classes"
);
$body .= "<div class=\"options\">";
$body .= sprintf("<h2>%s</h2>", $subtitle);
$body .= sprintf("<pre>%s</pre>", $output);
$body .= "</div>";
$body .= $renderer->renderFooter();
}
return $this->getResponse()->setBody($body);
}
public function canInit(): bool
{
return (
Director::isDev()
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
// "dev/tasks" from CLI.
|| (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
|| Permission::check(static::config()->get('init_permissions'))
);
}
public function providePermissions(): array
{
return [
'CAN_DEV_CONFIG' => [
'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
'category' => DevelopmentAdmin::permissionsCategory(),
'sort' => 100
],
];
}
/**
* Returns all the keys of a multi-dimensional array while maintining any nested structure
*
* @param array $array
* @param int $maxdepth
* @param int $depth
* @param array $arrayKeys
* @return array
*/
private function array_keys_recursive($array, $maxdepth = 20, $depth = 0, $arrayKeys = [])
{
if ($depth < $maxdepth) {
$depth++;
$keys = array_keys($array ?? []);
foreach ($keys as $key) {
if (!is_array($array[$key])) {
continue;
}
$arrayKeys[$key] = $this->array_keys_recursive($array[$key], $maxdepth, $depth);
}
}
return $arrayKeys;
}
}

View File

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

View File

@ -2,79 +2,81 @@
namespace SilverStripe\Dev; namespace SilverStripe\Dev;
use Exception; use LogicException;
use SilverStripe\Control\Controller; use SilverStripe\Control\Controller;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\RequestHandler;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\Command\DevCommand;
use SilverStripe\ORM\DatabaseAdmin; use SilverStripe\PolyExecution\HtmlOutputFormatter;
use SilverStripe\PolyExecution\HttpRequestInput;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\Security\Permission; use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\Versioned\Versioned; use SilverStripe\Versioned\Versioned;
use SilverStripe\Model\ModelData;
/** /**
* Base class for development tools. * Base class for development tools.
* *
* Configured in framework/_config/dev.yml, with the config key registeredControllers being * Configured via the `commands` and `controllers` configuration properties
* used to generate the list of links for /dev.
*/ */
class DevelopmentAdmin extends Controller implements PermissionProvider class DevelopmentAdmin extends Controller implements PermissionProvider
{ {
private static array $url_handlers = [
private static $url_handlers = [
'' => 'index', '' => 'index',
'build/defaults' => 'buildDefaults', '$Action' => 'runRegisteredAction',
'generatesecuretoken' => 'generatesecuretoken',
'$Action' => 'runRegisteredController',
]; ];
private static $allowed_actions = [ private static array $allowed_actions = [
'index', 'index',
'buildDefaults', 'runRegisteredAction',
'runRegisteredController',
'generatesecuretoken',
]; ];
/** /**
* 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 [ * e.g [
* 'urlsegment' => [ * 'urlsegment' => [
* 'controller' => 'SilverStripe\Dev\DevelopmentAdmin', * 'class' => 'App\Dev\MyHttpOnlyController',
* 'links' => [ * 'description' => 'See a list of build tasks to run',
* 'urlsegment' => 'description', * ],
* ...
* ]
* ]
* ] * ]
*
* @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 * Assume that CLI equals admin permissions
* If set to false, normal permission model will apply even in CLI mode * If set to false, normal permission model will apply even in CLI mode
* Applies to all development admin tasks (E.g. TaskRunner, DatabaseAdmin) * Applies to all development admin tasks (E.g. TaskRunner, DbBuild)
*
* @config
* @var bool
*/ */
private static $allow_all_cli = true; private static bool $allow_all_cli = true;
/** /**
* Deny all non-cli requests (browser based ones) to dev admin * 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() protected function init()
{ {
@ -89,7 +91,7 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
return; 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(), // for tasks like dev/build which call DataObject->requireDefaultRecords(),
// but also for other administrative tasks which have assumptions about the default stage. // but also for other administrative tasks which have assumptions about the default stage.
if (class_exists(Versioned::class)) { 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() public function index()
{ {
$links = $this->getLinks(); $renderer = DebugView::create();
// Web mode $base = Director::baseURL();
if (!Director::is_cli()) { $formatter = HtmlOutputFormatter::create();
$renderer = DebugView::create();
echo $renderer->renderHeader();
echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL());
$base = Director::baseURL();
echo '<div class="options"><ul>'; $list = [];
$evenOdd = "odd";
foreach ($links as $action => $description) { foreach ($this->getLinks() as $path => $info) {
echo "<li class=\"$evenOdd\"><a href=\"{$base}dev/$action\"><b>/dev/$action:</b>" $class = $info['class'];
. " $description</a></li>\n"; $description = $info['description'] ?? '';
$evenOdd = ($evenOdd == "odd") ? "even" : "odd"; $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 = [
echo $renderer->renderFooter(); 'Description' => $description,
'Link' => "{$base}$path",
// CLI mode 'Path' => $path,
} else { 'Parameters' => $parameters,
echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n"; 'Help' => $help,
echo "You can execute any of the following commands:\n\n"; ];
foreach ($links as $action => $description) { $list[] = $data;
echo " sake dev/$action: $description\n";
}
echo "\n\n";
} }
$data = [
'ArrayLinks' => $list,
'Header' => $renderer->renderHeader(),
'Footer' => $renderer->renderFooter(),
'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()),
];
return ModelData::create()->renderWith(static::class, $data);
} }
public function runRegisteredController(HTTPRequest $request) /**
* Run the command, or hand execution to the controller.
* Note this method is for execution from the web only. CLI takes a different path.
*/
public function runRegisteredAction(HTTPRequest $request)
{ {
$controllerClass = null; $returnUrl = $this->getBackURL();
$fullPath = $request->getURL();
$routes = $this->getRegisteredRoutes();
$class = null;
$baseUrlPart = $request->param('Action'); // If full path directly matches, use that class.
$reg = Config::inst()->get(static::class, 'registered_controllers'); if (isset($routes[$fullPath])) {
if (isset($reg[$baseUrlPart])) { $class = $routes[$fullPath]['class'];
$controllerClass = $reg[$baseUrlPart]['controller']; if (is_a($class, DevCommand::class, true)) {
// Tell the request we've matched the full URL
$request->shift($request->remaining());
}
} }
if ($controllerClass && class_exists($controllerClass ?? '')) { // The full path doesn't directly match any registered command or controller.
return $controllerClass::create(); // Look for a controller that can handle the request. We reject commands at this stage.
// The full path will be for an action on the controller and may include nested actions,
// so we need to check all urlsegment sections within the request URL.
if (!$class) {
$parts = explode('/', $fullPath);
array_pop($parts);
while (count($parts) > 0) {
$newPath = implode('/', $parts);
// Don't check dev itself - that's the controller we're currently in.
if ($newPath === 'dev') {
break;
}
// Check for a controller that matches this partial path.
$class = $routes[$newPath]['class'] ?? null;
if ($class !== null && is_a($class, RequestHandler::class, true)) {
break;
}
array_pop($parts);
}
} }
$msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action'); if (!$class) {
if (Director::is_cli()) { $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
// 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); $this->httpError(404, $msg);
} }
}
/* // Hand execution to the controller
* Internal methods if (is_a($class, RequestHandler::class, true)) {
*/ return $class::create();
}
/** @var DevCommand $command */
$command = $class::create();
$input = HttpRequestInput::create($request, $command->getOptions());
// DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed
// to the client as its available, so that if there's an error the client gets all of the output
// available until the error occurs.
$output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true);
$renderer = DebugView::create();
// Output header etc
$headerOutput = [
$renderer->renderHeader(),
$renderer->renderInfo(
$command->getTitle(),
Director::absoluteBaseURL()
),
'<div class="options">',
];
$output->writeForHtml($headerOutput);
// Run command
$command->run($input, $output);
// Output footer etc
$output->writeForHtml([
'</div>',
$renderer->renderFooter(),
]);
// Return to whence we came (e.g. if we had been redirected to dev/build)
if ($returnUrl) {
return $this->redirect($returnUrl);
}
}
/** /**
* @deprecated 5.2.0 use getLinks() instead to include permission checks * Get a map of all registered DevCommands.
* @return array of url => description * The key is the route used for browser execution.
*/ */
protected static function get_links() public function getCommands(): array
{ {
Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks'); $commands = [];
$links = []; foreach (Config::inst()->get(static::class, 'commands') as $name => $class) {
// Allow unsetting a command via YAML
$reg = Config::inst()->get(static::class, 'registered_controllers'); if ($class === null) {
foreach ($reg as $registeredController) { continue;
if (isset($registeredController['links'])) {
foreach ($registeredController['links'] as $url => $desc) {
$links[$url] = $desc;
}
} }
// Check that the class exists and is a DevCommand
if (!ClassInfo::exists($class)) {
throw new LogicException("Class '$class' doesn't exist");
}
if (!is_a($class, DevCommand::class, true)) {
throw new LogicException("Class '$class' must be a subclass of " . DevCommand::class);
}
// Add to list of commands
$commands['dev/' . $name] = $class;
} }
return $links; return $commands;
} }
protected function getLinks(): array /**
* Get a map of routes that can be run via this controller in an HTTP request.
* The key is the URI path, and the value is an associative array of information about the route.
*/
public function getRegisteredRoutes(): array
{ {
$canViewAll = $this->canViewAll(); $canViewAll = $this->canViewAll();
$links = []; $items = [];
$reg = Config::inst()->get(static::class, 'registered_controllers');
foreach ($reg as $registeredController) { foreach ($this->getCommands() as $urlSegment => $commandClass) {
if (isset($registeredController['links'])) { // Note we've already checked if command classes exist and are DevCommand
if (!ClassInfo::exists($registeredController['controller'])) { // Check command can run in current context
if (!$canViewAll && !$commandClass::canRunInBrowser()) {
continue;
}
$items[$urlSegment] = ['class' => $commandClass];
}
foreach (static::config()->get('controllers') as $urlSegment => $info) {
// Allow unsetting a controller via YAML
if ($info === null) {
continue;
}
$controllerClass = $info['class'];
// Check that the class exists and is a RequestHandler
if (!ClassInfo::exists($controllerClass)) {
throw new LogicException("Class '$controllerClass' doesn't exist");
}
if (!is_a($controllerClass, RequestHandler::class, true)) {
throw new LogicException("Class '$controllerClass' must be a subclass of " . RequestHandler::class);
}
if (!$canViewAll) {
// Check access to controller
$controllerSingleton = Injector::inst()->get($controllerClass);
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
continue; continue;
} }
}
if (!$canViewAll) { $items['dev/' . $urlSegment] = $info;
// Check access to controller }
$controllerSingleton = Injector::inst()->get($registeredController['controller']);
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
continue;
}
}
foreach ($registeredController['links'] as $url => $desc) { return $items;
$links[$url] = $desc; }
}
/**
* 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; 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::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\DbDefaults'
);
});
$da = DatabaseAdmin::create();
$renderer = null;
if (!Director::is_cli()) {
$renderer = DebugView::create();
echo $renderer->renderHeader();
echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL());
echo "<div class=\"build\">";
}
$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::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\GenerateSecureToken'
);
});
$generator = Injector::inst()->create('SilverStripe\\Security\\RandomGenerator');
$token = $generator->randomToken('sha1');
$body = <<<TXT
Generated new token. Please add the following code to your YAML configuration:
Security:
token: $token
TXT;
$response = new HTTPResponse($body);
return $response->addHeader('Content-Type', 'text/plain');
}
public function errors() public function errors()
{ {
$this->redirect("Debug_"); $this->redirect("Debug_");
@ -310,17 +340,17 @@ TXT;
protected function canViewAll(): bool protected function canViewAll(): bool
{ {
// Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957) // If dev/build was requested, we must defer to DbBuild permission checks explicitly
$requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0) // because otherwise the permission checks may result in an error
&& (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false); $url = rtrim($this->getRequest()->getURL(), '/');
if ($url === 'dev/build') {
// We allow access to this controller regardless of live-status or ADMIN permission only return false;
// 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'); // 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 ( return (
$requestedDevBuild Director::isDev()
|| Director::isDev() || (Director::is_cli() && static::config()->get('allow_all_cli'))
|| (Director::is_cli() && $allowAllCLI)
// Its important that we don't run this check if dev/build was requested // Its important that we don't run this check if dev/build was requested
|| Permission::check(['ADMIN', 'ALL_DEV_ADMIN']) || Permission::check(['ADMIN', 'ALL_DEV_ADMIN'])
); );

View File

@ -2,77 +2,49 @@
namespace SilverStripe\Dev; 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. * 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 * To create your own migration task, you need to define your own subclass of MigrationTask
* and implement the following methods * and implement the abstract 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>
*/ */
abstract class MigrationTask extends BuildTask abstract class MigrationTask extends BuildTask
{ {
protected function execute(InputInterface $input, PolyOutput $output): int
private static $segment = 'MigrationTask';
protected $title = "Database Migrations";
protected $description = "Provide atomic database changes (subclass this and implement yourself)";
public function run($request)
{ {
if ($request->param('Direction') == 'down') { if ($input->getOption('direction') === 'down') {
$this->down(); $this->down();
} else { } else {
$this->up(); $this->up();
} }
return Command::SUCCESS;
} }
public function up() /**
{ * Migrate from old to new
} */
abstract public function up();
public function down() /**
* Revert the migration (new to old)
*/
abstract public function down();
public function getOptions(): array
{ {
return [
new InputOption(
'direction',
null,
InputOption::VALUE_REQUIRED,
'"up" if migrating from old to new, "down" to revert a migration',
suggestedValues: ['up', 'down'],
),
];
} }
} }

View File

@ -88,7 +88,7 @@ class ExtensionTestState implements TestState
} }
// clear singletons, they're caching old extension info // clear singletons, they're caching old extension info
// which is used in DatabaseAdmin->doBuild() // which is used in DbBuild->doBuild()
Injector::inst()->unregisterObjects([ Injector::inst()->unregisterObjects([
DataObject::class, DataObject::class,
Extension::class Extension::class

View File

@ -12,6 +12,10 @@ use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ModuleResourceLoader; use SilverStripe\Core\Manifest\ModuleResourceLoader;
use SilverStripe\Model\List\ArrayList; 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\Permission;
use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
@ -20,7 +24,6 @@ use SilverStripe\Model\ModelData;
class TaskRunner extends Controller implements PermissionProvider class TaskRunner extends Controller implements PermissionProvider
{ {
use Configurable; use Configurable;
private static $url_handlers = [ private static $url_handlers = [
@ -59,25 +62,17 @@ class TaskRunner extends Controller implements PermissionProvider
{ {
$baseUrl = Director::absoluteBaseURL(); $baseUrl = Director::absoluteBaseURL();
$tasks = $this->getTasks(); $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(); $list = ArrayList::create();
foreach ($tasks as $task) { foreach ($tasks as $task) {
if (!$task['class']::canRunInBrowser()) {
continue;
}
$list->push(ArrayData::create([ $list->push(ArrayData::create([
'TaskLink' => Controller::join_links($baseUrl, 'dev/tasks/', $task['segment']), 'TaskLink' => Controller::join_links($baseUrl, 'dev/tasks/', $task['segment']),
'Title' => $task['title'], 'Title' => $task['title'],
'Description' => $task['description'], 'Description' => $task['description'],
'Parameters' => $task['parameters'],
'Help' => $task['help'],
])); ]));
} }
@ -104,26 +99,26 @@ class TaskRunner extends Controller implements PermissionProvider
$name = $request->param('TaskName'); $name = $request->param('TaskName');
$tasks = $this->getTasks(); $tasks = $this->getTasks();
$title = function ($content) {
printf(Director::is_cli() ? "%s\n\n" : '<h1>%s</h1>', $content);
};
$message = function ($content) { $message = function ($content) {
printf(Director::is_cli() ? "%s\n" : '<p>%s</p>', $content); printf('<p>%s</p>', $content);
}; };
foreach ($tasks as $task) { foreach ($tasks as $task) {
if ($task['segment'] == $name) { if ($task['segment'] == $name) {
/** @var BuildTask $inst */ /** @var BuildTask $inst */
$inst = Injector::inst()->create($task['class']); $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'); $message('The task is disabled or you do not have sufficient permission to run it');
return; 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; 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 = []; $availableTasks = [];
$formatter = HtmlOutputFormatter::create();
/** @var BuildTask $class */
foreach ($this->getTaskList() as $class) { foreach ($this->getTaskList() as $class) {
$singleton = BuildTask::singleton($class); if (!$class::canRunInBrowser()) {
$description = $singleton->getDescription(); continue;
$description = trim($description ?? ''); }
$desc = (Director::is_cli()) $singleton = BuildTask::singleton($class);
? Convert::html2raw($description) $description = DBField::create_field('HTMLText', $formatter->format($class::getDescription()));
: $description; $help = DBField::create_field('HTMLText', nl2br($formatter->format($class::getHelp())), false);
$availableTasks[] = [ $availableTasks[] = [
'class' => $class, 'class' => $class,
'title' => $singleton->getTitle(), 'title' => $singleton->getTitle(),
'segment' => $singleton->config()->segment ?: str_replace('\\', '-', $class ?? ''), 'segment' => $class::getNameWithoutNamespace(),
'description' => $desc, 'description' => $description,
'parameters' => $singleton->getOptionsForTemplate(),
'help' => $help,
]; ];
} }
return $availableTasks; 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 * @param string $class
* @return boolean * @return boolean
@ -181,6 +183,7 @@ class TaskRunner extends Controller implements PermissionProvider
return false; return false;
} }
/** @var BuildTask $task */
$task = Injector::inst()->get($class); $task = Injector::inst()->get($class);
if (!$task->isEnabled()) { if (!$task->isEnabled()) {
return false; return false;
@ -197,8 +200,7 @@ class TaskRunner extends Controller implements PermissionProvider
{ {
return ( return (
Director::isDev() Director::isDev()
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running // We need to ensure that unit tests can simulate permission failures when navigating to "dev/tasks"
// "dev/tasks" from CLI.
|| (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
|| Permission::check(static::config()->get('init_permissions')) || Permission::check(static::config()->get('init_permissions'))
); );

View File

@ -2,12 +2,11 @@
namespace SilverStripe\Dev\Tasks; namespace SilverStripe\Dev\Tasks;
use SilverStripe\Control\Director;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\Dev\Deprecation; use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\Connect\TempDatabase; use SilverStripe\ORM\Connect\TempDatabase;
use SilverStripe\Security\Permission; use Symfony\Component\Console\Command\Command;
use SilverStripe\Security\Security; use Symfony\Component\Console\Input\InputInterface;
/** /**
* Cleans up leftover databases from aborted test executions (starting with ss_tmpdb) * Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)
@ -15,33 +14,20 @@ use SilverStripe\Security\Security;
*/ */
class CleanupTestDatabasesTask extends BuildTask 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(); TempDatabase::create()->deleteAll();
} return Command::SUCCESS;
public function canView(): bool
{
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with canRunInBrowser()'
);
});
return Permission::check('ADMIN') || Director::is_cli();
} }
} }

View File

@ -2,83 +2,71 @@
namespace SilverStripe\Dev\Tasks; namespace SilverStripe\Dev\Tasks;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Environment; use SilverStripe\Core\Environment;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\i18n\TextCollection\i18nTextCollector; 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 * 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 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 = " protected function execute(InputInterface $input, PolyOutput $output): int
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)
{ {
Environment::increaseTimeLimitTo(); Environment::increaseTimeLimitTo();
$collector = i18nTextCollector::create($request->getVar('locale')); $collector = i18nTextCollector::create($input->getOption('locale'));
$merge = $this->getIsMerge($request); $merge = $this->getIsMerge($input);
// Custom writer // Custom writer
$writerName = $request->getVar('writer'); $writerName = $input->getOption('writer');
if ($writerName) { if ($writerName) {
$writer = Injector::inst()->get($writerName); $writer = Injector::inst()->get($writerName);
$collector->setWriter($writer); $collector->setWriter($writer);
} }
// Get restrictions // Get restrictions
$restrictModules = ($request->getVar('module')) $restrictModules = ($input->getOption('module'))
? explode(',', $request->getVar('module')) ? explode(',', $input->getOption('module'))
: null; : null;
$collector->run($restrictModules, $merge); $collector->run($restrictModules, $merge);
Debug::message(__CLASS__ . " completed!", false); return Command::SUCCESS;
} }
/** /**
* Check if we should merge * Check if we should merge
*
* @param HTTPRequest $request
* @return bool
*/ */
protected function getIsMerge($request) protected function getIsMerge(InputInterface $input): bool
{ {
$merge = $request->getVar('merge'); $merge = $input->getOption('merge');
// Default to true if not given
if (!isset($merge)) {
return true;
}
// merge=0 or merge=false will disable merge // merge=0 or merge=false will disable merge
return !in_array($merge, ['0', 'false']); return !in_array($merge, ['0', 'false']);
} }
public function getOptions(): array
{
return [
new InputOption('locale', null, InputOption::VALUE_REQUIRED, 'Sets default locale'),
new InputOption('writer', null, InputOption::VALUE_REQUIRED, 'Custom writer class (must implement the <info>SilverStripe\i18n\Messages\Writer</> interface)'),
new InputOption('module', null, InputOption::VALUE_REQUIRED, 'One or more modules to limit collection (comma-separated)'),
new InputOption('merge', null, InputOption::VALUE_NEGATABLE, 'Merge new strings with existing ones already defined in language files', true),
];
}
} }

View File

@ -4,24 +4,21 @@ namespace SilverStripe\Dev\Validation;
use ReflectionException; use ReflectionException;
use SilverStripe\Core\Extension; use SilverStripe\Core\Extension;
use SilverStripe\ORM\DatabaseAdmin; use SilverStripe\Dev\Command\DbBuild;
/** /**
* Hook up static validation to the deb/build process * 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 * @throws ReflectionException
*/ */
protected function onAfterBuild(bool $quiet, bool $populate, bool $testMode): void protected function onAfterBuild(): void
{ {
$service = RelationValidationService::singleton(); $service = RelationValidationService::singleton();

View File

@ -25,7 +25,7 @@ class GridFieldConfig_Base extends GridFieldConfig
$this->addComponent(GridFieldPageCount::create('toolbar-header-right')); $this->addComponent(GridFieldPageCount::create('toolbar-header-right'));
$this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage)); $this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage));
Deprecation::withNoReplacement(function () use ($sort, $filter, $pagination) { Deprecation::withSuppressedNotice(function () use ($sort, $filter, $pagination) {
$sort->setThrowExceptionOnBadDataType(false); $sort->setThrowExceptionOnBadDataType(false);
$filter->setThrowExceptionOnBadDataType(false); $filter->setThrowExceptionOnBadDataType(false);
$pagination->setThrowExceptionOnBadDataType(false); $pagination->setThrowExceptionOnBadDataType(false);

View File

@ -32,7 +32,7 @@ class GridFieldConfig_RecordEditor extends GridFieldConfig
$this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage)); $this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage));
$this->addComponent(GridFieldDetailForm::create(null, $showPagination, $showAdd)); $this->addComponent(GridFieldDetailForm::create(null, $showPagination, $showAdd));
Deprecation::withNoReplacement(function () use ($sort, $filter, $pagination) { Deprecation::withSuppressedNotice(function () use ($sort, $filter, $pagination) {
$sort->setThrowExceptionOnBadDataType(false); $sort->setThrowExceptionOnBadDataType(false);
$filter->setThrowExceptionOnBadDataType(false); $filter->setThrowExceptionOnBadDataType(false);
$pagination->setThrowExceptionOnBadDataType(false); $pagination->setThrowExceptionOnBadDataType(false);

View File

@ -45,7 +45,7 @@ class GridFieldConfig_RelationEditor extends GridFieldConfig
$this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage)); $this->addComponent($pagination = GridFieldPaginator::create($itemsPerPage));
$this->addComponent(GridFieldDetailForm::create()); $this->addComponent(GridFieldDetailForm::create());
Deprecation::withNoReplacement(function () use ($sort, $filter, $pagination) { Deprecation::withSuppressedNotice(function () use ($sort, $filter, $pagination) {
$sort->setThrowExceptionOnBadDataType(false); $sort->setThrowExceptionOnBadDataType(false);
$filter->setThrowExceptionOnBadDataType(false); $filter->setThrowExceptionOnBadDataType(false);
$pagination->setThrowExceptionOnBadDataType(false); $pagination->setThrowExceptionOnBadDataType(false);

View File

@ -4,6 +4,7 @@ namespace SilverStripe\Logging;
use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\FormatterInterface;
use Monolog\Handler\AbstractProcessingHandler; use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord; use Monolog\LogRecord;
use SilverStripe\Control\Controller; use SilverStripe\Control\Controller;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
@ -11,14 +12,11 @@ use SilverStripe\Control\HTTPResponse;
use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\Deprecation;
/** /**
* Output the error to the browser, with the given HTTP status code. * Output the error to either the browser or the terminal, depending on
* We recommend that you use a formatter that generates HTML with this. * the context we're running in.
*
* @deprecated 5.4.0 Will be renamed to ErrorOutputHandler
*/ */
class HTTPOutputHandler extends AbstractProcessingHandler class ErrorOutputHandler extends AbstractProcessingHandler
{ {
/** /**
* @var string * @var string
*/ */
@ -34,10 +32,10 @@ class HTTPOutputHandler extends AbstractProcessingHandler
*/ */
private $cliFormatter = null; private $cliFormatter = null;
public function __construct() public function __construct(int|string|Level $level = Level::Debug, bool $bubble = true)
{ {
parent::__construct(); parent::__construct($level, $bubble);
Deprecation::withNoReplacement(function () { Deprecation::withSuppressedNotice(function () {
Deprecation::notice( Deprecation::notice(
'5.4.0', '5.4.0',
'Will be renamed to ErrorOutputHandler', 'Will be renamed to ErrorOutputHandler',
@ -61,7 +59,7 @@ class HTTPOutputHandler extends AbstractProcessingHandler
* Default text/html * Default text/html
* *
* @param string $contentType * @param string $contentType
* @return HTTPOutputHandler Return $this to allow chainable calls * @return ErrorOutputHandler Return $this to allow chainable calls
*/ */
public function setContentType($contentType) public function setContentType($contentType)
{ {
@ -96,7 +94,7 @@ class HTTPOutputHandler extends AbstractProcessingHandler
* Set a formatter to use if Director::is_cli() is true * Set a formatter to use if Director::is_cli() is true
* *
* @param FormatterInterface $cliFormatter * @param FormatterInterface $cliFormatter
* @return HTTPOutputHandler Return $this to allow chainable calls * @return ErrorOutputHandler Return $this to allow chainable calls
*/ */
public function setCLIFormatter(FormatterInterface $cliFormatter) public function setCLIFormatter(FormatterInterface $cliFormatter)
{ {
@ -179,6 +177,11 @@ class HTTPOutputHandler extends AbstractProcessingHandler
} }
} }
if (Director::is_cli()) {
echo $record['formatted'];
return;
}
if (Controller::has_curr()) { if (Controller::has_curr()) {
$response = Controller::curr()->getResponse(); $response = Controller::curr()->getResponse();
} else { } else {
@ -197,14 +200,4 @@ class HTTPOutputHandler extends AbstractProcessingHandler
$response->setBody($record['formatted']); $response->setBody($record['formatted']);
$response->output(); $response->output();
} }
/**
* This method used to be used for unit testing but is no longer required.
* @deprecated 5.4.0 Use SilverStripe\Control\Director::is_cli() instead
*/
protected function isCli(): bool
{
Deprecation::notice('5.4.0', 'Use ' . Director::class . '::is_cli() instead');
return Director::is_cli();
}
} }

View File

@ -720,7 +720,7 @@ class ArrayList extends ModelData implements SS_List, Filterable, Sortable, Limi
// Apply default case sensitivity for backwards compatability // Apply default case sensitivity for backwards compatability
if (!str_contains($filterKey, ':case') && !str_contains($filterKey, ':nocase')) { if (!str_contains($filterKey, ':case') && !str_contains($filterKey, ':nocase')) {
$caseSensitive = Deprecation::withNoReplacement(fn() => static::config()->get('default_case_sensitive')); $caseSensitive = Deprecation::withSuppressedNotice(fn() => static::config()->get('default_case_sensitive'));
if ($caseSensitive && in_array('case', $searchFilter->getSupportedModifiers())) { if ($caseSensitive && in_array('case', $searchFilter->getSupportedModifiers())) {
$searchFilter->setModifiers($searchFilter->getModifiers() + ['case']); $searchFilter->setModifiers($searchFilter->getModifiers() + ['case']);
} elseif (!$caseSensitive && in_array('nocase', $searchFilter->getSupportedModifiers())) { } elseif (!$caseSensitive && in_array('nocase', $searchFilter->getSupportedModifiers())) {

View File

@ -19,9 +19,7 @@ abstract class DBSchemaManager
{ {
/** /**
* * Check tables when building the db, and repair them if necessary.
* @config
* Check tables when running /dev/build, and repair them if necessary.
* In case of large databases or more fine-grained control on how to handle * 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 * 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. * 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 * 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. * 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 * 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 = []; private static array $exclude_models_from_db_checks = [];

View File

@ -6,6 +6,7 @@ use mysqli;
use mysqli_sql_exception; use mysqli_sql_exception;
use mysqli_stmt; use mysqli_stmt;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Environment;
/** /**
* Connector for MySQL using the MySQLi method * Connector for MySQL using the MySQLi method
@ -81,15 +82,21 @@ class MySQLiConnector extends DBConnector
// Connection charset and collation // Connection charset and collation
$connCharset = Config::inst()->get(MySQLDatabase::class, 'connection_charset'); $connCharset = Config::inst()->get(MySQLDatabase::class, 'connection_charset');
$connCollation = Config::inst()->get(MySQLDatabase::class, 'connection_collation'); $connCollation = Config::inst()->get(MySQLDatabase::class, 'connection_collation');
$socket = Environment::getEnv('SS_DATABASE_SOCKET');
$flags = Environment::getEnv('SS_DATABASE_FLAGS');
$flags = $flags ? array_reduce(explode(',', $flags), function ($carry, $item) {
$item = trim($item);
return $carry | constant($item);
}, 0) : $flags;
$this->dbConn = mysqli_init(); $this->dbConn = mysqli_init();
// Use native types (MysqlND only) // Use native types (MysqlND only)
if (defined('MYSQLI_OPT_INT_AND_FLOAT_NATIVE')) { if (defined('MYSQLI_OPT_INT_AND_FLOAT_NATIVE')) {
$this->dbConn->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true); $this->dbConn->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true);
// The alternative is not ideal, throw a notice-level error
} else { } else {
// The alternative is not ideal, throw a notice-level error
user_error( user_error(
'mysqlnd PHP library is not available, numeric values will be fetched from the DB as strings', 'mysqlnd PHP library is not available, numeric values will be fetched from the DB as strings',
E_USER_NOTICE E_USER_NOTICE
@ -117,7 +124,9 @@ class MySQLiConnector extends DBConnector
$parameters['username'], $parameters['username'],
$parameters['password'], $parameters['password'],
$selectedDB, $selectedDB,
!empty($parameters['port']) ? $parameters['port'] : ini_get("mysqli.default_port") !empty($parameters['port']) ? $parameters['port'] : ini_get("mysqli.default_port"),
$socket ?? null,
$flags ?? 0
); );
if ($this->dbConn->connect_error) { if ($this->dbConn->connect_error) {
@ -126,8 +135,8 @@ class MySQLiConnector extends DBConnector
// Set charset and collation if given and not null. Can explicitly set to empty string to omit // Set charset and collation if given and not null. Can explicitly set to empty string to omit
$charset = isset($parameters['charset']) $charset = isset($parameters['charset'])
? $parameters['charset'] ? $parameters['charset']
: $connCharset; : $connCharset;
if (!empty($charset)) { if (!empty($charset)) {
$this->dbConn->set_charset($charset); $this->dbConn->set_charset($charset);

View File

@ -233,7 +233,7 @@ class TempDatabase
{ {
DataObject::reset(); 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); Injector::inst()->unregisterObjects(DataObject::class);
$dataClasses = ClassInfo::subclassesFor(DataObject::class); $dataClasses = ClassInfo::subclassesFor(DataObject::class);

View File

@ -190,7 +190,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
/** /**
* Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type, * 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 * Defaults will not be populated and data passed will be ignored
*/ */
const CREATE_SINGLETON = 1; const CREATE_SINGLETON = 1;
@ -1982,7 +1982,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
if ($details['polymorphic']) { if ($details['polymorphic']) {
$result = PolymorphicHasManyList::create($componentClass, $details['joinField'], static::class); $result = PolymorphicHasManyList::create($componentClass, $details['joinField'], static::class);
if ($details['needsRelation']) { if ($details['needsRelation']) {
Deprecation::withNoReplacement(fn () => $result->setForeignRelation($componentName)); Deprecation::withSuppressedNotice(fn () => $result->setForeignRelation($componentName));
} }
} else { } else {
$result = HasManyList::create($componentClass, $details['joinField']); $result = HasManyList::create($componentClass, $details['joinField']);
@ -3785,7 +3785,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
* Invoked after every database build is complete (including after table creation and * Invoked after every database build is complete (including after table creation and
* default record population). * default record population).
* *
* See {@link DatabaseAdmin::doBuild()} for context. * See {@link DbBuild::doBuild()} for context.
*/ */
public function onAfterBuild() public function onAfterBuild()
{ {

View File

@ -314,7 +314,7 @@ class DataObjectSchema
* Generate table name for a class. * 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. * 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 * @param string $class
* *

View File

@ -1,566 +0,0 @@
<?php
namespace SilverStripe\ORM;
use BadMethodCallException;
use Generator;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ClassLoader;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\DevBuildController;
use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\ORM\Connect\DatabaseException;
use SilverStripe\ORM\Connect\TableBuilder;
use SilverStripe\ORM\FieldType\DBClassName;
use SilverStripe\ORM\FieldType\DBClassNameVarchar;
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
use SilverStripe\Versioned\Versioned;
/**
* DatabaseAdmin class
*
* Utility functions for administrating the database. These can be accessed
* via URL, e.g. http://www.yourdomain.com/db/build.
*
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
*/
class DatabaseAdmin extends Controller
{
/// SECURITY ///
private static $allowed_actions = [
'index',
'build',
'cleanup',
'import'
];
/**
* Obsolete classname values that should be remapped in dev/build
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.classname_value_remapping
*/
private static $classname_value_remapping = [
'File' => 'SilverStripe\\Assets\\File',
'Image' => 'SilverStripe\\Assets\\Image',
'Folder' => 'SilverStripe\\Assets\\Folder',
'Group' => 'SilverStripe\\Security\\Group',
'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt',
'Member' => 'SilverStripe\\Security\\Member',
'MemberPassword' => 'SilverStripe\\Security\\MemberPassword',
'Permission' => 'SilverStripe\\Security\\Permission',
'PermissionRole' => 'SilverStripe\\Security\\PermissionRole',
'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode',
'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash',
];
/**
* Config setting to enabled/disable the display of record counts on the dev/build output
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.show_record_counts
*/
private static $show_record_counts = true;
public function __construct()
{
parent::__construct();
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\DbBuild',
Deprecation::SCOPE_CLASS
);
});
}
protected function init()
{
parent::init();
if (!$this->canInit()) {
Security::permissionFailure(
$this,
"This page is secured and you need elevated permissions to access it. " .
"Enter your credentials below and we will send you right along."
);
}
}
/**
* Get the data classes, grouped by their root class
*
* @return array Array of data classes, grouped by their root class
*/
public function groupedDataClasses()
{
// Get all root data objects
$allClasses = get_declared_classes();
$rootClasses = [];
foreach ($allClasses as $class) {
if (get_parent_class($class ?? '') == DataObject::class) {
$rootClasses[$class] = [];
}
}
// Assign every other data object one of those
foreach ($allClasses as $class) {
if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
foreach ($rootClasses as $rootClass => $dummy) {
if (is_subclass_of($class, $rootClass ?? '')) {
$rootClasses[$rootClass][] = $class;
break;
}
}
}
}
return $rootClasses;
}
/**
* When we're called as /dev/build, that's actually the index. Do the same
* as /dev/build/build.
*/
public function index()
{
return $this->build();
}
/**
* Updates the database schema, creating tables & fields as necessary.
*/
public function build()
{
// The default time limit of 30 seconds is normally not enough
Environment::increaseTimeLimitTo(600);
// If this code is being run outside of a dev/build or without a ?flush query string param,
// the class manifest hasn't been flushed, so do it here
$request = $this->getRequest();
if (!array_key_exists('flush', $request->getVars() ?? []) && strpos($request->getURL() ?? '', 'dev/build') !== 0) {
ClassLoader::inst()->getManifest()->regenerate(false);
}
$url = $this->getReturnURL();
if ($url) {
echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
$this->doBuild(true);
echo "<p>Done!</p>";
$this->redirect($url);
} else {
$quiet = $this->request->requestVar('quiet') !== null;
$fromInstaller = $this->request->requestVar('from_installer') !== null;
$populate = $this->request->requestVar('dont_populate') === null;
$this->doBuild($quiet || $fromInstaller, $populate);
}
}
/**
* Gets the url to return to after build
*
* @return string|null
*/
protected function getReturnURL()
{
$url = $this->request->getVar('returnURL');
// Check that this url is a site url
if (empty($url) || !Director::is_site_url($url)) {
return null;
}
// Convert to absolute URL
return Director::absoluteURL((string) $url, true);
}
/**
* Build the default data, calling requireDefaultRecords on all
* DataObject classes
*/
public function buildDefaults()
{
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
array_shift($dataClasses);
if (!Director::is_cli()) {
echo "<ul>";
}
foreach ($dataClasses as $dataClass) {
singleton($dataClass)->requireDefaultRecords();
if (Director::is_cli()) {
echo "Defaults loaded for $dataClass\n";
} else {
echo "<li>Defaults loaded for $dataClass</li>\n";
}
}
if (!Director::is_cli()) {
echo "</ul>";
}
}
/**
* Returns the timestamp of the time that the database was last built
*
* @return string Returns the timestamp of the time that the database was
* last built
*
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()
*/
public static function lastBuilt()
{
Deprecation::withNoReplacement(function () {
Deprecation::notice(
'5.4.0',
'Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()'
);
});
$file = TEMP_PATH
. DIRECTORY_SEPARATOR
. 'database-last-generated-'
. str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? '');
if (file_exists($file ?? '')) {
return filemtime($file ?? '');
}
return null;
}
/**
* Updates the database schema, creating tables & fields as necessary.
*
* @param boolean $quiet Don't show messages
* @param boolean $populate Populate the database, as well as setting up its schema
* @param bool $testMode
*/
public function doBuild($quiet = false, $populate = true, $testMode = false)
{
$this->extend('onBeforeBuild', $quiet, $populate, $testMode);
if ($quiet) {
DB::quiet();
} else {
$conn = DB::get_conn();
// Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
$dbType = substr(get_class($conn), 0, -8);
$dbVersion = $conn->getVersion();
$databaseName = $conn->getSelectedDatabase();
if (Director::is_cli()) {
echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion);
} else {
echo sprintf("<h2>Building database %s using %s %s</h2>", $databaseName, $dbType, $dbVersion);
}
}
// Set up the initial database
if (!DB::is_active()) {
if (!$quiet) {
echo '<p><b>Creating database</b></p>';
}
// Load parameters from existing configuration
$databaseConfig = DB::getConfig();
if (empty($databaseConfig) && empty($_REQUEST['db'])) {
throw new BadMethodCallException("No database configuration available");
}
$parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db'];
// Check database name is given
if (empty($parameters['database'])) {
throw new BadMethodCallException(
"No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
);
}
$database = $parameters['database'];
// Establish connection
unset($parameters['database']);
DB::connect($parameters);
// Check to ensure that the re-instated SS_DATABASE_SUFFIX functionality won't unexpectedly
// rename the database. To be removed for SS5
if ($suffix = Environment::getEnv('SS_DATABASE_SUFFIX')) {
$previousName = preg_replace("/{$suffix}$/", '', $database ?? '');
if (!isset($_GET['force_suffix_rename']) && DB::get_conn()->databaseExists($previousName)) {
throw new DatabaseException(
"SS_DATABASE_SUFFIX was previously broken, but has now been fixed. This will result in your "
. "database being named \"{$database}\" instead of \"{$previousName}\" from now on. If this "
. "change is intentional, please visit dev/build?force_suffix_rename=1 to continue"
);
}
}
// Create database
DB::create_database($database);
}
// Build the database. Most of the hard work is handled by DataObject
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
array_shift($dataClasses);
if (!$quiet) {
if (Director::is_cli()) {
echo "\nCREATING DATABASE TABLES\n\n";
} else {
echo "\n<p><b>Creating database tables</b></p><ul>\n\n";
}
}
$showRecordCounts = (boolean)$this->config()->show_record_counts;
// Initiate schema update
$dbSchema = DB::get_schema();
$tableBuilder = TableBuilder::singleton();
$tableBuilder->buildTables($dbSchema, $dataClasses, [], $quiet, $testMode, $showRecordCounts);
ClassInfo::reset_db_cache();
if (!$quiet && !Director::is_cli()) {
echo "</ul>";
}
if ($populate) {
if (!$quiet) {
if (Director::is_cli()) {
echo "\nCREATING DATABASE RECORDS\n\n";
} else {
echo "\n<p><b>Creating database records</b></p><ul>\n\n";
}
}
// Remap obsolete class names
$this->migrateClassNames();
// Require all default records
foreach ($dataClasses as $dataClass) {
// Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
// Test_ indicates that it's the data class is part of testing system
if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
if (!$quiet) {
if (Director::is_cli()) {
echo " * $dataClass\n";
} else {
echo "<li>$dataClass</li>\n";
}
}
DataObject::singleton($dataClass)->requireDefaultRecords();
}
}
if (!$quiet && !Director::is_cli()) {
echo "</ul>";
}
}
touch(TEMP_PATH
. DIRECTORY_SEPARATOR
. 'database-last-generated-'
. str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? ''));
if (isset($_REQUEST['from_installer'])) {
echo "OK";
}
if (!$quiet) {
echo (Director::is_cli()) ? "\n Database build completed!\n\n" : "<p>Database build completed!</p>";
}
foreach ($dataClasses as $dataClass) {
DataObject::singleton($dataClass)->onAfterBuild();
}
ClassInfo::reset_db_cache();
$this->extend('onAfterBuild', $quiet, $populate, $testMode);
}
public function canInit(): bool
{
// We allow access to this controller regardless of live-status or ADMIN permission only
// if on CLI or with the database not ready. The latter makes it less error-prone to do an
// initial schema build without requiring a default-admin login.
// Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
$allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
return (
Director::isDev()
|| !Security::database_is_ready()
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
// "dev/tests" from CLI.
|| (Director::is_cli() && $allowAllCLI)
|| Permission::check(DevBuildController::config()->get('init_permissions'))
);
}
/**
* Given a base data class, a field name and a mapping of class replacements, look for obsolete
* values in the $dataClass's $fieldName column and replace it with $mapping
*
* @param string $dataClass The data class to look up
* @param string $fieldName The field name to look in for obsolete class names
* @param string[] $mapping Map of old to new classnames
*/
protected function updateLegacyClassNameField($dataClass, $fieldName, $mapping)
{
$schema = DataObject::getSchema();
// Check first to ensure that the class has the specified field to update
if (!$schema->databaseField($dataClass, $fieldName, false)) {
return;
}
// Load a list of any records that have obsolete class names
$table = $schema->tableName($dataClass);
$currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
// Get all invalid classes for this field
$invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
if (!$invalidClasses) {
return;
}
$numberClasses = count($invalidClasses ?? []);
DB::alteration_message(
"Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
'obsolete'
);
// Build case assignment based on all intersected legacy classnames
$cases = [];
$params = [];
foreach ($invalidClasses as $invalidClass) {
$cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
$params[] = $invalidClass;
$params[] = $mapping[$invalidClass];
}
foreach ($this->getClassTables($dataClass) as $table) {
$casesSQL = implode(' ', $cases);
$sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
DB::prepared_query($sql, $params);
}
}
/**
* Get tables to update for this class
*
* @param string $dataClass
* @return Generator|string[]
*/
protected function getClassTables($dataClass)
{
$schema = DataObject::getSchema();
$table = $schema->tableName($dataClass);
// Base table
yield $table;
// Remap versioned table class name values as well
/** @var Versioned|DataObject $dataClass */
$dataClass = DataObject::singleton($dataClass);
if ($dataClass->hasExtension(Versioned::class)) {
if ($dataClass->hasStages()) {
yield "{$table}_Live";
}
yield "{$table}_Versions";
}
}
/**
* Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
* `ClassName` fields as well as polymorphic class name fields.
*
* @return array[]
*/
protected function getClassNameRemappingFields()
{
$dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
$schema = DataObject::getSchema();
$remapping = [];
foreach ($dataClasses as $className) {
$fieldSpecs = $schema->fieldSpecs($className);
foreach ($fieldSpecs as $fieldName => $fieldSpec) {
$dummy = Injector::inst()->create($fieldSpec, 'Dummy');
if ($dummy instanceof DBClassName || $dummy instanceof DBClassNameVarchar) {
$remapping[$className][] = $fieldName;
}
}
}
return $remapping;
}
/**
* Remove invalid records from tables - that is, records that don't have
* corresponding records in their parent class tables.
*/
public function cleanup()
{
$baseClasses = [];
foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
if (get_parent_class($class ?? '') == DataObject::class) {
$baseClasses[] = $class;
}
}
$schema = DataObject::getSchema();
foreach ($baseClasses as $baseClass) {
// Get data classes
$baseTable = $schema->baseDataTable($baseClass);
$subclasses = ClassInfo::subclassesFor($baseClass);
unset($subclasses[0]);
foreach ($subclasses as $k => $subclass) {
if (!DataObject::getSchema()->classHasTable($subclass)) {
unset($subclasses[$k]);
}
}
if ($subclasses) {
$records = DB::query("SELECT * FROM \"$baseTable\"");
foreach ($subclasses as $subclass) {
$subclassTable = $schema->tableName($subclass);
$recordExists[$subclass] =
DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
}
foreach ($records as $record) {
foreach ($subclasses as $subclass) {
$subclassTable = $schema->tableName($subclass);
$id = $record['ID'];
if (($record['ClassName'] != $subclass)
&& (!is_subclass_of($record['ClassName'], $subclass ?? ''))
&& isset($recordExists[$subclass][$id])
) {
$sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
echo "<li>$sql [{$id}]</li>";
DB::prepared_query($sql, [$id]);
}
}
}
}
}
}
/**
* Migrate all class names
*/
protected function migrateClassNames()
{
$remappingConfig = $this->config()->get('classname_value_remapping');
$remappingFields = $this->getClassNameRemappingFields();
foreach ($remappingFields as $className => $fieldNames) {
foreach ($fieldNames as $fieldName) {
$this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
}
}
}
}

View File

@ -56,7 +56,7 @@ trait DBClassNameTrait
if ($this->record) { if ($this->record) {
return $schema->baseDataClass($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()); $tableClass = $schema->tableClass($this->getTable());
if ($tableClass && ($baseClass = $schema->baseDataClass($tableClass))) { if ($tableClass && ($baseClass = $schema->baseDataClass($tableClass))) {
return $baseClass; return $baseClass;

View File

@ -0,0 +1,143 @@
<?php
namespace SilverStripe\PolyExecution;
use SensioLabs\AnsiConverter\AnsiToHtmlConverter as BaseAnsiConverter;
use SensioLabs\AnsiConverter\Theme\Theme;
use SilverStripe\Core\Injector\Injectable;
/**
* Converts an ANSI text to HTML5 but doesn't give an opinionated default colour that isn't specified in the ANSI.
*/
class AnsiToHtmlConverter extends BaseAnsiConverter
{
use Injectable;
public function __construct(Theme $theme = null, $inlineStyles = true, $charset = 'UTF-8')
{
$theme ??= AnsiToHtmlTheme::create();
parent::__construct($theme, $inlineStyles, $charset);
}
public function convert($text)
{
// remove cursor movement sequences
$text = preg_replace('#\e\[(K|s|u|2J|2K|\d+(A|B|C|D|E|F|G|J|K|S|T)|\d+;\d+(H|f))#', '', $text);
// remove character set sequences
$text = preg_replace('#\e(\(|\))(A|B|[0-2])#', '', $text);
$text = htmlspecialchars($text, PHP_VERSION_ID >= 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset);
// convert hyperlinks to `<a>` tags (this is new to this subclass)
$text = preg_replace('#\033]8;;(?<href>[^\033]*)\033\\\(?<text>[^\033]*)\033]8;;\033\\\#', '<a href="$1">$2</a>', $text);
// carriage return
$text = preg_replace('#^.*\r(?!\n)#m', '', $text);
$tokens = $this->tokenize($text);
// a backspace remove the previous character but only from a text token
foreach ($tokens as $i => $token) {
if ('backspace' == $token[0]) {
$j = $i;
while (--$j >= 0) {
if ('text' == $tokens[$j][0] && strlen($tokens[$j][1]) > 0) {
$tokens[$j][1] = substr($tokens[$j][1], 0, -1);
break;
}
}
}
}
$html = '';
foreach ($tokens as $token) {
if ('text' == $token[0]) {
$html .= $token[1];
} elseif ('color' == $token[0]) {
$html .= $this->convertAnsiToColor($token[1]);
}
}
// These lines commented out from the parent class implementation.
// We don't want this opinionated default colouring - it doesn't appear in the ANSI format so it doesn't belong in the output.
// if ($this->inlineStyles) {
// $html = sprintf('<span style="background-color: %s; color: %s">%s</span>', $this->inlineColors['black'], $this->inlineColors['white'], $html);
// } else {
// $html = sprintf('<span class="ansi_color_bg_black ansi_color_fg_white">%s</span>', $html);
// }
// We do need an opening and closing span though, or the HTML markup is broken
$html = '<span>' . $html . '</span>';
// remove empty span
$html = preg_replace('#<span[^>]*></span>#', '', $html);
// remove unnecessary span
$html = preg_replace('#<span>(.*?(?!</span>)[^<]*)</span>#', '$1', $html);
return $html;
}
protected function convertAnsiToColor($ansi)
{
// Set $bg and $fg to null so we don't have a default opinionated colouring
$bg = null;
$fg = null;
$style = [];
$classes = [];
if ('0' != $ansi && '' != $ansi) {
$options = explode(';', $ansi);
foreach ($options as $option) {
if ($option >= 30 && $option < 38) {
$fg = $option - 30;
} elseif ($option >= 40 && $option < 48) {
$bg = $option - 40;
} elseif (39 == $option) {
$fg = null; // reset to default
} elseif (49 == $option) {
$bg = null; // reset to default
}
}
// options: bold => 1, underscore => 4, blink => 5, reverse => 7, conceal => 8
if (in_array(1, $options)) {
$style[] = 'font-weight: bold';
$classes[] = 'ansi_bold';
}
if (in_array(4, $options)) {
$style[] = 'text-decoration: underline';
$classes[] = 'ansi_underline';
}
if (in_array(7, $options)) {
$tmp = $fg;
$fg = $bg;
$bg = $tmp;
}
}
// Biggest changes start here and go to the end of the method.
// We're explicitly only setting the styling that was included in the ANSI formatting. The original applies
// default colours regardless.
if ($bg !== null) {
$style[] = sprintf('background-color: %s', $this->inlineColors[$this->colorNames[$bg]]);
$classes[] = sprintf('ansi_color_bg_%s', $this->colorNames[$bg]);
}
if ($fg !== null) {
$style[] = sprintf('color: %s', $this->inlineColors[$this->colorNames[$fg]]);
$classes[] = sprintf('ansi_color_fg_%s', $this->colorNames[$fg]);
}
if ($this->inlineStyles && !empty($style)) {
return sprintf('</span><span style="%s">', implode('; ', $style));
}
if (!$this->inlineStyles && !empty($classes)) {
return sprintf('</span><span class="%s">', implode('; ', $classes));
}
// Because of the way the parent class is implemented, we need to stop the old span and start a new one
// even if we don't have any styling to apply.
return '</span><span>';
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace SilverStripe\PolyExecution;
use SensioLabs\AnsiConverter\Theme\Theme;
use SilverStripe\Core\Injector\Injectable;
/**
* Theme for converting ANSI colours to something suitable in a browser against a white background
*/
class AnsiToHtmlTheme extends Theme
{
use Injectable;
public function asArray()
{
$colourMap = parent::asArray();
$colourMap['cyan'] = 'royalblue';
$colourMap['yellow'] = 'goldenrod';
return $colourMap;
}
public function asArrayBackground()
{
$colourMap = parent::asArrayBackground();
$colourMap['cyan'] = 'royalblue';
$colourMap['yellow'] = 'goldenrod';
return $colourMap;
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace SilverStripe\PolyExecution;
use SilverStripe\Core\Injector\Injectable;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Formatter\OutputFormatterStyleInterface;
/**
* Wraps an ANSI formatter and converts the ANSI formatting to styled HTML.
*/
class HtmlOutputFormatter implements OutputFormatterInterface
{
use Injectable;
private OutputFormatterInterface $ansiFormatter;
private AnsiToHtmlConverter $ansiConverter;
public function __construct(OutputFormatterInterface $formatter)
{
$this->ansiFormatter = $formatter;
$this->ansiConverter = AnsiToHtmlConverter::create();
}
public function setDecorated(bool $decorated): void
{
$this->ansiFormatter->setDecorated($decorated);
}
public function isDecorated(): bool
{
return $this->ansiFormatter->isDecorated();
}
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
{
$this->ansiFormatter->setStyle($name, $style);
}
public function hasStyle(string $name): bool
{
return $this->ansiFormatter->hasStyle($name);
}
public function getStyle(string $name): OutputFormatterStyleInterface
{
return $this->ansiFormatter->getStyle($name);
}
public function format(?string $message): ?string
{
$formatted = $this->ansiFormatter->format($message);
if ($this->isDecorated()) {
return $this->ansiConverter->convert($formatted);
}
return $formatted;
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace SilverStripe\PolyExecution;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Injector\Injectable;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Input that populates options from an HTTPRequest
*
* Use this for inputs to PolyCommand when called from a web request.
*/
class HttpRequestInput extends ArrayInput
{
use Injectable;
protected bool $interactive = false;
/**
* @param array<InputOption> $commandOptions Any options that apply for the command itself.
* Do not include global options (e.g. flush) - they are added explicitly in the constructor.
*/
public function __construct(HTTPRequest $request, array $commandOptions = [])
{
$definition = new InputDefinition([
// Also add global options that are applicable for HTTP requests
new InputOption('quiet', null, InputOption::VALUE_NONE, 'Do not output any message'),
new InputOption('verbose', null, InputOption::VALUE_OPTIONAL, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'),
// The actual flushing already happened before this point, but we still need
// to declare the option in case someone's checking against it
new InputOption('flush', null, InputOption::VALUE_NONE, 'Flush the cache before running the command'),
...$commandOptions
]);
$optionValues = $this->getOptionValuesFromRequest($request, $definition);
parent::__construct($optionValues, $definition);
}
/**
* Get the verbosity that should be used based on the request vars.
* This is used to set the verbosity for PolyOutput.
*/
public function getVerbosity(): int
{
if ($this->getOption('quiet')) {
return OutputInterface::VERBOSITY_QUIET;
}
$verbose = $this->getOption('verbose');
if ($verbose === '1' || $verbose === 1 || $verbose === true) {
return OutputInterface::VERBOSITY_VERBOSE;
}
if ($verbose === '2' || $verbose === 2) {
return OutputInterface::VERBOSITY_VERY_VERBOSE;
}
if ($verbose === '3' || $verbose === 3) {
return OutputInterface::VERBOSITY_DEBUG;
}
return OutputInterface::VERBOSITY_NORMAL;
}
private function getOptionValuesFromRequest(HTTPRequest $request, InputDefinition $definition): array
{
$options = [];
foreach ($definition->getOptions() as $option) {
// We'll check for the long name and all shortcuts.
// Note the `--` and `-` prefixes are already stripped at this point.
$candidateParams = [$option->getName()];
$shortcutString = $option->getShortcut();
if ($shortcutString !== null) {
$shortcuts = explode('|', $shortcutString);
foreach ($shortcuts as $shortcut) {
$candidateParams[] = $shortcut;
}
}
// Get a value if there is one
$value = null;
foreach ($candidateParams as $candidateParam) {
$value = $request->requestVar($candidateParam);
}
$default = $option->getDefault();
// Set correct default value
if ($value === null && $default !== null) {
$value = $default;
}
// Ignore missing values if values aren't required
if (($value === null || $value === []) && $option->isValueRequired()) {
continue;
}
// Convert value to array if it should be one
if ($value !== null && $option->isArray() && !is_array($value)) {
$value = [$value];
}
// If there's a value (or the option accepts one and didn't get one), set the option.
if ($value !== null || $option->acceptValue()) {
// If the option doesn't accept a value, determine the correct boolean state for it.
// If we weren't able to determine if the value's boolean-ness, default to truthy=true
// because that's what you'd end up with with `if ($request->requestVar('myVar'))`
if (!$option->acceptValue()) {
$value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
}
// We need to prefix with `--` so the superclass knows it's an
// option rather than an argument.
$options['--' . $option->getName()] = $value;
}
}
return $options;
}
}

View File

@ -0,0 +1,197 @@
<?php
namespace SilverStripe\PolyExecution;
use RuntimeException;
use SilverStripe\Control\Director;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\PolyExecution\HtmlOutputFormatter;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\Security\Permission;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* Abstract class for commands which can be run either via an HTTP request or the CLI.
*/
abstract class PolyCommand
{
use Configurable;
use Injectable;
/**
* Defines whether this command can be run in the CLI via sake.
* Overridden if DevelopmentAdmin sets allow_all_cli to true.
*
* Note that in dev mode the command can always be run.
*/
private static bool $can_run_in_cli = true;
/**
* Defines whether this command can be run in the browser via a web request.
* If true, user must have the requisite permissions.
*
* Note that in dev mode the command can always be run.
*/
private static bool $can_run_in_browser = true;
/**
* Permissions required for users to execute this command via the browser.
* Users must have at least one of these permissions to run the command in an HTTP request.
* If `can_run_in_browser` is false, these permissions are ignored.
* Must be defined in the subclass.
*
* If permissions are set as keys, the value must be boolean, indicating whether to check
* that permission or not. This is useful for allowing developers to turn off permission checks
* that aren't valid for their project or for their subclass.
*/
private static array $permissions_for_browser_execution = [];
/**
* Name of the command. Also used as the end of the URL segment for browser execution.
* Must be defined in the subclass.
*/
protected static string $commandName = '';
/**
* Description of what the command does. Can use symfony console styling.
* See https://symfony.com/doc/current/console/coloring.html.
* Must be defined in the subclass.
*/
protected static string $description = '';
/**
* Get the title for this command.
*/
abstract public function getTitle(): string;
/**
* Execute this command.
*
* Output should be agnostic - do not include explicit HTML in the output unless there is no API
* on `PolyOutput` for what you want to do (in which case use the writeForHtml() method).
*
* Use symfony/console ANSI formatting to style the output.
* See https://symfony.com/doc/current/console/coloring.html
*
* @return int 0 if everything went fine, or an exit code
*/
abstract public function run(InputInterface $input, PolyOutput $output): int;
/**
* Get the name of this command.
*/
public static function getName(): string
{
return static::$commandName;
}
/**
* Get the description of this command. Includes unparsed symfony/console styling.
*/
public static function getDescription(): string
{
return _t(static::class . '.description', static::$description);
}
/**
* Return additional help context to avoid an overly long description.
*/
public static function getHelp(): string
{
return '';
}
/**
* Get input options that can be passed into the command.
*
* In CLI execution these will be passed as flags.
* In HTTP execution these will be passed in the query string.
*
* @return array<InputOption>
*/
public function getOptions(): array
{
return [];
}
public function getOptionsForTemplate(): array
{
$formatter = HtmlOutputFormatter::create();
$forTemplate = [];
foreach ($this->getOptions() as $option) {
$default = $option->getDefault();
if (is_bool($default)) {
// Use 1/0 for boolean, since that's what you'd pass in the query string
$default = $default ? '1' : '0';
}
if (is_array($default)) {
$default = implode(',', $default);
}
$forTemplate[] = [
'Name' => $option->getName(),
'Description' => DBField::create_field('HTMLText', $formatter->format($option->getDescription())),
'Default' => $default,
];
}
return $forTemplate;
}
/**
* Check whether this command can be run in CLI via sake
*/
public static function canRunInCli(): bool
{
static::checkPrerequisites();
return Director::isDev()
|| static::config()->get('can_run_in_cli')
|| DevelopmentAdmin::config()->get('allow_all_cli');
}
/**
* Check whether this command can be run in the browser via a web request
*/
public static function canRunInBrowser(): bool
{
static::checkPrerequisites();
// Can always run in browser in dev mode
if (Director::isDev()) {
return true;
}
if (!static::config()->get('can_run_in_browser')) {
return false;
}
// Check permissions if there are any
$permissions = static::config()->get('permissions_for_browser_execution');
if (!empty($permissions)) {
$usePermissions = [];
// Only use permissions that aren't set to false
// Allow permissions to also be simply set as values, for simplicity
foreach ($permissions as $key => $value) {
if (is_string($value)) {
$usePermissions[] = $value;
} elseif ($value) {
$usePermissions[] = $key;
}
}
return Permission::check($usePermissions);
}
return true;
}
private static function checkPrerequisites(): void
{
$mandatoryMethods = [
'getName' => 'commandName',
'getDescription' => 'description',
];
foreach ($mandatoryMethods as $getter => $property) {
if (!static::$getter()) {
throw new RuntimeException($property . ' property needs to be set.');
}
}
}
}

View File

@ -0,0 +1,268 @@
<?php
namespace SilverStripe\PolyExecution;
use InvalidArgumentException;
use LogicException;
use SensioLabs\AnsiConverter\AnsiToHtmlConverter;
use SilverStripe\Core\Injector\Injectable;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\Output;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Output that correctly formats for HTML or for the terminal, depending on the output type.
* Used for functionality that can be used both via CLI and via the browser.
*/
class PolyOutput extends Output
{
use Injectable;
public const LIST_UNORDERED = 'ul';
public const LIST_ORDERED = 'ol';
/** Use this if you want HTML markup in the output */
public const FORMAT_HTML = 'Html';
/** Use this for outputing to a terminal, or for plain text output */
public const FORMAT_ANSI = 'Ansi';
private string $outputFormat;
private ?OutputInterface $wrappedOutput = null;
private ?AnsiToHtmlConverter $ansiConverter = null;
/**
* Array of list types that are opened, and the options that were used to open them.
*/
private array $listTypeStack = [];
/**
* @param string $outputFormat The format to use for the output (one of the FORMAT_* constants)
* @param int The verbosity level (one of the VERBOSITY_* constants in OutputInterface)
* @param boolean $decorated Whether to decorate messages (if false, decoration tags will simply be removed)
* @param OutputInterface|null $wrappedOutput An optional output pipe messages through.
* Useful for capturing output instead of echoing directly to the client, for example.
*/
public function __construct(
string $outputFormat,
int $verbosity = OutputInterface::VERBOSITY_NORMAL,
bool $decorated = false,
?OutputInterface $wrappedOutput = null
) {
$this->setOutputFormat($outputFormat);
// Intentionally don't call parent constructor, because it doesn't use the setter methods.
if ($wrappedOutput) {
$this->setWrappedOutput($wrappedOutput);
} else {
$this->setFormatter(new OutputFormatter());
}
$this->setDecorated($decorated);
$this->setVerbosity($verbosity);
}
/**
* Writes messages to the output - but only if we're using HTML format.
* Useful for when HTML and ANSI formatted output need to diverge.
*
* Note that this method uses RAW output by default, which allows you to add HTML markup
* directly into the message. If you're using symfony/console style formatting, set
* $options to use the OUTPUT_NORMAL constant.
*
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
*/
public function writeForHtml(
string|iterable $messages,
bool $newline = false,
int $options = OutputInterface::OUTPUT_RAW
): void {
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$this->write($messages, $newline, $options);
}
}
/**
* Writes messages to the output - but only if we're using ANSI format.
* Useful for when HTML and ANSI formatted output need to diverge.
*
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
*/
public function writeForAnsi(
string|iterable $messages,
bool $newline = false,
int $options = OutputInterface::OUTPUT_NORMAL
): void {
if ($this->outputFormat === PolyOutput::FORMAT_ANSI) {
$this->write($messages, $newline, $options);
}
}
/**
* Start a list.
* In HTML format this will write the opening `<ul>` or `<ol>` tag.
* In ANSI format this will set up information for rendering list items.
*
* Call writeListItem() to add items to the list, then call stopList() when you're done.
*
* @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
*/
public function startList(string $listType = PolyOutput::LIST_UNORDERED, int $options = OutputInterface::OUTPUT_NORMAL): void
{
$this->listTypeStack[] = ['type' => $listType, 'options' => $options];
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$this->write("<{$listType}>", options: $this->forceRawOutput($options));
}
}
/**
* Stop a list.
* In HTML format this will write the closing `</ul>` or `</ol>` tag.
* In ANSI format this will mark the list as closed (useful when nesting lists)
*/
public function stopList(): void
{
if (empty($this->listTypeStack)) {
throw new LogicException('No list to close.');
}
$info = array_pop($this->listTypeStack);
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$this->write("</{$info['type']}>", options: $this->forceRawOutput($info['options']));
}
}
/**
* Writes messages formatted as a list.
* Make sure to call startList() before writing list items, and call stopList() when you're done.
*
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
* by default this will inherit the options used to start the list.
*/
public function writeListItem(string|iterable $items, ?int $options = null): void
{
if (empty($this->listTypeStack)) {
throw new LogicException('No lists started. Call startList() first.');
}
if (is_string($items)) {
$items = [$items];
}
$method = "writeListItem{$this->outputFormat}";
$this->$method($items, $options);
}
public function setFormatter(OutputFormatterInterface $formatter): void
{
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$formatter = HtmlOutputFormatter::create($formatter);
}
parent::setFormatter($formatter);
}
/**
* Set whether this will output in HTML or ANSI format.
*
* @throws InvalidArgumentException if the format isn't one of the FORMAT_* constants
*/
public function setOutputFormat(string $outputFormat): void
{
if (!in_array($outputFormat, [PolyOutput::FORMAT_ANSI, PolyOutput::FORMAT_HTML])) {
throw new InvalidArgumentException("Unexpected format - got '$outputFormat'.");
}
$this->outputFormat = $outputFormat;
}
/**
* Get the format used for output.
*/
public function getOutputFormat(): string
{
return $this->outputFormat;
}
/**
* Set an output to wrap inside this one. Useful for capturing output in a buffer.
*/
public function setWrappedOutput(OutputInterface $wrappedOutput): void
{
$this->wrappedOutput = $wrappedOutput;
$this->setFormatter($this->wrappedOutput->getFormatter());
// Give wrapped output a debug verbosity - that way it'll output everything we tell it to.
// Actual verbosity is handled by PolyOutput's parent Output class.
$this->wrappedOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
}
protected function doWrite(string $message, bool $newline): void
{
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
$output = $message . ($newline ? '<br>' . PHP_EOL : '');
} else {
$output = $message . ($newline ? PHP_EOL : '');
}
if ($this->wrappedOutput) {
$this->wrappedOutput->write($output, options: OutputInterface::OUTPUT_RAW);
} else {
echo $output;
}
}
private function writeListItemHtml(iterable $items, ?int $options): void
{
if ($options === null) {
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
$options = $listInfo['options'];
}
foreach ($items as $item) {
$this->write('<li>', options: $this->forceRawOutput($options));
$this->write($item, options: $options);
$this->write('</li>', options: $this->forceRawOutput($options));
}
}
private function writeListItemAnsi(iterable $items, ?int $options): void
{
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
$listType = $listInfo['type'];
if ($listType === PolyOutput::LIST_ORDERED) {
echo '';
}
if ($options === null) {
$options = $listInfo['options'];
}
foreach ($items as $i => $item) {
switch ($listType) {
case PolyOutput::LIST_UNORDERED:
$bullet = '*';
break;
case PolyOutput::LIST_ORDERED:
// Start at 1
$numberOffset = $listInfo['offset'] ?? 1;
$bullet = ($i + $numberOffset) . '.';
break;
default:
throw new InvalidArgumentException("Unexpected list type - got '$listType'.");
}
$indent = str_repeat(' ', count($this->listTypeStack));
$this->writeln("{$indent}{$bullet} {$item}", $options);
}
// Update the number offset so the next item in the list has the correct number
if ($listType === PolyOutput::LIST_ORDERED) {
$this->listTypeStack[array_key_last($this->listTypeStack)]['offset'] = $numberOffset + $i + 1;
}
}
private function getVerbosityOption(int $options): int
{
// Logic copied from Output::write() - uses bitwise operations to separate verbosity from output type.
$verbosities = OutputInterface::VERBOSITY_QUIET | OutputInterface::VERBOSITY_NORMAL | OutputInterface::VERBOSITY_VERBOSE | OutputInterface::VERBOSITY_VERY_VERBOSE | OutputInterface::VERBOSITY_DEBUG;
return $verbosities & $options ?: OutputInterface::VERBOSITY_NORMAL;
}
private function forceRawOutput(int $options): int
{
return $this->getVerbosityOption($options) | OutputInterface::OUTPUT_RAW;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace SilverStripe\PolyExecution;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
use SilverStripe\Core\Injector\Injectable;
/**
* Log handler that uses a PolyOutput to output log entries to the browser or CLI.
*/
class PolyOutputLogHandler extends AbstractProcessingHandler
{
use Injectable;
private PolyOutput $output;
public function __construct(PolyOutput $output, int|string|Level $level = Level::Debug, bool $bubble = true)
{
$this->output = $output;
parent::__construct($level, $bubble);
}
protected function write(LogRecord $record): void
{
$message = rtrim($record->formatted, PHP_EOL);
$this->output->write($message, true, PolyOutput::OUTPUT_RAW);
}
}

View File

@ -7,11 +7,6 @@ use SilverStripe\Control\Controller;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\RequestHandler; 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 * Confirmation form handler implementation

View File

@ -772,7 +772,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. // However, if TestMailer is in use this isn't a risk.
if ((Director::isLive() || Injector::inst()->get(MailerInterface::class) instanceof TestMailer) if ((Director::isLive() || Injector::inst()->get(MailerInterface::class) instanceof TestMailer)
&& $this->isChanged('Password') && $this->isChanged('Password')

View File

@ -176,7 +176,7 @@ class CookieAuthenticationHandler implements AuthenticationHandler
} }
// Renew the token // Renew the token
Deprecation::withNoReplacement(fn() => $rememberLoginHash->renew()); Deprecation::withSuppressedNotice(fn() => $rememberLoginHash->renew());
// Send the new token to the client if it was changed // Send the new token to the client if it was changed
if ($rememberLoginHash->getToken()) { if ($rememberLoginHash->getToken()) {

View File

@ -3,12 +3,15 @@
namespace SilverStripe\Security; namespace SilverStripe\Security;
use Exception; use Exception;
use SilverStripe\Core\Injector\Injectable;
/** /**
* Convenience class for generating cryptographically secure pseudo-random strings/tokens * Convenience class for generating cryptographically secure pseudo-random strings/tokens
*/ */
class RandomGenerator class RandomGenerator
{ {
use Injectable;
/** /**
* Generates a random token that can be used for session IDs, CSRF tokens etc., based on * Generates a random token that can be used for session IDs, CSRF tokens etc., based on
* hash algorithms. * hash algorithms.

View File

@ -1067,7 +1067,7 @@ class Security extends Controller implements TemplateGlobalProvider
/** /**
* Checks the database is in a state to perform security checks. * 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 * @return bool
*/ */

View File

@ -5,6 +5,7 @@ namespace SilverStripe\View;
use InvalidArgumentException; use InvalidArgumentException;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Model\ModelData; use SilverStripe\Model\ModelData;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBField;
/** /**
@ -433,6 +434,11 @@ class SSViewer_DataPresenter extends SSViewer_Scope
return $value; 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 // Get provided or default cast
$casting = empty($source['casting']) $casting = empty($source['casting'])
? ModelData::config()->uninherited('default_cast') ? ModelData::config()->uninherited('default_cast')

View File

@ -6,7 +6,6 @@ use Exception;
use LogicException; use LogicException;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extension; use SilverStripe\Core\Extension;
use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Manifest\ClassLoader; use SilverStripe\Core\Manifest\ClassLoader;
@ -39,8 +38,8 @@ use SilverStripe\ORM\DataObject;
* *
* Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask
* Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule
* Usage on CLI: sake dev/tasks/i18nTextCollectorTask * Usage on CLI: sake tasks:i18nTextCollectorTask
* Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule * Usage on CLI (module-specific): sake tasks:i18nTextCollectorTask --module=mymodule
* *
* @author Bernat Foj Capell <bernat@silverstripe.com> * @author Bernat Foj Capell <bernat@silverstripe.com>
* @author Ingo Schommer <FIRSTNAME@silverstripe.com> * @author Ingo Schommer <FIRSTNAME@silverstripe.com>

View File

@ -0,0 +1,37 @@
$Header.RAW
$Info.RAW
<% if $Title %>
<div class="info">
<h1>$Title</h1>
</div>
<% end_if %>
<div class="options">
<% if $Form %>
<%-- confirmation handler --%>
$Form
<% else %>
<ul>
<% loop $ArrayLinks %>
<li class="$EvenOdd">
<a href="$Link"><b>/$Path:</b> $Description</a>
<% if $Help %>
<details class="more-details">
<summary>Display additional information</summary>
$Help
</details>
<% end_if %>
<% if $Parameters %>
<div>Parameters:
<% include SilverStripe/Dev/Parameters %>
</div>
<% end_if %>
</li>
<% end_loop %>
</ul>
<% end_if %>
</div>
$Footer.RAW

View File

@ -0,0 +1,8 @@
<dl class="params">
<% loop $Parameters %>
<div class="param">
<dt class="param__name">$Name</dt>
<dd class="param__description">$Description<% if $Default %> [default: $Default]<% end_if %></dd>
</div>
<% end_loop %>
</dl>

View File

@ -9,7 +9,19 @@ $Info.RAW
<div class="task__item"> <div class="task__item">
<div> <div>
<h3 class="task__title">$Title</h3> <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>
<div> <div>
<a href="{$TaskLink.ATT}" class="task__button">Run task</a> <a href="{$TaskLink.ATT}" class="task__button">Run task</a>

View File

@ -25,9 +25,6 @@ $_SERVER = array_merge([
$frameworkPath = dirname(dirname(__FILE__)); $frameworkPath = dirname(dirname(__FILE__));
$frameworkDir = basename($frameworkPath ?? ''); $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. // Copied from cli-script.php, to enable same behaviour through phpunit runner.
if (isset($_SERVER['argv'][2])) { if (isset($_SERVER['argv'][2])) {
$args = array_slice($_SERVER['argv'] ?? [], 2); $args = array_slice($_SERVER['argv'] ?? [], 2);

View File

@ -0,0 +1,99 @@
<?php
namespace SilverStripe\Cli\Tests\Command;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Cli\Command\NavigateCommand;
use SilverStripe\Cli\Tests\Command\NavigateCommandTest\TestController;
use SilverStripe\Control\Director;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
class NavigateCommandTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideExecute(): array
{
return [
[
'path' => 'missing-route',
'getVars' => [],
'expectedExitCode' => 2,
'expectedOutput' => '',
],
[
'path' => 'test-controller',
'getVars' => [],
'expectedExitCode' => 0,
'expectedOutput' => 'This is the index for TestController.' . PHP_EOL,
],
[
'path' => 'test-controller/actionOne',
'getVars' => [],
'expectedExitCode' => 0,
'expectedOutput' => 'This is action one!' . PHP_EOL,
],
[
'path' => 'test-controller/errorResponse',
'getVars' => [],
'expectedExitCode' => 1,
'expectedOutput' => '',
],
[
'path' => 'test-controller/missing-action',
'getVars' => [],
'expectedExitCode' => 2,
'expectedOutput' => '',
],
[
'path' => 'test-controller',
'getVars' => [
'var1=1',
'var2=abcd',
'var3=',
'var4[]=a',
'var4[]=b',
'var4[]=c',
],
'expectedExitCode' => 0,
'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
],
[
'path' => 'test-controller',
'getVars' => [
'var1=1&var2=abcd&var3=&var4[]=a&var4[]=b&var4[]=c',
],
'expectedExitCode' => 0,
'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
],
];
}
#[DataProvider('provideExecute')]
public function testExecute(string $path, array $getVars, int $expectedExitCode, string $expectedOutput): void
{
// Intentionally override existing rules
Director::config()->set('rules', ['test-controller' => TestController::class]);
$navigateCommand = new NavigateCommand();
$inputParams = [
'path' => $path,
'get-vars' => $getVars,
];
$input = new ArrayInput($inputParams, $navigateCommand->getDefinition());
$input->setInteractive(false);
$buffer = new BufferedOutput();
$output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
$exitCode = $navigateCommand->run($input, $output);
// Don't asset specific output for failed or invalid responses
// The response body for those is handled outside of the navigate command's control
if ($expectedExitCode === 0) {
$this->assertSame($expectedOutput, $buffer->fetch());
}
$this->assertSame($expectedExitCode, $exitCode);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace SilverStripe\Cli\Tests\Command\NavigateCommandTest;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
class TestController extends Controller
{
private static $allowed_actions = [
'actionOne',
'errorResponse',
];
public function index(HTTPRequest $request): HTTPResponse
{
$var1 = $request->getVar('var1');
$var2 = $request->getVar('var2');
$var3 = $request->getVar('var3');
$var4 = $request->getVar('var4');
$output = 'This is the index for TestController.';
if ($var1) {
$output .= ' var1=' . $var1;
}
if ($var2) {
$output .= ' var2=' . $var2;
}
if ($var3) {
$output .= ' var3=' . $var3;
}
if ($var4) {
$output .= ' var4=' . implode(',', $var4);
}
$this->response->setBody($output);
return $this->response;
}
public function actionOne(HTTPRequest $request): HTTPResponse
{
$this->response->setBody('This is action one!');
return $this->response;
}
public function errorResponse(HTTPRequest $request): HTTPResponse
{
$this->httpError(500);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace SilverStripe\Cli\Tests\Command;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
use SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest\TestPolyCommand;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
class PolyCommandCliWrapperTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideExecute(): array
{
return [
'no-params' => [
'exitCode' => 0,
'params' => [],
'expectedOutput' => 'Has option 1: false' . PHP_EOL
. 'option 2 value: ' . PHP_EOL,
],
'with-params' => [
'exitCode' => 1,
'params' => [
'--option1' => true,
'--option2' => 'abc',
],
'expectedOutput' => 'Has option 1: true' . PHP_EOL
. 'option 2 value: abc' . PHP_EOL,
],
];
}
#[DataProvider('provideExecute')]
public function testExecute(int $exitCode, array $params, string $expectedOutput): void
{
$polyCommand = new TestPolyCommand();
$polyCommand->setExitCode($exitCode);
$wrapper = new PolyCommandCliWrapper($polyCommand);
$input = new ArrayInput($params, $wrapper->getDefinition());
$input->setInteractive(false);
$buffer = new BufferedOutput();
$output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
$this->assertSame($exitCode, $wrapper->run($input, $output));
$this->assertSame($expectedOutput, $buffer->fetch());
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
class TestPolyCommand extends PolyCommand implements TestOnly
{
protected static string $commandName = 'test:poly';
protected static string $description = 'simple command for testing CLI wrapper';
private int $exitCode = 0;
public function getTitle(): string
{
return 'This is the title!';
}
public function run(InputInterface $input, PolyOutput $output): int
{
$output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
$output->writeln('option 2 value: ' . $input->getOption('option2'));
return $this->exitCode;
}
public function setExitCode(int $code): void
{
$this->exitCode = $code;
}
public function getOptions(): array
{
return [
new InputOption('option1', null, InputOption::VALUE_NONE),
new InputOption('option2', null, InputOption::VALUE_REQUIRED),
];
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace SilverStripe\Cli\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Cli\LegacyParamArgvInput;
use SilverStripe\Dev\SapphireTest;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class LegacyParamArgvInputTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideHasParameterOption(): array
{
return [
'sake flush=1' => [
'argv' => [
'sake',
'flush=1'
],
'checkFor' => '--flush',
'expected' => true,
],
'sake flush=0' => [
'argv' => [
'sake',
'flush=0'
],
'checkFor' => '--flush',
'expected' => true,
],
'sake flush=1 --' => [
'argv' => [
'sake',
'flush=1',
'--'
],
'checkFor' => '--flush',
'expected' => true,
],
'sake -- flush=1' => [
'argv' => [
'sake',
'--',
'flush=1'
],
'checkFor' => '--flush',
'expected' => false,
],
];
}
#[DataProvider('provideHasParameterOption')]
public function testHasParameterOption(array $argv, string $checkFor, bool $expected): void
{
$input = new LegacyParamArgvInput($argv);
$this->assertSame($expected, $input->hasParameterOption($checkFor));
}
public static function provideGetParameterOption(): array
{
$scenarios = static::provideHasParameterOption();
$scenarios['sake flush=1']['expected'] = '1';
$scenarios['sake flush=0']['expected'] = '0';
$scenarios['sake flush=1 --']['expected'] = '1';
$scenarios['sake -- flush=1']['expected'] = false;
return $scenarios;
}
#[DataProvider('provideGetParameterOption')]
public function testGetParameterOption(array $argv, string $checkFor, false|string $expected): void
{
$input = new LegacyParamArgvInput($argv);
$this->assertSame($expected, $input->getParameterOption($checkFor));
}
public static function provideBind(): array
{
return [
'sake flush=1 arg=value' => [
'argv' => [
'sake',
'flush=1',
'arg=value',
],
'options' => [
new InputOption('--flush', null, InputOption::VALUE_NONE),
new InputOption('--arg', null, InputOption::VALUE_REQUIRED),
],
'expected' => [
'flush' => true,
'arg' => 'value',
],
],
'sake flush=yes arg=abc' => [
'argv' => [
'sake',
'flush=yes',
'arg=abc',
],
'options' => [
new InputOption('flush', null, InputOption::VALUE_NONE),
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
],
'expected' => [
'flush' => true,
'arg' => 'abc',
],
],
'sake flush=0 arg=' => [
'argv' => [
'sake',
'flush=0',
'arg=',
],
'options' => [
new InputOption('flush', null, InputOption::VALUE_NONE),
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
],
'expected' => [
'flush' => false,
'arg' => null,
],
],
'sake flush=1 -- arg=abc' => [
'argv' => [
'sake',
'flush=1',
'--',
'arg=abc',
],
'options' => [
new InputOption('flush', null, InputOption::VALUE_NONE),
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
// Since arg=abc is now included as an argument, we need to allow an argument.
new InputArgument('needed-to-avoid-error', InputArgument::REQUIRED),
],
'expected' => [
'flush' => true,
'arg' => null,
],
],
];
}
#[DataProvider('provideBind')]
public function testBind(array $argv, array $options, array $expected): void
{
$input = new LegacyParamArgvInput($argv);
$definition = new InputDefinition($options);
$input->bind($definition);
foreach ($expected as $option => $value) {
$this->assertSame($value, $input->getOption($option));
}
}
}

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

@ -0,0 +1,307 @@
<?php
namespace SilverStripe\Cli\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Error\Deprecated;
use ReflectionClass;
use SilverStripe\Cli\Sake;
use SilverStripe\Cli\Tests\SakeTest\TestBuildTask;
use SilverStripe\Cli\Tests\SakeTest\TestCommandLoader;
use SilverStripe\Cli\Tests\SakeTest\TestConfigCommand;
use SilverStripe\Cli\Tests\SakeTest\TestConfigPolyCommand;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel;
use SilverStripe\Core\Manifest\VersionProvider;
use SilverStripe\Dev\BuildTask;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestException;
use Symfony\Component\Console\Command\DumpCompletionCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
class SakeTest extends SapphireTest
{
protected $usesDatabase = false;
private $oldErrorHandler = null;
public static function provideList(): array
{
return [
'display all' => [
'addExtra' => true,
'hideCompletion' => true,
],
'display none' => [
'addExtra' => false,
'hideCompletion' => false,
],
];
}
/**
* Test adding commands and command loaders to Sake via configuration API
*/
#[DataProvider('provideList')]
public function testList(bool $addExtra, bool $hideCompletion): void
{
$sake = new Sake(Injector::inst()->get(Kernel::class));
$sake->setAutoExit(false);
$input = new ArrayInput(['list']);
$input->setInteractive(false);
$output = new BufferedOutput();
if ($addExtra) {
Sake::config()->merge('commands', [
TestConfigPolyCommand::class,
TestConfigCommand::class,
]);
Sake::config()->merge('command_loaders', [
TestCommandLoader::class,
]);
}
Sake::config()->set('hide_completion_command', $hideCompletion);
// Make sure all tasks are displayed - we'll test hiding them in testHideTasks
Sake::config()->set('max_tasks_to_display', 0);
$sake->run($input, $output);
$commandNames = [
'loader:test-command',
'test:from-config:standard',
'test:from-config:poly',
];
$commandDescriptions = [
'command for testing adding custom command loaders',
'command for testing adding standard commands via config',
'command for testing adding poly commands via config',
];
$listOutput = $output->fetch();
// Check if the extra commands are there or not
if ($addExtra) {
foreach ($commandNames as $name) {
$this->assertStringContainsString($name, $listOutput);
}
foreach ($commandDescriptions as $description) {
$this->assertStringContainsString($description, $listOutput);
}
} else {
foreach ($commandNames as $name) {
$this->assertStringNotContainsString($name, $listOutput);
}
foreach ($commandDescriptions as $description) {
$this->assertStringNotContainsString($description, $listOutput);
}
}
// Build task could display automagically as a matter of class inheritance.
$task = new TestBuildTask();
$this->assertStringContainsString($task->getName(), $listOutput);
$this->assertStringContainsString(TestBuildTask::getDescription(), $listOutput);
// Check if the completion command is there or not
$command = new DumpCompletionCommand();
$completionRegex = "/{$command->getName()}\s+{$command->getDescription()}/";
if ($hideCompletion) {
$this->assertDoesNotMatchRegularExpression($completionRegex, $listOutput);
} else {
$this->assertMatchesRegularExpression($completionRegex, $listOutput);
}
// Make sure the "help" and "list" commands aren't shown
$this->assertStringNotContainsString($listOutput, 'List commands', 'the list command should not display');
$this->assertStringNotContainsString($listOutput, 'Display help for a command', 'the help command should not display');
}
public function testPolyCommandCanRunInCli(): void
{
$kernel = Injector::inst()->get(Kernel::class);
$sake = new Sake($kernel);
$sake->setAutoExit(false);
$input = new ArrayInput(['list']);
$input->setInteractive(false);
$output = new BufferedOutput();
// Add test commands
Sake::config()->merge('commands', [
TestConfigPolyCommand::class,
]);
// Disallow these to run in CLI.
// Note the scenario where all are allowed is in testList().
TestConfigPolyCommand::config()->set('can_run_in_cli', false);
TestBuildTask::config()->set('can_run_in_cli', false);
DevelopmentAdmin::config()->set('allow_all_cli', false);
// Must not be in dev mode to test permissions, because all PolyCommand can be run in dev mode.
$origEnvironment = $kernel->getEnvironment();
$kernel->setEnvironment('live');
try {
$sake->run($input, $output);
} finally {
$kernel->setEnvironment($origEnvironment);
}
$listOutput = $output->fetch();
$allCommands = [
TestConfigPolyCommand::class,
TestBuildTask::class,
];
foreach ($allCommands as $commandClass) {
$command = new $commandClass();
$this->assertStringNotContainsString($command->getName(), $listOutput);
$this->assertStringNotContainsString($commandClass::getDescription(), $listOutput);
}
}
public static function provideHideTasks(): array
{
return [
'task count matches limit' => [
'taskLimit' => 'same',
'shouldShow' => true,
],
'task count lower than limit' => [
'taskLimit' => 'more',
'shouldShow' => true,
],
'task count greater than limit' => [
'taskLimit' => 'less',
'shouldShow' => false,
],
'unlimited tasks allowed' => [
'taskLimit' => 'all',
'shouldShow' => true,
],
];
}
#[DataProvider('provideHideTasks')]
public function testHideTasks(string $taskLimit, bool $shouldShow): void
{
$sake = new Sake(Injector::inst()->get(Kernel::class));
$sake->setAutoExit(false);
$input = new ArrayInput(['list']);
$input->setInteractive(false);
$output = new BufferedOutput();
// Determine max tasks config value
$taskInfo = [];
foreach (ClassInfo::subclassesFor(BuildTask::class, false) as $class) {
$reflectionClass = new ReflectionClass($class);
if ($reflectionClass->isAbstract()) {
continue;
}
$singleton = $class::singleton();
if ($class::canRunInCli() && $singleton->isEnabled()) {
$taskInfo[$singleton->getName()] = $class::getDescription();
}
}
$maxTasks = match ($taskLimit) {
'same' => count($taskInfo),
'more' => count($taskInfo) + 1,
'less' => count($taskInfo) - 1,
'all' => 0,
};
Sake::config()->set('max_tasks_to_display', $maxTasks);
$sake->run($input, $output);
$listOutput = $output->fetch();
// Check the tasks are showing/hidden as appropriate
if ($shouldShow) {
foreach ($taskInfo as $name => $description) {
$this->assertStringContainsString($name, $listOutput);
$this->assertStringContainsString($description, $listOutput);
}
// Shouldn't display the task command
$this->assertStringNotContainsString('See a list of build tasks to run', $listOutput);
} else {
foreach ($taskInfo as $name => $description) {
$this->assertStringNotContainsString($name, $listOutput);
$this->assertStringNotContainsString($description, $listOutput);
}
// Should display the task command
$this->assertStringContainsString('See a list of build tasks to run', $listOutput);
}
// Check `sake tasks` ALWAYS shows the tasks
$input = new ArrayInput(['tasks']);
$sake->run($input, $output);
$listOutput = $output->fetch();
foreach ($taskInfo as $name => $description) {
$this->assertStringContainsString($name, $listOutput);
$this->assertStringContainsString($description, $listOutput);
}
}
public function testVersion(): void
{
$sake = new Sake(Injector::inst()->get(Kernel::class));
$sake->setAutoExit(false);
$versionProvider = new VersionProvider();
$this->assertSame($versionProvider->getVersion(), $sake->getVersion());
}
public function testLegacyDevCommands(): void
{
$sake = new Sake(Injector::inst()->get(Kernel::class));
$sake->setAutoExit(false);
$input = new ArrayInput(['dev/config']);
$input->setInteractive(false);
$output = new BufferedOutput();
$deprecationsWereEnabled = Deprecation::isEnabled();
Deprecation::enable();
$this->expectException(DeprecationTestException::class);
$expectedErrorString = 'Using the command with the name \'dev/config\' is deprecated. Use \'config:dump\' instead';
$this->expectExceptionMessage($expectedErrorString);
$exitCode = $sake->run($input, $output);
$this->assertSame(0, $exitCode, 'command should run successfully');
// $this->assertStringContainsString('abababa', $output->fetch());
$this->allowCatchingDeprecations($expectedErrorString);
try {
// call outputNotices() directly because the regular shutdown function that emits
// the notices within Deprecation won't be called until after this unit-test has finished
Deprecation::outputNotices();
} finally {
restore_error_handler();
$this->oldErrorHandler = null;
// Disable if they weren't enabled before.
if (!$deprecationsWereEnabled) {
Deprecation::disable();
}
}
}
private function allowCatchingDeprecations(string $expectedErrorString): void
{
// Use custom error handler for two reasons:
// - Filter out errors for deprecations unrelated to this test class
// - Allow the use of expectDeprecation(), which doesn't work with E_USER_DEPRECATION by default
// https://github.com/laminas/laminas-di/pull/30#issuecomment-927585210
$this->oldErrorHandler = set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($expectedErrorString) {
if ($errno === E_USER_DEPRECATED) {
if (str_contains($errstr, $expectedErrorString)) {
throw new DeprecationTestException($errstr);
} else {
// Suppress any E_USER_DEPRECATED unrelated to this test class
return true;
}
}
if (is_callable($this->oldErrorHandler)) {
return call_user_func($this->oldErrorHandler, $errno, $errstr, $errfile, $errline);
}
// Fallback to default PHP error handler
return false;
});
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace SilverStripe\Cli\Tests\SakeTest;
use SilverStripe\Dev\BuildTask;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TestBuildTask extends BuildTask implements TestOnly
{
protected static string $commandName = 'test-build-task';
protected string $title = 'my title';
protected static string $description = 'command for testing build tasks display as expected';
protected function execute(InputInterface $input, PolyOutput $output): int
{
$output->writeln('This output is coming from a build task');
return 0;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace SilverStripe\Cli\Tests\SakeTest;
use SilverStripe\Dev\TestOnly;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Exception\CommandNotFoundException;
class TestCommandLoader implements CommandLoaderInterface, TestOnly
{
private string $commandName = 'loader:test-command';
public function get(string $name): Command
{
if ($name !== $this->commandName) {
throw new CommandNotFoundException("Wrong command fetched. Expected '$this->commandName' - got '$name'");
}
return new TestLoaderCommand();
}
public function has(string $name): bool
{
return $name === $this->commandName;
}
public function getNames(): array
{
return [$this->commandName];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace SilverStripe\Cli\Tests\SakeTest;
use SilverStripe\Dev\TestOnly;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('test:from-config:standard', 'command for testing adding standard commands via config')]
class TestConfigCommand extends Command implements TestOnly
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
return 'This is a standard command';
return 0;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace SilverStripe\Cli\Tests\SakeTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TestConfigPolyCommand extends PolyCommand implements TestOnly
{
protected static string $commandName = 'test:from-config:poly';
protected static string $description = 'command for testing adding poly commands via config';
public function getTitle(): string
{
return 'This is a poly command';
}
public function run(InputInterface $input, PolyOutput $output): int
{
$output->writeln('This output is coming from a poly command');
return 0;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace SilverStripe\Cli\Tests\SakeTest;
use SilverStripe\Dev\TestOnly;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('loader:test-command', 'command for testing adding custom command loaders')]
class TestLoaderCommand extends Command implements TestOnly
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
return 'This is a standard command';
return 0;
}
}

View File

@ -12,6 +12,7 @@ use SilverStripe\Control\Middleware\CanonicalURLMiddleware;
use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter; use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter;
use SilverStripe\Control\Middleware\TrustedProxyMiddleware; use SilverStripe\Control\Middleware\TrustedProxyMiddleware;
use SilverStripe\Control\Tests\DirectorTest\TestController; use SilverStripe\Control\Tests\DirectorTest\TestController;
use SilverStripe\Control\Tests\DirectorTest\TestPolyCommand;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Environment; use SilverStripe\Core\Environment;
@ -997,4 +998,18 @@ class DirectorTest extends SapphireTest
$this->assertEquals('/some-subdir/some-page/nested', $_SERVER['REQUEST_URI']); $this->assertEquals('/some-subdir/some-page/nested', $_SERVER['REQUEST_URI']);
}, 'some-page/nested?query=1'); }, 'some-page/nested?query=1');
} }
public function testPolyCommandRoute(): void
{
Director::config()->set('rules', [
'test-route' => TestPolyCommand::class,
]);
$response = Director::test('test-route');
$this->assertSame('Successful poly command request!', $response->getBody());
$this->assertSame(200, $response->getStatusCode());
// Arguments aren't available for PolyCommand yet so URLs with additional params should result in 404
$response = Director::test('test-route/more/params');
$this->assertSame(404, $response->getStatusCode());
}
} }

View File

@ -0,0 +1,26 @@
<?php
namespace SilverStripe\Control\Tests\DirectorTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TestPolyCommand extends PolyCommand implements TestOnly
{
protected static string $commandName = 'test:poly';
protected static string $description = 'simple command for testing Director routing to PolyCommand';
public function getTitle(): string
{
return 'This is the title!';
}
public function run(InputInterface $input, PolyOutput $output): int
{
$output->write('Successful poly command request!');
return 0;
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace SilverStripe\Control\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\PolyCommandController;
use SilverStripe\Control\Session;
use SilverStripe\Control\Tests\PolyCommandControllerTest\TestPolyCommand;
use SilverStripe\Dev\SapphireTest;
class PolyCommandControllerTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideHandleRequest(): array
{
return [
'no params' => [
'exitCode' => 0,
'params' => [],
'allowed' => true,
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
],
'with params' => [
'exitCode' => 1,
'params' => [
'option1' => true,
'option2' => 'abc',
'option3' => [
'val1',
'val2',
],
],
'allowed' => true,
'expectedOutput' => "Has option 1: true<br>\noption 2 value: abc<br>\noption 3 value: val1<br>\noption 3 value: val2<br>\n",
],
'explicit exit code' => [
'exitCode' => 418,
'params' => [],
'allowed' => true,
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
],
'not allowed to run' => [
'exitCode' => 404,
'params' => [],
'allowed' => false,
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
],
];
}
#[DataProvider('provideHandleRequest')]
public function testHandleRequest(int $exitCode, array $params, bool $allowed, string $expectedOutput): void
{
$polyCommand = new TestPolyCommand();
TestPolyCommand::setCanRunInBrowser($allowed);
if ($allowed) {
// Don't set the exit code if not allowed to run - we want to test that it's correctly forced to 404
$polyCommand->setExitCode($exitCode);
} else {
$this->expectException(HTTPResponse_Exception::class);
$this->expectExceptionCode(404);
}
$controller = new PolyCommandController($polyCommand);
$request = new HTTPRequest('GET', '', $params);
$request->setSession(new Session([]));
$response = $controller->handleRequest($request);
if ($exitCode === 0) {
$statusCode = 200;
} elseif ($exitCode === 1) {
$statusCode = 500;
} elseif ($exitCode === 2) {
$statusCode = 400;
} else {
$statusCode = $exitCode;
}
if ($allowed) {
$this->assertSame($expectedOutput, $response->getBody());
} else {
// The 404 response will NOT contain any output from the command, because the command didn't run.
$this->assertNotSame($expectedOutput, $response->getBody());
}
$this->assertSame($statusCode, $response->getStatusCode());
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace SilverStripe\Control\Tests\PolyCommandControllerTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
class TestPolyCommand extends PolyCommand implements TestOnly
{
protected static string $commandName = 'test:poly';
protected static string $description = 'simple command for testing controller wrapper';
protected static bool $canRunInBrowser = true;
private int $exitCode = 0;
public function getTitle(): string
{
return 'This is the title!';
}
public function run(InputInterface $input, PolyOutput $output): int
{
$output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
$output->writeln('option 2 value: ' . $input->getOption('option2'));
foreach ($input->getOption('option3') ?? [] as $value) {
$output->writeln('option 3 value: ' . $value);
}
return $this->exitCode;
}
public function setExitCode(int $code): void
{
$this->exitCode = $code;
}
public function getOptions(): array
{
return [
new InputOption('option1', null, InputOption::VALUE_NONE),
new InputOption('option2', null, InputOption::VALUE_REQUIRED),
new InputOption('option3', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
];
}
public static function canRunInBrowser(): bool
{
return static::$canRunInBrowser;
}
public static function setCanRunInBrowser(bool $canRun): void
{
static::$canRunInBrowser = $canRun;
}
}

View File

@ -3,41 +3,26 @@
namespace SilverStripe\Dev\Tests; namespace SilverStripe\Dev\Tests;
use SilverStripe\Dev\SapphireTest; 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 class BuildTaskTest extends SapphireTest
{ {
/** public function testRunOutput(): void
* 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
{ {
// enabledTask DBDatetime::set_mock_now('2024-01-01 12:00:00');
$enabledTask = new class extends BuildTask $task = new TestBuildTask();
{ $task->setTimeTo = '2024-01-01 12:00:15';
protected $enabled = true; $buffer = new BufferedOutput();
public function run($request) $output = new PolyOutput(PolyOutput::FORMAT_ANSI, wrappedOutput: $buffer);
{ $input = new ArrayInput([]);
// noop $input->setInteractive(false);
}
}; $task->run($input, $output);
$this->assertTrue($enabledTask->isEnabled());
$enabledTask->config()->set('is_enabled', false); $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());
$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());
} }
} }

View File

@ -0,0 +1,27 @@
<?php
namespace SilverStripe\Dev\Tests\BuildTaskTest;
use SilverStripe\Dev\BuildTask;
use SilverStripe\Dev\TestOnly;
use SilverStripe\PolyExecution\PolyOutput;
use SilverStripe\ORM\FieldType\DBDatetime;
use Symfony\Component\Console\Input\InputInterface;
class TestBuildTask extends BuildTask implements TestOnly
{
protected static string $commandName = 'test-build-task';
protected string $title = 'my title';
protected static string $description = 'command for testing build tasks display as expected';
public string $setTimeTo;
protected function execute(InputInterface $input, PolyOutput $output): int
{
DBDatetime::set_mock_now($this->setTimeTo);
$output->writeln('This output is coming from a build task');
return 0;
}
}

View File

@ -116,72 +116,72 @@ class DeprecationTest extends SapphireTest
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testWithNoReplacementDefault() public function testwithSuppressedNoticeDefault()
{ {
Deprecation::enable(); Deprecation::enable();
$ret = Deprecation::withNoReplacement(function () { $ret = Deprecation::withSuppressedNotice(function () {
return $this->myDeprecatedMethod(); return $this->myDeprecatedMethod();
}); });
$this->assertSame('abc', $ret); $this->assertSame('abc', $ret);
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testWithNoReplacementTrue() public function testwithSuppressedNoticeTrue()
{ {
$message = implode(' ', [ $message = implode(' ', [
'SilverStripe\Dev\Tests\DeprecationTest->myDeprecatedMethod is deprecated.', 'SilverStripe\Dev\Tests\DeprecationTest->myDeprecatedMethod is deprecated.',
'My message.', 'My message.',
'Called from SilverStripe\Dev\Tests\DeprecationTest->testWithNoReplacementTrue.' 'Called from SilverStripe\Dev\Tests\DeprecationTest->testwithSuppressedNoticeTrue.'
]); ]);
$this->expectException(DeprecationTestException::class); $this->expectException(DeprecationTestException::class);
$this->expectExceptionMessage($message); $this->expectExceptionMessage($message);
Deprecation::enable(true); Deprecation::enable(true);
$ret = Deprecation::withNoReplacement(function () { $ret = Deprecation::withSuppressedNotice(function () {
return $this->myDeprecatedMethod(); return $this->myDeprecatedMethod();
}); });
$this->assertSame('abc', $ret); $this->assertSame('abc', $ret);
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testWithNoReplacementTrueCallUserFunc() public function testwithSuppressedNoticeTrueCallUserFunc()
{ {
$message = implode(' ', [ $message = implode(' ', [
'SilverStripe\Dev\Tests\DeprecationTest->myDeprecatedMethod is deprecated.', 'SilverStripe\Dev\Tests\DeprecationTest->myDeprecatedMethod is deprecated.',
'My message.', 'My message.',
'Called from SilverStripe\Dev\Tests\DeprecationTest->testWithNoReplacementTrueCallUserFunc.' 'Called from SilverStripe\Dev\Tests\DeprecationTest->testwithSuppressedNoticeTrueCallUserFunc.'
]); ]);
$this->expectException(DeprecationTestException::class); $this->expectException(DeprecationTestException::class);
$this->expectExceptionMessage($message); $this->expectExceptionMessage($message);
Deprecation::enable(true); Deprecation::enable(true);
$ret = Deprecation::withNoReplacement(function () { $ret = Deprecation::withSuppressedNotice(function () {
return call_user_func([$this, 'myDeprecatedMethod']); return call_user_func([$this, 'myDeprecatedMethod']);
}); });
$this->assertSame('abc', $ret); $this->assertSame('abc', $ret);
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testNoticeWithNoReplacementTrue() public function testNoticewithSuppressedNoticeTrue()
{ {
$message = implode(' ', [ $message = implode(' ', [
'SilverStripe\Dev\Tests\DeprecationTest->testNoticeWithNoReplacementTrue is deprecated.', 'SilverStripe\Dev\Tests\DeprecationTest->testNoticewithSuppressedNoticeTrue is deprecated.',
'My message.', 'My message.',
'Called from PHPUnit\Framework\TestCase->runTest.' 'Called from PHPUnit\Framework\TestCase->runTest.'
]); ]);
$this->expectException(DeprecationTestException::class); $this->expectException(DeprecationTestException::class);
$this->expectExceptionMessage($message); $this->expectExceptionMessage($message);
Deprecation::enable(true); Deprecation::enable(true);
Deprecation::withNoReplacement(function () { Deprecation::withSuppressedNotice(function () {
Deprecation::notice('123', 'My message.'); Deprecation::notice('123', 'My message.');
}); });
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testClassWithNoReplacement() public function testClasswithSuppressedNotice()
{ {
$message = implode(' ', [ $message = implode(' ', [
'SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestObject is deprecated.', 'SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestObject is deprecated.',
'Some class message.', 'Some class message.',
'Called from SilverStripe\Dev\Tests\DeprecationTest->testClassWithNoReplacement.' 'Called from SilverStripe\Dev\Tests\DeprecationTest->testClasswithSuppressedNotice.'
]); ]);
$this->expectException(DeprecationTestException::class); $this->expectException(DeprecationTestException::class);
$this->expectExceptionMessage($message); $this->expectExceptionMessage($message);
@ -193,12 +193,12 @@ class DeprecationTest extends SapphireTest
Deprecation::outputNotices(); Deprecation::outputNotices();
} }
public function testClassWithInjectorWithNoReplacement() public function testClassWithInjectorwithSuppressedNotice()
{ {
$message = implode(' ', [ $message = implode(' ', [
'SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestObject is deprecated.', 'SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestObject is deprecated.',
'Some class message.', 'Some class message.',
'Called from SilverStripe\Dev\Tests\DeprecationTest->testClassWithInjectorWithNoReplacement.' 'Called from SilverStripe\Dev\Tests\DeprecationTest->testClassWithInjectorwithSuppressedNotice.'
]); ]);
$this->expectException(DeprecationTestException::class); $this->expectException(DeprecationTestException::class);
$this->expectExceptionMessage($message); $this->expectExceptionMessage($message);

View File

@ -11,7 +11,7 @@ class DeprecationTestObject extends DataObject implements TestOnly
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
Deprecation::withNoReplacement(function () { Deprecation::withSuppressedNotice(function () {
Deprecation::notice( Deprecation::notice(
'1.2.3', '1.2.3',
'Some class message', 'Some class message',

View File

@ -3,47 +3,47 @@
namespace SilverStripe\Dev\Tests; namespace SilverStripe\Dev\Tests;
use Exception; use Exception;
use ReflectionMethod; use LogicException;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Kernel; use SilverStripe\Core\Kernel;
use SilverStripe\Dev\Command\DevCommand;
use SilverStripe\Dev\DevelopmentAdmin; use SilverStripe\Dev\DevelopmentAdmin;
use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Dev\Tests\DevAdminControllerTest\Controller1; use SilverStripe\Dev\Tests\DevAdminControllerTest\Controller1;
use SilverStripe\Dev\Tests\DevAdminControllerTest\ControllerWithPermissions; use SilverStripe\Dev\Tests\DevAdminControllerTest\ControllerWithPermissions;
use SilverStripe\Dev\Tests\DevAdminControllerTest\TestCommand;
use SilverStripe\Dev\Tests\DevAdminControllerTest\TestHiddenController;
use PHPUnit\Framework\Attributes\DataProvider; 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 class DevAdminControllerTest extends FunctionalTest
{ {
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
DevelopmentAdmin::config()->merge( DevelopmentAdmin::config()->merge(
'registered_controllers', 'commands',
[
'c1' => TestCommand::class,
]
);
DevelopmentAdmin::config()->merge(
'controllers',
[ [
'x1' => [ 'x1' => [
'controller' => Controller1::class, 'class' => Controller1::class,
'links' => [ 'description' => 'controller1 description',
'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'
]
], ],
'x3' => [ 'x3' => [
'controller' => ControllerWithPermissions::class, 'class' => ControllerWithPermissions::class,
'links' => [ 'description' => 'permission controller description',
'x3' => 'x3 link description' ],
] 'x4' => [
'class' => TestHiddenController::class,
'skipLink' => true,
], ],
] ]
); );
@ -51,10 +51,12 @@ class DevAdminControllerTest extends FunctionalTest
public function testGoodRegisteredControllerOutput() public function testGoodRegisteredControllerOutput()
{ {
// Check for the controller running from the registered url above // Check for the controller or command running from the registered url above
// (we use contains rather than equals because sometimes you get a warning) // 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'));
$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() public function testGoodRegisteredControllerStatus()
@ -62,9 +64,8 @@ class DevAdminControllerTest extends FunctionalTest
// Check response code is 200/OK // Check response code is 200/OK
$this->assertEquals(false, $this->getAndCheckForError('/dev/x1')); $this->assertEquals(false, $this->getAndCheckForError('/dev/x1'));
$this->assertEquals(false, $this->getAndCheckForError('/dev/x1/y1')); $this->assertEquals(false, $this->getAndCheckForError('/dev/x1/y1'));
$this->assertEquals(false, $this->getAndCheckForError('/dev/x4'));
// Check response code is 500/ some sort of error $this->assertEquals(false, $this->getAndCheckForError('/dev/xc1'));
$this->assertEquals(true, $this->getAndCheckForError('/dev/x2'));
} }
#[DataProvider('getLinksPermissionsProvider')] #[DataProvider('getLinksPermissionsProvider')]
@ -77,29 +78,77 @@ class DevAdminControllerTest extends FunctionalTest
try { try {
$this->logInWithPermission($permission); $this->logInWithPermission($permission);
$controller = new DevelopmentAdmin(); $controller = new DevelopmentAdmin();
$method = new ReflectionMethod($controller, 'getLinks'); $links = $controller->getLinks();
$method->setAccessible(true);
$links = $method->invoke($controller);
foreach ($present as $expected) { 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) { 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 { } finally {
$kernel->setEnvironment($env); $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 public static function getLinksPermissionsProvider() : array
{ {
return [ return [
['ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']], 'admin access' => ['ADMIN', ['c1', 'x1', 'x3'], ['x4']],
['ALL_DEV_ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']], 'all dev access' => ['ALL_DEV_ADMIN', ['c1', 'x1', 'x3'], ['x4']],
['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['x1', 'x1/y1', 'x2']], 'dev test access' => ['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['c1', 'x1', 'x4']],
['NOTHING', [], ['x1', 'x1/y1', 'x2', 'x3']], 'no access' => ['NOTHING', [], ['c1', 'x1', 'x3', 'x4']],
]; ];
} }

View File

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

View File

@ -0,0 +1,32 @@
<?php
namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
use SilverStripe\Dev\Command\DevCommand;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TestCommand extends DevCommand
{
const OK_MSG = 'DevAdminControllerTest_TestCommand TEST OK';
protected static string $commandName = 'my-test-command';
protected static string $description = 'my test command';
public function getTitle(): string
{
return 'Test command';
}
protected function execute(InputInterface $input, PolyOutput $output): int
{
$output->write(TestCommand::OK_MSG);
return 0;
}
protected function getHeading(): string
{
return 'This is a test command';
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
use SilverStripe\Control\Controller;
class TestHiddenController extends Controller
{
const OK_MSG = 'DevAdminControllerTest_TestHiddenController TEST OK';
public function index()
{
echo TestHiddenController::OK_MSG;
}
}

View File

@ -3,13 +3,15 @@
namespace SilverStripe\Dev\Tests\TaskRunnerTest; namespace SilverStripe\Dev\Tests\TaskRunnerTest;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
abstract class TaskRunnerTest_AbstractTask extends BuildTask abstract class TaskRunnerTest_AbstractTask extends BuildTask
{ {
protected $enabled = true; protected $enabled = true;
public function run($request) protected function execute(InputInterface $input, PolyOutput $output): int
{ {
// NOOP return 0;
} }
} }

View File

@ -3,13 +3,15 @@
namespace SilverStripe\Dev\Tests\TaskRunnerTest; namespace SilverStripe\Dev\Tests\TaskRunnerTest;
use SilverStripe\Dev\Tests\TaskRunnerTest\TaskRunnerTest_AbstractTask; use SilverStripe\Dev\Tests\TaskRunnerTest\TaskRunnerTest_AbstractTask;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TaskRunnerTest_ChildOfAbstractTask extends TaskRunnerTest_AbstractTask class TaskRunnerTest_ChildOfAbstractTask extends TaskRunnerTest_AbstractTask
{ {
protected $enabled = true; protected $enabled = true;
public function run($request) protected function doRun(InputInterface $input, PolyOutput $output): int
{ {
// NOOP return 0;
} }
} }

View File

@ -3,13 +3,15 @@
namespace SilverStripe\Dev\Tests\TaskRunnerTest; namespace SilverStripe\Dev\Tests\TaskRunnerTest;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TaskRunnerTest_DisabledTask extends BuildTask class TaskRunnerTest_DisabledTask extends BuildTask
{ {
protected $enabled = false; private static bool $is_enabled = false;
public function run($request) protected function execute(InputInterface $input, PolyOutput $output): int
{ {
// NOOP return 0;
} }
} }

View File

@ -3,13 +3,15 @@
namespace SilverStripe\Dev\Tests\TaskRunnerTest; namespace SilverStripe\Dev\Tests\TaskRunnerTest;
use SilverStripe\Dev\BuildTask; use SilverStripe\Dev\BuildTask;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Input\InputInterface;
class TaskRunnerTest_EnabledTask extends BuildTask class TaskRunnerTest_EnabledTask extends BuildTask
{ {
protected $enabled = true; protected $enabled = true;
public function run($request) protected function execute(InputInterface $input, PolyOutput $output): int
{ {
// NOOP return 0;
} }
} }

View File

@ -13,9 +13,10 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\Logging\DebugViewFriendlyErrorFormatter; use SilverStripe\Logging\DebugViewFriendlyErrorFormatter;
use SilverStripe\Logging\DetailedErrorFormatter; use SilverStripe\Logging\DetailedErrorFormatter;
use SilverStripe\Logging\HTTPOutputHandler; use SilverStripe\Logging\HTTPOutputHandler;
use SilverStripe\Logging\ErrorOutputHandler;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
class HTTPOutputHandlerTest extends SapphireTest class ErrorOutputHandlerTest extends SapphireTest
{ {
protected function setUp(): void protected function setUp(): void
{ {
@ -28,7 +29,7 @@ class HTTPOutputHandlerTest extends SapphireTest
public function testGetFormatter() public function testGetFormatter()
{ {
$handler = new HTTPOutputHandler(); $handler = new ErrorOutputHandler();
$detailedFormatter = new DetailedErrorFormatter(); $detailedFormatter = new DetailedErrorFormatter();
$friendlyFormatter = new DebugViewFriendlyErrorFormatter(); $friendlyFormatter = new DebugViewFriendlyErrorFormatter();
@ -49,9 +50,9 @@ class HTTPOutputHandlerTest extends SapphireTest
*/ */
public function testDevConfig() public function testDevConfig()
{ {
/** @var HTTPOutputHandler $handler */ /** @var ErrorOutputHandler $handler */
$handler = Injector::inst()->get(HandlerInterface::class); $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 // Test only default formatter is set, but CLI specific formatter is left out
$this->assertNull($handler->getCLIFormatter()); $this->assertNull($handler->getCLIFormatter());
@ -154,7 +155,7 @@ class HTTPOutputHandlerTest extends SapphireTest
bool $shouldShow, bool $shouldShow,
bool $expected bool $expected
) { ) {
$reflectionShouldShow = new ReflectionMethod(HTTPOutputHandler::class, 'shouldShowError'); $reflectionShouldShow = new ReflectionMethod(ErrorOutputHandler::class, 'shouldShowError');
$reflectionShouldShow->setAccessible(true); $reflectionShouldShow->setAccessible(true);
$reflectionDeprecation = new ReflectionClass(Deprecation::class); $reflectionDeprecation = new ReflectionClass(Deprecation::class);
@ -175,16 +176,15 @@ class HTTPOutputHandlerTest extends SapphireTest
$reflectionDirector = new ReflectionClass(Environment::class); $reflectionDirector = new ReflectionClass(Environment::class);
$origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride'); $origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride');
$reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli); $reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli);
try { try {
$handler = new HTTPOutputHandler(); $handler = new ErrorOutputHandler();
$result = $reflectionShouldShow->invoke($handler, $errorCode); $result = $reflectionShouldShow->invoke($handler, $errorCode);
$this->assertSame($expected, $result); $this->assertSame($expected, $result);
Deprecation::setShouldShowForCli($cliShouldShowOrig); Deprecation::setShouldShowForCli($cliShouldShowOrig);
Deprecation::setShouldShowForHttp($httpShouldShowOrig); Deprecation::setShouldShowForHttp($httpShouldShowOrig);
$reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
} finally { } finally {
$reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
$reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli); $reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli);
} }
} }

View File

@ -0,0 +1,57 @@
<?php
namespace SilverStripe\PolyExecution\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\PolyExecution\AnsiToHtmlConverter;
use Symfony\Component\Console\Formatter\OutputFormatter;
class AnsiToHtmlConverterTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideConvert(): array
{
return [
'no text, no result' => [
'unformatted' => '',
'expected' => '',
],
'no empty span' => [
'unformatted' => 'This text <info></info> is unformatted',
'expected' => 'This text is unformatted',
],
'named formats are converted' => [
'unformatted' => 'This text <info>has some</info> formatting',
'expected' => 'This text <span style="color: green">has some</span> formatting',
],
'fg and bg are converted' => [
'unformatted' => 'This text <fg=red;bg=blue>has some</> formatting',
'expected' => 'This text <span style="background-color: blue; color: darkred">has some</span> formatting',
],
'bold and underscore are converted' => [
'unformatted' => 'This text <options=bold;options=underscore>has some</> formatting',
'expected' => 'This text <span style="font-weight: bold; text-decoration: underline">has some</span> formatting',
],
'multiple styles are converted' => [
'unformatted' => 'This text <options=bold;fg=green>has some</> <comment>formatting</comment>',
'expected' => 'This text <span style="font-weight: bold; color: green">has some</span> <span style="color: goldenrod">formatting</span>',
],
'hyperlinks are converted' => [
'unformatted' => 'This text <href=https://www.example.com/>has a</> link',
'expected' => 'This text <a href="https://www.example.com/">has a</a> link',
],
];
}
#[DataProvider('provideConvert')]
public function testConvert(string $unformatted, string $expected): void
{
$converter = new AnsiToHtmlConverter();
$ansiFormatter = new OutputFormatter(true);
$formatted = $ansiFormatter->format($unformatted);
$this->assertSame($expected, $converter->convert($formatted));
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace SilverStripe\PolyExecution\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\PolyExecution\HttpRequestInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class HttpRequestInputTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideInputOptions(): array
{
return [
'no vars, no options' => [
'requestVars' => [],
'commandOptions' => [],
'expected' => [],
],
'some vars, no options' => [
'requestVars' => [
'var1' => '1',
'var2' => 'abcd',
'var3' => null,
'var4' => ['a', 'b', 'c'],
],
'commandOptions' => [],
'expected' => [],
],
'no vars, some options' => [
'requestVars' => [],
'commandOptions' => [
new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
new InputOption('var2', null, InputOption::VALUE_REQUIRED),
new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
],
'expected' => [
'var1' => null,
'var2' => null,
'var3' => null,
'var4' => [],
],
],
'no vars, some options (with default values)' => [
'requestVars' => [],
'commandOptions' => [
new InputOption('var1', null, InputOption::VALUE_NEGATABLE, default: true),
new InputOption('var2', null, InputOption::VALUE_REQUIRED, default: 'def'),
new InputOption('var3', null, InputOption::VALUE_OPTIONAL, default: false),
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, default: [1, 2, 'banana']),
],
'expected' => [
'var1' => true,
'var2' => 'def',
'var3' => false,
'var4' => [1, 2, 'banana'],
],
],
'some vars and options' => [
'requestVars' => [
'var1' => '1',
'var2' => 'abcd',
'var3' => 2,
'var4' => ['a', 'b', 'c'],
],
'commandOptions' => [
new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
new InputOption('var2', null, InputOption::VALUE_REQUIRED),
new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
],
'expected' => [
'var1' => true,
'var2' => 'abcd',
'var3' => 2,
'var4' => ['a', 'b', 'c'],
],
],
];
}
#[DataProvider('provideInputOptions')]
public function testInputOptions(array $requestVars, array $commandOptions, array $expected): void
{
$request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
$input = new HttpRequestInput($request, $commandOptions);
foreach ($expected as $option => $value) {
$this->assertSame($value, $input->getOption($option), 'checking value for ' . $option);
}
// If there's no expected values, the success metric is that we didn't throw any exceptions.
if (empty($expected)) {
$this->expectNotToPerformAssertions();
}
}
public static function provideGetVerbosity(): array
{
return [
'default to normal' => [
'requestVars' => [],
'expected' => OutputInterface::VERBOSITY_NORMAL,
],
'shortcuts are ignored' => [
'requestVars' => ['v' => 1],
'expected' => OutputInterface::VERBOSITY_NORMAL,
],
'?verbose=1 is verbose' => [
'requestVars' => ['verbose' => 1],
'expected' => OutputInterface::VERBOSITY_VERBOSE,
],
'?verbose=2 is very verbose' => [
'requestVars' => ['verbose' => 2],
'expected' => OutputInterface::VERBOSITY_VERY_VERBOSE,
],
'?verbose=3 is debug' => [
// Check string works as well as int
'requestVars' => ['verbose' => '3'],
'expected' => OutputInterface::VERBOSITY_DEBUG,
],
'?quiet=1 is quiet' => [
'requestVars' => ['quiet' => 1],
'expected' => OutputInterface::VERBOSITY_QUIET,
],
];
}
#[DataProvider('provideGetVerbosity')]
public function testGetVerbosity(array $requestVars, int $expected): void
{
$request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
$input = new HttpRequestInput($request);
$this->assertSame($expected, $input->getVerbosity());
}
}

View File

@ -0,0 +1,220 @@
<?php
namespace SilverStripe\PolyExecution\Tests;
use LogicException;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\PolyExecution\PolyOutput;
use Symfony\Component\Console\Output\BufferedOutput;
class PolyOutputTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideWriteForHtml(): array
{
return [
'html for html' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'messages' => ['one message', 'two message'],
'expected' => "one message<br>\ntwo message<br>\n",
],
'ansi for html' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'messages' => ['one message', 'two message'],
'expected' => '',
],
];
}
#[DataProvider('provideWriteForHtml')]
public function testWriteForHtml(
string $outputFormat,
string|iterable $messages,
string $expected
): void {
$buffer = new BufferedOutput();
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
$output->writeForHtml($messages, true);
$this->assertSame($expected, $buffer->fetch());
}
public static function provideWriteForAnsi(): array
{
return [
'html for ansi' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'messages' => ['one message', 'two message'],
'expected' => '',
],
'ansi for ansi' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'messages' => ['one message', 'two message'],
'expected' => "one message\ntwo message\n",
],
];
}
#[DataProvider('provideWriteForAnsi')]
public function testWriteForAnsi(
string $outputFormat,
string|iterable $messages,
string $expected
): void {
$buffer = new BufferedOutput();
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
$output->writeForAnsi($messages, true);
$this->assertSame($expected, $buffer->fetch());
}
public static function provideList(): array
{
return [
'empty list ANSI' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => []
],
'expected' => '',
],
'empty list HTML' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => []
],
'expected' => '<ul></ul>',
],
'single list UL ANSI' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => ['item 1', 'item 2']
],
'expected' => <<< EOL
* item 1
* item 2
EOL,
],
'single list OL ANSI' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'list' => [
'type' => PolyOutput::LIST_ORDERED,
'items' => ['item 1', 'item 2']
],
'expected' => <<< EOL
1. item 1
2. item 2
EOL,
],
'single list UL HTML' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => ['item 1', 'item 2']
],
'expected' => '<ul><li>item 1</li><li>item 2</li></ul>',
],
'single list OL HTML' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'list' => [
'type' => PolyOutput::LIST_ORDERED,
'items' => ['item 1', 'item 2']
],
'expected' => '<ol><li>item 1</li><li>item 2</li></ol>',
],
'nested list ANSI' => [
'outputFormat' => PolyOutput::FORMAT_ANSI,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => [
'item 1',
'item 2',
[
'type' => PolyOutput::LIST_ORDERED,
'items' => [
'item 2a',
['item 2b','item 2c'],
'item 2d',
]
],
'item 3',
]
],
'expected' => <<< EOL
* item 1
* item 2
1. item 2a
2. item 2b
3. item 2c
4. item 2d
* item 3
EOL,
],
'nested list HTML' => [
'outputFormat' => PolyOutput::FORMAT_HTML,
'list' => [
'type' => PolyOutput::LIST_UNORDERED,
'items' => [
'item 1',
'item 2',
'list' => [
'type' => PolyOutput::LIST_ORDERED,
'items' => [
'item 2a',
['item 2b','item 2c'],
'item 2d',
]
],
'item 3',
]
],
'expected' => '<ul><li>item 1</li><li>item 2</li><ol><li>item 2a</li><li>item 2b</li><li>item 2c</li><li>item 2d</li></ol><li>item 3</li></ul>',
],
];
}
#[DataProvider('provideList')]
public function testList(string $outputFormat, array $list, string $expected): void
{
$buffer = new BufferedOutput();
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
$this->makeListRecursive($output, $list);
$this->assertSame($expected, $buffer->fetch());
}
public static function provideListMustBeStarted(): array
{
return [
[PolyOutput::FORMAT_ANSI],
[PolyOutput::FORMAT_HTML],
];
}
#[DataProvider('provideListMustBeStarted')]
public function testListMustBeStarted(string $outputFormat): void
{
$output = new PolyOutput($outputFormat);
$this->expectException(LogicException::class);
$this->expectExceptionMessage('No lists started. Call startList() first.');
$output->writeListItem('');
}
private function makeListRecursive(PolyOutput $output, array $list): void
{
$output->startList($list['type']);
foreach ($list['items'] as $item) {
if (isset($item['type'])) {
$this->makeListRecursive($output, $item);
continue;
}
$output->writeListItem($item);
}
$output->stopList();
}
}

View File

@ -156,7 +156,7 @@ class PasswordEncryptorTest extends SapphireTest
'encryptors', 'encryptors',
['test_sha1legacy' => [PasswordEncryptor_LegacyPHPHash::class => 'sha1']] ['test_sha1legacy' => [PasswordEncryptor_LegacyPHPHash::class => 'sha1']]
); );
$e = Deprecation::withNoReplacement(fn() => PasswordEncryptor::create_for_algorithm('test_sha1legacy')); $e = Deprecation::withSuppressedNotice(fn() => PasswordEncryptor::create_for_algorithm('test_sha1legacy'));
// precomputed hashes for 'mypassword' from different architectures // precomputed hashes for 'mypassword' from different architectures
$amdHash = 'h1fj0a6m4o6k0sosks88oo08ko4gc4s'; $amdHash = 'h1fj0a6m4o6k0sosks88oo08ko4gc4s';
$intelHash = 'h1fj0a6m4o0g04ocg00o4kwoc4wowws'; $intelHash = 'h1fj0a6m4o0g04ocg00o4kwoc4wowws';

View File

@ -111,7 +111,7 @@ class RememberLoginHashTest extends SapphireTest
$member = $this->objFromFixture(Member::class, 'main'); $member = $this->objFromFixture(Member::class, 'main');
Deprecation::withNoReplacement( Deprecation::withSuppressedNotice(
fn() => RememberLoginHash::config()->set('replace_token_during_session_renewal', $replaceToken) fn() => RememberLoginHash::config()->set('replace_token_during_session_renewal', $replaceToken)
); );
@ -122,7 +122,7 @@ class RememberLoginHashTest extends SapphireTest
// Fetch the token from the DB - otherwise we still have the token from when this was originally created // Fetch the token from the DB - otherwise we still have the token from when this was originally created
$storedHash = RememberLoginHash::get()->find('ID', $hash->ID); $storedHash = RememberLoginHash::get()->find('ID', $hash->ID);
Deprecation::withNoReplacement(fn() => $storedHash->renew()); Deprecation::withSuppressedNotice(fn() => $storedHash->renew());
if ($replaceToken) { if ($replaceToken) {
$this->assertNotEquals($oldToken, $storedHash->getToken()); $this->assertNotEquals($oldToken, $storedHash->getToken());

View File

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

View File

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