mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW Refactor CLI interaction with Silverstripe app (#11353)
- Turn sake into a symfony/console app - Avoid using HTTPRequest for CLI interaction - Implement abstract hybrid execution path
This commit is contained in:
parent
730b891e10
commit
e46135be0a
13
_config/cli.yml
Normal file
13
_config/cli.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
Name: cli-config
|
||||||
|
---
|
||||||
|
SilverStripe\Core\Injector\Injector:
|
||||||
|
Symfony\Contracts\EventDispatcher\EventDispatcherInterface.sake:
|
||||||
|
class: 'Symfony\Component\EventDispatcher\EventDispatcher'
|
||||||
|
Symfony\Component\Console\Formatter\OutputFormatterInterface:
|
||||||
|
class: 'Symfony\Component\Console\Formatter\OutputFormatter'
|
||||||
|
calls:
|
||||||
|
- ['setDecorated', [true]]
|
||||||
|
SilverStripe\PolyExecution\HtmlOutputFormatter:
|
||||||
|
constructor:
|
||||||
|
formatter: '%$Symfony\Component\Console\Formatter\OutputFormatterInterface'
|
@ -22,10 +22,6 @@ SilverStripe\Core\Injector\Injector:
|
|||||||
class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
15
bin/sake
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use SilverStripe\Cli\Sake;
|
||||||
|
|
||||||
|
// Ensure that people can't access this from a web-server
|
||||||
|
if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
|
||||||
|
echo 'sake cannot be run from a web request, you have to run it on the command-line.';
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/includes/autoload.php';
|
||||||
|
|
||||||
|
$sake = new Sake();
|
||||||
|
$sake->run();
|
@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// CLI specific bootstrapping
|
|
||||||
use SilverStripe\Control\CLIRequestBuilder;
|
|
||||||
use SilverStripe\Control\HTTPApplication;
|
|
||||||
use SilverStripe\Core\CoreKernel;
|
|
||||||
use SilverStripe\ORM\DB;
|
|
||||||
use SilverStripe\ORM\Connect\NullDatabase;
|
|
||||||
|
|
||||||
require __DIR__ . '/src/includes/autoload.php';
|
|
||||||
|
|
||||||
// Ensure that people can't access this from a web-server
|
|
||||||
if (!in_array(PHP_SAPI, ["cli", "cgi", "cgi-fcgi"])) {
|
|
||||||
echo "cli-script.php can't be run from a web request, you have to run it on the command-line.";
|
|
||||||
die();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build request and detect flush
|
|
||||||
$request = CLIRequestBuilder::createFromEnvironment();
|
|
||||||
|
|
||||||
|
|
||||||
$skipDatabase = in_array('--no-database', $argv);
|
|
||||||
if ($skipDatabase) {
|
|
||||||
DB::set_conn(new NullDatabase());
|
|
||||||
}
|
|
||||||
// Default application
|
|
||||||
$kernel = new CoreKernel(BASE_PATH);
|
|
||||||
if ($skipDatabase) {
|
|
||||||
$kernel->setBootDatabase(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$app = new HTTPApplication($kernel);
|
|
||||||
$response = $app->handle($request);
|
|
||||||
|
|
||||||
$response->output();
|
|
@ -113,7 +113,6 @@ a:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Content types */
|
/* 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;
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
119
sake
@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Check for an argument
|
|
||||||
if [ ${1:-""} = "" ]; then
|
|
||||||
echo "SilverStripe Sake
|
|
||||||
|
|
||||||
Usage: $0 (command-url) (params)
|
|
||||||
Executes a SilverStripe command"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
command -v which >/dev/null 2>&1
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Error: sake requires the 'which' command to operate." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# find the silverstripe installation, looking first at sake
|
|
||||||
# bin location, but falling back to current directory
|
|
||||||
sakedir=`dirname $0`
|
|
||||||
directory="$PWD"
|
|
||||||
if [ -f "$sakedir/cli-script.php" ]; then
|
|
||||||
# Calling sake from vendor/silverstripe/framework/sake
|
|
||||||
framework="$sakedir"
|
|
||||||
base="$sakedir/../../.."
|
|
||||||
elif [ -f "$sakedir/../silverstripe/framework/cli-script.php" ]; then
|
|
||||||
# Calling sake from vendor/bin/sake
|
|
||||||
framework="$sakedir/../silverstripe/framework"
|
|
||||||
base="$sakedir/../.."
|
|
||||||
elif [ -f "$directory/vendor/silverstripe/framework/cli-script.php" ]; then
|
|
||||||
# Vendor framework (from base) if sake installed globally
|
|
||||||
framework="$directory/vendor/silverstripe/framework"
|
|
||||||
base=.
|
|
||||||
elif [ -f "$directory/framework/cli-script.php" ]; then
|
|
||||||
# Legacy directory (from base) if sake installed globally
|
|
||||||
framework="$directory/framework"
|
|
||||||
base=.
|
|
||||||
else
|
|
||||||
echo "Can't find cli-script.php in $sakedir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the PHP binary
|
|
||||||
for candidatephp in php php5; do
|
|
||||||
if [ "`which $candidatephp 2>/dev/null`" -a -f "`which $candidatephp 2>/dev/null`" ]; then
|
|
||||||
php=`which $candidatephp 2>/dev/null`
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$php" = "" ]; then
|
|
||||||
echo "Can't find any php binary"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
################################################################################################
|
|
||||||
## Installation to /usr/bin
|
|
||||||
|
|
||||||
if [ "$1" = "installsake" ]; then
|
|
||||||
echo "Installing sake to /usr/local/bin..."
|
|
||||||
rm -rf /usr/local/bin/sake
|
|
||||||
cp $0 /usr/local/bin
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
################################################################################################
|
|
||||||
## Process control
|
|
||||||
|
|
||||||
if [ "$1" = "-start" ]; then
|
|
||||||
if [ "`which daemon`" = "" ]; then
|
|
||||||
echo "You need to install the 'daemon' tool. In debian, go 'sudo apt-get install daemon'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f $base/$2.pid ]; then
|
|
||||||
echo "Starting service $2 $3"
|
|
||||||
touch $base/$2.pid
|
|
||||||
pidfile=`realpath $base/$2.pid`
|
|
||||||
|
|
||||||
outlog=$base/$2.log
|
|
||||||
errlog=$base/$2.err
|
|
||||||
|
|
||||||
echo "Logging to $outlog"
|
|
||||||
|
|
||||||
sake=`realpath $0`
|
|
||||||
base=`realpath $base`
|
|
||||||
|
|
||||||
# if third argument is not explicitly given, copy from second argument
|
|
||||||
if [ "$3" = "" ]; then
|
|
||||||
url=$2
|
|
||||||
else
|
|
||||||
url=$3
|
|
||||||
fi
|
|
||||||
|
|
||||||
processname=$2
|
|
||||||
|
|
||||||
daemon -n $processname -r -D $base --pidfile=$pidfile --stdout=$outlog --stderr=$errlog $sake $url
|
|
||||||
else
|
|
||||||
echo "Service $2 seems to already be running"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$1" = "-stop" ]; then
|
|
||||||
pidfile=$base/$2.pid
|
|
||||||
if [ -f $pidfile ]; then
|
|
||||||
echo "Stopping service $2"
|
|
||||||
|
|
||||||
kill -KILL `cat $pidfile`
|
|
||||||
unlink $pidfile
|
|
||||||
else
|
|
||||||
echo "Service $2 doesn't seem to be running."
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
################################################################################################
|
|
||||||
## Basic execution
|
|
||||||
|
|
||||||
"$php" "$framework/cli-script.php" "${@}"
|
|
63
src/Cli/Command/NavigateCommand.php
Normal file
63
src/Cli/Command/NavigateCommand.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Control\CLIRequestBuilder;
|
||||||
|
use SilverStripe\Control\HTTPApplication;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\Core\Kernel;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command that simulates an HTTP request to the Silverstripe App based on CLI input.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'navigate', description: 'Navigate to a URL on your site via a simulated HTTP request')]
|
||||||
|
class NavigateCommand extends Command
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
// Convert input into HTTP request.
|
||||||
|
// Use the kernel we already booted for consistency and performance reasons
|
||||||
|
$app = new HTTPApplication(Injector::inst()->get(Kernel::class));
|
||||||
|
$request = CLIRequestBuilder::createFromInput($input);
|
||||||
|
|
||||||
|
// Handle request and output resonse body
|
||||||
|
$response = $app->handle($request);
|
||||||
|
$output->writeln($response->getBody(), OutputInterface::OUTPUT_RAW);
|
||||||
|
|
||||||
|
// Transform HTTP status code into sensible exit code
|
||||||
|
$responseCode = $response->getStatusCode();
|
||||||
|
$output->writeln("<options=bold>RESPONSE STATUS CODE WAS {$responseCode}</>", OutputInterface::VERBOSITY_VERBOSE);
|
||||||
|
// We can't use the response code for unsuccessful requests directly as the exit code
|
||||||
|
// because symfony gives us an exit code ceiling of 255. So just use the regular constants.
|
||||||
|
return match (true) {
|
||||||
|
($responseCode >= 200 && $responseCode < 400) => Command::SUCCESS,
|
||||||
|
($responseCode >= 400 && $responseCode < 500) => Command::INVALID,
|
||||||
|
default => Command::FAILURE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setHelp(<<<HELP
|
||||||
|
Use verbose mode to see the HTTP response status code.
|
||||||
|
The <info>get-vars</> arg can either be separated GET variables, or a full query string
|
||||||
|
e.g: <comment>sake navigate about-us/team q=test arrayval[]=value1 arrayval[]=value2</>
|
||||||
|
e.g: <comment>sake navigate about-us/team q=test<info>&</info>arrayval[]=value1<info>&</info>arrayval[]=value2</>
|
||||||
|
HELP);
|
||||||
|
$this->addArgument(
|
||||||
|
'path',
|
||||||
|
InputArgument::REQUIRED,
|
||||||
|
'Relative path to navigate to (e.g: <info>about-us/team</>). Can optionally start with a "/"'
|
||||||
|
);
|
||||||
|
$this->addArgument(
|
||||||
|
'get-vars',
|
||||||
|
InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
|
||||||
|
'Optional GET variables or a query string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
48
src/Cli/Command/PolyCommandCliWrapper.php
Normal file
48
src/Cli/Command/PolyCommandCliWrapper.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a PolyCommand for use in CLI.
|
||||||
|
*/
|
||||||
|
class PolyCommandCliWrapper extends Command
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
private PolyCommand $command;
|
||||||
|
|
||||||
|
public function __construct(PolyCommand $command, string $alias = '')
|
||||||
|
{
|
||||||
|
$this->command = $command;
|
||||||
|
parent::__construct($command->getName());
|
||||||
|
if ($alias) {
|
||||||
|
$this->setAliases([$alias]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$polyOutput = PolyOutput::create(
|
||||||
|
PolyOutput::FORMAT_ANSI,
|
||||||
|
$output->getVerbosity(),
|
||||||
|
$output->isDecorated(),
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
return $this->command->run($input, $polyOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setDescription($this->command::getDescription());
|
||||||
|
$this->setDefinition(new InputDefinition($this->command->getOptions()));
|
||||||
|
$this->setHelp($this->command->getHelp());
|
||||||
|
}
|
||||||
|
}
|
85
src/Cli/Command/TasksCommand.php
Normal file
85
src/Cli/Command/TasksCommand.php
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Cli\CommandLoader\DevTaskLoader;
|
||||||
|
use SilverStripe\Cli\Sake;
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\ListCommand;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command that runs `sake list tasks` under the hood to list all of the available tasks.
|
||||||
|
* Useful when you have too many tasks to show in the main commands list.
|
||||||
|
*
|
||||||
|
* Note the description is blue so it stands out, to avoid developers missing it if they add a new
|
||||||
|
* task and suddenly they don't see the tasks in their main commands list anymore.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'tasks', description: '<fg=blue>See a list of build tasks to run</>')]
|
||||||
|
class TasksCommand extends Command
|
||||||
|
{
|
||||||
|
private Command $listCommand;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->listCommand = new ListCommand();
|
||||||
|
$this->setDefinition($this->listCommand->getDefinition());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setApplication(?Application $application): void
|
||||||
|
{
|
||||||
|
$this->listCommand->setApplication($application);
|
||||||
|
parent::setApplication($application);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||||
|
{
|
||||||
|
if ($input->getCompletionType() === CompletionInput::TYPE_ARGUMENT_VALUE) {
|
||||||
|
// Make this command transparent to completion, so we can `sake tasks<tab>` and see all tasks
|
||||||
|
if ($input->getCompletionValue() === $this->getName()) {
|
||||||
|
$taskLoader = DevTaskLoader::create();
|
||||||
|
$suggestions->suggestValues($taskLoader->getNames());
|
||||||
|
}
|
||||||
|
// Don't allow completion for the namespace argument, because we will override their value anyway
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Still allow completion for options e.g. --format
|
||||||
|
parent::complete($input, $suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHidden(): bool
|
||||||
|
{
|
||||||
|
return !$this->getApplication()->shouldHideTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
// Explicitly don't allow any namespace other than tasks
|
||||||
|
$input->setArgument('namespace', 'tasks');
|
||||||
|
// We have to call execute() here instead of run(), because run() would re-bind
|
||||||
|
// the input which would throw away the namespace argument.
|
||||||
|
$this->getApplication()?->setIgnoreTaskLimit(true);
|
||||||
|
$exitCode = $this->listCommand->execute($input, $output);
|
||||||
|
$this->getApplication()?->setIgnoreTaskLimit(false);
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$sakeClass = Sake::class;
|
||||||
|
$this->setHelp(<<<HELP
|
||||||
|
If you want to display the tasks in the main commands list, update the <info>$sakeClass.max_tasks_to_display</info> configuration.
|
||||||
|
<comment>
|
||||||
|
$sakeClass:
|
||||||
|
max_tasks_to_display: 50
|
||||||
|
</>
|
||||||
|
Set the value to 0 to always display tasks in the main command list regardless of how many there are.
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
}
|
55
src/Cli/CommandLoader/ArrayCommandLoader.php
Normal file
55
src/Cli/CommandLoader/ArrayCommandLoader.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\CommandLoader;
|
||||||
|
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
|
||||||
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command loader that holds more command loaders
|
||||||
|
*/
|
||||||
|
class ArrayCommandLoader implements CommandLoaderInterface
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<CommandLoaderInterface>
|
||||||
|
*/
|
||||||
|
private array $loaders = [];
|
||||||
|
|
||||||
|
public function __construct(array $loaders)
|
||||||
|
{
|
||||||
|
$this->loaders = $loaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name): Command
|
||||||
|
{
|
||||||
|
foreach ($this->loaders as $loader) {
|
||||||
|
if ($loader->has($name)) {
|
||||||
|
return $loader->get($name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
foreach ($this->loaders as $loader) {
|
||||||
|
if ($loader->has($name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNames(): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
foreach ($this->loaders as $loader) {
|
||||||
|
$names = array_merge($names, $loader->getNames());
|
||||||
|
}
|
||||||
|
return array_unique($names);
|
||||||
|
}
|
||||||
|
}
|
16
src/Cli/CommandLoader/DevCommandLoader.php
Normal file
16
src/Cli/CommandLoader/DevCommandLoader.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\CommandLoader;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\DevelopmentAdmin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commands for the controllers registered in DevelopmentAdmin
|
||||||
|
*/
|
||||||
|
class DevCommandLoader extends PolyCommandLoader
|
||||||
|
{
|
||||||
|
protected function getCommands(): array
|
||||||
|
{
|
||||||
|
return DevelopmentAdmin::singleton()->getCommands();
|
||||||
|
}
|
||||||
|
}
|
25
src/Cli/CommandLoader/DevTaskLoader.php
Normal file
25
src/Cli/CommandLoader/DevTaskLoader.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\CommandLoader;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TaskRunner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commands for the dev:tasks namespace
|
||||||
|
*/
|
||||||
|
class DevTaskLoader extends PolyCommandLoader
|
||||||
|
{
|
||||||
|
protected function getCommands(): array
|
||||||
|
{
|
||||||
|
$commands = [];
|
||||||
|
foreach (TaskRunner::singleton()->getTaskList() as $name => $class) {
|
||||||
|
$singleton = $class::singleton();
|
||||||
|
// Don't add disabled tasks.
|
||||||
|
// No need to check canRunInCli() - the superclass will take care of that.
|
||||||
|
if ($singleton->isEnabled()) {
|
||||||
|
$commands['dev/' . str_replace('tasks:', 'tasks/', $name)] = $class;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return $commands;
|
||||||
|
}
|
||||||
|
}
|
73
src/Cli/CommandLoader/InjectorCommandLoader.php
Normal file
73
src/Cli/CommandLoader/InjectorCommandLoader.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\CommandLoader;
|
||||||
|
|
||||||
|
use LogicException;
|
||||||
|
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
|
||||||
|
use SilverStripe\Cli\Sake;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
|
||||||
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command loader that loads commands from the injector if they were registered with Sake.
|
||||||
|
*/
|
||||||
|
class InjectorCommandLoader implements CommandLoaderInterface
|
||||||
|
{
|
||||||
|
private array $commands = [];
|
||||||
|
private array $commandAliases = [];
|
||||||
|
|
||||||
|
public function get(string $name): Command
|
||||||
|
{
|
||||||
|
if (!$this->has($name)) {
|
||||||
|
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
|
||||||
|
}
|
||||||
|
return $this->commands[$name] ?? $this->commandAliases[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
$this->initCommands();
|
||||||
|
return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNames(): array
|
||||||
|
{
|
||||||
|
$this->initCommands();
|
||||||
|
return array_keys($this->commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function initCommands(): void
|
||||||
|
{
|
||||||
|
if (empty($this->commands)) {
|
||||||
|
$commandClasses = Sake::config()->get('commands');
|
||||||
|
foreach ($commandClasses as $class) {
|
||||||
|
if ($class === null) {
|
||||||
|
// Allow unsetting commands via yaml
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$command = Injector::inst()->create($class);
|
||||||
|
// Wrap poly commands (if they're allowed to be run)
|
||||||
|
if ($command instanceof PolyCommand) {
|
||||||
|
if (!$command::canRunInCli()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$command = PolyCommandCliWrapper::create($command);
|
||||||
|
}
|
||||||
|
/** @var Command $command */
|
||||||
|
if (!$command->getName()) {
|
||||||
|
throw new LogicException(sprintf(
|
||||||
|
'The command defined in "%s" cannot have an empty name.',
|
||||||
|
get_debug_type($command)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$this->commands[$command->getName()] = $command;
|
||||||
|
foreach ($command->getAliases() as $alias) {
|
||||||
|
$this->commandAliases[$alias] = $command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
src/Cli/CommandLoader/PolyCommandLoader.php
Normal file
82
src/Cli/CommandLoader/PolyCommandLoader.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\CommandLoader;
|
||||||
|
|
||||||
|
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
|
||||||
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commands for PolyCommand classes
|
||||||
|
*/
|
||||||
|
abstract class PolyCommandLoader implements CommandLoaderInterface
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
private array $commands = [];
|
||||||
|
private array $commandAliases = [];
|
||||||
|
|
||||||
|
public function get(string $name): Command
|
||||||
|
{
|
||||||
|
if (!$this->has($name)) {
|
||||||
|
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
|
||||||
|
}
|
||||||
|
$info = $this->commands[$name] ?? $this->commandAliases[$name];
|
||||||
|
/** @var PolyCommand $commandClass */
|
||||||
|
$commandClass = $info['class'];
|
||||||
|
$polyCommand = $commandClass::create();
|
||||||
|
return PolyCommandCliWrapper::create($polyCommand, $info['alias']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
$this->initCommands();
|
||||||
|
return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNames(): array
|
||||||
|
{
|
||||||
|
$this->initCommands();
|
||||||
|
return array_keys($this->commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array of PolyCommand objects this loader is responsible for.
|
||||||
|
* Do not filter canRunInCli().
|
||||||
|
*
|
||||||
|
* @return array<string, PolyCommand> Associative array of commands.
|
||||||
|
* The key is an alias, or if no alias exists, the name of the command.
|
||||||
|
*/
|
||||||
|
abstract protected function getCommands(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit to only the commands that are allowed to be run in CLI.
|
||||||
|
*/
|
||||||
|
private function initCommands(): void
|
||||||
|
{
|
||||||
|
if (empty($this->commands)) {
|
||||||
|
$commands = $this->getCommands();
|
||||||
|
/** @var PolyCommand $class */
|
||||||
|
foreach ($commands as $alias => $class) {
|
||||||
|
if (!$class::canRunInCli()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$commandName = $class::getName();
|
||||||
|
$hasAlias = $alias !== $commandName;
|
||||||
|
$this->commands[$commandName] = [
|
||||||
|
'class' => $class,
|
||||||
|
'alias' => $hasAlias ? $alias : null,
|
||||||
|
];
|
||||||
|
if ($hasAlias) {
|
||||||
|
$this->commandAliases[$alias] = [
|
||||||
|
'class' => $class,
|
||||||
|
'alias' => $alias,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
168
src/Cli/LegacyParamArgvInput.php
Normal file
168
src/Cli/LegacyParamArgvInput.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\Deprecation;
|
||||||
|
use SilverStripe\Core\ArrayLib;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an input coming from the CLI arguments - but converts legacy arg-style parameters to flags.
|
||||||
|
*
|
||||||
|
* e.g. `ddev dev:build flush=1` is converted to `ddev dev:build --flush`.
|
||||||
|
* Doesn't convert anything that isn't explicitly an InputOption in the relevant InputDefinition.
|
||||||
|
* Removes the parameters from the input args (e.g. doesn't become `ddev dev:build flush=1 --flush`).
|
||||||
|
*
|
||||||
|
* @deprecated 6.0.0 Use Symfony\Component\Console\Input\ArgvInput instead.
|
||||||
|
*/
|
||||||
|
class LegacyParamArgvInput extends ArgvInput
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Input from the command line.
|
||||||
|
*
|
||||||
|
* We need a separate copy of this because the one held by the parent class is private
|
||||||
|
* and not exposed until symfony/console 7.1
|
||||||
|
*/
|
||||||
|
private array $argv;
|
||||||
|
|
||||||
|
public function __construct(?array $argv = null, ?InputDefinition $definition = null)
|
||||||
|
{
|
||||||
|
Deprecation::withSuppressedNotice(
|
||||||
|
fn() => Deprecation::notice('6.0.0', 'Use ' . ArgvInput::class . ' instead', Deprecation::SCOPE_CLASS)
|
||||||
|
);
|
||||||
|
$argv ??= $_SERVER['argv'] ?? [];
|
||||||
|
parent::__construct($argv, $definition);
|
||||||
|
// Strip the application name, matching what the parent class did with its copy
|
||||||
|
array_shift($argv);
|
||||||
|
$this->argv = $argv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasParameterOption(string|array $values, bool $onlyParams = false): bool
|
||||||
|
{
|
||||||
|
if (parent::hasParameterOption($values, $onlyParams)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $this->hasLegacyParameterOption($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParameterOption(string|array $values, string|bool|int|float|array|null $default = false, bool $onlyParams = false): mixed
|
||||||
|
{
|
||||||
|
if (parent::hasParameterOption($values, $onlyParams)) {
|
||||||
|
return parent::getParameterOption($values, $default, $onlyParams);
|
||||||
|
}
|
||||||
|
return $this->getLegacyParameterOption($values, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the current Input instance with the given arguments and options.
|
||||||
|
*
|
||||||
|
* Also converts any arg-style params into true flags, based on the options defined.
|
||||||
|
*/
|
||||||
|
public function bind(InputDefinition $definition): void
|
||||||
|
{
|
||||||
|
// Convert arg-style params into flags
|
||||||
|
$tokens = $this->argv;
|
||||||
|
$convertedFlags = [];
|
||||||
|
$hadLegacyParams = false;
|
||||||
|
foreach ($definition->getOptions() as $option) {
|
||||||
|
$flagName = '--' . $option->getName();
|
||||||
|
// Check if there is a legacy param first. This saves us from accidentally getting
|
||||||
|
// values that come after the end of options (--) signal
|
||||||
|
if (!$this->hasLegacyParameterOption($flagName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Get the value from the legacy param
|
||||||
|
$value = $this->getLegacyParameterOption($flagName);
|
||||||
|
if ($value && !$this->hasLegacyParameterOption($flagName . '=' . $value)) {
|
||||||
|
// symfony/console will try to get the value from the next argument if the current argument ends with `=`
|
||||||
|
// We don't want to count that as the value, so double check it.
|
||||||
|
$value = null;
|
||||||
|
} elseif ($option->acceptValue()) {
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
$convertedFlags[] = $flagName;
|
||||||
|
} else {
|
||||||
|
$convertedFlags[] = $flagName . '=' . $value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the option doesn't accept a value, only add the flag if the value is true.
|
||||||
|
$valueAsBool = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
|
||||||
|
if ($valueAsBool) {
|
||||||
|
$convertedFlags[] = $flagName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$hadLegacyParams = true;
|
||||||
|
// Remove the legacy param from the token set
|
||||||
|
foreach ($tokens as $i => $token) {
|
||||||
|
if (str_starts_with($token, $option->getName() . '=')) {
|
||||||
|
unset($tokens[$i]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($convertedFlags)) {
|
||||||
|
// Make sure it's before the end of options (--) signal if there is one.
|
||||||
|
$tokens = ArrayLib::insertBefore($tokens, $convertedFlags, '--', true, true);
|
||||||
|
}
|
||||||
|
if ($hadLegacyParams) {
|
||||||
|
// We only want the "notice" once regardless of how many params there are.
|
||||||
|
Deprecation::notice(
|
||||||
|
'6.0.0',
|
||||||
|
'Using `param=value` style flags is deprecated. Use `--flag=value` CLI flags instead.',
|
||||||
|
Deprecation::SCOPE_GLOBAL
|
||||||
|
);
|
||||||
|
// Set the new tokens so the parent class can operate on them.
|
||||||
|
// Specifically skip setting $this->argv in case someone decides to bind to a different
|
||||||
|
// input definition afterwards for whatever reason.
|
||||||
|
parent::setTokens($tokens);
|
||||||
|
}
|
||||||
|
parent::bind($definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setTokens(array $tokens): void
|
||||||
|
{
|
||||||
|
$this->argv = $tokens;
|
||||||
|
parent::setTokens($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasLegacyParameterOption(string|array $values): bool
|
||||||
|
{
|
||||||
|
$values = $this->getLegacyParamsForFlags((array) $values);
|
||||||
|
if (empty($values)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return parent::hasParameterOption($values, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLegacyParameterOption(string|array $values, string|bool|int|float|array|null $default = false): mixed
|
||||||
|
{
|
||||||
|
$values = $this->getLegacyParamsForFlags((array) $values);
|
||||||
|
if (empty($values)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return parent::getParameterOption($values, $default, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a set of flag names, return what they would be called in the legacy format.
|
||||||
|
*/
|
||||||
|
private function getLegacyParamsForFlags(array $flags): array
|
||||||
|
{
|
||||||
|
$legacyParams = [];
|
||||||
|
foreach ($flags as $flag) {
|
||||||
|
// Only allow full flags e.g. `--flush`, not shortcuts like `-f`
|
||||||
|
if (!str_starts_with($flag, '--')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Convert to legacy format, e.g. `--flush` becomes `flush=`
|
||||||
|
// but if there's already an equals e.g. `--flush=1` keep it (`flush=1`)
|
||||||
|
// because the developer is checking for a specific value set to the flag.
|
||||||
|
$flag = ltrim($flag, '-');
|
||||||
|
if (!str_contains($flag, '=')) {
|
||||||
|
$flag .= '=';
|
||||||
|
}
|
||||||
|
$legacyParams[] = $flag;
|
||||||
|
}
|
||||||
|
return $legacyParams;
|
||||||
|
}
|
||||||
|
}
|
282
src/Cli/Sake.php
Normal file
282
src/Cli/Sake.php
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli;
|
||||||
|
|
||||||
|
use SilverStripe\Cli\Command\NavigateCommand;
|
||||||
|
use SilverStripe\Cli\Command\TasksCommand;
|
||||||
|
use SilverStripe\Cli\CommandLoader\ArrayCommandLoader;
|
||||||
|
use SilverStripe\Cli\CommandLoader\DevCommandLoader;
|
||||||
|
use SilverStripe\Cli\CommandLoader\DevTaskLoader;
|
||||||
|
use SilverStripe\Cli\CommandLoader\InjectorCommandLoader;
|
||||||
|
use SilverStripe\Core\Config\Configurable;
|
||||||
|
use SilverStripe\Core\CoreKernel;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\Core\Kernel;
|
||||||
|
use SilverStripe\Core\Manifest\VersionProvider;
|
||||||
|
use SilverStripe\Dev\Deprecation;
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\DumpCompletionCommand;
|
||||||
|
use Symfony\Component\Console\Command\HelpCommand;
|
||||||
|
use Symfony\Component\Console\Command\ListCommand;
|
||||||
|
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||||
|
use Symfony\Component\Console\Completion\Suggestion;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI application for running commands against a Silverstripe CMS project
|
||||||
|
* Boots up a full kernel, using the same configuration and database the web server uses.
|
||||||
|
*/
|
||||||
|
class Sake extends Application
|
||||||
|
{
|
||||||
|
use Configurable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commands that can be run. These commands will be instantiated via the Injector.
|
||||||
|
* Does not include commands in the dev/ namespace (see command_loaders).
|
||||||
|
*
|
||||||
|
* @var array<Command>
|
||||||
|
*/
|
||||||
|
private static array $commands = [
|
||||||
|
'navigate' => NavigateCommand::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command loaders for dynamically adding commands to sake.
|
||||||
|
* These loaders will be instantiated via the Injector.
|
||||||
|
*
|
||||||
|
* @var array<CommandLoaderInterface>
|
||||||
|
*/
|
||||||
|
private static array $command_loaders = [
|
||||||
|
'dev-commands' => DevCommandLoader::class,
|
||||||
|
'dev-tasks' => DevTaskLoader::class,
|
||||||
|
'injected' => InjectorCommandLoader::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of tasks to display in the main command list.
|
||||||
|
*
|
||||||
|
* If there are more tasks than this, they will be hidden from the main command list - running `sake tasks` will show them.
|
||||||
|
* Set to 0 to always show tasks in the main list.
|
||||||
|
*/
|
||||||
|
private static int $max_tasks_to_display = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true to hide the "completion" command.
|
||||||
|
* Useful if you never intend to set up shell completion, or if you've already done so.
|
||||||
|
*/
|
||||||
|
private static bool $hide_completion_command = false;
|
||||||
|
|
||||||
|
private ?Kernel $kernel;
|
||||||
|
|
||||||
|
private bool $ignoreTaskLimit = false;
|
||||||
|
|
||||||
|
public function __construct(?Kernel $kernel = null)
|
||||||
|
{
|
||||||
|
$this->kernel = $kernel;
|
||||||
|
parent::__construct('Silverstripe Sake');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): string
|
||||||
|
{
|
||||||
|
return VersionProvider::singleton()->getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
|
||||||
|
{
|
||||||
|
$input = $input ?? new LegacyParamArgvInput();
|
||||||
|
$flush = $input->hasParameterOption('--flush', true) || $input->getFirstArgument() === 'flush';
|
||||||
|
$bootDatabase = !$input->hasParameterOption('--no-database', true);
|
||||||
|
|
||||||
|
$managingKernel = !$this->kernel;
|
||||||
|
if ($managingKernel) {
|
||||||
|
// Instantiate the kernel if we weren't given a pre-loaded one
|
||||||
|
$this->kernel = new CoreKernel(BASE_PATH);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Boot if not already booted
|
||||||
|
if (!$this->kernel->getBooted()) {
|
||||||
|
if ($this->kernel instanceof CoreKernel) {
|
||||||
|
$this->kernel->setBootDatabase($bootDatabase);
|
||||||
|
}
|
||||||
|
$this->kernel->boot($flush);
|
||||||
|
}
|
||||||
|
// Allow developers to hook into symfony/console events
|
||||||
|
/** @var EventDispatcherInterface $dispatcher */
|
||||||
|
$dispatcher = Injector::inst()->get(EventDispatcherInterface::class . '.sake');
|
||||||
|
$this->setDispatcher($dispatcher);
|
||||||
|
// Add commands and finally execute
|
||||||
|
$this->addCommandLoadersFromConfig();
|
||||||
|
return parent::run($input, $output);
|
||||||
|
} finally {
|
||||||
|
// If we instantiated the kernel, we're also responsible for shutting it down.
|
||||||
|
if ($managingKernel) {
|
||||||
|
$this->kernel->shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(?string $namespace = null): array
|
||||||
|
{
|
||||||
|
$commands = parent::all($namespace);
|
||||||
|
// If number of tasks is greater than the limit, hide them from the main comands list.
|
||||||
|
$maxTasks = Sake::config()->get('max_tasks_to_display');
|
||||||
|
if (!$this->ignoreTaskLimit && $maxTasks > 0 && $namespace === null) {
|
||||||
|
$tasks = [];
|
||||||
|
// Find all commands in the tasks: namespace
|
||||||
|
foreach (array_keys($commands) as $name) {
|
||||||
|
if (str_starts_with($name, 'tasks:') || str_starts_with($name, 'dev/tasks/')) {
|
||||||
|
$tasks[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($tasks) > $maxTasks) {
|
||||||
|
// Hide the commands
|
||||||
|
foreach ($tasks as $name) {
|
||||||
|
unset($commands[$name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether tasks should currently be hidden from the main command list
|
||||||
|
*/
|
||||||
|
public function shouldHideTasks(): bool
|
||||||
|
{
|
||||||
|
$maxLimit = Sake::config()->get('max_tasks_to_display');
|
||||||
|
return $maxLimit > 0 && count($this->all('tasks')) > $maxLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether the task limit should be ignored.
|
||||||
|
* Used by the tasks command and completion to allow listing tasks when there's too many of them
|
||||||
|
* to list in the main command list.
|
||||||
|
*/
|
||||||
|
public function setIgnoreTaskLimit(bool $ignore): void
|
||||||
|
{
|
||||||
|
$this->ignoreTaskLimit = $ignore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||||
|
{
|
||||||
|
// Make sure tasks can always be shown in completion even if there's too many of them to list
|
||||||
|
// in the main command list.
|
||||||
|
$this->setIgnoreTaskLimit(true);
|
||||||
|
|
||||||
|
// Remove legacy dev/* aliases from completion suggestions, but only
|
||||||
|
// if the user isn't explicitly looking for them (i.e. hasn't typed anything yet)
|
||||||
|
if (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
|
||||||
|
&& $input->getCompletionName() === 'command'
|
||||||
|
&& $input->getCompletionValue() === ''
|
||||||
|
) {
|
||||||
|
foreach ($this->all() as $name => $command) {
|
||||||
|
// skip hidden commands
|
||||||
|
// skip aliased commands as they get added below
|
||||||
|
if ($command->isHidden() || $command->getName() !== $name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$suggestions->suggestValue(new Suggestion($command->getName(), $command->getDescription()));
|
||||||
|
foreach ($command->getAliases() as $name) {
|
||||||
|
// Skip legacy dev aliases
|
||||||
|
if (str_starts_with($name, 'dev/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$suggestions->suggestValue(new Suggestion($name, $command->getDescription()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// For everything else, use the superclass
|
||||||
|
parent::complete($input, $suggestions);
|
||||||
|
}
|
||||||
|
$this->setIgnoreTaskLimit(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$name = $command->getName() ?? '';
|
||||||
|
$nameUsedAs = $input->getFirstArgument() ?? '';
|
||||||
|
if (str_starts_with($nameUsedAs, 'dev/')) {
|
||||||
|
Deprecation::notice(
|
||||||
|
'6.0.0',
|
||||||
|
"Using the command with the name '$nameUsedAs' is deprecated. Use '$name' instead",
|
||||||
|
Deprecation::SCOPE_GLOBAL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parent::doRunCommand($command, $input, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultInputDefinition(): InputDefinition
|
||||||
|
{
|
||||||
|
$definition = parent::getDefaultInputDefinition();
|
||||||
|
$definition->addOptions([
|
||||||
|
new InputOption('no-database', null, InputOption::VALUE_NONE, 'Run the command without connecting to the database'),
|
||||||
|
new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'),
|
||||||
|
]);
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultCommands(): array
|
||||||
|
{
|
||||||
|
$commands = parent::getDefaultCommands();
|
||||||
|
|
||||||
|
// Hide commands that are just cluttering up the list
|
||||||
|
$toHide = [
|
||||||
|
// List is the default command, and you have to have used it to see it anyway.
|
||||||
|
ListCommand::class,
|
||||||
|
// The --help flag is more common and is already displayed.
|
||||||
|
HelpCommand::class,
|
||||||
|
];
|
||||||
|
// Completion is just clutter if you've already used it or aren't going to.
|
||||||
|
if (Sake::config()->get('hide_completion_command')) {
|
||||||
|
$toHide[] = DumpCompletionCommand::class;
|
||||||
|
}
|
||||||
|
foreach ($commands as $command) {
|
||||||
|
if (in_array(get_class($command), $toHide)) {
|
||||||
|
$command->setHidden(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commands[] = $this->createFlushCommand();
|
||||||
|
$commands[] = new TasksCommand();
|
||||||
|
|
||||||
|
return $commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addCommandLoadersFromConfig(): void
|
||||||
|
{
|
||||||
|
$loaderClasses = Sake::config()->get('command_loaders');
|
||||||
|
$loaders = [];
|
||||||
|
foreach ($loaderClasses as $class) {
|
||||||
|
if ($class === null) {
|
||||||
|
// Allow unsetting loaders via yaml
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$loaders[] = Injector::inst()->create($class);
|
||||||
|
}
|
||||||
|
$this->setCommandLoader(ArrayCommandLoader::create($loaders));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a dummy "flush" command for when you just want to flush without running another command.
|
||||||
|
*/
|
||||||
|
private function createFlushCommand(): Command
|
||||||
|
{
|
||||||
|
$command = new Command('flush');
|
||||||
|
$command->setDescription('Flush the cache (or use the <info>--flush</info> flag with any command)');
|
||||||
|
$command->setCode(function (InputInterface $input, OutputInterface $ouput) {
|
||||||
|
// Actual flushing happens in `run()` when booting the kernel, so there's nothing to do here.
|
||||||
|
$ouput->writeln('Cache flushed.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
});
|
||||||
|
return $command;
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@
|
|||||||
namespace SilverStripe\Control;
|
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']);
|
||||||
@ -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, []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\Control;
|
|
||||||
|
|
||||||
use SilverStripe\Core\ClassInfo;
|
|
||||||
use SilverStripe\Core\Injector\Injector;
|
|
||||||
use SilverStripe\Dev\Deprecation;
|
|
||||||
use SilverStripe\Security\Permission;
|
|
||||||
use SilverStripe\Security\Security;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class invoked from CLI rather than the webserver (Cron jobs, handling email bounces).
|
|
||||||
* You can call subclasses of CliController directly, which will trigger a
|
|
||||||
* call to {@link process()} on every sub-subclass. For instance, calling
|
|
||||||
* "sake DailyTask" from the commandline will call {@link process()} on every subclass
|
|
||||||
* of DailyTask.
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with symfony/console commands
|
|
||||||
*/
|
|
||||||
abstract class CliController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
Deprecation::notice('5.4.0', 'Will be replaced with symfony/console commands', Deprecation::SCOPE_CLASS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static $allowed_actions = [
|
|
||||||
'index'
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function init()
|
|
||||||
{
|
|
||||||
parent::init();
|
|
||||||
// Unless called from the command line, all CliControllers need ADMIN privileges
|
|
||||||
if (!Director::is_cli() && !Permission::check("ADMIN")) {
|
|
||||||
Security::permissionFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
foreach (ClassInfo::subclassesFor(static::class) as $subclass) {
|
|
||||||
echo $subclass . "\n";
|
|
||||||
/** @var CliController $task */
|
|
||||||
$task = Injector::inst()->create($subclass);
|
|
||||||
$task->doInit();
|
|
||||||
$task->process();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overload this method to contain the task logic.
|
|
||||||
*/
|
|
||||||
public function process()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
@ -12,6 +12,7 @@ use SilverStripe\Core\Injector\Injectable;
|
|||||||
use SilverStripe\Core\Injector\Injector;
|
use SilverStripe\Core\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();
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
|
|
||||||
|
|
||||||
use SilverStripe\Control\Director;
|
|
||||||
use SilverStripe\Control\HTTPRequest;
|
|
||||||
use SilverStripe\Core\Kernel;
|
|
||||||
use SilverStripe\Dev\Deprecation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows a bypass when the request has been run in CLI mode
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
|
|
||||||
*/
|
|
||||||
class CliBypass implements Bypass
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be removed without equivalent functionality to replace it',
|
|
||||||
Deprecation::SCOPE_CLASS
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the current process is running in CLI mode
|
|
||||||
*
|
|
||||||
* @param HTTPRequest $request
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function checkRequestForBypass(HTTPRequest $request)
|
|
||||||
{
|
|
||||||
return Director::is_cli();
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,7 +4,6 @@ namespace SilverStripe\Control\Middleware;
|
|||||||
|
|
||||||
use SilverStripe\Control\HTTPRequest;
|
use SilverStripe\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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
*/
|
*/
|
||||||
|
65
src/Control/PolyCommandController.php
Normal file
65
src/Control/PolyCommandController.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Control;
|
||||||
|
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\HttpRequestInput;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||||
|
use Symfony\Component\Console\Exception\InvalidOptionException;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller that allows routing HTTP requests to PolyCommands
|
||||||
|
*
|
||||||
|
* This controller is automatically wrapped around any PolyCommand
|
||||||
|
* that is added to the regular routing configuration.
|
||||||
|
*/
|
||||||
|
class PolyCommandController extends Controller
|
||||||
|
{
|
||||||
|
private PolyCommand $command;
|
||||||
|
|
||||||
|
public function __construct(PolyCommand $polyCommand)
|
||||||
|
{
|
||||||
|
$this->command = $polyCommand;
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function init()
|
||||||
|
{
|
||||||
|
parent::init();
|
||||||
|
if (!$this->command::canRunInBrowser()) {
|
||||||
|
$this->httpError(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(HTTPRequest $request): HTTPResponse
|
||||||
|
{
|
||||||
|
$response = $this->getResponse();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$input = HttpRequestInput::create($request, $this->command->getOptions());
|
||||||
|
} catch (InvalidOptionException|InvalidArgumentException $e) {
|
||||||
|
$response->setBody($e->getMessage());
|
||||||
|
$response->setStatusCode(400);
|
||||||
|
$this->afterHandleRequest();
|
||||||
|
return $this->getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true, $buffer);
|
||||||
|
$exitCode = $this->command->run($input, $output);
|
||||||
|
$response->setBody($buffer->fetch());
|
||||||
|
$responseCode = match (true) {
|
||||||
|
$exitCode === Command::SUCCESS => 200,
|
||||||
|
$exitCode === Command::FAILURE => 500,
|
||||||
|
$exitCode === Command::INVALID => 400,
|
||||||
|
// If someone's using an unexpected exit code, we shouldn't guess what they meant,
|
||||||
|
// just assume they intentionally set it to something meaningful.
|
||||||
|
default => $exitCode,
|
||||||
|
};
|
||||||
|
$response->setStatusCode($responseCode);
|
||||||
|
return $this->getResponse();
|
||||||
|
}
|
||||||
|
}
|
@ -30,7 +30,6 @@ class CoreKernel extends BaseKernel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param false $flush
|
|
||||||
* @throws HTTPResponse_Exception
|
* @throws HTTPResponse_Exception
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\Core;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use SilverStripe\Dev\Deprecation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Boot a kernel without requiring a database connection.
|
|
||||||
* This is a workaround for the lack of composition in the boot stages
|
|
||||||
* of CoreKernel, as well as for the framework's misguided assumptions
|
|
||||||
* around the availability of a database for every execution path.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
* @deprecated 5.4.0 Use SilverStripe\Core\CoreKernel::setBootDatabase() instead
|
|
||||||
*/
|
|
||||||
class DatabaselessKernel extends BaseKernel
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Indicates whether the Kernel has been flushed on boot
|
|
||||||
* Null before boot
|
|
||||||
*/
|
|
||||||
private ?bool $flush = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows disabling of the configured error handling.
|
|
||||||
* This can be useful to ensure the execution context (e.g. composer)
|
|
||||||
* can consistently use its own error handling.
|
|
||||||
*
|
|
||||||
* @var boolean
|
|
||||||
*/
|
|
||||||
protected $bootErrorHandling = true;
|
|
||||||
|
|
||||||
public function __construct($basePath)
|
|
||||||
{
|
|
||||||
parent::__construct($basePath);
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Use ' . CoreKernel::class . '::setBootDatabase() instead',
|
|
||||||
Deprecation::SCOPE_CLASS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBootErrorHandling(bool $bool)
|
|
||||||
{
|
|
||||||
$this->bootErrorHandling = $bool;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param false $flush
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function boot($flush = false)
|
|
||||||
{
|
|
||||||
$this->flush = $flush;
|
|
||||||
|
|
||||||
$this->bootPHP();
|
|
||||||
$this->bootManifests($flush);
|
|
||||||
$this->bootErrorHandling();
|
|
||||||
$this->bootConfigs();
|
|
||||||
|
|
||||||
$this->setBooted(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFlushed(): ?bool
|
|
||||||
{
|
|
||||||
return $this->flush;
|
|
||||||
}
|
|
||||||
}
|
|
@ -139,4 +139,9 @@ interface Kernel
|
|||||||
* @return bool|null null if the kernel hasn't been booted yet
|
* @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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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::withSuppressedNotice(
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
109
src/Dev/Command/ConfigAudit.php
Normal file
109
src/Dev/Command/ConfigAudit.php
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Core\ClassInfo;
|
||||||
|
use SilverStripe\Core\Config\Config;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to audit the configuration.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class ConfigAudit extends DevCommand
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'config:audit';
|
||||||
|
|
||||||
|
protected static string $description = 'Find configuration properties that are not defined (or inherited) by their respective classes';
|
||||||
|
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'CAN_DEV_CONFIG',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Configuration';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$body = '';
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($this->arrayKeysRecursive(Config::inst()->getAll(), 2) as $className => $props) {
|
||||||
|
$props = array_keys($props ?? []);
|
||||||
|
|
||||||
|
if (!count($props ?? [])) {
|
||||||
|
// We can skip this entry
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($className == strtolower(Injector::class)) {
|
||||||
|
// We don't want to check the injector config
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($props as $prop) {
|
||||||
|
$defined = false;
|
||||||
|
// Check ancestry (private properties don't inherit natively)
|
||||||
|
foreach (ClassInfo::ancestry($className) as $cn) {
|
||||||
|
if (property_exists($cn, $prop ?? '')) {
|
||||||
|
$defined = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($defined) {
|
||||||
|
// No need to record this property
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing[] = sprintf("%s::$%s\n", $className, $prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = count($missing ?? [])
|
||||||
|
? implode("\n", $missing)
|
||||||
|
: "All configured properties are defined\n";
|
||||||
|
|
||||||
|
$output->writeForHtml('<pre>');
|
||||||
|
$output->write($body);
|
||||||
|
$output->writeForHtml('</pre>');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Missing configuration property definitions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the keys of a multi-dimensional array while maintining any nested structure.
|
||||||
|
* Does not include keys where the values are not arrays, so not suitable as a generic method.
|
||||||
|
*/
|
||||||
|
private function arrayKeysRecursive(
|
||||||
|
array $array,
|
||||||
|
int $maxdepth = 20,
|
||||||
|
int $depth = 0,
|
||||||
|
array $arrayKeys = []
|
||||||
|
): array {
|
||||||
|
if ($depth < $maxdepth) {
|
||||||
|
$depth++;
|
||||||
|
$keys = array_keys($array ?? []);
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!is_array($array[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arrayKeys[$key] = $this->arrayKeysRecursive($array[$key], $maxdepth, $depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $arrayKeys;
|
||||||
|
}
|
||||||
|
}
|
58
src/Dev/Command/ConfigDump.php
Normal file
58
src/Dev/Command/ConfigDump.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Core\Config\Config;
|
||||||
|
use SilverStripe\Dev\DevelopmentAdmin;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\Security\PermissionProvider;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to dump the configuration.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class ConfigDump extends DevCommand implements PermissionProvider
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'config:dump';
|
||||||
|
|
||||||
|
protected static string $description = 'View the current config, useful for debugging';
|
||||||
|
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'CAN_DEV_CONFIG',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Configuration';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->writeForHtml('<pre>');
|
||||||
|
|
||||||
|
$output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
|
||||||
|
|
||||||
|
$output->writeForHtml('</pre>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Config manifest';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providePermissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'CAN_DEV_CONFIG' => [
|
||||||
|
'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
|
||||||
|
'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
|
||||||
|
'category' => DevelopmentAdmin::permissionsCategory(),
|
||||||
|
'sort' => 100
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
347
src/Dev/Command/DbBuild.php
Normal file
347
src/Dev/Command/DbBuild.php
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use BadMethodCallException;
|
||||||
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Core\ClassInfo;
|
||||||
|
use SilverStripe\Core\Environment;
|
||||||
|
use SilverStripe\Core\Extensible;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\Core\Manifest\ClassLoader;
|
||||||
|
use SilverStripe\Dev\Deprecation;
|
||||||
|
use SilverStripe\Dev\DevelopmentAdmin;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\ORM\Connect\TableBuilder;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
use SilverStripe\ORM\DB;
|
||||||
|
use SilverStripe\ORM\FieldType\DBClassName;
|
||||||
|
use SilverStripe\Security\PermissionProvider;
|
||||||
|
use SilverStripe\Security\Security;
|
||||||
|
use SilverStripe\Versioned\Versioned;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to build the database.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class DbBuild extends DevCommand implements PermissionProvider
|
||||||
|
{
|
||||||
|
use Extensible;
|
||||||
|
|
||||||
|
protected static string $commandName = 'db:build';
|
||||||
|
|
||||||
|
protected static string $description = 'Build/rebuild this environment. Run this whenever you have updated your project sources';
|
||||||
|
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'CAN_DEV_BUILD',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsolete classname values that should be remapped while building the database.
|
||||||
|
* Map old FQCN to new FQCN, e.g
|
||||||
|
* 'App\\OldNamespace\\MyClass' => 'App\\NewNamespace\\MyClass'
|
||||||
|
*/
|
||||||
|
private static array $classname_value_remapping = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config setting to enabled/disable the display of record counts on the build output
|
||||||
|
*/
|
||||||
|
private static bool $show_record_counts = true;
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Environment Builder';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
// The default time limit of 30 seconds is normally not enough
|
||||||
|
Environment::increaseTimeLimitTo(600);
|
||||||
|
|
||||||
|
// If this code is being run without a flush, we need to at least flush the class manifest
|
||||||
|
if (!$input->getOption('flush')) {
|
||||||
|
ClassLoader::inst()->getManifest()->regenerate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$populate = !$input->getOption('no-populate');
|
||||||
|
if ($input->getOption('dont_populate')) {
|
||||||
|
$populate = false;
|
||||||
|
Deprecation::notice(
|
||||||
|
'6.0.0',
|
||||||
|
'`dont_populate` is deprecated. Use `no-populate` instead',
|
||||||
|
Deprecation::SCOPE_GLOBAL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->doBuild($output, $populate);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
$conn = DB::get_conn();
|
||||||
|
// Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
|
||||||
|
$dbType = substr(get_class($conn), 0, -8);
|
||||||
|
$dbVersion = $conn->getVersion();
|
||||||
|
$databaseName = $conn->getSelectedDatabase();
|
||||||
|
return sprintf('Building database %s using %s %s', $databaseName, $dbType, $dbVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the database schema, creating tables & fields as necessary.
|
||||||
|
*
|
||||||
|
* @param bool $populate Populate the database, as well as setting up its schema
|
||||||
|
*/
|
||||||
|
public function doBuild(PolyOutput $output, bool $populate = true, bool $testMode = false): void
|
||||||
|
{
|
||||||
|
$this->extend('onBeforeBuild', $output, $populate, $testMode);
|
||||||
|
|
||||||
|
if ($output->isQuiet()) {
|
||||||
|
DB::quiet();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the initial database
|
||||||
|
if (!DB::is_active()) {
|
||||||
|
$output->writeln(['<options=bold>Creating database</>', '']);
|
||||||
|
|
||||||
|
// Load parameters from existing configuration
|
||||||
|
$databaseConfig = DB::getConfig();
|
||||||
|
if (empty($databaseConfig)) {
|
||||||
|
throw new BadMethodCallException("No database configuration available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database name is given
|
||||||
|
if (empty($databaseConfig['database'])) {
|
||||||
|
throw new BadMethodCallException(
|
||||||
|
"No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$database = $databaseConfig['database'];
|
||||||
|
|
||||||
|
// Establish connection
|
||||||
|
unset($databaseConfig['database']);
|
||||||
|
DB::connect($databaseConfig);
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
DB::create_database($database);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the database. Most of the hard work is handled by DataObject
|
||||||
|
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
|
||||||
|
array_shift($dataClasses);
|
||||||
|
|
||||||
|
$output->writeln(['<options=bold>Creating database tables</>', '']);
|
||||||
|
$output->startList(PolyOutput::LIST_UNORDERED);
|
||||||
|
|
||||||
|
$showRecordCounts = (bool) static::config()->get('show_record_counts');
|
||||||
|
|
||||||
|
// Initiate schema update
|
||||||
|
$dbSchema = DB::get_schema();
|
||||||
|
$tableBuilder = TableBuilder::singleton();
|
||||||
|
$tableBuilder->buildTables($dbSchema, $dataClasses, [], $output->isQuiet(), $testMode, $showRecordCounts);
|
||||||
|
ClassInfo::reset_db_cache();
|
||||||
|
|
||||||
|
$output->stopList();
|
||||||
|
|
||||||
|
if ($populate) {
|
||||||
|
$output->writeln(['<options=bold>Creating database records</>', '']);
|
||||||
|
$output->startList(PolyOutput::LIST_UNORDERED);
|
||||||
|
|
||||||
|
// Remap obsolete class names
|
||||||
|
$this->migrateClassNames();
|
||||||
|
|
||||||
|
// Require all default records
|
||||||
|
foreach ($dataClasses as $dataClass) {
|
||||||
|
// Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
|
||||||
|
// Test_ indicates that it's the data class is part of testing system
|
||||||
|
if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
|
||||||
|
$output->writeListItem($dataClass);
|
||||||
|
DataObject::singleton($dataClass)->requireDefaultRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->stopList();
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(static::getLastGeneratedFilePath());
|
||||||
|
|
||||||
|
$output->writeln(['<options=bold>Database build completed!</>', '']);
|
||||||
|
|
||||||
|
foreach ($dataClasses as $dataClass) {
|
||||||
|
DataObject::singleton($dataClass)->onAfterBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassInfo::reset_db_cache();
|
||||||
|
|
||||||
|
$this->extend('onAfterBuild', $output, $populate, $testMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new InputOption(
|
||||||
|
'no-populate',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Don\'t run <info>requireDefaultRecords()</info> on the models when building.'
|
||||||
|
. 'This will build the table but not insert any records'
|
||||||
|
),
|
||||||
|
new InputOption(
|
||||||
|
'dont_populate',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Deprecated - use <info>no-populate</info> instead'
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providePermissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'CAN_DEV_BUILD' => [
|
||||||
|
'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
|
||||||
|
'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
|
||||||
|
'category' => DevelopmentAdmin::permissionsCategory(),
|
||||||
|
'sort' => 100
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a base data class, a field name and a mapping of class replacements, look for obsolete
|
||||||
|
* values in the $dataClass's $fieldName column and replace it with $mapping
|
||||||
|
*
|
||||||
|
* @param string $dataClass The data class to look up
|
||||||
|
* @param string $fieldName The field name to look in for obsolete class names
|
||||||
|
* @param string[] $mapping Map of old to new classnames
|
||||||
|
*/
|
||||||
|
protected function updateLegacyClassNameField(string $dataClass, string $fieldName, array $mapping): void
|
||||||
|
{
|
||||||
|
$schema = DataObject::getSchema();
|
||||||
|
// Check first to ensure that the class has the specified field to update
|
||||||
|
if (!$schema->databaseField($dataClass, $fieldName, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a list of any records that have obsolete class names
|
||||||
|
$table = $schema->tableName($dataClass);
|
||||||
|
$currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
|
||||||
|
|
||||||
|
// Get all invalid classes for this field
|
||||||
|
$invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
|
||||||
|
if (!$invalidClasses) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberClasses = count($invalidClasses ?? []);
|
||||||
|
DB::alteration_message(
|
||||||
|
"Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
|
||||||
|
'obsolete'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build case assignment based on all intersected legacy classnames
|
||||||
|
$cases = [];
|
||||||
|
$params = [];
|
||||||
|
foreach ($invalidClasses as $invalidClass) {
|
||||||
|
$cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
|
||||||
|
$params[] = $invalidClass;
|
||||||
|
$params[] = $mapping[$invalidClass];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->getClassTables($dataClass) as $table) {
|
||||||
|
$casesSQL = implode(' ', $cases);
|
||||||
|
$sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
|
||||||
|
DB::prepared_query($sql, $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tables to update for this class
|
||||||
|
*/
|
||||||
|
protected function getClassTables(string $dataClass): iterable
|
||||||
|
{
|
||||||
|
$schema = DataObject::getSchema();
|
||||||
|
$table = $schema->tableName($dataClass);
|
||||||
|
|
||||||
|
// Base table
|
||||||
|
yield $table;
|
||||||
|
|
||||||
|
// Remap versioned table class name values as well
|
||||||
|
/** @var Versioned|DataObject $dataClass */
|
||||||
|
$dataClass = DataObject::singleton($dataClass);
|
||||||
|
if ($dataClass->hasExtension(Versioned::class)) {
|
||||||
|
if ($dataClass->hasStages()) {
|
||||||
|
yield "{$table}_Live";
|
||||||
|
}
|
||||||
|
yield "{$table}_Versions";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
|
||||||
|
* `ClassName` fields as well as polymorphic class name fields.
|
||||||
|
*
|
||||||
|
* @return array[]
|
||||||
|
*/
|
||||||
|
protected function getClassNameRemappingFields(): array
|
||||||
|
{
|
||||||
|
$dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
|
||||||
|
$schema = DataObject::getSchema();
|
||||||
|
$remapping = [];
|
||||||
|
|
||||||
|
foreach ($dataClasses as $className) {
|
||||||
|
$fieldSpecs = $schema->fieldSpecs($className);
|
||||||
|
foreach ($fieldSpecs as $fieldName => $fieldSpec) {
|
||||||
|
if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) {
|
||||||
|
$remapping[$className][] = $fieldName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate all class names
|
||||||
|
*/
|
||||||
|
protected function migrateClassNames(): void
|
||||||
|
{
|
||||||
|
$remappingConfig = static::config()->get('classname_value_remapping');
|
||||||
|
$remappingFields = $this->getClassNameRemappingFields();
|
||||||
|
foreach ($remappingFields as $className => $fieldNames) {
|
||||||
|
foreach ($fieldNames as $fieldName) {
|
||||||
|
$this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the timestamp of the time that the database was last built
|
||||||
|
* or an empty string if we can't find that information.
|
||||||
|
*/
|
||||||
|
public static function lastBuilt(): string
|
||||||
|
{
|
||||||
|
$file = static::getLastGeneratedFilePath();
|
||||||
|
if (file_exists($file)) {
|
||||||
|
return filemtime($file);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canRunInBrowser(): bool
|
||||||
|
{
|
||||||
|
// Must allow running in browser if DB hasn't been built yet or is broken
|
||||||
|
// or the permission checks will throw an error
|
||||||
|
return !Security::database_is_ready() || parent::canRunInBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getLastGeneratedFilePath(): string
|
||||||
|
{
|
||||||
|
return TEMP_PATH
|
||||||
|
. DIRECTORY_SEPARATOR
|
||||||
|
. 'database-last-generated-'
|
||||||
|
. str_replace(['\\', '/', ':'], '.', Director::baseFolder());
|
||||||
|
}
|
||||||
|
}
|
89
src/Dev/Command/DbCleanup.php
Normal file
89
src/Dev/Command/DbCleanup.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Core\ClassInfo;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
use SilverStripe\ORM\DB;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to clean up the database.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class DbCleanup extends DevCommand
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'db:cleanup';
|
||||||
|
|
||||||
|
protected static string $description = 'Remove records that don\'t have corresponding rows in their parent class tables';
|
||||||
|
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'CAN_DEV_BUILD',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Database Cleanup';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$schema = DataObject::getSchema();
|
||||||
|
$baseClasses = [];
|
||||||
|
foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
|
||||||
|
if (get_parent_class($class ?? '') == DataObject::class) {
|
||||||
|
$baseClasses[] = $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$countDeleted = 0;
|
||||||
|
$output->startList(PolyOutput::LIST_UNORDERED);
|
||||||
|
foreach ($baseClasses as $baseClass) {
|
||||||
|
// Get data classes
|
||||||
|
$baseTable = $schema->baseDataTable($baseClass);
|
||||||
|
$subclasses = ClassInfo::subclassesFor($baseClass);
|
||||||
|
unset($subclasses[0]);
|
||||||
|
foreach ($subclasses as $k => $subclass) {
|
||||||
|
if (!DataObject::getSchema()->classHasTable($subclass)) {
|
||||||
|
unset($subclasses[$k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subclasses) {
|
||||||
|
$records = DB::query("SELECT * FROM \"$baseTable\"");
|
||||||
|
|
||||||
|
foreach ($subclasses as $subclass) {
|
||||||
|
$subclassTable = $schema->tableName($subclass);
|
||||||
|
$recordExists[$subclass] =
|
||||||
|
DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
foreach ($subclasses as $subclass) {
|
||||||
|
$subclassTable = $schema->tableName($subclass);
|
||||||
|
$id = $record['ID'];
|
||||||
|
if (($record['ClassName'] != $subclass)
|
||||||
|
&& (!is_subclass_of($record['ClassName'], $subclass ?? ''))
|
||||||
|
&& isset($recordExists[$subclass][$id])
|
||||||
|
) {
|
||||||
|
$sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
|
||||||
|
$output->writeListItem("$sql [{$id}]");
|
||||||
|
DB::prepared_query($sql, [$id]);
|
||||||
|
$countDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output->stopList();
|
||||||
|
$output->writeln("Deleted {$countDeleted} rows");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Deleting records with no corresponding row in their parent class tables';
|
||||||
|
}
|
||||||
|
}
|
49
src/Dev/Command/DbDefaults.php
Normal file
49
src/Dev/Command/DbDefaults.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\Core\ClassInfo;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to build default records in the database.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class DbDefaults extends DevCommand
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'db:defaults';
|
||||||
|
|
||||||
|
protected static string $description = 'Build the default data, calling requireDefaultRecords on all DataObject classes';
|
||||||
|
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'CAN_DEV_BUILD',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Defaults Builder';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
|
||||||
|
array_shift($dataClasses);
|
||||||
|
|
||||||
|
$output->startList(PolyOutput::LIST_UNORDERED);
|
||||||
|
foreach ($dataClasses as $dataClass) {
|
||||||
|
singleton($dataClass)->requireDefaultRecords();
|
||||||
|
$output->writeListItem("Defaults loaded for $dataClass");
|
||||||
|
}
|
||||||
|
$output->stopList();
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Building default data for all DataObject classes';
|
||||||
|
}
|
||||||
|
}
|
58
src/Dev/Command/DevCommand.php
Normal file
58
src/Dev/Command/DevCommand.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Terminal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command that can be run from CLI or via an HTTP request in a dev/* route
|
||||||
|
*/
|
||||||
|
abstract class DevCommand extends PolyCommand
|
||||||
|
{
|
||||||
|
private static array $permissions_for_browser_execution = [
|
||||||
|
'ADMIN',
|
||||||
|
'ALL_DEV_ADMIN' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$terminal = new Terminal();
|
||||||
|
$heading = $this->getHeading();
|
||||||
|
if ($heading) {
|
||||||
|
// Output heading
|
||||||
|
$underline = str_repeat('-', min($terminal->getWidth(), strlen($heading)));
|
||||||
|
$output->writeForAnsi(["<options=bold>{$heading}</>", $underline], true);
|
||||||
|
$output->writeForHtml("<h2>{$heading}</h2>");
|
||||||
|
} else {
|
||||||
|
// Only print the title in CLI (and only if there's no heading)
|
||||||
|
// The DevAdminController outputs the title already for HTTP stuff.
|
||||||
|
$title = $this->getTitle();
|
||||||
|
$underline = str_repeat('-', min($terminal->getWidth(), strlen($title)));
|
||||||
|
$output->writeForAnsi(["<options=bold>{$title}</>", $underline], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->execute($input, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code for running this command.
|
||||||
|
*
|
||||||
|
* Output should be agnostic - do not include explicit HTML in the output unless there is no API
|
||||||
|
* on `PolyOutput` for what you want to do (in which case use the writeForHtml() method).
|
||||||
|
*
|
||||||
|
* Use symfony/console ANSI formatting to style the output.
|
||||||
|
* See https://symfony.com/doc/current/console/coloring.html
|
||||||
|
*
|
||||||
|
* @return int 0 if everything went fine, or an exit code
|
||||||
|
*/
|
||||||
|
abstract protected function execute(InputInterface $input, PolyOutput $output): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to output before command is executed.
|
||||||
|
* In HTML format this will be an h2.
|
||||||
|
*/
|
||||||
|
abstract protected function getHeading(): string;
|
||||||
|
}
|
55
src/Dev/Command/GenerateSecureToken.php
Normal file
55
src/Dev/Command/GenerateSecureToken.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Command;
|
||||||
|
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\Security\RandomGenerator;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to generate a secure token.
|
||||||
|
* Can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
class GenerateSecureToken extends DevCommand
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'generatesecuretoken';
|
||||||
|
|
||||||
|
protected static string $description = 'Generate a secure token';
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Secure token';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$token = RandomGenerator::create()->randomToken($input->getOption('algorithm'));
|
||||||
|
|
||||||
|
$output->writeForHtml('<code>');
|
||||||
|
$output->writeln($token);
|
||||||
|
$output->writeForHtml('</code>');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Generating new token';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new InputOption(
|
||||||
|
'algorithm',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'The hashing algorithm used to generate the token. Can be any identifier listed in <href=https://www.php.net/manual/en/function.hash-algos.php>hash_algos()</>',
|
||||||
|
'sha1',
|
||||||
|
hash_algos()
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -290,7 +290,7 @@ class Deprecation
|
|||||||
$data = null;
|
$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,
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\Dev;
|
|
||||||
|
|
||||||
use SilverStripe\Control\Controller;
|
|
||||||
use SilverStripe\Control\Director;
|
|
||||||
use SilverStripe\Control\HTTPRequest;
|
|
||||||
use SilverStripe\Control\HTTPResponse;
|
|
||||||
use SilverStripe\ORM\DatabaseAdmin;
|
|
||||||
use SilverStripe\Security\Permission;
|
|
||||||
use SilverStripe\Security\PermissionProvider;
|
|
||||||
use SilverStripe\Security\Security;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
|
|
||||||
*/
|
|
||||||
class DevBuildController extends Controller implements PermissionProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
private static $url_handlers = [
|
|
||||||
'' => 'build'
|
|
||||||
];
|
|
||||||
|
|
||||||
private static $allowed_actions = [
|
|
||||||
'build'
|
|
||||||
];
|
|
||||||
|
|
||||||
private static $init_permissions = [
|
|
||||||
'ADMIN',
|
|
||||||
'ALL_DEV_ADMIN',
|
|
||||||
'CAN_DEV_BUILD',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\DbBuild',
|
|
||||||
Deprecation::SCOPE_CLASS
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function init(): void
|
|
||||||
{
|
|
||||||
parent::init();
|
|
||||||
|
|
||||||
if (!$this->canInit()) {
|
|
||||||
Security::permissionFailure($this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function build(HTTPRequest $request): HTTPResponse
|
|
||||||
{
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
$da = DatabaseAdmin::create();
|
|
||||||
return $da->handleRequest($request);
|
|
||||||
} else {
|
|
||||||
$renderer = DebugView::create();
|
|
||||||
echo $renderer->renderHeader();
|
|
||||||
echo $renderer->renderInfo("Environment Builder", Director::absoluteBaseURL());
|
|
||||||
echo "<div class=\"build\">";
|
|
||||||
|
|
||||||
$da = DatabaseAdmin::create();
|
|
||||||
$response = $da->handleRequest($request);
|
|
||||||
|
|
||||||
echo "</div>";
|
|
||||||
echo $renderer->renderFooter();
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canInit(): bool
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
Director::isDev()
|
|
||||||
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
|
|
||||||
// "dev/tasks" from CLI.
|
|
||||||
|| (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
|
|
||||||
|| Permission::check(static::config()->get('init_permissions'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function providePermissions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'CAN_DEV_BUILD' => [
|
|
||||||
'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
|
|
||||||
'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
|
|
||||||
'category' => DevelopmentAdmin::permissionsCategory(),
|
|
||||||
'sort' => 100
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,214 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\Dev;
|
|
||||||
|
|
||||||
use SilverStripe\Control\Controller;
|
|
||||||
use SilverStripe\Control\Director;
|
|
||||||
use SilverStripe\Control\HTTPResponse;
|
|
||||||
use SilverStripe\Core\ClassInfo;
|
|
||||||
use SilverStripe\Core\Config\Config;
|
|
||||||
use SilverStripe\Core\Injector\Injector;
|
|
||||||
use SilverStripe\Security\Permission;
|
|
||||||
use SilverStripe\Security\PermissionProvider;
|
|
||||||
use SilverStripe\Security\Security;
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outputs the full configuration.
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\ConfigDump
|
|
||||||
*/
|
|
||||||
class DevConfigController extends Controller implements PermissionProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
private static $url_handlers = [
|
|
||||||
'audit' => 'audit',
|
|
||||||
'' => 'index'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
private static $allowed_actions = [
|
|
||||||
'index',
|
|
||||||
'audit',
|
|
||||||
];
|
|
||||||
|
|
||||||
private static $init_permissions = [
|
|
||||||
'ADMIN',
|
|
||||||
'ALL_DEV_ADMIN',
|
|
||||||
'CAN_DEV_CONFIG',
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\ConfigDump',
|
|
||||||
Deprecation::SCOPE_CLASS
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function init(): void
|
|
||||||
{
|
|
||||||
parent::init();
|
|
||||||
|
|
||||||
if (!$this->canInit()) {
|
|
||||||
Security::permissionFailure($this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Note: config() method is already defined, so let's just use index()
|
|
||||||
*
|
|
||||||
* @return string|HTTPResponse
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$body = '';
|
|
||||||
$subtitle = "Config manifest";
|
|
||||||
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
$body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
|
|
||||||
$body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
|
|
||||||
} else {
|
|
||||||
$renderer = DebugView::create();
|
|
||||||
$body .= $renderer->renderHeader();
|
|
||||||
$body .= $renderer->renderInfo("Configuration", Director::absoluteBaseURL());
|
|
||||||
$body .= "<div class=\"options\">";
|
|
||||||
$body .= sprintf("<h2>%s</h2>", $subtitle);
|
|
||||||
$body .= "<pre>";
|
|
||||||
$body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
|
|
||||||
$body .= "</pre>";
|
|
||||||
$body .= "</div>";
|
|
||||||
$body .= $renderer->renderFooter();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getResponse()->setBody($body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Output the extraneous config properties which are defined in .yaml but not in a corresponding class
|
|
||||||
*
|
|
||||||
* @return string|HTTPResponse
|
|
||||||
*/
|
|
||||||
public function audit()
|
|
||||||
{
|
|
||||||
$body = '';
|
|
||||||
$missing = [];
|
|
||||||
$subtitle = "Missing Config property definitions";
|
|
||||||
|
|
||||||
foreach ($this->array_keys_recursive(Config::inst()->getAll(), 2) as $className => $props) {
|
|
||||||
$props = array_keys($props ?? []);
|
|
||||||
|
|
||||||
if (!count($props ?? [])) {
|
|
||||||
// We can skip this entry
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($className == strtolower(Injector::class)) {
|
|
||||||
// We don't want to check the injector config
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($props as $prop) {
|
|
||||||
$defined = false;
|
|
||||||
// Check ancestry (private properties don't inherit natively)
|
|
||||||
foreach (ClassInfo::ancestry($className) as $cn) {
|
|
||||||
if (property_exists($cn, $prop ?? '')) {
|
|
||||||
$defined = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($defined) {
|
|
||||||
// No need to record this property
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$missing[] = sprintf("%s::$%s\n", $className, $prop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = count($missing ?? [])
|
|
||||||
? implode("\n", $missing)
|
|
||||||
: "All configured properties are defined\n";
|
|
||||||
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
$body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
|
|
||||||
$body .= $output;
|
|
||||||
} else {
|
|
||||||
$renderer = DebugView::create();
|
|
||||||
$body .= $renderer->renderHeader();
|
|
||||||
$body .= $renderer->renderInfo(
|
|
||||||
"Configuration",
|
|
||||||
Director::absoluteBaseURL(),
|
|
||||||
"Config properties that are not defined (or inherited) by their respective classes"
|
|
||||||
);
|
|
||||||
$body .= "<div class=\"options\">";
|
|
||||||
$body .= sprintf("<h2>%s</h2>", $subtitle);
|
|
||||||
$body .= sprintf("<pre>%s</pre>", $output);
|
|
||||||
$body .= "</div>";
|
|
||||||
$body .= $renderer->renderFooter();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getResponse()->setBody($body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canInit(): bool
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
Director::isDev()
|
|
||||||
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
|
|
||||||
// "dev/tasks" from CLI.
|
|
||||||
|| (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
|
|
||||||
|| Permission::check(static::config()->get('init_permissions'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function providePermissions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'CAN_DEV_CONFIG' => [
|
|
||||||
'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
|
|
||||||
'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
|
|
||||||
'category' => DevelopmentAdmin::permissionsCategory(),
|
|
||||||
'sort' => 100
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the keys of a multi-dimensional array while maintining any nested structure
|
|
||||||
*
|
|
||||||
* @param array $array
|
|
||||||
* @param int $maxdepth
|
|
||||||
* @param int $depth
|
|
||||||
* @param array $arrayKeys
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
private function array_keys_recursive($array, $maxdepth = 20, $depth = 0, $arrayKeys = [])
|
|
||||||
{
|
|
||||||
if ($depth < $maxdepth) {
|
|
||||||
$depth++;
|
|
||||||
$keys = array_keys($array ?? []);
|
|
||||||
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
if (!is_array($array[$key])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$arrayKeys[$key] = $this->array_keys_recursive($array[$key], $maxdepth, $depth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $arrayKeys;
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,7 +3,6 @@
|
|||||||
namespace SilverStripe\Dev;
|
namespace SilverStripe\Dev;
|
||||||
|
|
||||||
use SilverStripe\Control\Director;
|
use SilverStripe\Control\Director;
|
||||||
use SilverStripe\ORM\DatabaseAdmin;
|
|
||||||
use SilverStripe\Security\Confirmation;
|
use SilverStripe\Security\Confirmation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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();
|
|
||||||
// Web mode
|
|
||||||
if (!Director::is_cli()) {
|
|
||||||
$renderer = DebugView::create();
|
$renderer = DebugView::create();
|
||||||
echo $renderer->renderHeader();
|
|
||||||
echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL());
|
|
||||||
$base = Director::baseURL();
|
$base = Director::baseURL();
|
||||||
|
$formatter = HtmlOutputFormatter::create();
|
||||||
|
|
||||||
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 = [
|
||||||
|
'Description' => $description,
|
||||||
|
'Link' => "{$base}$path",
|
||||||
|
'Path' => $path,
|
||||||
|
'Parameters' => $parameters,
|
||||||
|
'Help' => $help,
|
||||||
|
];
|
||||||
|
$list[] = $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo $renderer->renderFooter();
|
$data = [
|
||||||
|
'ArrayLinks' => $list,
|
||||||
|
'Header' => $renderer->renderHeader(),
|
||||||
|
'Footer' => $renderer->renderFooter(),
|
||||||
|
'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()),
|
||||||
|
];
|
||||||
|
|
||||||
// CLI mode
|
return ModelData::create()->renderWith(static::class, $data);
|
||||||
} else {
|
|
||||||
echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n";
|
|
||||||
echo "You can execute any of the following commands:\n\n";
|
|
||||||
foreach ($links as $action => $description) {
|
|
||||||
echo " sake dev/$action: $description\n";
|
|
||||||
}
|
}
|
||||||
echo "\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runRegisteredController(HTTPRequest $request)
|
|
||||||
{
|
|
||||||
$controllerClass = null;
|
|
||||||
|
|
||||||
$baseUrlPart = $request->param('Action');
|
|
||||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
|
||||||
if (isset($reg[$baseUrlPart])) {
|
|
||||||
$controllerClass = $reg[$baseUrlPart]['controller'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($controllerClass && class_exists($controllerClass ?? '')) {
|
|
||||||
return $controllerClass::create();
|
|
||||||
}
|
|
||||||
|
|
||||||
$msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
// in CLI we cant use httpError because of a bug with stuff being in the output already, see DevAdminControllerTest
|
|
||||||
throw new Exception($msg);
|
|
||||||
} else {
|
|
||||||
$this->httpError(404, $msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Internal methods
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated 5.2.0 use getLinks() instead to include permission checks
|
* Run the command, or hand execution to the controller.
|
||||||
* @return array of url => description
|
* Note this method is for execution from the web only. CLI takes a different path.
|
||||||
*/
|
*/
|
||||||
protected static function get_links()
|
public function runRegisteredAction(HTTPRequest $request)
|
||||||
{
|
{
|
||||||
Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks');
|
$returnUrl = $this->getBackURL();
|
||||||
$links = [];
|
$fullPath = $request->getURL();
|
||||||
|
$routes = $this->getRegisteredRoutes();
|
||||||
|
$class = null;
|
||||||
|
|
||||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
// If full path directly matches, use that class.
|
||||||
foreach ($reg as $registeredController) {
|
if (isset($routes[$fullPath])) {
|
||||||
if (isset($registeredController['links'])) {
|
$class = $routes[$fullPath]['class'];
|
||||||
foreach ($registeredController['links'] as $url => $desc) {
|
if (is_a($class, DevCommand::class, true)) {
|
||||||
$links[$url] = $desc;
|
// Tell the request we've matched the full URL
|
||||||
|
$request->shift($request->remaining());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return $links;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getLinks(): array
|
// The full path doesn't directly match any registered command or controller.
|
||||||
|
// Look for a controller that can handle the request. We reject commands at this stage.
|
||||||
|
// The full path will be for an action on the controller and may include nested actions,
|
||||||
|
// so we need to check all urlsegment sections within the request URL.
|
||||||
|
if (!$class) {
|
||||||
|
$parts = explode('/', $fullPath);
|
||||||
|
array_pop($parts);
|
||||||
|
while (count($parts) > 0) {
|
||||||
|
$newPath = implode('/', $parts);
|
||||||
|
// Don't check dev itself - that's the controller we're currently in.
|
||||||
|
if ($newPath === 'dev') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Check for a controller that matches this partial path.
|
||||||
|
$class = $routes[$newPath]['class'] ?? null;
|
||||||
|
if ($class !== null && is_a($class, RequestHandler::class, true)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
array_pop($parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$class) {
|
||||||
|
$msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
|
||||||
|
$this->httpError(404, $msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand execution to the controller
|
||||||
|
if (is_a($class, RequestHandler::class, true)) {
|
||||||
|
return $class::create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var DevCommand $command */
|
||||||
|
$command = $class::create();
|
||||||
|
$input = HttpRequestInput::create($request, $command->getOptions());
|
||||||
|
// DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed
|
||||||
|
// to the client as its available, so that if there's an error the client gets all of the output
|
||||||
|
// available until the error occurs.
|
||||||
|
$output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true);
|
||||||
|
$renderer = DebugView::create();
|
||||||
|
|
||||||
|
// Output header etc
|
||||||
|
$headerOutput = [
|
||||||
|
$renderer->renderHeader(),
|
||||||
|
$renderer->renderInfo(
|
||||||
|
$command->getTitle(),
|
||||||
|
Director::absoluteBaseURL()
|
||||||
|
),
|
||||||
|
'<div class="options">',
|
||||||
|
];
|
||||||
|
$output->writeForHtml($headerOutput);
|
||||||
|
|
||||||
|
// Run command
|
||||||
|
$command->run($input, $output);
|
||||||
|
|
||||||
|
// Output footer etc
|
||||||
|
$output->writeForHtml([
|
||||||
|
'</div>',
|
||||||
|
$renderer->renderFooter(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return to whence we came (e.g. if we had been redirected to dev/build)
|
||||||
|
if ($returnUrl) {
|
||||||
|
return $this->redirect($returnUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a map of all registered DevCommands.
|
||||||
|
* The key is the route used for browser execution.
|
||||||
|
*/
|
||||||
|
public function getCommands(): array
|
||||||
|
{
|
||||||
|
$commands = [];
|
||||||
|
foreach (Config::inst()->get(static::class, 'commands') as $name => $class) {
|
||||||
|
// Allow unsetting a command via YAML
|
||||||
|
if ($class === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check that the class exists and is a DevCommand
|
||||||
|
if (!ClassInfo::exists($class)) {
|
||||||
|
throw new LogicException("Class '$class' doesn't exist");
|
||||||
|
}
|
||||||
|
if (!is_a($class, DevCommand::class, true)) {
|
||||||
|
throw new LogicException("Class '$class' must be a subclass of " . DevCommand::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to list of commands
|
||||||
|
$commands['dev/' . $name] = $class;
|
||||||
|
}
|
||||||
|
return $commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a map of routes that can be run via this controller in an HTTP request.
|
||||||
|
* The key is the URI path, and the value is an associative array of information about the route.
|
||||||
|
*/
|
||||||
|
public function getRegisteredRoutes(): array
|
||||||
{
|
{
|
||||||
$canViewAll = $this->canViewAll();
|
$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;
|
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) {
|
if (!$canViewAll) {
|
||||||
// Check access to controller
|
// Check access to controller
|
||||||
$controllerSingleton = Injector::inst()->get($registeredController['controller']);
|
$controllerSingleton = Injector::inst()->get($controllerClass);
|
||||||
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
|
if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($registeredController['links'] as $url => $desc) {
|
$items['dev/' . $urlSegment] = $info;
|
||||||
$links[$url] = $desc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a map of links to be displayed in the /dev route.
|
||||||
|
* The key is the URI path, and the value is an associative array of information about the route.
|
||||||
|
*/
|
||||||
|
public function getLinks(): array
|
||||||
|
{
|
||||||
|
$links = $this->getRegisteredRoutes();
|
||||||
|
foreach ($links as $i => $info) {
|
||||||
|
// Allow a controller without a link, e.g. DevConfirmationController
|
||||||
|
if ($info['skipLink'] ?? false) {
|
||||||
|
unset($links[$i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $links;
|
return $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
|
|
||||||
*/
|
|
||||||
protected function getRegisteredController($baseUrlPart)
|
|
||||||
{
|
|
||||||
Deprecation::notice('5.4.0', 'Will be removed without equivalent functionality to replace it');
|
|
||||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
|
||||||
|
|
||||||
if (isset($reg[$baseUrlPart])) {
|
|
||||||
$controllerClass = $reg[$baseUrlPart]['controller'];
|
|
||||||
return $controllerClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Unregistered (hidden) actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the default data, calling requireDefaultRecords on all
|
|
||||||
* DataObject classes
|
|
||||||
* Should match the $url_handlers rule:
|
|
||||||
* 'build/defaults' => 'buildDefaults',
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\DbDefaults
|
|
||||||
*/
|
|
||||||
public function buildDefaults()
|
|
||||||
{
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\DbDefaults'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$da = DatabaseAdmin::create();
|
|
||||||
|
|
||||||
$renderer = null;
|
|
||||||
if (!Director::is_cli()) {
|
|
||||||
$renderer = DebugView::create();
|
|
||||||
echo $renderer->renderHeader();
|
|
||||||
echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL());
|
|
||||||
echo "<div class=\"build\">";
|
|
||||||
}
|
|
||||||
|
|
||||||
$da->buildDefaults();
|
|
||||||
|
|
||||||
if (!Director::is_cli()) {
|
|
||||||
echo "</div>";
|
|
||||||
echo $renderer->renderFooter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure token which can be used as a crypto key.
|
|
||||||
* Returns the token and suggests PHP configuration to set it.
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\GenerateSecureToken
|
|
||||||
*/
|
|
||||||
public function generatesecuretoken()
|
|
||||||
{
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\GenerateSecureToken'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$generator = Injector::inst()->create('SilverStripe\\Security\\RandomGenerator');
|
|
||||||
$token = $generator->randomToken('sha1');
|
|
||||||
$body = <<<TXT
|
|
||||||
Generated new token. Please add the following code to your YAML configuration:
|
|
||||||
|
|
||||||
Security:
|
|
||||||
token: $token
|
|
||||||
|
|
||||||
TXT;
|
|
||||||
$response = new HTTPResponse($body);
|
|
||||||
return $response->addHeader('Content-Type', 'text/plain');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function errors()
|
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'])
|
||||||
);
|
);
|
||||||
|
@ -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'],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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'))
|
||||||
);
|
);
|
||||||
|
@ -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::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with canRunInBrowser()'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return Permission::check('ADMIN') || Director::is_cli();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
|
@ -12,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
|
||||||
*/
|
*/
|
||||||
@ -62,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)
|
||||||
{
|
{
|
||||||
@ -97,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)
|
||||||
{
|
{
|
||||||
@ -180,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 {
|
||||||
@ -198,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();
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -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 = [];
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
@ -1,566 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace SilverStripe\ORM;
|
|
||||||
|
|
||||||
use BadMethodCallException;
|
|
||||||
use Generator;
|
|
||||||
use SilverStripe\Control\Controller;
|
|
||||||
use SilverStripe\Control\Director;
|
|
||||||
use SilverStripe\Core\ClassInfo;
|
|
||||||
use SilverStripe\Core\Environment;
|
|
||||||
use SilverStripe\Core\Injector\Injector;
|
|
||||||
use SilverStripe\Core\Manifest\ClassLoader;
|
|
||||||
use SilverStripe\Dev\Deprecation;
|
|
||||||
use SilverStripe\Dev\DevBuildController;
|
|
||||||
use SilverStripe\Dev\DevelopmentAdmin;
|
|
||||||
use SilverStripe\ORM\Connect\DatabaseException;
|
|
||||||
use SilverStripe\ORM\Connect\TableBuilder;
|
|
||||||
use SilverStripe\ORM\FieldType\DBClassName;
|
|
||||||
use SilverStripe\ORM\FieldType\DBClassNameVarchar;
|
|
||||||
use SilverStripe\Security\Permission;
|
|
||||||
use SilverStripe\Security\Security;
|
|
||||||
use SilverStripe\Versioned\Versioned;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DatabaseAdmin class
|
|
||||||
*
|
|
||||||
* Utility functions for administrating the database. These can be accessed
|
|
||||||
* via URL, e.g. http://www.yourdomain.com/db/build.
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
|
|
||||||
*/
|
|
||||||
class DatabaseAdmin extends Controller
|
|
||||||
{
|
|
||||||
|
|
||||||
/// SECURITY ///
|
|
||||||
private static $allowed_actions = [
|
|
||||||
'index',
|
|
||||||
'build',
|
|
||||||
'cleanup',
|
|
||||||
'import'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obsolete classname values that should be remapped in dev/build
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.classname_value_remapping
|
|
||||||
*/
|
|
||||||
private static $classname_value_remapping = [
|
|
||||||
'File' => 'SilverStripe\\Assets\\File',
|
|
||||||
'Image' => 'SilverStripe\\Assets\\Image',
|
|
||||||
'Folder' => 'SilverStripe\\Assets\\Folder',
|
|
||||||
'Group' => 'SilverStripe\\Security\\Group',
|
|
||||||
'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt',
|
|
||||||
'Member' => 'SilverStripe\\Security\\Member',
|
|
||||||
'MemberPassword' => 'SilverStripe\\Security\\MemberPassword',
|
|
||||||
'Permission' => 'SilverStripe\\Security\\Permission',
|
|
||||||
'PermissionRole' => 'SilverStripe\\Security\\PermissionRole',
|
|
||||||
'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode',
|
|
||||||
'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Config setting to enabled/disable the display of record counts on the dev/build output
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.show_record_counts
|
|
||||||
*/
|
|
||||||
private static $show_record_counts = true;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\DbBuild',
|
|
||||||
Deprecation::SCOPE_CLASS
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function init()
|
|
||||||
{
|
|
||||||
parent::init();
|
|
||||||
|
|
||||||
if (!$this->canInit()) {
|
|
||||||
Security::permissionFailure(
|
|
||||||
$this,
|
|
||||||
"This page is secured and you need elevated permissions to access it. " .
|
|
||||||
"Enter your credentials below and we will send you right along."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the data classes, grouped by their root class
|
|
||||||
*
|
|
||||||
* @return array Array of data classes, grouped by their root class
|
|
||||||
*/
|
|
||||||
public function groupedDataClasses()
|
|
||||||
{
|
|
||||||
// Get all root data objects
|
|
||||||
$allClasses = get_declared_classes();
|
|
||||||
$rootClasses = [];
|
|
||||||
foreach ($allClasses as $class) {
|
|
||||||
if (get_parent_class($class ?? '') == DataObject::class) {
|
|
||||||
$rootClasses[$class] = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign every other data object one of those
|
|
||||||
foreach ($allClasses as $class) {
|
|
||||||
if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
|
|
||||||
foreach ($rootClasses as $rootClass => $dummy) {
|
|
||||||
if (is_subclass_of($class, $rootClass ?? '')) {
|
|
||||||
$rootClasses[$rootClass][] = $class;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $rootClasses;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When we're called as /dev/build, that's actually the index. Do the same
|
|
||||||
* as /dev/build/build.
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
return $this->build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the database schema, creating tables & fields as necessary.
|
|
||||||
*/
|
|
||||||
public function build()
|
|
||||||
{
|
|
||||||
// The default time limit of 30 seconds is normally not enough
|
|
||||||
Environment::increaseTimeLimitTo(600);
|
|
||||||
|
|
||||||
// If this code is being run outside of a dev/build or without a ?flush query string param,
|
|
||||||
// the class manifest hasn't been flushed, so do it here
|
|
||||||
$request = $this->getRequest();
|
|
||||||
if (!array_key_exists('flush', $request->getVars() ?? []) && strpos($request->getURL() ?? '', 'dev/build') !== 0) {
|
|
||||||
ClassLoader::inst()->getManifest()->regenerate(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->getReturnURL();
|
|
||||||
if ($url) {
|
|
||||||
echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
|
|
||||||
$this->doBuild(true);
|
|
||||||
echo "<p>Done!</p>";
|
|
||||||
$this->redirect($url);
|
|
||||||
} else {
|
|
||||||
$quiet = $this->request->requestVar('quiet') !== null;
|
|
||||||
$fromInstaller = $this->request->requestVar('from_installer') !== null;
|
|
||||||
$populate = $this->request->requestVar('dont_populate') === null;
|
|
||||||
$this->doBuild($quiet || $fromInstaller, $populate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the url to return to after build
|
|
||||||
*
|
|
||||||
* @return string|null
|
|
||||||
*/
|
|
||||||
protected function getReturnURL()
|
|
||||||
{
|
|
||||||
$url = $this->request->getVar('returnURL');
|
|
||||||
|
|
||||||
// Check that this url is a site url
|
|
||||||
if (empty($url) || !Director::is_site_url($url)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to absolute URL
|
|
||||||
return Director::absoluteURL((string) $url, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the default data, calling requireDefaultRecords on all
|
|
||||||
* DataObject classes
|
|
||||||
*/
|
|
||||||
public function buildDefaults()
|
|
||||||
{
|
|
||||||
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
|
|
||||||
array_shift($dataClasses);
|
|
||||||
|
|
||||||
if (!Director::is_cli()) {
|
|
||||||
echo "<ul>";
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($dataClasses as $dataClass) {
|
|
||||||
singleton($dataClass)->requireDefaultRecords();
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
echo "Defaults loaded for $dataClass\n";
|
|
||||||
} else {
|
|
||||||
echo "<li>Defaults loaded for $dataClass</li>\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Director::is_cli()) {
|
|
||||||
echo "</ul>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the timestamp of the time that the database was last built
|
|
||||||
*
|
|
||||||
* @return string Returns the timestamp of the time that the database was
|
|
||||||
* last built
|
|
||||||
*
|
|
||||||
* @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()
|
|
||||||
*/
|
|
||||||
public static function lastBuilt()
|
|
||||||
{
|
|
||||||
Deprecation::withSuppressedNotice(function () {
|
|
||||||
Deprecation::notice(
|
|
||||||
'5.4.0',
|
|
||||||
'Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$file = TEMP_PATH
|
|
||||||
. DIRECTORY_SEPARATOR
|
|
||||||
. 'database-last-generated-'
|
|
||||||
. str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? '');
|
|
||||||
|
|
||||||
if (file_exists($file ?? '')) {
|
|
||||||
return filemtime($file ?? '');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the database schema, creating tables & fields as necessary.
|
|
||||||
*
|
|
||||||
* @param boolean $quiet Don't show messages
|
|
||||||
* @param boolean $populate Populate the database, as well as setting up its schema
|
|
||||||
* @param bool $testMode
|
|
||||||
*/
|
|
||||||
public function doBuild($quiet = false, $populate = true, $testMode = false)
|
|
||||||
{
|
|
||||||
$this->extend('onBeforeBuild', $quiet, $populate, $testMode);
|
|
||||||
|
|
||||||
if ($quiet) {
|
|
||||||
DB::quiet();
|
|
||||||
} else {
|
|
||||||
$conn = DB::get_conn();
|
|
||||||
// Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
|
|
||||||
$dbType = substr(get_class($conn), 0, -8);
|
|
||||||
$dbVersion = $conn->getVersion();
|
|
||||||
$databaseName = $conn->getSelectedDatabase();
|
|
||||||
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion);
|
|
||||||
} else {
|
|
||||||
echo sprintf("<h2>Building database %s using %s %s</h2>", $databaseName, $dbType, $dbVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the initial database
|
|
||||||
if (!DB::is_active()) {
|
|
||||||
if (!$quiet) {
|
|
||||||
echo '<p><b>Creating database</b></p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load parameters from existing configuration
|
|
||||||
$databaseConfig = DB::getConfig();
|
|
||||||
if (empty($databaseConfig) && empty($_REQUEST['db'])) {
|
|
||||||
throw new BadMethodCallException("No database configuration available");
|
|
||||||
}
|
|
||||||
$parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db'];
|
|
||||||
|
|
||||||
// Check database name is given
|
|
||||||
if (empty($parameters['database'])) {
|
|
||||||
throw new BadMethodCallException(
|
|
||||||
"No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$database = $parameters['database'];
|
|
||||||
|
|
||||||
// Establish connection
|
|
||||||
unset($parameters['database']);
|
|
||||||
DB::connect($parameters);
|
|
||||||
|
|
||||||
// Check to ensure that the re-instated SS_DATABASE_SUFFIX functionality won't unexpectedly
|
|
||||||
// rename the database. To be removed for SS5
|
|
||||||
if ($suffix = Environment::getEnv('SS_DATABASE_SUFFIX')) {
|
|
||||||
$previousName = preg_replace("/{$suffix}$/", '', $database ?? '');
|
|
||||||
|
|
||||||
if (!isset($_GET['force_suffix_rename']) && DB::get_conn()->databaseExists($previousName)) {
|
|
||||||
throw new DatabaseException(
|
|
||||||
"SS_DATABASE_SUFFIX was previously broken, but has now been fixed. This will result in your "
|
|
||||||
. "database being named \"{$database}\" instead of \"{$previousName}\" from now on. If this "
|
|
||||||
. "change is intentional, please visit dev/build?force_suffix_rename=1 to continue"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create database
|
|
||||||
DB::create_database($database);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the database. Most of the hard work is handled by DataObject
|
|
||||||
$dataClasses = ClassInfo::subclassesFor(DataObject::class);
|
|
||||||
array_shift($dataClasses);
|
|
||||||
|
|
||||||
if (!$quiet) {
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
echo "\nCREATING DATABASE TABLES\n\n";
|
|
||||||
} else {
|
|
||||||
echo "\n<p><b>Creating database tables</b></p><ul>\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$showRecordCounts = (boolean)$this->config()->show_record_counts;
|
|
||||||
|
|
||||||
// Initiate schema update
|
|
||||||
$dbSchema = DB::get_schema();
|
|
||||||
$tableBuilder = TableBuilder::singleton();
|
|
||||||
$tableBuilder->buildTables($dbSchema, $dataClasses, [], $quiet, $testMode, $showRecordCounts);
|
|
||||||
ClassInfo::reset_db_cache();
|
|
||||||
|
|
||||||
if (!$quiet && !Director::is_cli()) {
|
|
||||||
echo "</ul>";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($populate) {
|
|
||||||
if (!$quiet) {
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
echo "\nCREATING DATABASE RECORDS\n\n";
|
|
||||||
} else {
|
|
||||||
echo "\n<p><b>Creating database records</b></p><ul>\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remap obsolete class names
|
|
||||||
$this->migrateClassNames();
|
|
||||||
|
|
||||||
// Require all default records
|
|
||||||
foreach ($dataClasses as $dataClass) {
|
|
||||||
// Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
|
|
||||||
// Test_ indicates that it's the data class is part of testing system
|
|
||||||
if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
|
|
||||||
if (!$quiet) {
|
|
||||||
if (Director::is_cli()) {
|
|
||||||
echo " * $dataClass\n";
|
|
||||||
} else {
|
|
||||||
echo "<li>$dataClass</li>\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DataObject::singleton($dataClass)->requireDefaultRecords();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$quiet && !Director::is_cli()) {
|
|
||||||
echo "</ul>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
touch(TEMP_PATH
|
|
||||||
. DIRECTORY_SEPARATOR
|
|
||||||
. 'database-last-generated-'
|
|
||||||
. str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? ''));
|
|
||||||
|
|
||||||
if (isset($_REQUEST['from_installer'])) {
|
|
||||||
echo "OK";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$quiet) {
|
|
||||||
echo (Director::is_cli()) ? "\n Database build completed!\n\n" : "<p>Database build completed!</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($dataClasses as $dataClass) {
|
|
||||||
DataObject::singleton($dataClass)->onAfterBuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
ClassInfo::reset_db_cache();
|
|
||||||
|
|
||||||
$this->extend('onAfterBuild', $quiet, $populate, $testMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canInit(): bool
|
|
||||||
{
|
|
||||||
// We allow access to this controller regardless of live-status or ADMIN permission only
|
|
||||||
// if on CLI or with the database not ready. The latter makes it less error-prone to do an
|
|
||||||
// initial schema build without requiring a default-admin login.
|
|
||||||
// Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
|
|
||||||
$allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
|
|
||||||
return (
|
|
||||||
Director::isDev()
|
|
||||||
|| !Security::database_is_ready()
|
|
||||||
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
|
|
||||||
// "dev/tests" from CLI.
|
|
||||||
|| (Director::is_cli() && $allowAllCLI)
|
|
||||||
|| Permission::check(DevBuildController::config()->get('init_permissions'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a base data class, a field name and a mapping of class replacements, look for obsolete
|
|
||||||
* values in the $dataClass's $fieldName column and replace it with $mapping
|
|
||||||
*
|
|
||||||
* @param string $dataClass The data class to look up
|
|
||||||
* @param string $fieldName The field name to look in for obsolete class names
|
|
||||||
* @param string[] $mapping Map of old to new classnames
|
|
||||||
*/
|
|
||||||
protected function updateLegacyClassNameField($dataClass, $fieldName, $mapping)
|
|
||||||
{
|
|
||||||
$schema = DataObject::getSchema();
|
|
||||||
// Check first to ensure that the class has the specified field to update
|
|
||||||
if (!$schema->databaseField($dataClass, $fieldName, false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load a list of any records that have obsolete class names
|
|
||||||
$table = $schema->tableName($dataClass);
|
|
||||||
$currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
|
|
||||||
|
|
||||||
// Get all invalid classes for this field
|
|
||||||
$invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
|
|
||||||
if (!$invalidClasses) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$numberClasses = count($invalidClasses ?? []);
|
|
||||||
DB::alteration_message(
|
|
||||||
"Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
|
|
||||||
'obsolete'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build case assignment based on all intersected legacy classnames
|
|
||||||
$cases = [];
|
|
||||||
$params = [];
|
|
||||||
foreach ($invalidClasses as $invalidClass) {
|
|
||||||
$cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
|
|
||||||
$params[] = $invalidClass;
|
|
||||||
$params[] = $mapping[$invalidClass];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->getClassTables($dataClass) as $table) {
|
|
||||||
$casesSQL = implode(' ', $cases);
|
|
||||||
$sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
|
|
||||||
DB::prepared_query($sql, $params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tables to update for this class
|
|
||||||
*
|
|
||||||
* @param string $dataClass
|
|
||||||
* @return Generator|string[]
|
|
||||||
*/
|
|
||||||
protected function getClassTables($dataClass)
|
|
||||||
{
|
|
||||||
$schema = DataObject::getSchema();
|
|
||||||
$table = $schema->tableName($dataClass);
|
|
||||||
|
|
||||||
// Base table
|
|
||||||
yield $table;
|
|
||||||
|
|
||||||
// Remap versioned table class name values as well
|
|
||||||
/** @var Versioned|DataObject $dataClass */
|
|
||||||
$dataClass = DataObject::singleton($dataClass);
|
|
||||||
if ($dataClass->hasExtension(Versioned::class)) {
|
|
||||||
if ($dataClass->hasStages()) {
|
|
||||||
yield "{$table}_Live";
|
|
||||||
}
|
|
||||||
yield "{$table}_Versions";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
|
|
||||||
* `ClassName` fields as well as polymorphic class name fields.
|
|
||||||
*
|
|
||||||
* @return array[]
|
|
||||||
*/
|
|
||||||
protected function getClassNameRemappingFields()
|
|
||||||
{
|
|
||||||
$dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
|
|
||||||
$schema = DataObject::getSchema();
|
|
||||||
$remapping = [];
|
|
||||||
|
|
||||||
foreach ($dataClasses as $className) {
|
|
||||||
$fieldSpecs = $schema->fieldSpecs($className);
|
|
||||||
foreach ($fieldSpecs as $fieldName => $fieldSpec) {
|
|
||||||
$dummy = Injector::inst()->create($fieldSpec, 'Dummy');
|
|
||||||
if ($dummy instanceof DBClassName || $dummy instanceof DBClassNameVarchar) {
|
|
||||||
$remapping[$className][] = $fieldName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $remapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove invalid records from tables - that is, records that don't have
|
|
||||||
* corresponding records in their parent class tables.
|
|
||||||
*/
|
|
||||||
public function cleanup()
|
|
||||||
{
|
|
||||||
$baseClasses = [];
|
|
||||||
foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
|
|
||||||
if (get_parent_class($class ?? '') == DataObject::class) {
|
|
||||||
$baseClasses[] = $class;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$schema = DataObject::getSchema();
|
|
||||||
foreach ($baseClasses as $baseClass) {
|
|
||||||
// Get data classes
|
|
||||||
$baseTable = $schema->baseDataTable($baseClass);
|
|
||||||
$subclasses = ClassInfo::subclassesFor($baseClass);
|
|
||||||
unset($subclasses[0]);
|
|
||||||
foreach ($subclasses as $k => $subclass) {
|
|
||||||
if (!DataObject::getSchema()->classHasTable($subclass)) {
|
|
||||||
unset($subclasses[$k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($subclasses) {
|
|
||||||
$records = DB::query("SELECT * FROM \"$baseTable\"");
|
|
||||||
|
|
||||||
|
|
||||||
foreach ($subclasses as $subclass) {
|
|
||||||
$subclassTable = $schema->tableName($subclass);
|
|
||||||
$recordExists[$subclass] =
|
|
||||||
DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
foreach ($subclasses as $subclass) {
|
|
||||||
$subclassTable = $schema->tableName($subclass);
|
|
||||||
$id = $record['ID'];
|
|
||||||
if (($record['ClassName'] != $subclass)
|
|
||||||
&& (!is_subclass_of($record['ClassName'], $subclass ?? ''))
|
|
||||||
&& isset($recordExists[$subclass][$id])
|
|
||||||
) {
|
|
||||||
$sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
|
|
||||||
echo "<li>$sql [{$id}]</li>";
|
|
||||||
DB::prepared_query($sql, [$id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate all class names
|
|
||||||
*/
|
|
||||||
protected function migrateClassNames()
|
|
||||||
{
|
|
||||||
$remappingConfig = $this->config()->get('classname_value_remapping');
|
|
||||||
$remappingFields = $this->getClassNameRemappingFields();
|
|
||||||
foreach ($remappingFields as $className => $fieldNames) {
|
|
||||||
foreach ($fieldNames as $fieldName) {
|
|
||||||
$this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -56,7 +56,7 @@ trait DBClassNameTrait
|
|||||||
if ($this->record) {
|
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;
|
||||||
|
143
src/PolyExecution/AnsiToHtmlConverter.php
Normal file
143
src/PolyExecution/AnsiToHtmlConverter.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use SensioLabs\AnsiConverter\AnsiToHtmlConverter as BaseAnsiConverter;
|
||||||
|
use SensioLabs\AnsiConverter\Theme\Theme;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an ANSI text to HTML5 but doesn't give an opinionated default colour that isn't specified in the ANSI.
|
||||||
|
*/
|
||||||
|
class AnsiToHtmlConverter extends BaseAnsiConverter
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
public function __construct(Theme $theme = null, $inlineStyles = true, $charset = 'UTF-8')
|
||||||
|
{
|
||||||
|
$theme ??= AnsiToHtmlTheme::create();
|
||||||
|
parent::__construct($theme, $inlineStyles, $charset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function convert($text)
|
||||||
|
{
|
||||||
|
// remove cursor movement sequences
|
||||||
|
$text = preg_replace('#\e\[(K|s|u|2J|2K|\d+(A|B|C|D|E|F|G|J|K|S|T)|\d+;\d+(H|f))#', '', $text);
|
||||||
|
// remove character set sequences
|
||||||
|
$text = preg_replace('#\e(\(|\))(A|B|[0-2])#', '', $text);
|
||||||
|
|
||||||
|
$text = htmlspecialchars($text, PHP_VERSION_ID >= 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset);
|
||||||
|
|
||||||
|
// convert hyperlinks to `<a>` tags (this is new to this subclass)
|
||||||
|
$text = preg_replace('#\033]8;;(?<href>[^\033]*)\033\\\(?<text>[^\033]*)\033]8;;\033\\\#', '<a href="$1">$2</a>', $text);
|
||||||
|
|
||||||
|
// carriage return
|
||||||
|
$text = preg_replace('#^.*\r(?!\n)#m', '', $text);
|
||||||
|
|
||||||
|
$tokens = $this->tokenize($text);
|
||||||
|
|
||||||
|
// a backspace remove the previous character but only from a text token
|
||||||
|
foreach ($tokens as $i => $token) {
|
||||||
|
if ('backspace' == $token[0]) {
|
||||||
|
$j = $i;
|
||||||
|
while (--$j >= 0) {
|
||||||
|
if ('text' == $tokens[$j][0] && strlen($tokens[$j][1]) > 0) {
|
||||||
|
$tokens[$j][1] = substr($tokens[$j][1], 0, -1);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ('text' == $token[0]) {
|
||||||
|
$html .= $token[1];
|
||||||
|
} elseif ('color' == $token[0]) {
|
||||||
|
$html .= $this->convertAnsiToColor($token[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These lines commented out from the parent class implementation.
|
||||||
|
// We don't want this opinionated default colouring - it doesn't appear in the ANSI format so it doesn't belong in the output.
|
||||||
|
// if ($this->inlineStyles) {
|
||||||
|
// $html = sprintf('<span style="background-color: %s; color: %s">%s</span>', $this->inlineColors['black'], $this->inlineColors['white'], $html);
|
||||||
|
// } else {
|
||||||
|
// $html = sprintf('<span class="ansi_color_bg_black ansi_color_fg_white">%s</span>', $html);
|
||||||
|
// }
|
||||||
|
// We do need an opening and closing span though, or the HTML markup is broken
|
||||||
|
$html = '<span>' . $html . '</span>';
|
||||||
|
|
||||||
|
// remove empty span
|
||||||
|
$html = preg_replace('#<span[^>]*></span>#', '', $html);
|
||||||
|
// remove unnecessary span
|
||||||
|
$html = preg_replace('#<span>(.*?(?!</span>)[^<]*)</span>#', '$1', $html);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function convertAnsiToColor($ansi)
|
||||||
|
{
|
||||||
|
// Set $bg and $fg to null so we don't have a default opinionated colouring
|
||||||
|
$bg = null;
|
||||||
|
$fg = null;
|
||||||
|
$style = [];
|
||||||
|
$classes = [];
|
||||||
|
if ('0' != $ansi && '' != $ansi) {
|
||||||
|
$options = explode(';', $ansi);
|
||||||
|
|
||||||
|
foreach ($options as $option) {
|
||||||
|
if ($option >= 30 && $option < 38) {
|
||||||
|
$fg = $option - 30;
|
||||||
|
} elseif ($option >= 40 && $option < 48) {
|
||||||
|
$bg = $option - 40;
|
||||||
|
} elseif (39 == $option) {
|
||||||
|
$fg = null; // reset to default
|
||||||
|
} elseif (49 == $option) {
|
||||||
|
$bg = null; // reset to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// options: bold => 1, underscore => 4, blink => 5, reverse => 7, conceal => 8
|
||||||
|
if (in_array(1, $options)) {
|
||||||
|
$style[] = 'font-weight: bold';
|
||||||
|
$classes[] = 'ansi_bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(4, $options)) {
|
||||||
|
$style[] = 'text-decoration: underline';
|
||||||
|
$classes[] = 'ansi_underline';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(7, $options)) {
|
||||||
|
$tmp = $fg;
|
||||||
|
$fg = $bg;
|
||||||
|
$bg = $tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biggest changes start here and go to the end of the method.
|
||||||
|
// We're explicitly only setting the styling that was included in the ANSI formatting. The original applies
|
||||||
|
// default colours regardless.
|
||||||
|
if ($bg !== null) {
|
||||||
|
$style[] = sprintf('background-color: %s', $this->inlineColors[$this->colorNames[$bg]]);
|
||||||
|
$classes[] = sprintf('ansi_color_bg_%s', $this->colorNames[$bg]);
|
||||||
|
}
|
||||||
|
if ($fg !== null) {
|
||||||
|
$style[] = sprintf('color: %s', $this->inlineColors[$this->colorNames[$fg]]);
|
||||||
|
$classes[] = sprintf('ansi_color_fg_%s', $this->colorNames[$fg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->inlineStyles && !empty($style)) {
|
||||||
|
return sprintf('</span><span style="%s">', implode('; ', $style));
|
||||||
|
}
|
||||||
|
if (!$this->inlineStyles && !empty($classes)) {
|
||||||
|
return sprintf('</span><span class="%s">', implode('; ', $classes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because of the way the parent class is implemented, we need to stop the old span and start a new one
|
||||||
|
// even if we don't have any styling to apply.
|
||||||
|
return '</span><span>';
|
||||||
|
}
|
||||||
|
}
|
30
src/PolyExecution/AnsiToHtmlTheme.php
Normal file
30
src/PolyExecution/AnsiToHtmlTheme.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use SensioLabs\AnsiConverter\Theme\Theme;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme for converting ANSI colours to something suitable in a browser against a white background
|
||||||
|
*/
|
||||||
|
class AnsiToHtmlTheme extends Theme
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
public function asArray()
|
||||||
|
{
|
||||||
|
$colourMap = parent::asArray();
|
||||||
|
$colourMap['cyan'] = 'royalblue';
|
||||||
|
$colourMap['yellow'] = 'goldenrod';
|
||||||
|
return $colourMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asArrayBackground()
|
||||||
|
{
|
||||||
|
$colourMap = parent::asArrayBackground();
|
||||||
|
$colourMap['cyan'] = 'royalblue';
|
||||||
|
$colourMap['yellow'] = 'goldenrod';
|
||||||
|
return $colourMap;
|
||||||
|
}
|
||||||
|
}
|
58
src/PolyExecution/HtmlOutputFormatter.php
Normal file
58
src/PolyExecution/HtmlOutputFormatter.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
|
||||||
|
use Symfony\Component\Console\Formatter\OutputFormatterStyleInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an ANSI formatter and converts the ANSI formatting to styled HTML.
|
||||||
|
*/
|
||||||
|
class HtmlOutputFormatter implements OutputFormatterInterface
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
private OutputFormatterInterface $ansiFormatter;
|
||||||
|
private AnsiToHtmlConverter $ansiConverter;
|
||||||
|
|
||||||
|
public function __construct(OutputFormatterInterface $formatter)
|
||||||
|
{
|
||||||
|
$this->ansiFormatter = $formatter;
|
||||||
|
$this->ansiConverter = AnsiToHtmlConverter::create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDecorated(bool $decorated): void
|
||||||
|
{
|
||||||
|
$this->ansiFormatter->setDecorated($decorated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDecorated(): bool
|
||||||
|
{
|
||||||
|
return $this->ansiFormatter->isDecorated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
|
||||||
|
{
|
||||||
|
$this->ansiFormatter->setStyle($name, $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasStyle(string $name): bool
|
||||||
|
{
|
||||||
|
return $this->ansiFormatter->hasStyle($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStyle(string $name): OutputFormatterStyleInterface
|
||||||
|
{
|
||||||
|
return $this->ansiFormatter->getStyle($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function format(?string $message): ?string
|
||||||
|
{
|
||||||
|
$formatted = $this->ansiFormatter->format($message);
|
||||||
|
if ($this->isDecorated()) {
|
||||||
|
return $this->ansiConverter->convert($formatted);
|
||||||
|
}
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
}
|
111
src/PolyExecution/HttpRequestInput.php
Normal file
111
src/PolyExecution/HttpRequestInput.php
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input that populates options from an HTTPRequest
|
||||||
|
*
|
||||||
|
* Use this for inputs to PolyCommand when called from a web request.
|
||||||
|
*/
|
||||||
|
class HttpRequestInput extends ArrayInput
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
protected bool $interactive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<InputOption> $commandOptions Any options that apply for the command itself.
|
||||||
|
* Do not include global options (e.g. flush) - they are added explicitly in the constructor.
|
||||||
|
*/
|
||||||
|
public function __construct(HTTPRequest $request, array $commandOptions = [])
|
||||||
|
{
|
||||||
|
$definition = new InputDefinition([
|
||||||
|
// Also add global options that are applicable for HTTP requests
|
||||||
|
new InputOption('quiet', null, InputOption::VALUE_NONE, 'Do not output any message'),
|
||||||
|
new InputOption('verbose', null, InputOption::VALUE_OPTIONAL, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'),
|
||||||
|
// The actual flushing already happened before this point, but we still need
|
||||||
|
// to declare the option in case someone's checking against it
|
||||||
|
new InputOption('flush', null, InputOption::VALUE_NONE, 'Flush the cache before running the command'),
|
||||||
|
...$commandOptions
|
||||||
|
]);
|
||||||
|
$optionValues = $this->getOptionValuesFromRequest($request, $definition);
|
||||||
|
parent::__construct($optionValues, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the verbosity that should be used based on the request vars.
|
||||||
|
* This is used to set the verbosity for PolyOutput.
|
||||||
|
*/
|
||||||
|
public function getVerbosity(): int
|
||||||
|
{
|
||||||
|
if ($this->getOption('quiet')) {
|
||||||
|
return OutputInterface::VERBOSITY_QUIET;
|
||||||
|
}
|
||||||
|
$verbose = $this->getOption('verbose');
|
||||||
|
if ($verbose === '1' || $verbose === 1 || $verbose === true) {
|
||||||
|
return OutputInterface::VERBOSITY_VERBOSE;
|
||||||
|
}
|
||||||
|
if ($verbose === '2' || $verbose === 2) {
|
||||||
|
return OutputInterface::VERBOSITY_VERY_VERBOSE;
|
||||||
|
}
|
||||||
|
if ($verbose === '3' || $verbose === 3) {
|
||||||
|
return OutputInterface::VERBOSITY_DEBUG;
|
||||||
|
}
|
||||||
|
return OutputInterface::VERBOSITY_NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOptionValuesFromRequest(HTTPRequest $request, InputDefinition $definition): array
|
||||||
|
{
|
||||||
|
$options = [];
|
||||||
|
foreach ($definition->getOptions() as $option) {
|
||||||
|
// We'll check for the long name and all shortcuts.
|
||||||
|
// Note the `--` and `-` prefixes are already stripped at this point.
|
||||||
|
$candidateParams = [$option->getName()];
|
||||||
|
$shortcutString = $option->getShortcut();
|
||||||
|
if ($shortcutString !== null) {
|
||||||
|
$shortcuts = explode('|', $shortcutString);
|
||||||
|
foreach ($shortcuts as $shortcut) {
|
||||||
|
$candidateParams[] = $shortcut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get a value if there is one
|
||||||
|
$value = null;
|
||||||
|
foreach ($candidateParams as $candidateParam) {
|
||||||
|
$value = $request->requestVar($candidateParam);
|
||||||
|
}
|
||||||
|
$default = $option->getDefault();
|
||||||
|
// Set correct default value
|
||||||
|
if ($value === null && $default !== null) {
|
||||||
|
$value = $default;
|
||||||
|
}
|
||||||
|
// Ignore missing values if values aren't required
|
||||||
|
if (($value === null || $value === []) && $option->isValueRequired()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Convert value to array if it should be one
|
||||||
|
if ($value !== null && $option->isArray() && !is_array($value)) {
|
||||||
|
$value = [$value];
|
||||||
|
}
|
||||||
|
// If there's a value (or the option accepts one and didn't get one), set the option.
|
||||||
|
if ($value !== null || $option->acceptValue()) {
|
||||||
|
// If the option doesn't accept a value, determine the correct boolean state for it.
|
||||||
|
// If we weren't able to determine if the value's boolean-ness, default to truthy=true
|
||||||
|
// because that's what you'd end up with with `if ($request->requestVar('myVar'))`
|
||||||
|
if (!$option->acceptValue()) {
|
||||||
|
$value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
|
||||||
|
}
|
||||||
|
// We need to prefix with `--` so the superclass knows it's an
|
||||||
|
// option rather than an argument.
|
||||||
|
$options['--' . $option->getName()] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
}
|
197
src/PolyExecution/PolyCommand.php
Normal file
197
src/PolyExecution/PolyCommand.php
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Core\Config\Configurable;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use SilverStripe\Dev\DevelopmentAdmin;
|
||||||
|
use SilverStripe\PolyExecution\HtmlOutputFormatter;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\ORM\FieldType\DBField;
|
||||||
|
use SilverStripe\Security\Permission;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class for commands which can be run either via an HTTP request or the CLI.
|
||||||
|
*/
|
||||||
|
abstract class PolyCommand
|
||||||
|
{
|
||||||
|
use Configurable;
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether this command can be run in the CLI via sake.
|
||||||
|
* Overridden if DevelopmentAdmin sets allow_all_cli to true.
|
||||||
|
*
|
||||||
|
* Note that in dev mode the command can always be run.
|
||||||
|
*/
|
||||||
|
private static bool $can_run_in_cli = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether this command can be run in the browser via a web request.
|
||||||
|
* If true, user must have the requisite permissions.
|
||||||
|
*
|
||||||
|
* Note that in dev mode the command can always be run.
|
||||||
|
*/
|
||||||
|
private static bool $can_run_in_browser = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions required for users to execute this command via the browser.
|
||||||
|
* Users must have at least one of these permissions to run the command in an HTTP request.
|
||||||
|
* If `can_run_in_browser` is false, these permissions are ignored.
|
||||||
|
* Must be defined in the subclass.
|
||||||
|
*
|
||||||
|
* If permissions are set as keys, the value must be boolean, indicating whether to check
|
||||||
|
* that permission or not. This is useful for allowing developers to turn off permission checks
|
||||||
|
* that aren't valid for their project or for their subclass.
|
||||||
|
*/
|
||||||
|
private static array $permissions_for_browser_execution = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the command. Also used as the end of the URL segment for browser execution.
|
||||||
|
* Must be defined in the subclass.
|
||||||
|
*/
|
||||||
|
protected static string $commandName = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Description of what the command does. Can use symfony console styling.
|
||||||
|
* See https://symfony.com/doc/current/console/coloring.html.
|
||||||
|
* Must be defined in the subclass.
|
||||||
|
*/
|
||||||
|
protected static string $description = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the title for this command.
|
||||||
|
*/
|
||||||
|
abstract public function getTitle(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute this command.
|
||||||
|
*
|
||||||
|
* Output should be agnostic - do not include explicit HTML in the output unless there is no API
|
||||||
|
* on `PolyOutput` for what you want to do (in which case use the writeForHtml() method).
|
||||||
|
*
|
||||||
|
* Use symfony/console ANSI formatting to style the output.
|
||||||
|
* See https://symfony.com/doc/current/console/coloring.html
|
||||||
|
*
|
||||||
|
* @return int 0 if everything went fine, or an exit code
|
||||||
|
*/
|
||||||
|
abstract public function run(InputInterface $input, PolyOutput $output): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name of this command.
|
||||||
|
*/
|
||||||
|
public static function getName(): string
|
||||||
|
{
|
||||||
|
return static::$commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the description of this command. Includes unparsed symfony/console styling.
|
||||||
|
*/
|
||||||
|
public static function getDescription(): string
|
||||||
|
{
|
||||||
|
return _t(static::class . '.description', static::$description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return additional help context to avoid an overly long description.
|
||||||
|
*/
|
||||||
|
public static function getHelp(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input options that can be passed into the command.
|
||||||
|
*
|
||||||
|
* In CLI execution these will be passed as flags.
|
||||||
|
* In HTTP execution these will be passed in the query string.
|
||||||
|
*
|
||||||
|
* @return array<InputOption>
|
||||||
|
*/
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptionsForTemplate(): array
|
||||||
|
{
|
||||||
|
$formatter = HtmlOutputFormatter::create();
|
||||||
|
$forTemplate = [];
|
||||||
|
foreach ($this->getOptions() as $option) {
|
||||||
|
$default = $option->getDefault();
|
||||||
|
if (is_bool($default)) {
|
||||||
|
// Use 1/0 for boolean, since that's what you'd pass in the query string
|
||||||
|
$default = $default ? '1' : '0';
|
||||||
|
}
|
||||||
|
if (is_array($default)) {
|
||||||
|
$default = implode(',', $default);
|
||||||
|
}
|
||||||
|
$forTemplate[] = [
|
||||||
|
'Name' => $option->getName(),
|
||||||
|
'Description' => DBField::create_field('HTMLText', $formatter->format($option->getDescription())),
|
||||||
|
'Default' => $default,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $forTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether this command can be run in CLI via sake
|
||||||
|
*/
|
||||||
|
public static function canRunInCli(): bool
|
||||||
|
{
|
||||||
|
static::checkPrerequisites();
|
||||||
|
return Director::isDev()
|
||||||
|
|| static::config()->get('can_run_in_cli')
|
||||||
|
|| DevelopmentAdmin::config()->get('allow_all_cli');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether this command can be run in the browser via a web request
|
||||||
|
*/
|
||||||
|
public static function canRunInBrowser(): bool
|
||||||
|
{
|
||||||
|
static::checkPrerequisites();
|
||||||
|
// Can always run in browser in dev mode
|
||||||
|
if (Director::isDev()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!static::config()->get('can_run_in_browser')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check permissions if there are any
|
||||||
|
$permissions = static::config()->get('permissions_for_browser_execution');
|
||||||
|
if (!empty($permissions)) {
|
||||||
|
$usePermissions = [];
|
||||||
|
// Only use permissions that aren't set to false
|
||||||
|
// Allow permissions to also be simply set as values, for simplicity
|
||||||
|
foreach ($permissions as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$usePermissions[] = $value;
|
||||||
|
} elseif ($value) {
|
||||||
|
$usePermissions[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Permission::check($usePermissions);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkPrerequisites(): void
|
||||||
|
{
|
||||||
|
$mandatoryMethods = [
|
||||||
|
'getName' => 'commandName',
|
||||||
|
'getDescription' => 'description',
|
||||||
|
];
|
||||||
|
foreach ($mandatoryMethods as $getter => $property) {
|
||||||
|
if (!static::$getter()) {
|
||||||
|
throw new RuntimeException($property . ' property needs to be set.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
268
src/PolyExecution/PolyOutput.php
Normal file
268
src/PolyExecution/PolyOutput.php
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use SensioLabs\AnsiConverter\AnsiToHtmlConverter;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||||
|
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
|
||||||
|
use Symfony\Component\Console\Output\Output;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output that correctly formats for HTML or for the terminal, depending on the output type.
|
||||||
|
* Used for functionality that can be used both via CLI and via the browser.
|
||||||
|
*/
|
||||||
|
class PolyOutput extends Output
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
public const LIST_UNORDERED = 'ul';
|
||||||
|
public const LIST_ORDERED = 'ol';
|
||||||
|
|
||||||
|
/** Use this if you want HTML markup in the output */
|
||||||
|
public const FORMAT_HTML = 'Html';
|
||||||
|
/** Use this for outputing to a terminal, or for plain text output */
|
||||||
|
public const FORMAT_ANSI = 'Ansi';
|
||||||
|
|
||||||
|
private string $outputFormat;
|
||||||
|
|
||||||
|
private ?OutputInterface $wrappedOutput = null;
|
||||||
|
|
||||||
|
private ?AnsiToHtmlConverter $ansiConverter = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of list types that are opened, and the options that were used to open them.
|
||||||
|
*/
|
||||||
|
private array $listTypeStack = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $outputFormat The format to use for the output (one of the FORMAT_* constants)
|
||||||
|
* @param int The verbosity level (one of the VERBOSITY_* constants in OutputInterface)
|
||||||
|
* @param boolean $decorated Whether to decorate messages (if false, decoration tags will simply be removed)
|
||||||
|
* @param OutputInterface|null $wrappedOutput An optional output pipe messages through.
|
||||||
|
* Useful for capturing output instead of echoing directly to the client, for example.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $outputFormat,
|
||||||
|
int $verbosity = OutputInterface::VERBOSITY_NORMAL,
|
||||||
|
bool $decorated = false,
|
||||||
|
?OutputInterface $wrappedOutput = null
|
||||||
|
) {
|
||||||
|
$this->setOutputFormat($outputFormat);
|
||||||
|
// Intentionally don't call parent constructor, because it doesn't use the setter methods.
|
||||||
|
if ($wrappedOutput) {
|
||||||
|
$this->setWrappedOutput($wrappedOutput);
|
||||||
|
} else {
|
||||||
|
$this->setFormatter(new OutputFormatter());
|
||||||
|
}
|
||||||
|
$this->setDecorated($decorated);
|
||||||
|
$this->setVerbosity($verbosity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes messages to the output - but only if we're using HTML format.
|
||||||
|
* Useful for when HTML and ANSI formatted output need to diverge.
|
||||||
|
*
|
||||||
|
* Note that this method uses RAW output by default, which allows you to add HTML markup
|
||||||
|
* directly into the message. If you're using symfony/console style formatting, set
|
||||||
|
* $options to use the OUTPUT_NORMAL constant.
|
||||||
|
*
|
||||||
|
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
|
||||||
|
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
|
||||||
|
*/
|
||||||
|
public function writeForHtml(
|
||||||
|
string|iterable $messages,
|
||||||
|
bool $newline = false,
|
||||||
|
int $options = OutputInterface::OUTPUT_RAW
|
||||||
|
): void {
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
|
||||||
|
$this->write($messages, $newline, $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes messages to the output - but only if we're using ANSI format.
|
||||||
|
* Useful for when HTML and ANSI formatted output need to diverge.
|
||||||
|
*
|
||||||
|
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
|
||||||
|
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
|
||||||
|
*/
|
||||||
|
public function writeForAnsi(
|
||||||
|
string|iterable $messages,
|
||||||
|
bool $newline = false,
|
||||||
|
int $options = OutputInterface::OUTPUT_NORMAL
|
||||||
|
): void {
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_ANSI) {
|
||||||
|
$this->write($messages, $newline, $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a list.
|
||||||
|
* In HTML format this will write the opening `<ul>` or `<ol>` tag.
|
||||||
|
* In ANSI format this will set up information for rendering list items.
|
||||||
|
*
|
||||||
|
* Call writeListItem() to add items to the list, then call stopList() when you're done.
|
||||||
|
*
|
||||||
|
* @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED
|
||||||
|
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
|
||||||
|
* 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
|
||||||
|
*/
|
||||||
|
public function startList(string $listType = PolyOutput::LIST_UNORDERED, int $options = OutputInterface::OUTPUT_NORMAL): void
|
||||||
|
{
|
||||||
|
$this->listTypeStack[] = ['type' => $listType, 'options' => $options];
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
|
||||||
|
$this->write("<{$listType}>", options: $this->forceRawOutput($options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a list.
|
||||||
|
* In HTML format this will write the closing `</ul>` or `</ol>` tag.
|
||||||
|
* In ANSI format this will mark the list as closed (useful when nesting lists)
|
||||||
|
*/
|
||||||
|
public function stopList(): void
|
||||||
|
{
|
||||||
|
if (empty($this->listTypeStack)) {
|
||||||
|
throw new LogicException('No list to close.');
|
||||||
|
}
|
||||||
|
$info = array_pop($this->listTypeStack);
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
|
||||||
|
$this->write("</{$info['type']}>", options: $this->forceRawOutput($info['options']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes messages formatted as a list.
|
||||||
|
* Make sure to call startList() before writing list items, and call stopList() when you're done.
|
||||||
|
*
|
||||||
|
* @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
|
||||||
|
* by default this will inherit the options used to start the list.
|
||||||
|
*/
|
||||||
|
public function writeListItem(string|iterable $items, ?int $options = null): void
|
||||||
|
{
|
||||||
|
if (empty($this->listTypeStack)) {
|
||||||
|
throw new LogicException('No lists started. Call startList() first.');
|
||||||
|
}
|
||||||
|
if (is_string($items)) {
|
||||||
|
$items = [$items];
|
||||||
|
}
|
||||||
|
$method = "writeListItem{$this->outputFormat}";
|
||||||
|
$this->$method($items, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFormatter(OutputFormatterInterface $formatter): void
|
||||||
|
{
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
|
||||||
|
$formatter = HtmlOutputFormatter::create($formatter);
|
||||||
|
}
|
||||||
|
parent::setFormatter($formatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether this will output in HTML or ANSI format.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException if the format isn't one of the FORMAT_* constants
|
||||||
|
*/
|
||||||
|
public function setOutputFormat(string $outputFormat): void
|
||||||
|
{
|
||||||
|
if (!in_array($outputFormat, [PolyOutput::FORMAT_ANSI, PolyOutput::FORMAT_HTML])) {
|
||||||
|
throw new InvalidArgumentException("Unexpected format - got '$outputFormat'.");
|
||||||
|
}
|
||||||
|
$this->outputFormat = $outputFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the format used for output.
|
||||||
|
*/
|
||||||
|
public function getOutputFormat(): string
|
||||||
|
{
|
||||||
|
return $this->outputFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an output to wrap inside this one. Useful for capturing output in a buffer.
|
||||||
|
*/
|
||||||
|
public function setWrappedOutput(OutputInterface $wrappedOutput): void
|
||||||
|
{
|
||||||
|
$this->wrappedOutput = $wrappedOutput;
|
||||||
|
$this->setFormatter($this->wrappedOutput->getFormatter());
|
||||||
|
// Give wrapped output a debug verbosity - that way it'll output everything we tell it to.
|
||||||
|
// Actual verbosity is handled by PolyOutput's parent Output class.
|
||||||
|
$this->wrappedOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function doWrite(string $message, bool $newline): void
|
||||||
|
{
|
||||||
|
if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
|
||||||
|
$output = $message . ($newline ? '<br>' . PHP_EOL : '');
|
||||||
|
} else {
|
||||||
|
$output = $message . ($newline ? PHP_EOL : '');
|
||||||
|
}
|
||||||
|
if ($this->wrappedOutput) {
|
||||||
|
$this->wrappedOutput->write($output, options: OutputInterface::OUTPUT_RAW);
|
||||||
|
} else {
|
||||||
|
echo $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeListItemHtml(iterable $items, ?int $options): void
|
||||||
|
{
|
||||||
|
if ($options === null) {
|
||||||
|
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
|
||||||
|
$options = $listInfo['options'];
|
||||||
|
}
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$this->write('<li>', options: $this->forceRawOutput($options));
|
||||||
|
$this->write($item, options: $options);
|
||||||
|
$this->write('</li>', options: $this->forceRawOutput($options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeListItemAnsi(iterable $items, ?int $options): void
|
||||||
|
{
|
||||||
|
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
|
||||||
|
$listType = $listInfo['type'];
|
||||||
|
if ($listType === PolyOutput::LIST_ORDERED) {
|
||||||
|
echo '';
|
||||||
|
}
|
||||||
|
if ($options === null) {
|
||||||
|
$options = $listInfo['options'];
|
||||||
|
}
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
switch ($listType) {
|
||||||
|
case PolyOutput::LIST_UNORDERED:
|
||||||
|
$bullet = '*';
|
||||||
|
break;
|
||||||
|
case PolyOutput::LIST_ORDERED:
|
||||||
|
// Start at 1
|
||||||
|
$numberOffset = $listInfo['offset'] ?? 1;
|
||||||
|
$bullet = ($i + $numberOffset) . '.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidArgumentException("Unexpected list type - got '$listType'.");
|
||||||
|
}
|
||||||
|
$indent = str_repeat(' ', count($this->listTypeStack));
|
||||||
|
$this->writeln("{$indent}{$bullet} {$item}", $options);
|
||||||
|
}
|
||||||
|
// Update the number offset so the next item in the list has the correct number
|
||||||
|
if ($listType === PolyOutput::LIST_ORDERED) {
|
||||||
|
$this->listTypeStack[array_key_last($this->listTypeStack)]['offset'] = $numberOffset + $i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getVerbosityOption(int $options): int
|
||||||
|
{
|
||||||
|
// Logic copied from Output::write() - uses bitwise operations to separate verbosity from output type.
|
||||||
|
$verbosities = OutputInterface::VERBOSITY_QUIET | OutputInterface::VERBOSITY_NORMAL | OutputInterface::VERBOSITY_VERBOSE | OutputInterface::VERBOSITY_VERY_VERBOSE | OutputInterface::VERBOSITY_DEBUG;
|
||||||
|
return $verbosities & $options ?: OutputInterface::VERBOSITY_NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forceRawOutput(int $options): int
|
||||||
|
{
|
||||||
|
return $this->getVerbosityOption($options) | OutputInterface::OUTPUT_RAW;
|
||||||
|
}
|
||||||
|
}
|
30
src/PolyExecution/PolyOutputLogHandler.php
Normal file
30
src/PolyExecution/PolyOutputLogHandler.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution;
|
||||||
|
|
||||||
|
use Monolog\Handler\AbstractProcessingHandler;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\LogRecord;
|
||||||
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log handler that uses a PolyOutput to output log entries to the browser or CLI.
|
||||||
|
*/
|
||||||
|
class PolyOutputLogHandler extends AbstractProcessingHandler
|
||||||
|
{
|
||||||
|
use Injectable;
|
||||||
|
|
||||||
|
private PolyOutput $output;
|
||||||
|
|
||||||
|
public function __construct(PolyOutput $output, int|string|Level $level = Level::Debug, bool $bubble = true)
|
||||||
|
{
|
||||||
|
$this->output = $output;
|
||||||
|
parent::__construct($level, $bubble);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function write(LogRecord $record): void
|
||||||
|
{
|
||||||
|
$message = rtrim($record->formatted, PHP_EOL);
|
||||||
|
$this->output->write($message, true, PolyOutput::OUTPUT_RAW);
|
||||||
|
}
|
||||||
|
}
|
@ -7,11 +7,6 @@ use SilverStripe\Control\Controller;
|
|||||||
use SilverStripe\Control\Director;
|
use SilverStripe\Control\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
|
||||||
|
@ -773,7 +773,7 @@ class Member extends DataObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't send emails out on dev/tests sites to prevent accidentally spamming users.
|
// We don't send emails out during tests to prevent accidentally spamming users.
|
||||||
// However, if TestMailer is in use this isn't a risk.
|
// 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')
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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')
|
||||||
|
@ -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>
|
||||||
|
37
templates/SilverStripe/Dev/DevelopmentAdmin.ss
Normal file
37
templates/SilverStripe/Dev/DevelopmentAdmin.ss
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
$Header.RAW
|
||||||
|
$Info.RAW
|
||||||
|
|
||||||
|
<% if $Title %>
|
||||||
|
<div class="info">
|
||||||
|
<h1>$Title</h1>
|
||||||
|
</div>
|
||||||
|
<% end_if %>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<% if $Form %>
|
||||||
|
<%-- confirmation handler --%>
|
||||||
|
$Form
|
||||||
|
<% else %>
|
||||||
|
<ul>
|
||||||
|
<% loop $ArrayLinks %>
|
||||||
|
<li class="$EvenOdd">
|
||||||
|
<a href="$Link"><b>/$Path:</b> $Description</a>
|
||||||
|
<% if $Help %>
|
||||||
|
<details class="more-details">
|
||||||
|
<summary>Display additional information</summary>
|
||||||
|
$Help
|
||||||
|
</details>
|
||||||
|
<% end_if %>
|
||||||
|
<% if $Parameters %>
|
||||||
|
<div>Parameters:
|
||||||
|
<% include SilverStripe/Dev/Parameters %>
|
||||||
|
</div>
|
||||||
|
<% end_if %>
|
||||||
|
</li>
|
||||||
|
<% end_loop %>
|
||||||
|
</ul>
|
||||||
|
<% end_if %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
$Footer.RAW
|
||||||
|
|
8
templates/SilverStripe/Dev/Parameters.ss
Normal file
8
templates/SilverStripe/Dev/Parameters.ss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<dl class="params">
|
||||||
|
<% loop $Parameters %>
|
||||||
|
<div class="param">
|
||||||
|
<dt class="param__name">$Name</dt>
|
||||||
|
<dd class="param__description">$Description<% if $Default %> [default: $Default]<% end_if %></dd>
|
||||||
|
</div>
|
||||||
|
<% end_loop %>
|
||||||
|
</dl>
|
@ -9,7 +9,19 @@ $Info.RAW
|
|||||||
<div class="task__item">
|
<div 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>
|
||||||
|
@ -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);
|
||||||
|
99
tests/php/Cli/Command/NavigateCommandTest.php
Normal file
99
tests/php/Cli/Command/NavigateCommandTest.php
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\Command;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Cli\Command\NavigateCommand;
|
||||||
|
use SilverStripe\Cli\Tests\Command\NavigateCommandTest\TestController;
|
||||||
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
class NavigateCommandTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideExecute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'path' => 'missing-route',
|
||||||
|
'getVars' => [],
|
||||||
|
'expectedExitCode' => 2,
|
||||||
|
'expectedOutput' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller',
|
||||||
|
'getVars' => [],
|
||||||
|
'expectedExitCode' => 0,
|
||||||
|
'expectedOutput' => 'This is the index for TestController.' . PHP_EOL,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller/actionOne',
|
||||||
|
'getVars' => [],
|
||||||
|
'expectedExitCode' => 0,
|
||||||
|
'expectedOutput' => 'This is action one!' . PHP_EOL,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller/errorResponse',
|
||||||
|
'getVars' => [],
|
||||||
|
'expectedExitCode' => 1,
|
||||||
|
'expectedOutput' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller/missing-action',
|
||||||
|
'getVars' => [],
|
||||||
|
'expectedExitCode' => 2,
|
||||||
|
'expectedOutput' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller',
|
||||||
|
'getVars' => [
|
||||||
|
'var1=1',
|
||||||
|
'var2=abcd',
|
||||||
|
'var3=',
|
||||||
|
'var4[]=a',
|
||||||
|
'var4[]=b',
|
||||||
|
'var4[]=c',
|
||||||
|
],
|
||||||
|
'expectedExitCode' => 0,
|
||||||
|
'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'test-controller',
|
||||||
|
'getVars' => [
|
||||||
|
'var1=1&var2=abcd&var3=&var4[]=a&var4[]=b&var4[]=c',
|
||||||
|
],
|
||||||
|
'expectedExitCode' => 0,
|
||||||
|
'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideExecute')]
|
||||||
|
public function testExecute(string $path, array $getVars, int $expectedExitCode, string $expectedOutput): void
|
||||||
|
{
|
||||||
|
// Intentionally override existing rules
|
||||||
|
Director::config()->set('rules', ['test-controller' => TestController::class]);
|
||||||
|
$navigateCommand = new NavigateCommand();
|
||||||
|
$inputParams = [
|
||||||
|
'path' => $path,
|
||||||
|
'get-vars' => $getVars,
|
||||||
|
];
|
||||||
|
$input = new ArrayInput($inputParams, $navigateCommand->getDefinition());
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
|
||||||
|
|
||||||
|
$exitCode = $navigateCommand->run($input, $output);
|
||||||
|
|
||||||
|
// Don't asset specific output for failed or invalid responses
|
||||||
|
// The response body for those is handled outside of the navigate command's control
|
||||||
|
if ($expectedExitCode === 0) {
|
||||||
|
$this->assertSame($expectedOutput, $buffer->fetch());
|
||||||
|
}
|
||||||
|
$this->assertSame($expectedExitCode, $exitCode);
|
||||||
|
}
|
||||||
|
}
|
52
tests/php/Cli/Command/NavigateCommandTest/TestController.php
Normal file
52
tests/php/Cli/Command/NavigateCommandTest/TestController.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\Command\NavigateCommandTest;
|
||||||
|
|
||||||
|
use SilverStripe\Control\Controller;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Control\HTTPResponse;
|
||||||
|
|
||||||
|
class TestController extends Controller
|
||||||
|
{
|
||||||
|
private static $allowed_actions = [
|
||||||
|
'actionOne',
|
||||||
|
'errorResponse',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function index(HTTPRequest $request): HTTPResponse
|
||||||
|
{
|
||||||
|
$var1 = $request->getVar('var1');
|
||||||
|
$var2 = $request->getVar('var2');
|
||||||
|
$var3 = $request->getVar('var3');
|
||||||
|
$var4 = $request->getVar('var4');
|
||||||
|
|
||||||
|
$output = 'This is the index for TestController.';
|
||||||
|
|
||||||
|
if ($var1) {
|
||||||
|
$output .= ' var1=' . $var1;
|
||||||
|
}
|
||||||
|
if ($var2) {
|
||||||
|
$output .= ' var2=' . $var2;
|
||||||
|
}
|
||||||
|
if ($var3) {
|
||||||
|
$output .= ' var3=' . $var3;
|
||||||
|
}
|
||||||
|
if ($var4) {
|
||||||
|
$output .= ' var4=' . implode(',', $var4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->response->setBody($output);
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actionOne(HTTPRequest $request): HTTPResponse
|
||||||
|
{
|
||||||
|
$this->response->setBody('This is action one!');
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function errorResponse(HTTPRequest $request): HTTPResponse
|
||||||
|
{
|
||||||
|
$this->httpError(500);
|
||||||
|
}
|
||||||
|
}
|
52
tests/php/Cli/Command/PolyCommandCliWrapperTest.php
Normal file
52
tests/php/Cli/Command/PolyCommandCliWrapperTest.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\Command;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Cli\Command\PolyCommandCliWrapper;
|
||||||
|
use SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest\TestPolyCommand;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
class PolyCommandCliWrapperTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideExecute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no-params' => [
|
||||||
|
'exitCode' => 0,
|
||||||
|
'params' => [],
|
||||||
|
'expectedOutput' => 'Has option 1: false' . PHP_EOL
|
||||||
|
. 'option 2 value: ' . PHP_EOL,
|
||||||
|
],
|
||||||
|
'with-params' => [
|
||||||
|
'exitCode' => 1,
|
||||||
|
'params' => [
|
||||||
|
'--option1' => true,
|
||||||
|
'--option2' => 'abc',
|
||||||
|
],
|
||||||
|
'expectedOutput' => 'Has option 1: true' . PHP_EOL
|
||||||
|
. 'option 2 value: abc' . PHP_EOL,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideExecute')]
|
||||||
|
public function testExecute(int $exitCode, array $params, string $expectedOutput): void
|
||||||
|
{
|
||||||
|
$polyCommand = new TestPolyCommand();
|
||||||
|
$polyCommand->setExitCode($exitCode);
|
||||||
|
$wrapper = new PolyCommandCliWrapper($polyCommand);
|
||||||
|
$input = new ArrayInput($params, $wrapper->getDefinition());
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
|
||||||
|
|
||||||
|
$this->assertSame($exitCode, $wrapper->run($input, $output));
|
||||||
|
$this->assertSame($expectedOutput, $buffer->fetch());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
class TestPolyCommand extends PolyCommand implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test:poly';
|
||||||
|
|
||||||
|
protected static string $description = 'simple command for testing CLI wrapper';
|
||||||
|
|
||||||
|
private int $exitCode = 0;
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'This is the title!';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
|
||||||
|
$output->writeln('option 2 value: ' . $input->getOption('option2'));
|
||||||
|
return $this->exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExitCode(int $code): void
|
||||||
|
{
|
||||||
|
$this->exitCode = $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new InputOption('option1', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('option2', null, InputOption::VALUE_REQUIRED),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
159
tests/php/Cli/LegacyParamArgvInputTest.php
Normal file
159
tests/php/Cli/LegacyParamArgvInputTest.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Cli\LegacyParamArgvInput;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
class LegacyParamArgvInputTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideHasParameterOption(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sake flush=1' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=1'
|
||||||
|
],
|
||||||
|
'checkFor' => '--flush',
|
||||||
|
'expected' => true,
|
||||||
|
],
|
||||||
|
'sake flush=0' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=0'
|
||||||
|
],
|
||||||
|
'checkFor' => '--flush',
|
||||||
|
'expected' => true,
|
||||||
|
],
|
||||||
|
'sake flush=1 --' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=1',
|
||||||
|
'--'
|
||||||
|
],
|
||||||
|
'checkFor' => '--flush',
|
||||||
|
'expected' => true,
|
||||||
|
],
|
||||||
|
'sake -- flush=1' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'--',
|
||||||
|
'flush=1'
|
||||||
|
],
|
||||||
|
'checkFor' => '--flush',
|
||||||
|
'expected' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideHasParameterOption')]
|
||||||
|
public function testHasParameterOption(array $argv, string $checkFor, bool $expected): void
|
||||||
|
{
|
||||||
|
$input = new LegacyParamArgvInput($argv);
|
||||||
|
$this->assertSame($expected, $input->hasParameterOption($checkFor));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideGetParameterOption(): array
|
||||||
|
{
|
||||||
|
$scenarios = static::provideHasParameterOption();
|
||||||
|
$scenarios['sake flush=1']['expected'] = '1';
|
||||||
|
$scenarios['sake flush=0']['expected'] = '0';
|
||||||
|
$scenarios['sake flush=1 --']['expected'] = '1';
|
||||||
|
$scenarios['sake -- flush=1']['expected'] = false;
|
||||||
|
return $scenarios;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideGetParameterOption')]
|
||||||
|
public function testGetParameterOption(array $argv, string $checkFor, false|string $expected): void
|
||||||
|
{
|
||||||
|
$input = new LegacyParamArgvInput($argv);
|
||||||
|
$this->assertSame($expected, $input->getParameterOption($checkFor));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideBind(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sake flush=1 arg=value' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=1',
|
||||||
|
'arg=value',
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
new InputOption('--flush', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('--arg', null, InputOption::VALUE_REQUIRED),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'flush' => true,
|
||||||
|
'arg' => 'value',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'sake flush=yes arg=abc' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=yes',
|
||||||
|
'arg=abc',
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
new InputOption('flush', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'flush' => true,
|
||||||
|
'arg' => 'abc',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'sake flush=0 arg=' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=0',
|
||||||
|
'arg=',
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
new InputOption('flush', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'flush' => false,
|
||||||
|
'arg' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'sake flush=1 -- arg=abc' => [
|
||||||
|
'argv' => [
|
||||||
|
'sake',
|
||||||
|
'flush=1',
|
||||||
|
'--',
|
||||||
|
'arg=abc',
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
new InputOption('flush', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
|
||||||
|
// Since arg=abc is now included as an argument, we need to allow an argument.
|
||||||
|
new InputArgument('needed-to-avoid-error', InputArgument::REQUIRED),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'flush' => true,
|
||||||
|
'arg' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideBind')]
|
||||||
|
public function testBind(array $argv, array $options, array $expected): void
|
||||||
|
{
|
||||||
|
$input = new LegacyParamArgvInput($argv);
|
||||||
|
$definition = new InputDefinition($options);
|
||||||
|
$input->bind($definition);
|
||||||
|
foreach ($expected as $option => $value) {
|
||||||
|
$this->assertSame($value, $input->getOption($option));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
307
tests/php/Cli/SakeTest.php
Normal file
307
tests/php/Cli/SakeTest.php
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Error\Deprecated;
|
||||||
|
use ReflectionClass;
|
||||||
|
use SilverStripe\Cli\Sake;
|
||||||
|
use SilverStripe\Cli\Tests\SakeTest\TestBuildTask;
|
||||||
|
use SilverStripe\Cli\Tests\SakeTest\TestCommandLoader;
|
||||||
|
use SilverStripe\Cli\Tests\SakeTest\TestConfigCommand;
|
||||||
|
use SilverStripe\Cli\Tests\SakeTest\TestConfigPolyCommand;
|
||||||
|
use SilverStripe\Core\ClassInfo;
|
||||||
|
use SilverStripe\Core\Injector\Injector;
|
||||||
|
use SilverStripe\Core\Kernel;
|
||||||
|
use SilverStripe\Core\Manifest\VersionProvider;
|
||||||
|
use SilverStripe\Dev\BuildTask;
|
||||||
|
use SilverStripe\Dev\Deprecation;
|
||||||
|
use SilverStripe\Dev\DevelopmentAdmin;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestException;
|
||||||
|
use Symfony\Component\Console\Command\DumpCompletionCommand;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
class SakeTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
private $oldErrorHandler = null;
|
||||||
|
|
||||||
|
public static function provideList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'display all' => [
|
||||||
|
'addExtra' => true,
|
||||||
|
'hideCompletion' => true,
|
||||||
|
],
|
||||||
|
'display none' => [
|
||||||
|
'addExtra' => false,
|
||||||
|
'hideCompletion' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test adding commands and command loaders to Sake via configuration API
|
||||||
|
*/
|
||||||
|
#[DataProvider('provideList')]
|
||||||
|
public function testList(bool $addExtra, bool $hideCompletion): void
|
||||||
|
{
|
||||||
|
$sake = new Sake(Injector::inst()->get(Kernel::class));
|
||||||
|
$sake->setAutoExit(false);
|
||||||
|
$input = new ArrayInput(['list']);
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$output = new BufferedOutput();
|
||||||
|
|
||||||
|
if ($addExtra) {
|
||||||
|
Sake::config()->merge('commands', [
|
||||||
|
TestConfigPolyCommand::class,
|
||||||
|
TestConfigCommand::class,
|
||||||
|
]);
|
||||||
|
Sake::config()->merge('command_loaders', [
|
||||||
|
TestCommandLoader::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Sake::config()->set('hide_completion_command', $hideCompletion);
|
||||||
|
// Make sure all tasks are displayed - we'll test hiding them in testHideTasks
|
||||||
|
Sake::config()->set('max_tasks_to_display', 0);
|
||||||
|
|
||||||
|
$sake->run($input, $output);
|
||||||
|
|
||||||
|
$commandNames = [
|
||||||
|
'loader:test-command',
|
||||||
|
'test:from-config:standard',
|
||||||
|
'test:from-config:poly',
|
||||||
|
];
|
||||||
|
$commandDescriptions = [
|
||||||
|
'command for testing adding custom command loaders',
|
||||||
|
'command for testing adding standard commands via config',
|
||||||
|
'command for testing adding poly commands via config',
|
||||||
|
];
|
||||||
|
|
||||||
|
$listOutput = $output->fetch();
|
||||||
|
|
||||||
|
// Check if the extra commands are there or not
|
||||||
|
if ($addExtra) {
|
||||||
|
foreach ($commandNames as $name) {
|
||||||
|
$this->assertStringContainsString($name, $listOutput);
|
||||||
|
}
|
||||||
|
foreach ($commandDescriptions as $description) {
|
||||||
|
$this->assertStringContainsString($description, $listOutput);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($commandNames as $name) {
|
||||||
|
$this->assertStringNotContainsString($name, $listOutput);
|
||||||
|
}
|
||||||
|
foreach ($commandDescriptions as $description) {
|
||||||
|
$this->assertStringNotContainsString($description, $listOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build task could display automagically as a matter of class inheritance.
|
||||||
|
$task = new TestBuildTask();
|
||||||
|
$this->assertStringContainsString($task->getName(), $listOutput);
|
||||||
|
$this->assertStringContainsString(TestBuildTask::getDescription(), $listOutput);
|
||||||
|
|
||||||
|
// Check if the completion command is there or not
|
||||||
|
$command = new DumpCompletionCommand();
|
||||||
|
$completionRegex = "/{$command->getName()}\s+{$command->getDescription()}/";
|
||||||
|
if ($hideCompletion) {
|
||||||
|
$this->assertDoesNotMatchRegularExpression($completionRegex, $listOutput);
|
||||||
|
} else {
|
||||||
|
$this->assertMatchesRegularExpression($completionRegex, $listOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the "help" and "list" commands aren't shown
|
||||||
|
$this->assertStringNotContainsString($listOutput, 'List commands', 'the list command should not display');
|
||||||
|
$this->assertStringNotContainsString($listOutput, 'Display help for a command', 'the help command should not display');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPolyCommandCanRunInCli(): void
|
||||||
|
{
|
||||||
|
$kernel = Injector::inst()->get(Kernel::class);
|
||||||
|
$sake = new Sake($kernel);
|
||||||
|
$sake->setAutoExit(false);
|
||||||
|
$input = new ArrayInput(['list']);
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$output = new BufferedOutput();
|
||||||
|
|
||||||
|
// Add test commands
|
||||||
|
Sake::config()->merge('commands', [
|
||||||
|
TestConfigPolyCommand::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Disallow these to run in CLI.
|
||||||
|
// Note the scenario where all are allowed is in testList().
|
||||||
|
TestConfigPolyCommand::config()->set('can_run_in_cli', false);
|
||||||
|
TestBuildTask::config()->set('can_run_in_cli', false);
|
||||||
|
DevelopmentAdmin::config()->set('allow_all_cli', false);
|
||||||
|
|
||||||
|
// Must not be in dev mode to test permissions, because all PolyCommand can be run in dev mode.
|
||||||
|
$origEnvironment = $kernel->getEnvironment();
|
||||||
|
$kernel->setEnvironment('live');
|
||||||
|
try {
|
||||||
|
$sake->run($input, $output);
|
||||||
|
} finally {
|
||||||
|
$kernel->setEnvironment($origEnvironment);
|
||||||
|
}
|
||||||
|
$listOutput = $output->fetch();
|
||||||
|
|
||||||
|
$allCommands = [
|
||||||
|
TestConfigPolyCommand::class,
|
||||||
|
TestBuildTask::class,
|
||||||
|
];
|
||||||
|
foreach ($allCommands as $commandClass) {
|
||||||
|
$command = new $commandClass();
|
||||||
|
$this->assertStringNotContainsString($command->getName(), $listOutput);
|
||||||
|
$this->assertStringNotContainsString($commandClass::getDescription(), $listOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideHideTasks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'task count matches limit' => [
|
||||||
|
'taskLimit' => 'same',
|
||||||
|
'shouldShow' => true,
|
||||||
|
],
|
||||||
|
'task count lower than limit' => [
|
||||||
|
'taskLimit' => 'more',
|
||||||
|
'shouldShow' => true,
|
||||||
|
],
|
||||||
|
'task count greater than limit' => [
|
||||||
|
'taskLimit' => 'less',
|
||||||
|
'shouldShow' => false,
|
||||||
|
],
|
||||||
|
'unlimited tasks allowed' => [
|
||||||
|
'taskLimit' => 'all',
|
||||||
|
'shouldShow' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideHideTasks')]
|
||||||
|
public function testHideTasks(string $taskLimit, bool $shouldShow): void
|
||||||
|
{
|
||||||
|
$sake = new Sake(Injector::inst()->get(Kernel::class));
|
||||||
|
$sake->setAutoExit(false);
|
||||||
|
$input = new ArrayInput(['list']);
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$output = new BufferedOutput();
|
||||||
|
|
||||||
|
// Determine max tasks config value
|
||||||
|
$taskInfo = [];
|
||||||
|
foreach (ClassInfo::subclassesFor(BuildTask::class, false) as $class) {
|
||||||
|
$reflectionClass = new ReflectionClass($class);
|
||||||
|
if ($reflectionClass->isAbstract()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$singleton = $class::singleton();
|
||||||
|
if ($class::canRunInCli() && $singleton->isEnabled()) {
|
||||||
|
$taskInfo[$singleton->getName()] = $class::getDescription();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$maxTasks = match ($taskLimit) {
|
||||||
|
'same' => count($taskInfo),
|
||||||
|
'more' => count($taskInfo) + 1,
|
||||||
|
'less' => count($taskInfo) - 1,
|
||||||
|
'all' => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
Sake::config()->set('max_tasks_to_display', $maxTasks);
|
||||||
|
$sake->run($input, $output);
|
||||||
|
$listOutput = $output->fetch();
|
||||||
|
|
||||||
|
// Check the tasks are showing/hidden as appropriate
|
||||||
|
if ($shouldShow) {
|
||||||
|
foreach ($taskInfo as $name => $description) {
|
||||||
|
$this->assertStringContainsString($name, $listOutput);
|
||||||
|
$this->assertStringContainsString($description, $listOutput);
|
||||||
|
}
|
||||||
|
// Shouldn't display the task command
|
||||||
|
$this->assertStringNotContainsString('See a list of build tasks to run', $listOutput);
|
||||||
|
} else {
|
||||||
|
foreach ($taskInfo as $name => $description) {
|
||||||
|
$this->assertStringNotContainsString($name, $listOutput);
|
||||||
|
$this->assertStringNotContainsString($description, $listOutput);
|
||||||
|
}
|
||||||
|
// Should display the task command
|
||||||
|
$this->assertStringContainsString('See a list of build tasks to run', $listOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check `sake tasks` ALWAYS shows the tasks
|
||||||
|
$input = new ArrayInput(['tasks']);
|
||||||
|
$sake->run($input, $output);
|
||||||
|
$listOutput = $output->fetch();
|
||||||
|
foreach ($taskInfo as $name => $description) {
|
||||||
|
$this->assertStringContainsString($name, $listOutput);
|
||||||
|
$this->assertStringContainsString($description, $listOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVersion(): void
|
||||||
|
{
|
||||||
|
$sake = new Sake(Injector::inst()->get(Kernel::class));
|
||||||
|
$sake->setAutoExit(false);
|
||||||
|
$versionProvider = new VersionProvider();
|
||||||
|
$this->assertSame($versionProvider->getVersion(), $sake->getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLegacyDevCommands(): void
|
||||||
|
{
|
||||||
|
$sake = new Sake(Injector::inst()->get(Kernel::class));
|
||||||
|
$sake->setAutoExit(false);
|
||||||
|
$input = new ArrayInput(['dev/config']);
|
||||||
|
$input->setInteractive(false);
|
||||||
|
$output = new BufferedOutput();
|
||||||
|
|
||||||
|
$deprecationsWereEnabled = Deprecation::isEnabled();
|
||||||
|
Deprecation::enable();
|
||||||
|
$this->expectException(DeprecationTestException::class);
|
||||||
|
$expectedErrorString = 'Using the command with the name \'dev/config\' is deprecated. Use \'config:dump\' instead';
|
||||||
|
$this->expectExceptionMessage($expectedErrorString);
|
||||||
|
|
||||||
|
$exitCode = $sake->run($input, $output);
|
||||||
|
$this->assertSame(0, $exitCode, 'command should run successfully');
|
||||||
|
// $this->assertStringContainsString('abababa', $output->fetch());
|
||||||
|
|
||||||
|
$this->allowCatchingDeprecations($expectedErrorString);
|
||||||
|
try {
|
||||||
|
// call outputNotices() directly because the regular shutdown function that emits
|
||||||
|
// the notices within Deprecation won't be called until after this unit-test has finished
|
||||||
|
Deprecation::outputNotices();
|
||||||
|
} finally {
|
||||||
|
restore_error_handler();
|
||||||
|
$this->oldErrorHandler = null;
|
||||||
|
// Disable if they weren't enabled before.
|
||||||
|
if (!$deprecationsWereEnabled) {
|
||||||
|
Deprecation::disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allowCatchingDeprecations(string $expectedErrorString): void
|
||||||
|
{
|
||||||
|
// Use custom error handler for two reasons:
|
||||||
|
// - Filter out errors for deprecations unrelated to this test class
|
||||||
|
// - Allow the use of expectDeprecation(), which doesn't work with E_USER_DEPRECATION by default
|
||||||
|
// https://github.com/laminas/laminas-di/pull/30#issuecomment-927585210
|
||||||
|
$this->oldErrorHandler = set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($expectedErrorString) {
|
||||||
|
if ($errno === E_USER_DEPRECATED) {
|
||||||
|
if (str_contains($errstr, $expectedErrorString)) {
|
||||||
|
throw new DeprecationTestException($errstr);
|
||||||
|
} else {
|
||||||
|
// Suppress any E_USER_DEPRECATED unrelated to this test class
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_callable($this->oldErrorHandler)) {
|
||||||
|
return call_user_func($this->oldErrorHandler, $errno, $errstr, $errfile, $errline);
|
||||||
|
}
|
||||||
|
// Fallback to default PHP error handler
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
23
tests/php/Cli/SakeTest/TestBuildTask.php
Normal file
23
tests/php/Cli/SakeTest/TestBuildTask.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\SakeTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\BuildTask;
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
class TestBuildTask extends BuildTask implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test-build-task';
|
||||||
|
|
||||||
|
protected string $title = 'my title';
|
||||||
|
|
||||||
|
protected static string $description = 'command for testing build tasks display as expected';
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('This output is coming from a build task');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
31
tests/php/Cli/SakeTest/TestCommandLoader.php
Normal file
31
tests/php/Cli/SakeTest/TestCommandLoader.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\SakeTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
|
||||||
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
|
||||||
|
class TestCommandLoader implements CommandLoaderInterface, TestOnly
|
||||||
|
{
|
||||||
|
private string $commandName = 'loader:test-command';
|
||||||
|
|
||||||
|
public function get(string $name): Command
|
||||||
|
{
|
||||||
|
if ($name !== $this->commandName) {
|
||||||
|
throw new CommandNotFoundException("Wrong command fetched. Expected '$this->commandName' - got '$name'");
|
||||||
|
}
|
||||||
|
return new TestLoaderCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
return $name === $this->commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNames(): array
|
||||||
|
{
|
||||||
|
return [$this->commandName];
|
||||||
|
}
|
||||||
|
}
|
19
tests/php/Cli/SakeTest/TestConfigCommand.php
Normal file
19
tests/php/Cli/SakeTest/TestConfigCommand.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\SakeTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand('test:from-config:standard', 'command for testing adding standard commands via config')]
|
||||||
|
class TestConfigCommand extends Command implements TestOnly
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
return 'This is a standard command';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
26
tests/php/Cli/SakeTest/TestConfigPolyCommand.php
Normal file
26
tests/php/Cli/SakeTest/TestConfigPolyCommand.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\SakeTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
class TestConfigPolyCommand extends PolyCommand implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test:from-config:poly';
|
||||||
|
|
||||||
|
protected static string $description = 'command for testing adding poly commands via config';
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'This is a poly command';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('This output is coming from a poly command');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
19
tests/php/Cli/SakeTest/TestLoaderCommand.php
Normal file
19
tests/php/Cli/SakeTest/TestLoaderCommand.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Cli\Tests\SakeTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand('loader:test-command', 'command for testing adding custom command loaders')]
|
||||||
|
class TestLoaderCommand extends Command implements TestOnly
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
return 'This is a standard command';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ use SilverStripe\Control\Middleware\CanonicalURLMiddleware;
|
|||||||
use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter;
|
use SilverStripe\Control\Middleware\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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
26
tests/php/Control/DirectorTest/TestPolyCommand.php
Normal file
26
tests/php/Control/DirectorTest/TestPolyCommand.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Control\Tests\DirectorTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
class TestPolyCommand extends PolyCommand implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test:poly';
|
||||||
|
|
||||||
|
protected static string $description = 'simple command for testing Director routing to PolyCommand';
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'This is the title!';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->write('Successful poly command request!');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
90
tests/php/Control/PolyCommandControllerTest.php
Normal file
90
tests/php/Control/PolyCommandControllerTest.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Control\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Control\HTTPResponse_Exception;
|
||||||
|
use SilverStripe\Control\PolyCommandController;
|
||||||
|
use SilverStripe\Control\Session;
|
||||||
|
use SilverStripe\Control\Tests\PolyCommandControllerTest\TestPolyCommand;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
|
||||||
|
class PolyCommandControllerTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideHandleRequest(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no params' => [
|
||||||
|
'exitCode' => 0,
|
||||||
|
'params' => [],
|
||||||
|
'allowed' => true,
|
||||||
|
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
|
||||||
|
],
|
||||||
|
'with params' => [
|
||||||
|
'exitCode' => 1,
|
||||||
|
'params' => [
|
||||||
|
'option1' => true,
|
||||||
|
'option2' => 'abc',
|
||||||
|
'option3' => [
|
||||||
|
'val1',
|
||||||
|
'val2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'allowed' => true,
|
||||||
|
'expectedOutput' => "Has option 1: true<br>\noption 2 value: abc<br>\noption 3 value: val1<br>\noption 3 value: val2<br>\n",
|
||||||
|
],
|
||||||
|
'explicit exit code' => [
|
||||||
|
'exitCode' => 418,
|
||||||
|
'params' => [],
|
||||||
|
'allowed' => true,
|
||||||
|
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
|
||||||
|
],
|
||||||
|
'not allowed to run' => [
|
||||||
|
'exitCode' => 404,
|
||||||
|
'params' => [],
|
||||||
|
'allowed' => false,
|
||||||
|
'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideHandleRequest')]
|
||||||
|
public function testHandleRequest(int $exitCode, array $params, bool $allowed, string $expectedOutput): void
|
||||||
|
{
|
||||||
|
$polyCommand = new TestPolyCommand();
|
||||||
|
TestPolyCommand::setCanRunInBrowser($allowed);
|
||||||
|
if ($allowed) {
|
||||||
|
// Don't set the exit code if not allowed to run - we want to test that it's correctly forced to 404
|
||||||
|
$polyCommand->setExitCode($exitCode);
|
||||||
|
} else {
|
||||||
|
$this->expectException(HTTPResponse_Exception::class);
|
||||||
|
$this->expectExceptionCode(404);
|
||||||
|
}
|
||||||
|
$controller = new PolyCommandController($polyCommand);
|
||||||
|
|
||||||
|
$request = new HTTPRequest('GET', '', $params);
|
||||||
|
$request->setSession(new Session([]));
|
||||||
|
$response = $controller->handleRequest($request);
|
||||||
|
|
||||||
|
if ($exitCode === 0) {
|
||||||
|
$statusCode = 200;
|
||||||
|
} elseif ($exitCode === 1) {
|
||||||
|
$statusCode = 500;
|
||||||
|
} elseif ($exitCode === 2) {
|
||||||
|
$statusCode = 400;
|
||||||
|
} else {
|
||||||
|
$statusCode = $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allowed) {
|
||||||
|
$this->assertSame($expectedOutput, $response->getBody());
|
||||||
|
} else {
|
||||||
|
// The 404 response will NOT contain any output from the command, because the command didn't run.
|
||||||
|
$this->assertNotSame($expectedOutput, $response->getBody());
|
||||||
|
}
|
||||||
|
$this->assertSame($statusCode, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Control\Tests\PolyCommandControllerTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
class TestPolyCommand extends PolyCommand implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test:poly';
|
||||||
|
|
||||||
|
protected static string $description = 'simple command for testing controller wrapper';
|
||||||
|
|
||||||
|
protected static bool $canRunInBrowser = true;
|
||||||
|
|
||||||
|
private int $exitCode = 0;
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'This is the title!';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
|
||||||
|
$output->writeln('option 2 value: ' . $input->getOption('option2'));
|
||||||
|
foreach ($input->getOption('option3') ?? [] as $value) {
|
||||||
|
$output->writeln('option 3 value: ' . $value);
|
||||||
|
}
|
||||||
|
return $this->exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExitCode(int $code): void
|
||||||
|
{
|
||||||
|
$this->exitCode = $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new InputOption('option1', null, InputOption::VALUE_NONE),
|
||||||
|
new InputOption('option2', null, InputOption::VALUE_REQUIRED),
|
||||||
|
new InputOption('option3', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canRunInBrowser(): bool
|
||||||
|
{
|
||||||
|
return static::$canRunInBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setCanRunInBrowser(bool $canRun): void
|
||||||
|
{
|
||||||
|
static::$canRunInBrowser = $canRun;
|
||||||
|
}
|
||||||
|
}
|
@ -3,41 +3,26 @@
|
|||||||
namespace SilverStripe\Dev\Tests;
|
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
27
tests/php/Dev/BuildTaskTest/TestBuildTask.php
Normal file
27
tests/php/Dev/BuildTaskTest/TestBuildTask.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\BuildTaskTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\BuildTask;
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use SilverStripe\ORM\FieldType\DBDatetime;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
class TestBuildTask extends BuildTask implements TestOnly
|
||||||
|
{
|
||||||
|
protected static string $commandName = 'test-build-task';
|
||||||
|
|
||||||
|
protected string $title = 'my title';
|
||||||
|
|
||||||
|
protected static string $description = 'command for testing build tasks display as expected';
|
||||||
|
|
||||||
|
public string $setTimeTo;
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
DBDatetime::set_mock_now($this->setTimeTo);
|
||||||
|
$output->writeln('This output is coming from a build task');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
@ -3,47 +3,47 @@
|
|||||||
namespace SilverStripe\Dev\Tests;
|
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']],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,6 @@ class Controller1 extends Controller
|
|||||||
|
|
||||||
public function y1Action()
|
public function y1Action()
|
||||||
{
|
{
|
||||||
echo Controller1::OK_MSG;
|
echo Controller1::OK_MSG . ' y1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
32
tests/php/Dev/DevAdminControllerTest/TestCommand.php
Normal file
32
tests/php/Dev/DevAdminControllerTest/TestCommand.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\Command\DevCommand;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|
||||||
|
class TestCommand extends DevCommand
|
||||||
|
{
|
||||||
|
const OK_MSG = 'DevAdminControllerTest_TestCommand TEST OK';
|
||||||
|
|
||||||
|
protected static string $commandName = 'my-test-command';
|
||||||
|
|
||||||
|
protected static string $description = 'my test command';
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Test command';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, PolyOutput $output): int
|
||||||
|
{
|
||||||
|
$output->write(TestCommand::OK_MSG);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'This is a test command';
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
|
||||||
|
|
||||||
|
use SilverStripe\Control\Controller;
|
||||||
|
|
||||||
|
class TestHiddenController extends Controller
|
||||||
|
{
|
||||||
|
const OK_MSG = 'DevAdminControllerTest_TestHiddenController TEST OK';
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
echo TestHiddenController::OK_MSG;
|
||||||
|
}
|
||||||
|
}
|
@ -3,13 +3,15 @@
|
|||||||
namespace SilverStripe\Dev\Tests\TaskRunnerTest;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
57
tests/php/PolyExecution/AnsiToHtmlConverterTest.php
Normal file
57
tests/php/PolyExecution/AnsiToHtmlConverterTest.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\PolyExecution\AnsiToHtmlConverter;
|
||||||
|
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||||
|
|
||||||
|
class AnsiToHtmlConverterTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideConvert(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no text, no result' => [
|
||||||
|
'unformatted' => '',
|
||||||
|
'expected' => '',
|
||||||
|
],
|
||||||
|
'no empty span' => [
|
||||||
|
'unformatted' => 'This text <info></info> is unformatted',
|
||||||
|
'expected' => 'This text is unformatted',
|
||||||
|
],
|
||||||
|
'named formats are converted' => [
|
||||||
|
'unformatted' => 'This text <info>has some</info> formatting',
|
||||||
|
'expected' => 'This text <span style="color: green">has some</span> formatting',
|
||||||
|
],
|
||||||
|
'fg and bg are converted' => [
|
||||||
|
'unformatted' => 'This text <fg=red;bg=blue>has some</> formatting',
|
||||||
|
'expected' => 'This text <span style="background-color: blue; color: darkred">has some</span> formatting',
|
||||||
|
],
|
||||||
|
'bold and underscore are converted' => [
|
||||||
|
'unformatted' => 'This text <options=bold;options=underscore>has some</> formatting',
|
||||||
|
'expected' => 'This text <span style="font-weight: bold; text-decoration: underline">has some</span> formatting',
|
||||||
|
],
|
||||||
|
'multiple styles are converted' => [
|
||||||
|
'unformatted' => 'This text <options=bold;fg=green>has some</> <comment>formatting</comment>',
|
||||||
|
'expected' => 'This text <span style="font-weight: bold; color: green">has some</span> <span style="color: goldenrod">formatting</span>',
|
||||||
|
],
|
||||||
|
'hyperlinks are converted' => [
|
||||||
|
'unformatted' => 'This text <href=https://www.example.com/>has a</> link',
|
||||||
|
'expected' => 'This text <a href="https://www.example.com/">has a</a> link',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideConvert')]
|
||||||
|
public function testConvert(string $unformatted, string $expected): void
|
||||||
|
{
|
||||||
|
$converter = new AnsiToHtmlConverter();
|
||||||
|
$ansiFormatter = new OutputFormatter(true);
|
||||||
|
$formatted = $ansiFormatter->format($unformatted);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $converter->convert($formatted));
|
||||||
|
}
|
||||||
|
}
|
141
tests/php/PolyExecution/HttpRequestInputTest.php
Normal file
141
tests/php/PolyExecution/HttpRequestInputTest.php
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\PolyExecution\HttpRequestInput;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class HttpRequestInputTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideInputOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no vars, no options' => [
|
||||||
|
'requestVars' => [],
|
||||||
|
'commandOptions' => [],
|
||||||
|
'expected' => [],
|
||||||
|
],
|
||||||
|
'some vars, no options' => [
|
||||||
|
'requestVars' => [
|
||||||
|
'var1' => '1',
|
||||||
|
'var2' => 'abcd',
|
||||||
|
'var3' => null,
|
||||||
|
'var4' => ['a', 'b', 'c'],
|
||||||
|
],
|
||||||
|
'commandOptions' => [],
|
||||||
|
'expected' => [],
|
||||||
|
],
|
||||||
|
'no vars, some options' => [
|
||||||
|
'requestVars' => [],
|
||||||
|
'commandOptions' => [
|
||||||
|
new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
|
||||||
|
new InputOption('var2', null, InputOption::VALUE_REQUIRED),
|
||||||
|
new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
|
||||||
|
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'var1' => null,
|
||||||
|
'var2' => null,
|
||||||
|
'var3' => null,
|
||||||
|
'var4' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'no vars, some options (with default values)' => [
|
||||||
|
'requestVars' => [],
|
||||||
|
'commandOptions' => [
|
||||||
|
new InputOption('var1', null, InputOption::VALUE_NEGATABLE, default: true),
|
||||||
|
new InputOption('var2', null, InputOption::VALUE_REQUIRED, default: 'def'),
|
||||||
|
new InputOption('var3', null, InputOption::VALUE_OPTIONAL, default: false),
|
||||||
|
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, default: [1, 2, 'banana']),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'var1' => true,
|
||||||
|
'var2' => 'def',
|
||||||
|
'var3' => false,
|
||||||
|
'var4' => [1, 2, 'banana'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'some vars and options' => [
|
||||||
|
'requestVars' => [
|
||||||
|
'var1' => '1',
|
||||||
|
'var2' => 'abcd',
|
||||||
|
'var3' => 2,
|
||||||
|
'var4' => ['a', 'b', 'c'],
|
||||||
|
],
|
||||||
|
'commandOptions' => [
|
||||||
|
new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
|
||||||
|
new InputOption('var2', null, InputOption::VALUE_REQUIRED),
|
||||||
|
new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
|
||||||
|
new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
|
||||||
|
],
|
||||||
|
'expected' => [
|
||||||
|
'var1' => true,
|
||||||
|
'var2' => 'abcd',
|
||||||
|
'var3' => 2,
|
||||||
|
'var4' => ['a', 'b', 'c'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideInputOptions')]
|
||||||
|
public function testInputOptions(array $requestVars, array $commandOptions, array $expected): void
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
|
||||||
|
$input = new HttpRequestInput($request, $commandOptions);
|
||||||
|
|
||||||
|
foreach ($expected as $option => $value) {
|
||||||
|
$this->assertSame($value, $input->getOption($option), 'checking value for ' . $option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no expected values, the success metric is that we didn't throw any exceptions.
|
||||||
|
if (empty($expected)) {
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideGetVerbosity(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'default to normal' => [
|
||||||
|
'requestVars' => [],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_NORMAL,
|
||||||
|
],
|
||||||
|
'shortcuts are ignored' => [
|
||||||
|
'requestVars' => ['v' => 1],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_NORMAL,
|
||||||
|
],
|
||||||
|
'?verbose=1 is verbose' => [
|
||||||
|
'requestVars' => ['verbose' => 1],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_VERBOSE,
|
||||||
|
],
|
||||||
|
'?verbose=2 is very verbose' => [
|
||||||
|
'requestVars' => ['verbose' => 2],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_VERY_VERBOSE,
|
||||||
|
],
|
||||||
|
'?verbose=3 is debug' => [
|
||||||
|
// Check string works as well as int
|
||||||
|
'requestVars' => ['verbose' => '3'],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_DEBUG,
|
||||||
|
],
|
||||||
|
'?quiet=1 is quiet' => [
|
||||||
|
'requestVars' => ['quiet' => 1],
|
||||||
|
'expected' => OutputInterface::VERBOSITY_QUIET,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideGetVerbosity')]
|
||||||
|
public function testGetVerbosity(array $requestVars, int $expected): void
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
|
||||||
|
$input = new HttpRequestInput($request);
|
||||||
|
$this->assertSame($expected, $input->getVerbosity());
|
||||||
|
}
|
||||||
|
}
|
220
tests/php/PolyExecution/PolyOutputTest.php
Normal file
220
tests/php/PolyExecution/PolyOutputTest.php
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\PolyExecution\Tests;
|
||||||
|
|
||||||
|
use LogicException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
use SilverStripe\PolyExecution\PolyOutput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
class PolyOutputTest extends SapphireTest
|
||||||
|
{
|
||||||
|
protected $usesDatabase = false;
|
||||||
|
|
||||||
|
public static function provideWriteForHtml(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'html for html' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'messages' => ['one message', 'two message'],
|
||||||
|
'expected' => "one message<br>\ntwo message<br>\n",
|
||||||
|
],
|
||||||
|
'ansi for html' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'messages' => ['one message', 'two message'],
|
||||||
|
'expected' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideWriteForHtml')]
|
||||||
|
public function testWriteForHtml(
|
||||||
|
string $outputFormat,
|
||||||
|
string|iterable $messages,
|
||||||
|
string $expected
|
||||||
|
): void {
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
|
||||||
|
$output->writeForHtml($messages, true);
|
||||||
|
$this->assertSame($expected, $buffer->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideWriteForAnsi(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'html for ansi' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'messages' => ['one message', 'two message'],
|
||||||
|
'expected' => '',
|
||||||
|
],
|
||||||
|
'ansi for ansi' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'messages' => ['one message', 'two message'],
|
||||||
|
'expected' => "one message\ntwo message\n",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideWriteForAnsi')]
|
||||||
|
public function testWriteForAnsi(
|
||||||
|
string $outputFormat,
|
||||||
|
string|iterable $messages,
|
||||||
|
string $expected
|
||||||
|
): void {
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
|
||||||
|
$output->writeForAnsi($messages, true);
|
||||||
|
$this->assertSame($expected, $buffer->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty list ANSI' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => []
|
||||||
|
],
|
||||||
|
'expected' => '',
|
||||||
|
],
|
||||||
|
'empty list HTML' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => []
|
||||||
|
],
|
||||||
|
'expected' => '<ul></ul>',
|
||||||
|
],
|
||||||
|
'single list UL ANSI' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => ['item 1', 'item 2']
|
||||||
|
],
|
||||||
|
'expected' => <<< EOL
|
||||||
|
* item 1
|
||||||
|
* item 2
|
||||||
|
|
||||||
|
EOL,
|
||||||
|
],
|
||||||
|
'single list OL ANSI' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_ORDERED,
|
||||||
|
'items' => ['item 1', 'item 2']
|
||||||
|
],
|
||||||
|
'expected' => <<< EOL
|
||||||
|
1. item 1
|
||||||
|
2. item 2
|
||||||
|
|
||||||
|
EOL,
|
||||||
|
],
|
||||||
|
'single list UL HTML' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => ['item 1', 'item 2']
|
||||||
|
],
|
||||||
|
'expected' => '<ul><li>item 1</li><li>item 2</li></ul>',
|
||||||
|
],
|
||||||
|
'single list OL HTML' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_ORDERED,
|
||||||
|
'items' => ['item 1', 'item 2']
|
||||||
|
],
|
||||||
|
'expected' => '<ol><li>item 1</li><li>item 2</li></ol>',
|
||||||
|
],
|
||||||
|
'nested list ANSI' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_ANSI,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => [
|
||||||
|
'item 1',
|
||||||
|
'item 2',
|
||||||
|
[
|
||||||
|
'type' => PolyOutput::LIST_ORDERED,
|
||||||
|
'items' => [
|
||||||
|
'item 2a',
|
||||||
|
['item 2b','item 2c'],
|
||||||
|
'item 2d',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'item 3',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'expected' => <<< EOL
|
||||||
|
* item 1
|
||||||
|
* item 2
|
||||||
|
1. item 2a
|
||||||
|
2. item 2b
|
||||||
|
3. item 2c
|
||||||
|
4. item 2d
|
||||||
|
* item 3
|
||||||
|
|
||||||
|
EOL,
|
||||||
|
],
|
||||||
|
'nested list HTML' => [
|
||||||
|
'outputFormat' => PolyOutput::FORMAT_HTML,
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_UNORDERED,
|
||||||
|
'items' => [
|
||||||
|
'item 1',
|
||||||
|
'item 2',
|
||||||
|
'list' => [
|
||||||
|
'type' => PolyOutput::LIST_ORDERED,
|
||||||
|
'items' => [
|
||||||
|
'item 2a',
|
||||||
|
['item 2b','item 2c'],
|
||||||
|
'item 2d',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'item 3',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'expected' => '<ul><li>item 1</li><li>item 2</li><ol><li>item 2a</li><li>item 2b</li><li>item 2c</li><li>item 2d</li></ol><li>item 3</li></ul>',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideList')]
|
||||||
|
public function testList(string $outputFormat, array $list, string $expected): void
|
||||||
|
{
|
||||||
|
$buffer = new BufferedOutput();
|
||||||
|
$output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
|
||||||
|
$this->makeListRecursive($output, $list);
|
||||||
|
$this->assertSame($expected, $buffer->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideListMustBeStarted(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[PolyOutput::FORMAT_ANSI],
|
||||||
|
[PolyOutput::FORMAT_HTML],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideListMustBeStarted')]
|
||||||
|
public function testListMustBeStarted(string $outputFormat): void
|
||||||
|
{
|
||||||
|
$output = new PolyOutput($outputFormat);
|
||||||
|
$this->expectException(LogicException::class);
|
||||||
|
$this->expectExceptionMessage('No lists started. Call startList() first.');
|
||||||
|
$output->writeListItem('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeListRecursive(PolyOutput $output, array $list): void
|
||||||
|
{
|
||||||
|
$output->startList($list['type']);
|
||||||
|
foreach ($list['items'] as $item) {
|
||||||
|
if (isset($item['type'])) {
|
||||||
|
$this->makeListRecursive($output, $item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$output->writeListItem($item);
|
||||||
|
}
|
||||||
|
$output->stopList();
|
||||||
|
}
|
||||||
|
}
|
@ -2367,4 +2367,17 @@ EOC;
|
|||||||
};
|
};
|
||||||
$this->render('$Me', $myArrayData);
|
$this->render('$Me', $myArrayData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLoopingThroughArrayInOverlay(): void
|
||||||
|
{
|
||||||
|
$modelData = new ModelData();
|
||||||
|
$theArray = [
|
||||||
|
['Val' => 'one'],
|
||||||
|
['Val' => 'two'],
|
||||||
|
['Val' => 'red'],
|
||||||
|
['Val' => 'blue'],
|
||||||
|
];
|
||||||
|
$output = $modelData->renderWith('SSViewerTestLoopArray', ['MyArray' => $theArray]);
|
||||||
|
$this->assertEqualIgnoringWhitespace('one two red blue', $output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
<% loop $MyArray %>
|
||||||
|
$Val
|
||||||
|
<% end_loop %>
|
Loading…
Reference in New Issue
Block a user