暂存
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
build
|
||||
vendor
|
||||
.idea
|
||||
.vscode
|
||||
.phpunit*
|
||||
composer.lock
|
||||
Vendored
+1199
File diff suppressed because it is too large
Load Diff
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "webman/console",
|
||||
"type": "library",
|
||||
"keywords": [
|
||||
"webman console"
|
||||
],
|
||||
"homepage": "http://www.workerman.net",
|
||||
"license": "MIT",
|
||||
"description": "Webman console",
|
||||
"authors": [
|
||||
{
|
||||
"name": "walkor",
|
||||
"email": "walkor@workerman.net",
|
||||
"homepage": "http://www.workerman.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"email": "walkor@workerman.net",
|
||||
"issues": "https://github.com/webman-php/console/issues",
|
||||
"forum": "http://www.workerman.net/questions",
|
||||
"wiki": "http://www.workerman.net/doc/webman",
|
||||
"source": "https://github.com/webman-php/console"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/console": "^6.1 || ^7.0",
|
||||
"doctrine/inflector": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webman\\Console\\" : "src"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"workerman/webman": "^2.1"
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
namespace Webman\Console;
|
||||
|
||||
use support\Container;
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
use Webman\App;
|
||||
use Webman\Config;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Protocols\Http;
|
||||
use Workerman\Worker;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
ini_set('display_errors', 'on');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// 为了兼容老版保留的类,后续可以删除
|
||||
class Application
|
||||
{
|
||||
public static function run()
|
||||
{
|
||||
$runtime_logs_path = runtime_path() . DIRECTORY_SEPARATOR . 'logs';
|
||||
if ( !file_exists($runtime_logs_path) || !is_dir($runtime_logs_path) ) {
|
||||
if (!mkdir($runtime_logs_path,0777,true)) {
|
||||
throw new \RuntimeException("Failed to create runtime logs directory. Please check the permission.");
|
||||
}
|
||||
}
|
||||
|
||||
$runtime_views_path = runtime_path() . DIRECTORY_SEPARATOR . 'views';
|
||||
if ( !file_exists($runtime_views_path) || !is_dir($runtime_views_path) ) {
|
||||
if (!mkdir($runtime_views_path,0777,true)) {
|
||||
throw new \RuntimeException("Failed to create runtime views directory. Please check the permission.");
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
|
||||
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
|
||||
Dotenv::createUnsafeImmutable(base_path())->load();
|
||||
} else {
|
||||
Dotenv::createMutable(base_path())->load();
|
||||
}
|
||||
}
|
||||
|
||||
Config::reload(config_path(), ['route', 'container']);
|
||||
|
||||
Worker::$onMasterReload = function (){
|
||||
if (function_exists('opcache_get_status')) {
|
||||
if ($status = opcache_get_status()) {
|
||||
if (isset($status['scripts']) && $scripts = $status['scripts']) {
|
||||
foreach (array_keys($scripts) as $file) {
|
||||
opcache_invalidate($file, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$config = config('server');
|
||||
Worker::$pidFile = $config['pid_file'];
|
||||
Worker::$stdoutFile = $config['stdout_file'];
|
||||
Worker::$logFile = $config['log_file'];
|
||||
Worker::$eventLoopClass = $config['event_loop'] ?? '';
|
||||
TcpConnection::$defaultMaxPackageSize = $config['max_package_size'] ?? 10*1024*1024;
|
||||
if (property_exists(Worker::class, 'statusFile')) {
|
||||
Worker::$statusFile = $config['status_file'] ?? '';
|
||||
}
|
||||
if (property_exists(Worker::class, 'stopTimeout')) {
|
||||
Worker::$stopTimeout = $config['stop_timeout'] ?? 2;
|
||||
}
|
||||
|
||||
if ($config['listen']) {
|
||||
$worker = new Worker($config['listen'], $config['context']);
|
||||
$property_map = [
|
||||
'name',
|
||||
'count',
|
||||
'user',
|
||||
'group',
|
||||
'reusePort',
|
||||
'transport',
|
||||
'protocol'
|
||||
];
|
||||
foreach ($property_map as $property) {
|
||||
if (isset($config[$property])) {
|
||||
$worker->$property = $config[$property];
|
||||
}
|
||||
}
|
||||
|
||||
$worker->onWorkerStart = function ($worker) {
|
||||
require_once base_path() . '/support/bootstrap.php';
|
||||
$app = new App($worker, Container::instance(), Log::channel('default'), app_path(), public_path());
|
||||
Http::requestClass(config('app.request_class', config('server.request_class', Request::class)));
|
||||
$worker->onMessage = [$app, 'onMessage'];
|
||||
};
|
||||
}
|
||||
|
||||
// Windows does not support custom processes.
|
||||
if (\DIRECTORY_SEPARATOR === '/') {
|
||||
foreach (config('process', []) as $process_name => $config) {
|
||||
// Remove monitor process.
|
||||
if (class_exists(\Phar::class, false) && \Phar::running() && 'monitor' === $process_name) {
|
||||
continue;
|
||||
}
|
||||
worker_start($process_name, $config);
|
||||
}
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
foreach ($projects as $name => $project) {
|
||||
foreach ($project['process'] ?? [] as $process_name => $config) {
|
||||
worker_start("plugin.$firm.$name.$process_name", $config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Worker::runAll();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command as Commands;
|
||||
use support\Container;
|
||||
|
||||
class Command extends Application
|
||||
{
|
||||
public function installInternalCommands()
|
||||
{
|
||||
$this->installCommands(__DIR__ . '/Commands', 'Webman\Console\Commands');
|
||||
}
|
||||
|
||||
public function installCommands($path, $namespace = 'app\command')
|
||||
{
|
||||
$dir_iterator = new \RecursiveDirectoryIterator($path);
|
||||
$iterator = new \RecursiveIteratorIterator($dir_iterator);
|
||||
foreach ($iterator as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
if (strpos($file->getFilename(), '.') === 0) {
|
||||
continue;
|
||||
}
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
// abc\def.php
|
||||
$relativePath = str_replace(str_replace('/', '\\', $path . '\\'), '', str_replace('/', '\\', $file->getRealPath()));
|
||||
// app\command\abc
|
||||
$realNamespace = trim($namespace . '\\' . trim(dirname(str_replace('\\', DIRECTORY_SEPARATOR, $relativePath)), '.'), '\\');
|
||||
$realNamespace = str_replace('/', '\\', $realNamespace);
|
||||
// app\command\doc\def
|
||||
$class_name = trim($realNamespace . '\\' . $file->getBasename('.php'), '\\');
|
||||
if (!class_exists($class_name) || !is_a($class_name, Commands::class, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createCommandInstance($class_name);
|
||||
}
|
||||
}
|
||||
|
||||
public function createCommandInstance($class_name)
|
||||
{
|
||||
$reflection = new \ReflectionClass($class_name);
|
||||
if ($reflection->isAbstract()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attributes = $reflection->getAttributes(AsCommand::class);
|
||||
if (!empty($attributes)) {
|
||||
$properties = current($attributes)->newInstance();
|
||||
$name = $properties->name;
|
||||
$description = $properties->description;
|
||||
} else {
|
||||
$properties = $reflection->getStaticProperties();
|
||||
$name = $properties['defaultName'] ?? null;
|
||||
if (!$name) {
|
||||
throw new RuntimeException("Command {$class_name} has no defaultName");
|
||||
}
|
||||
$description = $properties['defaultDescription'] ?? null;
|
||||
}
|
||||
$command = Container::get($class_name);
|
||||
$command->setName($name);
|
||||
if ($description) {
|
||||
$command->setDescription($description);
|
||||
}
|
||||
$this->add($command);
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
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;
|
||||
use Webman\Console\Commands\Concerns\AppPluginCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('app-plugin:create', 'Create App Plugin')]
|
||||
class AppPluginCreateCommand extends Command
|
||||
{
|
||||
use AppPluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getAppPluginCreateHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $this->normalizeAppPluginName($input->getArgument('name'));
|
||||
$this->writeln($output, $this->msg('create_title', ['{name}' => $name]));
|
||||
|
||||
if (!$this->isValidAppPluginName($name)) {
|
||||
$this->writeln($output, $this->msg('bad_name', ['{name}' => $name]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$pluginBase = $this->appPluginBasePath($name);
|
||||
if (is_dir($pluginBase)) {
|
||||
$this->writeln($output, $this->msg('dir_exists', ['{path}' => $this->toRelativePath($pluginBase)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->createAll($name, $output);
|
||||
} catch (\Throwable $e) {
|
||||
$this->writeln($output, $this->msg('failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('done'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createAll($name, OutputInterface $output): void
|
||||
{
|
||||
$base = $this->appPluginBasePath($name);
|
||||
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'controller', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'model', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'middleware', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR . 'index', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'config', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'public', 0777, true, $output);
|
||||
$this->mkdir($base . DIRECTORY_SEPARATOR . 'api', 0777, true, $output);
|
||||
|
||||
$this->createFunctionsFile($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'functions.php', $output);
|
||||
$this->createControllerFile($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'controller' . DIRECTORY_SEPARATOR . 'IndexController.php', $name, $output);
|
||||
$this->createViewFile($base . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR . 'index' . DIRECTORY_SEPARATOR . 'index.html', $output);
|
||||
$this->createConfigFiles($base . DIRECTORY_SEPARATOR . 'config', $name, $output);
|
||||
$this->createApiFiles($base . DIRECTORY_SEPARATOR . 'api', $name, $output);
|
||||
$this->createInstallSqlFile($base . DIRECTORY_SEPARATOR . 'install.sql', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $path
|
||||
* @param int $mode
|
||||
* @param bool $recursive
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function mkdir($path, int $mode, bool $recursive, OutputInterface $output): void
|
||||
{
|
||||
if (is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
if (!mkdir($path, $mode, $recursive) && !is_dir($path)) {
|
||||
throw new \RuntimeException("Unable to create directory: $path");
|
||||
}
|
||||
$this->writeln($output, $this->msg('created_dir', ['{path}' => $this->toRelativePath($path)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $path
|
||||
* @param $name
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createControllerFile($path, $name, OutputInterface $output): void
|
||||
{
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace plugin\\$name\\app\\controller;
|
||||
|
||||
use support\\Request;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('index/index', ['name' => '$name']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
$this->writeFile($path, $content, $output);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $path
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createViewFile($path, OutputInterface $output): void
|
||||
{
|
||||
$content = <<<EOF
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="/favicon.ico"/>
|
||||
<title>webman app plugin</title>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
hello <?=htmlspecialchars(\$name)?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
EOF;
|
||||
$this->writeFile($path, $content, $output);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param $file
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createFunctionsFile($file, OutputInterface $output): void
|
||||
{
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
EOF;
|
||||
$this->writeFile($file, $content, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $base
|
||||
* @param $name
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createApiFiles($base, $name, OutputInterface $output): void
|
||||
{
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace plugin\\$name\\api;
|
||||
|
||||
use plugin\admin\api\Menu;
|
||||
use support\Db;
|
||||
use Throwable;
|
||||
|
||||
class Install
|
||||
{
|
||||
|
||||
/**
|
||||
* 数据库连接
|
||||
*/
|
||||
protected static \$connection = 'plugin.admin.mysql';
|
||||
|
||||
/**
|
||||
* 安装
|
||||
*
|
||||
* @param \$version
|
||||
* @return void
|
||||
*/
|
||||
public static function install(\$version)
|
||||
{
|
||||
// 安装数据库
|
||||
static::installSql();
|
||||
// 导入菜单
|
||||
if(\$menus = static::getMenus()) {
|
||||
Menu::import(\$menus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载
|
||||
*
|
||||
* @param \$version
|
||||
* @return void
|
||||
*/
|
||||
public static function uninstall(\$version)
|
||||
{
|
||||
// 删除菜单
|
||||
foreach (static::getMenus() as \$menu) {
|
||||
Menu::delete(\$menu['key']);
|
||||
}
|
||||
// 卸载数据库
|
||||
static::uninstallSql();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*
|
||||
* @param \$from_version
|
||||
* @param \$to_version
|
||||
* @param \$context
|
||||
* @return void
|
||||
*/
|
||||
public static function update(\$from_version, \$to_version, \$context = null)
|
||||
{
|
||||
// 删除不用的菜单
|
||||
if (isset(\$context['previous_menus'])) {
|
||||
static::removeUnnecessaryMenus(\$context['previous_menus']);
|
||||
}
|
||||
// 安装数据库
|
||||
static::installSql();
|
||||
// 导入新菜单
|
||||
if (\$menus = static::getMenus()) {
|
||||
Menu::import(\$menus);
|
||||
}
|
||||
// 执行更新操作
|
||||
\$update_file = __DIR__ . '/../update.php';
|
||||
if (is_file(\$update_file)) {
|
||||
include \$update_file;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前数据收集等
|
||||
*
|
||||
* @param \$from_version
|
||||
* @param \$to_version
|
||||
* @return array|array[]
|
||||
*/
|
||||
public static function beforeUpdate(\$from_version, \$to_version)
|
||||
{
|
||||
// 在更新之前获得老菜单,通过context传递给 update
|
||||
return ['previous_menus' => static::getMenus()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单
|
||||
*
|
||||
* @return array|mixed
|
||||
*/
|
||||
public static function getMenus()
|
||||
{
|
||||
clearstatcache();
|
||||
if (is_file(\$menu_file = __DIR__ . '/../config/menu.php')) {
|
||||
\$menus = include \$menu_file;
|
||||
return \$menus ?: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除不需要的菜单
|
||||
*
|
||||
* @param \$previous_menus
|
||||
* @return void
|
||||
*/
|
||||
public static function removeUnnecessaryMenus(\$previous_menus)
|
||||
{
|
||||
\$menus_to_remove = array_diff(Menu::column(\$previous_menus, 'name'), Menu::column(static::getMenus(), 'name'));
|
||||
foreach (\$menus_to_remove as \$name) {
|
||||
Menu::delete(\$name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装SQL
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function installSql()
|
||||
{
|
||||
static::importSql(__DIR__ . '/../install.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载SQL
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function uninstallSql() {
|
||||
// 如果卸载数据库文件存在责直接使用
|
||||
\$uninstallSqlFile = __DIR__ . '/../uninstall.sql';
|
||||
if (is_file(\$uninstallSqlFile)) {
|
||||
static::importSql(\$uninstallSqlFile);
|
||||
return;
|
||||
}
|
||||
// 否则根据install.sql生成卸载数据库文件uninstall.sql
|
||||
\$installSqlFile = __DIR__ . '/../install.sql';
|
||||
if (!is_file(\$installSqlFile)) {
|
||||
return;
|
||||
}
|
||||
\$installSql = file_get_contents(\$installSqlFile);
|
||||
preg_match_all('/CREATE TABLE `(.+?)`/si', \$installSql, \$matches);
|
||||
\$dropSql = '';
|
||||
foreach (\$matches[1] as \$table) {
|
||||
\$dropSql .= "DROP TABLE IF EXISTS `\$table`;\\n";
|
||||
}
|
||||
file_put_contents(\$uninstallSqlFile, \$dropSql);
|
||||
static::importSql(\$uninstallSqlFile);
|
||||
unlink(\$uninstallSqlFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入数据库
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function importSql(\$mysqlDumpFile)
|
||||
{
|
||||
if (!\$mysqlDumpFile || !is_file(\$mysqlDumpFile)) {
|
||||
return;
|
||||
}
|
||||
foreach (explode(';', file_get_contents(\$mysqlDumpFile)) as \$sql) {
|
||||
if (\$sql = trim(\$sql)) {
|
||||
try {
|
||||
Db::connection(static::\$connection)->statement(\$sql);
|
||||
} catch (Throwable \$e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
EOF;
|
||||
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'Install.php', $content, $output);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $file
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createInstallSqlFile($file, OutputInterface $output): void
|
||||
{
|
||||
$this->writeFile($file, '', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $base
|
||||
* @param $name
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createConfigFiles($base, $name, OutputInterface $output): void
|
||||
{
|
||||
// app.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
use support\\Request;
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
'controller_suffix' => 'Controller',
|
||||
'controller_reuse' => false,
|
||||
'version' => '1.0.0'
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'app.php', $content, $output);
|
||||
|
||||
// menu.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'menu.php', $content, $output);
|
||||
|
||||
// autoload.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
return [
|
||||
'files' => [
|
||||
base_path() . '/plugin/$name/app/functions.php',
|
||||
]
|
||||
];
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'autoload.php', $content, $output);
|
||||
|
||||
// container.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
return new Webman\\Container;
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'container.php', $content, $output);
|
||||
|
||||
|
||||
// database.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
return [];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'database.php', $content, $output);
|
||||
|
||||
// exception.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [
|
||||
'' => support\\exception\\Handler::class,
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'exception.php', $content, $output);
|
||||
|
||||
// log.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\\Handler\\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/$name.log',
|
||||
7,
|
||||
Monolog\\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\\Formatter\\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'log.php', $content, $output);
|
||||
|
||||
// middleware.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [
|
||||
'' => [
|
||||
|
||||
]
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'middleware.php', $content, $output);
|
||||
|
||||
// process.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
return [];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'process.php', $content, $output);
|
||||
|
||||
// redis.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
return [
|
||||
'default' => [
|
||||
'host' => '127.0.0.1',
|
||||
'password' => null,
|
||||
'port' => 6379,
|
||||
'database' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'redis.php', $content, $output);
|
||||
|
||||
// route.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
use Webman\\Route;
|
||||
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'route.php', $content, $output);
|
||||
|
||||
// static.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'middleware' => [], // Static file Middleware
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'static.php', $content, $output);
|
||||
|
||||
// translation.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Default language
|
||||
'locale' => 'zh_CN',
|
||||
// Fallback language
|
||||
'fallback_locale' => ['zh_CN', 'en'],
|
||||
// Folder where language files are stored
|
||||
'path' => base_path() . "/plugin/$name/resource/translations",
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'translation.php', $content, $output);
|
||||
|
||||
// view.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
use support\\view\\Raw;
|
||||
use support\\view\\Twig;
|
||||
use support\\view\\Blade;
|
||||
use support\\view\\ThinkPHP;
|
||||
|
||||
return [
|
||||
'handler' => Raw::class
|
||||
];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'view.php', $content, $output);
|
||||
|
||||
// thinkorm.php
|
||||
$content = <<<EOF
|
||||
<?php
|
||||
|
||||
return [];
|
||||
|
||||
EOF;
|
||||
$this->writeFile($base . DIRECTORY_SEPARATOR . 'thinkorm.php', $content, $output);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $file
|
||||
* @param string $content
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function writeFile(string $file, string $content, OutputInterface $output): void
|
||||
{
|
||||
$dir = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException("Unable to create directory: $dir");
|
||||
}
|
||||
}
|
||||
if (file_put_contents($file, $content) === false) {
|
||||
throw new \RuntimeException("Unable to write file: $file");
|
||||
}
|
||||
$this->writeln($output, $this->msg('created_file', ['{path}' => $this->toRelativePath($file)]));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
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;
|
||||
use Webman\Console\Commands\Concerns\AppPluginCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('app-plugin:install', 'Install App Plugin')]
|
||||
class AppPluginInstallCommand extends Command
|
||||
{
|
||||
use AppPluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getAppPluginInstallHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $this->normalizeAppPluginName($input->getArgument('name'));
|
||||
$this->writeln($output, $this->msg('install_title', ['{name}' => $name]));
|
||||
|
||||
if (!$this->isValidAppPluginName($name)) {
|
||||
$this->writeln($output, $this->msg('bad_name', ['{name}' => $name]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$pluginBase = $this->appPluginBasePath($name);
|
||||
if (!is_dir($pluginBase)) {
|
||||
$this->writeln($output, $this->msg('plugin_not_exists', ['{path}' => $this->toRelativePath($pluginBase)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$class = $this->appPluginInstallClass($name);
|
||||
try {
|
||||
$version = $this->appPluginVersion($name);
|
||||
$this->writeln($output, $this->msg('running', [
|
||||
'{class}' => $class,
|
||||
'{method}' => 'install',
|
||||
'{args}' => var_export([$version], true),
|
||||
]));
|
||||
$this->callInstallMethod($class, 'install', [$version]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->isScriptMissingThrowable($e)) {
|
||||
$this->writeln($output, $this->msg('script_missing', ['{class}' => $class]));
|
||||
}
|
||||
$this->writeln($output, $this->msg('failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('done'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Webman\Console\Commands\Concerns\AppPluginCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('app-plugin:uninstall', 'App Plugin Uninstall')]
|
||||
class AppPluginUninstallCommand extends Command
|
||||
{
|
||||
use AppPluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('description_name'));
|
||||
$this->addOption('yes', 'y', InputOption::VALUE_NONE, $this->msg('description_yes'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getAppPluginUninstallHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $this->normalizeAppPluginName($input->getArgument('name'));
|
||||
$this->writeln($output, $this->msg('uninstall_title', ['{name}' => $name]));
|
||||
|
||||
if (!$this->isValidAppPluginName($name)) {
|
||||
$this->writeln($output, $this->msg('bad_name', ['{name}' => $name]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$pluginBase = $this->appPluginBasePath($name);
|
||||
if (!is_dir($pluginBase)) {
|
||||
$this->writeln($output, $this->msg('plugin_not_exists', ['{path}' => $this->toRelativePath($pluginBase)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!(bool)$input->getOption('yes')) {
|
||||
$question = new ConfirmationQuestion($this->msg('uninstall_confirm'), false);
|
||||
if (!$this->askOrAbort($input, $output, $question)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$class = $this->appPluginInstallClass($name);
|
||||
try {
|
||||
$version = $this->appPluginVersion($name);
|
||||
$this->writeln($output, $this->msg('running', [
|
||||
'{class}' => $class,
|
||||
'{method}' => 'uninstall',
|
||||
'{args}' => var_export([$version], true),
|
||||
]));
|
||||
$this->callInstallMethod($class, 'uninstall', [$version]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->isScriptMissingThrowable($e)) {
|
||||
$this->writeln($output, $this->msg('script_missing', ['{class}' => $class]));
|
||||
}
|
||||
$this->writeln($output, $this->msg('failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('done'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\AppPluginCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('app-plugin:update', 'Update App Plugin')]
|
||||
class AppPluginUpdateCommand extends Command
|
||||
{
|
||||
use AppPluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('description_name'));
|
||||
$this->addOption('from', 'f', InputOption::VALUE_REQUIRED, $this->msg('description_from'));
|
||||
$this->addOption('to', 't', InputOption::VALUE_REQUIRED, $this->msg('description_to'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getAppPluginUpdateHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $this->normalizeAppPluginName($input->getArgument('name'));
|
||||
$this->writeln($output, $this->msg('update_title', ['{name}' => $name]));
|
||||
|
||||
if (!$this->isValidAppPluginName($name)) {
|
||||
$this->writeln($output, $this->msg('bad_name', ['{name}' => $name]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$pluginBase = $this->appPluginBasePath($name);
|
||||
if (!is_dir($pluginBase)) {
|
||||
$this->writeln($output, $this->msg('plugin_not_exists', ['{path}' => $this->toRelativePath($pluginBase)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$current = $this->appPluginVersion($name);
|
||||
$from = trim((string)$input->getOption('from'));
|
||||
$to = trim((string)$input->getOption('to'));
|
||||
$from = $from !== '' ? $from : $current;
|
||||
$to = $to !== '' ? $to : $current;
|
||||
|
||||
if ($from === $to) {
|
||||
$this->writeln($output, $this->msg('version_same', ['{version}' => $from]));
|
||||
}
|
||||
|
||||
$class = $this->appPluginInstallClass($name);
|
||||
try {
|
||||
$context = null;
|
||||
if (method_exists($class, 'beforeUpdate')) {
|
||||
$this->writeln($output, $this->msg('running', [
|
||||
'{class}' => $class,
|
||||
'{method}' => 'beforeUpdate',
|
||||
'{args}' => var_export([$from, $to], true),
|
||||
]));
|
||||
$context = $this->callInstallMethod($class, 'beforeUpdate', [$from, $to]);
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('running', [
|
||||
'{class}' => $class,
|
||||
'{method}' => 'update',
|
||||
'{args}' => var_export([$from, $to, $context], true),
|
||||
]));
|
||||
$this->callInstallMethod($class, 'update', [$from, $to, $context]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->isScriptMissingThrowable($e)) {
|
||||
$this->writeln($output, $this->msg('script_missing', ['{class}' => $class]));
|
||||
}
|
||||
$this->writeln($output, $this->msg('failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('done'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
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;
|
||||
use Webman\Console\Commands\Concerns\AppPluginCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
use ZipArchive;
|
||||
use Exception;
|
||||
use RecursiveIteratorIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
|
||||
#[AsCommand('app-plugin:zip', 'App Plugin Zip')]
|
||||
class AppPluginZipCommand extends Command
|
||||
{
|
||||
use AppPluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getAppPluginZipHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $this->normalizeAppPluginName($input->getArgument('name'));
|
||||
$this->writeln($output, $this->msg('zip_title', ['{name}' => $name]));
|
||||
|
||||
if (!$this->isValidAppPluginName($name)) {
|
||||
$this->writeln($output, $this->msg('bad_name', ['{name}' => $name]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sourceDir = $this->appPluginBasePath($name);
|
||||
$zipFilePath = base_path('plugin' . DIRECTORY_SEPARATOR . $name . '.zip');
|
||||
if (!is_dir($sourceDir)) {
|
||||
$this->writeln($output, $this->msg('plugin_not_exists', ['{path}' => $this->toRelativePath($sourceDir)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (is_file($zipFilePath)) {
|
||||
if (!@unlink($zipFilePath) && is_file($zipFilePath)) {
|
||||
$this->writeln($output, $this->msg('zip_delete_failed', ['{path}' => $this->toRelativePath($zipFilePath)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$excludePaths = ['node_modules', '.git', '.idea', '.vscode', '__pycache__'];
|
||||
|
||||
try {
|
||||
$this->zipDirectory($name, $sourceDir, $zipFilePath, $excludePaths);
|
||||
} catch (\Throwable $e) {
|
||||
$this->writeln($output, $this->msg('failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->writeln($output, $this->msg('zip_saved', ['{path}' => $this->toRelativePath($zipFilePath)]));
|
||||
$this->writeln($output, $this->msg('done'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $sourceDir
|
||||
* @param $zipFilePath
|
||||
* @param array $excludePaths
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function zipDirectory($name, $sourceDir, $zipFilePath, array $excludePaths = []): bool
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
throw new Exception($this->msg('zip_open_failed', ['{path}' => $zipFilePath]));
|
||||
}
|
||||
|
||||
$rawSourceDir = $sourceDir;
|
||||
$sourceDir = realpath($sourceDir);
|
||||
if ($sourceDir === false) {
|
||||
throw new Exception($this->msg('source_not_exists', ['{path}' => $rawSourceDir]));
|
||||
}
|
||||
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!$file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
// 关键修复:统一使用正斜杠 '/',避免 Windows 反斜杠污染 ZIP
|
||||
$relativePath = $name . '/' . str_replace('\\', '/', substr($filePath, strlen($sourceDir) + 1));
|
||||
|
||||
// 修正排除目录的判断逻辑,确保所有层级都能排除
|
||||
$shouldExclude = false;
|
||||
foreach ($excludePaths as $excludePath) {
|
||||
// 统一路径分隔符为正斜杠,兼容 Windows
|
||||
$normalizedRelativePath = str_replace('\\', '/', $relativePath);
|
||||
$normalizedExcludePath = str_replace('\\', '/', $excludePath);
|
||||
if (preg_match('#/(?:' . preg_quote($normalizedExcludePath, '#') . ')(/|$)#i', $normalizedRelativePath)) {
|
||||
$shouldExclude = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($shouldExclude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return $zip->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use ZipArchive;
|
||||
|
||||
#[AsCommand('build:bin', 'build bin')]
|
||||
class BuildBinCommand extends BuildPharCommand
|
||||
{
|
||||
protected string $binFileName;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->binFileName = config('plugin.webman.console.app.bin_filename', 'webman.bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('version', InputArgument::OPTIONAL, $this->msg('arg_version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->checkEnv();
|
||||
|
||||
$output->writeln($this->msg('phar_packing'));
|
||||
|
||||
$version = $input->getArgument('version');
|
||||
if (!$version) {
|
||||
$version = (float)PHP_VERSION;
|
||||
}
|
||||
$version = max($version, 8.1);
|
||||
$supportZip = class_exists(ZipArchive::class);
|
||||
$microZipFileName = $supportZip ? "php$version.micro.sfx.zip" : "php$version.micro.sfx";
|
||||
$customIni = config('plugin.webman.console.app.custom_ini', '');
|
||||
|
||||
$binFile = $this->buildDir. DIRECTORY_SEPARATOR . $this->binFileName;
|
||||
$pharFile = $this->buildDir . DIRECTORY_SEPARATOR . $this->getPharFileName();
|
||||
$zipFile = $this->buildDir. DIRECTORY_SEPARATOR . $microZipFileName;
|
||||
$sfxFile = $this->buildDir. DIRECTORY_SEPARATOR . "php$version.micro.sfx";
|
||||
$customIniHeaderFile = $this->buildDir. DIRECTORY_SEPARATOR . "custominiheader.bin";
|
||||
|
||||
// 打包
|
||||
$command = new BuildPharCommand();
|
||||
$command->execute($input, $output);
|
||||
|
||||
// 下载 micro.sfx.zip
|
||||
if (!is_file($sfxFile) && !is_file($zipFile)) {
|
||||
$domain = 'download.workerman.net';
|
||||
$output->writeln($this->msg('downloading_php', ['{version}' => (string)$version]));
|
||||
if (extension_loaded('openssl')) {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
]
|
||||
]);
|
||||
$client = stream_socket_client("ssl://$domain:443", $context);
|
||||
} else {
|
||||
$client = stream_socket_client("tcp://$domain:80");
|
||||
}
|
||||
if (!$client) {
|
||||
$output->writeln($this->msg('download_stream_failed'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
fwrite($client, "GET /php/$microZipFileName HTTP/1.1\r\nAccept: text/html\r\nHost: $domain\r\nUser-Agent: webman/console\r\n\r\n");
|
||||
$bodyLength = 0;
|
||||
$bodyBuffer = '';
|
||||
$lastPercent = 0;
|
||||
while (true) {
|
||||
$buffer = fread($client, 65535);
|
||||
if ($buffer !== false) {
|
||||
$bodyBuffer .= $buffer;
|
||||
if (!$bodyLength && $pos = strpos($bodyBuffer, "\r\n\r\n")) {
|
||||
if (!preg_match('/Content-Length: (\d+)\r\n/', $bodyBuffer, $match)) {
|
||||
$output->writeln($this->msg('download_failed', ['{message}' => "php{$version}.micro.sfx.zip: missing Content-Length"]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$firstLine = substr($bodyBuffer, 9, strpos($bodyBuffer, "\r\n") - 9);
|
||||
if (!preg_match('/200 /', $bodyBuffer)) {
|
||||
$output->writeln($this->msg('download_failed', ['{message}' => "php{$version}.micro.sfx.zip: {$firstLine}"]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$bodyLength = (int)$match[1];
|
||||
$bodyBuffer = substr($bodyBuffer, $pos + 4);
|
||||
}
|
||||
}
|
||||
$receiveLength = strlen($bodyBuffer);
|
||||
$percent = ceil($receiveLength * 100 / $bodyLength);
|
||||
if ($percent != $lastPercent) {
|
||||
echo '[' . str_pad('', $percent, '=') . '>' . str_pad('', 100 - $percent) . "$percent%]";
|
||||
echo $percent < 100 ? "\r" : "\n";
|
||||
}
|
||||
$lastPercent = $percent;
|
||||
if ($bodyLength && $receiveLength >= $bodyLength) {
|
||||
file_put_contents($zipFile, $bodyBuffer);
|
||||
break;
|
||||
}
|
||||
if ($buffer === false || !is_resource($client) || feof($client)) {
|
||||
$output->writeln($this->msg('download_failed', ['{message}' => "PHP{$version}"]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$output->writeln($this->msg('use_php', ['{version}' => (string)$version]));
|
||||
}
|
||||
|
||||
// 解压
|
||||
if (!is_file($sfxFile) && $supportZip) {
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($zipFile, ZipArchive::CHECKCONS);
|
||||
$zip->extractTo($this->buildDir);
|
||||
}
|
||||
|
||||
// 生成二进制文件
|
||||
file_put_contents($binFile, file_get_contents($sfxFile));
|
||||
// 自定义INI
|
||||
if (!empty($customIni)) {
|
||||
if (file_exists($customIniHeaderFile)) {
|
||||
unlink($customIniHeaderFile);
|
||||
}
|
||||
$f = fopen($customIniHeaderFile, 'wb');
|
||||
fwrite($f, "\xfd\xf6\x69\xe6");
|
||||
fwrite($f, pack('N', strlen($customIni)));
|
||||
fwrite($f, $customIni);
|
||||
fclose($f);
|
||||
file_put_contents($binFile, file_get_contents($customIniHeaderFile),FILE_APPEND);
|
||||
unlink($customIniHeaderFile);
|
||||
}
|
||||
file_put_contents($binFile, file_get_contents($pharFile), FILE_APPEND);
|
||||
|
||||
// 添加执行权限
|
||||
chmod($binFile, 0755);
|
||||
|
||||
$output->writeln($this->msg('saved_bin', ['{name}' => $this->binFileName, '{path}' => $binFile]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Phar;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
#[AsCommand('build:phar', 'Can be easily packaged a project into phar files. Easy to distribute and use.')]
|
||||
class BuildPharCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
protected string $pharFileName;
|
||||
|
||||
protected string $buildDir;
|
||||
|
||||
protected int $pharFormat;
|
||||
|
||||
protected int $pharCompression;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->pharFileName = config('plugin.webman.console.app.phar_filename', 'webman.phar');
|
||||
$this->buildDir = rtrim(config('plugin.webman.console.app.build_dir', base_path() . '/build'), DIRECTORY_SEPARATOR);
|
||||
$this->pharFormat = config('plugin.webman.console.app.phar_format', Phar::PHAR);
|
||||
$this->pharCompression = config('plugin.webman.console.app.phar_compression', Phar::NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->checkEnv();
|
||||
if (!file_exists($this->buildDir) && !is_dir($this->buildDir)) {
|
||||
if (!mkdir($this->buildDir,0777,true)) {
|
||||
throw new RuntimeException($this->err('mkdir_build_dir_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
$phar_file = $this->buildDir . DIRECTORY_SEPARATOR . $this->getPharFileName();
|
||||
if (file_exists($phar_file)) {
|
||||
unlink($phar_file);
|
||||
}
|
||||
|
||||
$exclude_pattern = config('plugin.webman.console.app.exclude_pattern','');
|
||||
|
||||
$phar = new Phar($this->buildDir . DIRECTORY_SEPARATOR . $this->pharFileName,0 , 'webman');
|
||||
if(!str_ends_with($this->getPharFileName(), '.phar')) {
|
||||
$phar = $phar->convertToExecutable($this->pharFormat, $this->pharCompression);
|
||||
}
|
||||
|
||||
$phar->startBuffering();
|
||||
|
||||
$signature_algorithm = config('plugin.webman.console.app.signature_algorithm');
|
||||
if (!in_array($signature_algorithm,[Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512,Phar::OPENSSL])) {
|
||||
throw new RuntimeException($this->err('bad_signature_algorithm'));
|
||||
}
|
||||
if ($signature_algorithm === Phar::OPENSSL) {
|
||||
$private_key_file = config('plugin.webman.console.app.private_key_file');
|
||||
if (!file_exists($private_key_file)) {
|
||||
throw new RuntimeException($this->err('openssl_private_key_missing'));
|
||||
}
|
||||
$private = openssl_get_privatekey(file_get_contents($private_key_file));
|
||||
$pkey = '';
|
||||
openssl_pkey_export($private, $pkey);
|
||||
!$phar->getSignature() && $phar->setSignatureAlgorithm($signature_algorithm, $pkey);
|
||||
} else {
|
||||
!$phar->getSignature() && $phar->setSignatureAlgorithm($signature_algorithm);
|
||||
}
|
||||
|
||||
$phar->buildFromDirectory(BASE_PATH,$exclude_pattern);
|
||||
|
||||
|
||||
$exclude_files = config('plugin.webman.console.app.exclude_files',[]);
|
||||
// 打包生成的phar和bin文件是面向生产环境的,所以以下这些命令没有任何意义,执行的话甚至会出错,需要排除在外。
|
||||
$exclude_command_files = [
|
||||
'AppPluginCreateCommand.php',
|
||||
'BuildBinCommand.php',
|
||||
'BuildPharCommand.php',
|
||||
'MakeBootstrapCommand.php',
|
||||
'MakeCommandCommand.php',
|
||||
'MakeControllerCommand.php',
|
||||
'MakeMiddlewareCommand.php',
|
||||
'MakeModelCommand.php',
|
||||
'PluginCreateCommand.php',
|
||||
'PluginDisableCommand.php',
|
||||
'PluginEnableCommand.php',
|
||||
'PluginExportCommand.php',
|
||||
'PluginInstallCommand.php',
|
||||
'PluginUninstallCommand.php'
|
||||
];
|
||||
$exclude_command_files = array_map(function ($cmd_file) {
|
||||
return 'vendor/webman/console/src/Commands/'.$cmd_file;
|
||||
},$exclude_command_files);
|
||||
$exclude_files = array_unique(array_merge($exclude_command_files,$exclude_files));
|
||||
foreach ($exclude_files as $file) {
|
||||
if($phar->offsetExists($file)){
|
||||
$phar->delete($file);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->pharCompression != Phar::NONE) {
|
||||
$phar->addFromString('vendor/composer/ClassLoader.php', $this->getClassLoaderContents());
|
||||
$phar->addFromString('/vendor/workerman/workerman/src/Worker.php', $this->getWorkerContents());
|
||||
}
|
||||
$output->writeln($this->msg('collect_complete'));
|
||||
|
||||
$phar->setStub("#!/usr/bin/env php
|
||||
<?php
|
||||
define('IN_PHAR', true);
|
||||
Phar::mapPhar('webman');
|
||||
require 'phar://webman/webman';
|
||||
__HALT_COMPILER();
|
||||
");
|
||||
|
||||
$output->writeln($this->msg('write_to_disk'));
|
||||
|
||||
$phar->stopBuffering();
|
||||
|
||||
unset($phar);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getBuildMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text error messages for exceptions (bilingual).
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function err(string $key, array $replace = []): string
|
||||
{
|
||||
return $this->msg($key, $replace);
|
||||
}
|
||||
|
||||
protected function getPhpIniDisplayPath(): string
|
||||
{
|
||||
$loaded = php_ini_loaded_file();
|
||||
if (is_string($loaded) && $loaded !== '') {
|
||||
return $loaded;
|
||||
}
|
||||
return $this->msg('ini_not_loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function checkEnv(): void
|
||||
{
|
||||
if (!class_exists(Phar::class, false)) {
|
||||
throw new RuntimeException($this->err('phar_extension_required'));
|
||||
}
|
||||
|
||||
if (ini_get('phar.readonly')) {
|
||||
$command = $this->getName();
|
||||
throw new RuntimeException(
|
||||
$this->err('phar_readonly_on', [
|
||||
'{command}' => (string)$command,
|
||||
'{ini}' => $this->getPhpIniDisplayPath(),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getPharFileName(): string
|
||||
{
|
||||
$phar_filename = $this->pharFileName;
|
||||
if (empty($phar_filename)) {
|
||||
throw new RuntimeException($this->err('phar_filename_required'));
|
||||
}
|
||||
$phar_filename .= match ($this->pharFormat) {
|
||||
Phar::TAR => '.tar',
|
||||
Phar::ZIP => 'zip',
|
||||
default => ''
|
||||
};
|
||||
$phar_filename .= match ($this->pharCompression) {
|
||||
Phar::GZ => '.gz',
|
||||
Phar::BZ2 => '.bz2',
|
||||
default => ''
|
||||
};
|
||||
return $phar_filename;
|
||||
}
|
||||
|
||||
public function getClassLoaderContents(): string
|
||||
{
|
||||
$fileContents = file_get_contents(BASE_PATH . '/vendor/composer/ClassLoader.php');
|
||||
$replaceContents = <<<'PHP'
|
||||
if (str_starts_with($file, 'phar://')) {
|
||||
$lockFile = sys_get_temp_dir() . '/phar_' . md5($file) . '.lock';
|
||||
$fp = fopen($lockFile, 'c');
|
||||
flock($fp, LOCK_EX) && include $file;
|
||||
fclose($fp);
|
||||
file_exists($lockFile) && @unlink($lockFile);
|
||||
} else {
|
||||
include $file;
|
||||
}
|
||||
PHP;
|
||||
return str_replace(' include $file;', $replaceContents, $fileContents);
|
||||
}
|
||||
public function getWorkerContents(): string
|
||||
{
|
||||
$fileContents = file_get_contents(BASE_PATH . '/vendor/workerman/workerman/src/Worker.php');
|
||||
$replaceContents = <<<'PHP'
|
||||
static::forkOneWorkerForLinux($worker); php_sapi_name() == 'micro' && usleep(50000);
|
||||
PHP;
|
||||
return str_replace('static::forkOneWorkerForLinux($worker);', $replaceContents, $fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands\Concerns;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
/**
|
||||
* Helpers for AppPlugin* commands (plugin/<name>).
|
||||
*/
|
||||
trait AppPluginCommandHelpers
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* Prefer admin plugin locale if available, fallback to global translation/app locale.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getLocale(): string
|
||||
{
|
||||
$locale = null;
|
||||
if (function_exists('config')) {
|
||||
$locale = config('plugin.admin.translation.locale')
|
||||
?: config('translation.locale')
|
||||
?: config('app.locale');
|
||||
}
|
||||
|
||||
$locale = is_string($locale) ? trim($locale) : '';
|
||||
if ($locale === '') {
|
||||
$locale = $this->resolveLocaleFromAdminTranslationConfigFile() ?? '';
|
||||
}
|
||||
|
||||
return $locale !== '' ? $locale : Util::getLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve locale from admin plugin translation config file.
|
||||
* This is a fallback when config() is not ready or the admin plugin is not loaded.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
protected function resolveLocaleFromAdminTranslationConfigFile(): ?string
|
||||
{
|
||||
$ds = DIRECTORY_SEPARATOR;
|
||||
$candidates = [
|
||||
base_path('plugin' . $ds . 'admin' . $ds . 'config' . $ds . 'translation.php'),
|
||||
base_path('vendor' . $ds . 'webman' . $ds . 'admin' . $ds . 'src' . $ds . 'plugin' . $ds . 'admin' . $ds . 'config' . $ds . 'translation.php'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $file) {
|
||||
$config = $this->loadPhpConfigArray($file);
|
||||
if ($config === null || $config === []) {
|
||||
continue;
|
||||
}
|
||||
$locale = $config['locale'] ?? null;
|
||||
$locale = is_string($locale) ? trim($locale) : '';
|
||||
if ($locale !== '') {
|
||||
return $locale;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return string
|
||||
*/
|
||||
protected function normalizeAppPluginName(mixed $value): string
|
||||
{
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* App plugin name is a folder name under plugin/<name>.
|
||||
*
|
||||
* @param string $name
|
||||
* @return bool
|
||||
*/
|
||||
protected function isValidAppPluginName(string $name): bool
|
||||
{
|
||||
if ($name === '') {
|
||||
return false;
|
||||
}
|
||||
if (str_contains($name, '/') || str_contains($name, '\\')) {
|
||||
return false;
|
||||
}
|
||||
// Keep it safe for directory/namespace usage.
|
||||
return (bool)preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function appPluginBasePath(string $name): string
|
||||
{
|
||||
return base_path('plugin' . DIRECTORY_SEPARATOR . $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function appPluginInstallClass(string $name): string
|
||||
{
|
||||
return "\\plugin\\{$name}\\api\\Install";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function appPluginVersion(string $name): string
|
||||
{
|
||||
$v = config("plugin.$name.app.version");
|
||||
$v = is_string($v) ? trim($v) : '';
|
||||
return $v !== '' ? $v : '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely call plugin Install static method with signature tolerance.
|
||||
*
|
||||
* @param class-string $class
|
||||
* @param string $method
|
||||
* @param array<int,mixed> $args
|
||||
* @return mixed
|
||||
*/
|
||||
protected function callInstallMethod(string $class, string $method, array $args): mixed
|
||||
{
|
||||
if (!class_exists($class)) {
|
||||
throw new \RuntimeException("Class $class not exists");
|
||||
}
|
||||
if (!method_exists($class, $method)) {
|
||||
throw new \RuntimeException("Method $class::$method not exists");
|
||||
}
|
||||
|
||||
$ref = new \ReflectionMethod($class, $method);
|
||||
$required = $ref->getNumberOfRequiredParameters();
|
||||
$total = $ref->getNumberOfParameters();
|
||||
|
||||
if (count($args) < $required) {
|
||||
throw new \RuntimeException("Method $class::$method requires $required parameter(s)");
|
||||
}
|
||||
|
||||
$useArgs = array_slice($args, 0, $total);
|
||||
return $ref->invokeArgs(null, $useArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Throwable $e
|
||||
* @return bool
|
||||
*/
|
||||
protected function isScriptMissingThrowable(\Throwable $e): bool
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
return str_contains($msg, ' not exists') && (str_starts_with($msg, 'Class ') || str_starts_with($msg, 'Method '));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* CLI messages for app plugin commands. Locale selected by getLocale() / Util fallback.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array<string,string> $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
$localeToMessages = Messages::getAppPluginMessages();
|
||||
$messages = Util::selectLocaleMessages($localeToMessages);
|
||||
$text = $messages[$key] ?? $key;
|
||||
return strtr($text, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OutputInterface $output
|
||||
* @param string $message
|
||||
* @return void
|
||||
*/
|
||||
protected function writeln(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln($message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands\Concerns;
|
||||
|
||||
use Webman\Console\Util;
|
||||
|
||||
/**
|
||||
* Base command helpers for common functionality across all command types
|
||||
*/
|
||||
trait BaseCommandHelpers
|
||||
{
|
||||
/**
|
||||
* Get localized message with optional placeholder replacement
|
||||
*
|
||||
* @param array $messages Localized messages array
|
||||
* @param string $key Message key
|
||||
* @param array $replace Placeholder replacements ['{placeholder}' => 'value']
|
||||
* @return mixed String message or array if message is array type
|
||||
*/
|
||||
protected function getLocalizedMessage(array $messages, string $key, array $replace = []): mixed
|
||||
{
|
||||
$text = $messages[$key] ?? $key;
|
||||
|
||||
if (is_array($text)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return strtr($text, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands\Concerns;
|
||||
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
trait MakeCommandHelpers
|
||||
{
|
||||
/**
|
||||
* Check whether a plugin exists by config value.
|
||||
*
|
||||
* The existence rule is: config("plugin.<name>") is not empty.
|
||||
*
|
||||
* @param string $plugin
|
||||
* @return bool
|
||||
*/
|
||||
protected function pluginExists(string $plugin): bool
|
||||
{
|
||||
$plugin = trim($plugin);
|
||||
if ($plugin === '') {
|
||||
return false;
|
||||
}
|
||||
$cfg = config("plugin.$plugin");
|
||||
if ($cfg === null) {
|
||||
return false;
|
||||
}
|
||||
if (is_array($cfg)) {
|
||||
return $cfg !== [];
|
||||
}
|
||||
if (is_string($cfg)) {
|
||||
return trim($cfg) !== '';
|
||||
}
|
||||
return (bool)$cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plugin name from a relative path like "plugin/<name>/...".
|
||||
*
|
||||
* @param string|null $path
|
||||
* @return string|null
|
||||
*/
|
||||
protected function extractPluginNameFromRelativePath(?string $path): ?string
|
||||
{
|
||||
$path = $this->normalizeOptionValue($path);
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->normalizeRelativePath($path);
|
||||
if (preg_match('#^plugin/([^/]+)/#i', $path, $m)) {
|
||||
$name = trim((string)($m[1] ?? ''));
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate plugin existence and output error message when missing.
|
||||
*
|
||||
* @param string|null $plugin
|
||||
* @param \Symfony\Component\Console\Output\OutputInterface $output
|
||||
* @return bool
|
||||
*/
|
||||
protected function assertPluginExists(?string $plugin, \Symfony\Component\Console\Output\OutputInterface $output): bool
|
||||
{
|
||||
$plugin = $this->normalizeOptionValue($plugin);
|
||||
if (!$plugin) {
|
||||
return true;
|
||||
}
|
||||
if ($this->pluginExists($plugin)) {
|
||||
return true;
|
||||
}
|
||||
$output->writeln($this->renderPluginNotExistsMessage($plugin));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string
|
||||
*/
|
||||
protected function renderPluginNotExistsMessage(string $plugin): string
|
||||
{
|
||||
$plugin = trim($plugin);
|
||||
$line1 = Util::selectByLocale([
|
||||
'zh_CN' => "<error>插件不存在:</error> <comment>{plugin}</comment>",
|
||||
'zh_TW' => "<error>外掛不存在:</error> <comment>{plugin}</comment>",
|
||||
'en' => "<error>Plugin does not exist:</error> <comment>{plugin}</comment>",
|
||||
'ja' => "<error>プラグインが存在しません:</error> <comment>{plugin}</comment>",
|
||||
'ko' => "<error>플러그인이 존재하지 않습니다:</error> <comment>{plugin}</comment>",
|
||||
'fr' => "<error>Le plugin n'existe pas :</error> <comment>{plugin}</comment>",
|
||||
'de' => "<error>Plugin existiert nicht:</error> <comment>{plugin}</comment>",
|
||||
'es' => "<error>El plugin no existe:</error> <comment>{plugin}</comment>",
|
||||
'pt_BR' => "<error>O plugin não existe:</error> <comment>{plugin}</comment>",
|
||||
'ru' => "<error>Плагин не существует:</error> <comment>{plugin}</comment>",
|
||||
'vi' => "<error>Plugin không tồn tại:</error> <comment>{plugin}</comment>",
|
||||
'tr' => "<error>Eklenti mevcut değil:</error> <comment>{plugin}</comment>",
|
||||
'id' => "<error>Plugin tidak ada:</error> <comment>{plugin}</comment>",
|
||||
'th' => "<error>ไม่พบปลั๊กอิน:</error> <comment>{plugin}</comment>",
|
||||
]);
|
||||
$line2 = Util::selectByLocale([
|
||||
'zh_CN' => '请检查插件名是否输入正确,或确认插件已正确安装/启用。',
|
||||
'zh_TW' => '請檢查外掛名稱是否輸入正確,或確認外掛已正確安裝/啟用。',
|
||||
'en' => 'Please check the plugin name, or make sure the plugin is installed/enabled.',
|
||||
'ja' => 'プラグイン名を確認するか、プラグインがインストール/有効化されていることを確認してください。',
|
||||
'ko' => '플러그인 이름을 확인하거나, 플러그인이 설치/활성화되었는지 확인하세요.',
|
||||
'fr' => "Vérifiez le nom du plugin ou assurez-vous qu'il est installé/activé.",
|
||||
'de' => 'Bitte prüfen Sie den Plugin-Namen oder ob das Plugin installiert/aktiviert ist.',
|
||||
'es' => 'Compruebe el nombre del plugin o asegúrese de que esté instalado/habilitado.',
|
||||
'pt_BR' => 'Verifique o nome do plugin ou se o plugin está instalado/ativado.',
|
||||
'ru' => 'Проверьте имя плагина или убедитесь, что плагин установлен/включён.',
|
||||
'vi' => 'Hãy kiểm tra tên plugin hoặc đảm bảo plugin đã được cài đặt/bật.',
|
||||
'tr' => 'Eklenti adını kontrol edin veya eklentinin kurulu/etkin olduğundan emin olun.',
|
||||
'id' => 'Periksa nama plugin atau pastikan plugin sudah terpasang/diaktifkan.',
|
||||
'th' => 'โปรดตรวจสอบชื่อปลั๊กอิน หรือยืนยันว่าปลั๊กอินถูกติดตั้ง/เปิดใช้งานแล้ว',
|
||||
]);
|
||||
return strtr($line1, ['{plugin}' => $plugin]) . "\n" . $line2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symfony short options on some environments may return value like "=foo" for "-p=foo".
|
||||
* Normalize to "foo".
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return string|null
|
||||
*/
|
||||
protected function normalizeOptionValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string)$value);
|
||||
$value = ltrim($value, '=');
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a relative path to use "/" separators and no leading/trailing slashes.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
protected function normalizeRelativePath(string $path): string
|
||||
{
|
||||
$path = trim($path);
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = preg_replace('#^\\./+#', '', $path);
|
||||
$path = trim($path, '/');
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return bool
|
||||
*/
|
||||
protected function isAbsolutePath(string $path): bool
|
||||
{
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
// Windows drive letter, UNC path, or root slash.
|
||||
if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path)) {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($path, '\\\\') || str_starts_with($path, '//')) {
|
||||
return true;
|
||||
}
|
||||
return str_starts_with($path, '/') || str_starts_with($path, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two relative paths on Windows-friendly rules.
|
||||
*
|
||||
* @param string $a
|
||||
* @param string $b
|
||||
* @return bool
|
||||
*/
|
||||
protected function pathsEqual(string $a, string $b): bool
|
||||
{
|
||||
$a = strtolower($this->normalizeRelativePath($a));
|
||||
$b = strtolower($this->normalizeRelativePath($b));
|
||||
return $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute path to a workspace-relative path for nicer CLI output.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
protected function toRelativePath(string $path): string
|
||||
{
|
||||
$base = base_path();
|
||||
$baseNorm = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $base), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
|
||||
if (str_starts_with(strtolower($pathNorm), strtolower($baseNorm))) {
|
||||
$rel = substr($pathNorm, strlen($baseNorm));
|
||||
} else {
|
||||
$rel = $pathNorm;
|
||||
}
|
||||
// Use forward slashes for nicer CLI output.
|
||||
return str_replace(DIRECTORY_SEPARATOR, '/', $rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current locale for CLI messages. Delegates to Util::getLocale().
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getLocale(): string
|
||||
{
|
||||
return Util::getLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question with Ctrl+C safety.
|
||||
*
|
||||
* On Windows, Ctrl+C during a prompt can corrupt the console code page,
|
||||
* causing subsequent UTF-8 output to display as garbled text.
|
||||
* This method restores the code page after every prompt to prevent that.
|
||||
*
|
||||
* @param \Symfony\Component\Console\Input\InputInterface $input
|
||||
* @param \Symfony\Component\Console\Output\OutputInterface $output
|
||||
* @param \Symfony\Component\Console\Question\Question $question
|
||||
* @return mixed
|
||||
*/
|
||||
protected function askOrAbort(
|
||||
\Symfony\Component\Console\Input\InputInterface $input,
|
||||
\Symfony\Component\Console\Output\OutputInterface $output,
|
||||
\Symfony\Component\Console\Question\Question $question
|
||||
): mixed {
|
||||
$cpBefore = function_exists('sapi_windows_cp_get') ? sapi_windows_cp_get() : null;
|
||||
|
||||
/** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
|
||||
$helper = $this->getHelper('question');
|
||||
try {
|
||||
$value = $helper->ask($input, $output, $question);
|
||||
} catch (\Throwable $e) {
|
||||
$this->restoreConsoleCodePage($cpBefore);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->restoreConsoleCodePage($cpBefore);
|
||||
|
||||
if ($value === null || (is_string($value) && str_contains($value, "\x03"))) {
|
||||
exit(130);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore console code page on Windows after an interactive prompt.
|
||||
* Ctrl+C can corrupt the code page; this resets it to what it was before.
|
||||
*/
|
||||
private function restoreConsoleCodePage(?int $codepage): void
|
||||
{
|
||||
if ($codepage !== null && function_exists('sapi_windows_cp_set')) {
|
||||
sapi_windows_cp_set($codepage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve namespace/file path by --plugin/-p or --path/-P.
|
||||
* - --path/-P: must be a relative path (to project root).
|
||||
* - If both are provided, they must point to the same directory, otherwise it's an error.
|
||||
*
|
||||
* @param string $name Name like "admin/User"
|
||||
* @param string|null $plugin
|
||||
* @param string|null $path
|
||||
* @param \Symfony\Component\Console\Output\OutputInterface $output
|
||||
* @param callable(string):string $pluginDefaultPathResolver Returns relative dir like "plugin/admin/app/controller"
|
||||
* @param callable(string,array<string,string>):string $msg Message resolver: fn($key,$replace)=>string
|
||||
* @return array{0:string,1:string,2:string}|null [class, namespace, file]
|
||||
*/
|
||||
protected function resolveTargetByPluginOrPath(
|
||||
string $name,
|
||||
?string $plugin,
|
||||
?string $path,
|
||||
\Symfony\Component\Console\Output\OutputInterface $output,
|
||||
callable $pluginDefaultPathResolver,
|
||||
callable $msg
|
||||
): ?array {
|
||||
$pathNorm = $path ? $this->normalizeRelativePath($path) : null;
|
||||
if ($pathNorm !== null && $this->isAbsolutePath($pathNorm)) {
|
||||
$output->writeln($msg('invalid_path', ['{path}' => (string)$path]));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate plugin existence (from --plugin/-p or inferred from --path/-P).
|
||||
$pluginToCheck = $this->normalizeOptionValue($plugin) ?: $this->extractPluginNameFromRelativePath($pathNorm);
|
||||
if ($pluginToCheck && !$this->assertPluginExists($pluginToCheck, $output)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expected = null;
|
||||
if ($plugin) {
|
||||
$expected = $pluginDefaultPathResolver($plugin);
|
||||
}
|
||||
|
||||
if ($plugin && $pathNorm) {
|
||||
$pluginPrefix = 'plugin/' . $plugin . '/';
|
||||
if (!str_starts_with(strtolower($pathNorm), strtolower($pluginPrefix))) {
|
||||
$output->writeln(strtr(
|
||||
Util::selectByLocale(Messages::getPluginPathConflictMessage()),
|
||||
['{plugin}' => $plugin, '{path}' => $pathNorm]
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$targetRel = $pathNorm ?: $expected;
|
||||
if (!$targetRel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetDir = base_path($targetRel);
|
||||
$namespaceRoot = trim(str_replace('/', '\\', $targetRel), '\\');
|
||||
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$class = ucfirst($name);
|
||||
$subPath = '';
|
||||
} else {
|
||||
$subPath = substr($name, 0, $pos);
|
||||
$class = ucfirst(substr($name, $pos + 1));
|
||||
}
|
||||
|
||||
$subDir = $subPath ? str_replace('/', DIRECTORY_SEPARATOR, $subPath) . DIRECTORY_SEPARATOR : '';
|
||||
$file = $targetDir . DIRECTORY_SEPARATOR . $subDir . $class . '.php';
|
||||
$namespace = $namespaceRoot . ($subPath ? '\\' . str_replace('/', '\\', $subPath) : '');
|
||||
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a php config file (return array).
|
||||
*
|
||||
* @param string $file
|
||||
* @return array|null null when file exists but does not return an array or cannot be included
|
||||
*/
|
||||
protected function loadPhpConfigArray(string $file): ?array
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$data = include $file;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a simple PHP header, preserving the top docblock if present.
|
||||
*
|
||||
* @param string $file
|
||||
* @return string
|
||||
*/
|
||||
protected function getPhpHeaderWithDocblock(string $file): string
|
||||
{
|
||||
$default = "<?php\n\n";
|
||||
if (!is_file($file)) {
|
||||
return $default;
|
||||
}
|
||||
$content = file_get_contents($file);
|
||||
if (!is_string($content) || $content === '') {
|
||||
return $default;
|
||||
}
|
||||
if (preg_match('/\A<\?php\s*(\/\*\*[\s\S]*?\*\/\s*)/i', $content, $m)) {
|
||||
$doc = rtrim($m[1]) . "\n\n";
|
||||
return "<?php\n" . $doc;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure parent directory exists.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
protected function ensureParentDir(string $file): void
|
||||
{
|
||||
$dir = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a class to a flat config list (return [ClassA::class, ...]).
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $classFqn e.g. app\bootstrap\Test
|
||||
* @return bool changed or not
|
||||
*/
|
||||
protected function addClassToFlatClassListConfig(string $file, string $classFqn): bool
|
||||
{
|
||||
$config = $this->loadPhpConfigArray($file);
|
||||
if ($config === null) {
|
||||
return false;
|
||||
}
|
||||
$classFqn = ltrim(trim($classFqn), '\\');
|
||||
if (in_array($classFqn, $config, true)) {
|
||||
return false;
|
||||
}
|
||||
$config[] = $classFqn;
|
||||
$this->ensureParentDir($file);
|
||||
$header = $this->getPhpHeaderWithDocblock($file);
|
||||
$body = $this->renderFlatClassListConfig($config);
|
||||
file_put_contents($file, $header . $body);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a class to middleware config under empty key ''.
|
||||
* Return value format:
|
||||
* return [
|
||||
* '' => [
|
||||
* Foo::class,
|
||||
* ],
|
||||
* ];
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $classFqn e.g. app\middleware\StaticFile
|
||||
* @return bool changed or not
|
||||
*/
|
||||
protected function addClassToMiddlewareConfig(string $file, string $classFqn): bool
|
||||
{
|
||||
$config = $this->loadPhpConfigArray($file);
|
||||
if ($config === null) {
|
||||
return false;
|
||||
}
|
||||
$classFqn = ltrim(trim($classFqn), '\\');
|
||||
|
||||
if (!array_key_exists('', $config)) {
|
||||
// Put '' first for readability.
|
||||
$config = ['' => []] + $config;
|
||||
}
|
||||
$list = $config[''];
|
||||
if (!is_array($list)) {
|
||||
$list = [];
|
||||
}
|
||||
if (in_array($classFqn, $list, true)) {
|
||||
return false;
|
||||
}
|
||||
$list[] = $classFqn;
|
||||
$config[''] = $list;
|
||||
|
||||
$this->ensureParentDir($file);
|
||||
$header = $this->getPhpHeaderWithDocblock($file);
|
||||
$body = $this->renderMiddlewareConfig($config);
|
||||
file_put_contents($file, $header . $body);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $classes
|
||||
* @return string
|
||||
*/
|
||||
protected function renderFlatClassListConfig(array $classes): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = "return [";
|
||||
foreach ($classes as $c) {
|
||||
$c = ltrim((string)$c, '\\');
|
||||
if ($c === '') {
|
||||
continue;
|
||||
}
|
||||
$lines[] = " {$c}::class,";
|
||||
}
|
||||
$lines[] = "];\n";
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @return string
|
||||
*/
|
||||
protected function renderMiddlewareConfig(array $config): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = "return [";
|
||||
foreach ($config as $key => $value) {
|
||||
$keyExport = var_export((string)$key, true);
|
||||
$lines[] = " {$keyExport} => [";
|
||||
$list = is_array($value) ? $value : [];
|
||||
foreach ($list as $c) {
|
||||
$c = ltrim((string)$c, '\\');
|
||||
if ($c === '') {
|
||||
continue;
|
||||
}
|
||||
$lines[] = " {$c}::class,";
|
||||
}
|
||||
$lines[] = " ],";
|
||||
}
|
||||
$lines[] = "];\n";
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands\Concerns;
|
||||
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
trait PluginCommandHelpers
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* Normalize plugin name input and canonicalize it to lowercase.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return string|null
|
||||
*/
|
||||
protected function normalizePluginName(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string)$value);
|
||||
return $value === '' ? null : strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate composer package name "vendor/name".
|
||||
* We accept input and canonicalize to lowercase in normalizePluginName().
|
||||
*
|
||||
* @param string $name lowercase package name
|
||||
* @return bool
|
||||
*/
|
||||
protected function isValidComposerPackageName(string $name): bool
|
||||
{
|
||||
if (substr_count($name, '/') !== 1) {
|
||||
return false;
|
||||
}
|
||||
return (bool)preg_match('/^[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?\/[a-z0-9](?:[a-z0-9_.-]*[a-z0-9])?$/', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a plugin package exists in current project.
|
||||
*
|
||||
* Rules:
|
||||
* - Prefer directory existence under "<project>/vendor/<vendor>/<name>" (works for both composer-installed and local plugin skeletons).
|
||||
* - Fallback to Composer runtime API when available.
|
||||
*
|
||||
* @param string $name composer package name in vendor/name format (lowercase recommended)
|
||||
* @return bool
|
||||
*/
|
||||
protected function pluginPackageExists(string $name): bool
|
||||
{
|
||||
$relativeVendorPath = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $name);
|
||||
$vendorDir = base_path() . DIRECTORY_SEPARATOR . $relativeVendorPath;
|
||||
if (is_dir($vendorDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (class_exists(\Composer\InstalledVersions::class)) {
|
||||
return \Composer\InstalledVersions::isInstalled($name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update "enable" flag in config/plugin/<name>/app.php with minimal diffs.
|
||||
*
|
||||
* @param string $configFile
|
||||
* @param bool $enable
|
||||
* @return array{ok:bool,changed:bool,already:bool,missingFile:bool,missingKey:bool,error:string|null}
|
||||
*/
|
||||
protected function setPluginEnableFlag(string $configFile, bool $enable): array
|
||||
{
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'changed' => false,
|
||||
'already' => false,
|
||||
'missingFile' => false,
|
||||
'missingKey' => false,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
if (!is_file($configFile)) {
|
||||
$result['missingFile'] = true;
|
||||
$result['ok'] = true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
$config = $this->loadPhpConfigArray($configFile);
|
||||
if ($config === null) {
|
||||
$result['error'] = "Bad config file: {$configFile}";
|
||||
return $result;
|
||||
}
|
||||
if (!array_key_exists('enable', $config)) {
|
||||
$result['missingKey'] = true;
|
||||
$result['ok'] = true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
$current = (bool)$config['enable'];
|
||||
if ($current === $enable) {
|
||||
$result['already'] = true;
|
||||
$result['ok'] = true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
$content = file_get_contents($configFile);
|
||||
if (!is_string($content) || $content === '') {
|
||||
$result['error'] = "Unable to read file: {$configFile}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
$target = $enable ? 'true' : 'false';
|
||||
$pattern = '/([\'"]enable[\'"]\s*=>\s*)(true|false)/i';
|
||||
$count = 0;
|
||||
$patched = preg_replace($pattern, '$1' . $target, $content, -1, $count);
|
||||
if (!is_string($patched) || $count === 0) {
|
||||
// Do not rewrite full config to preserve user formatting/comments.
|
||||
$result['error'] = "Config key 'enable' not found in file: {$configFile}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (file_put_contents($configFile, $patched) === false) {
|
||||
$result['error'] = "Unable to write file: {$configFile}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['ok'] = true;
|
||||
$result['changed'] = true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI messages for plugin commands. Locale selected by getLocale() / Util fallback.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function pluginMsg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getPluginMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands\Concerns;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Webman\Console\Application;
|
||||
|
||||
/**
|
||||
* Shared execution logic for service management commands
|
||||
* (start, stop, restart, reload, status, connections)
|
||||
*/
|
||||
trait ServiceCommandExecutor
|
||||
{
|
||||
/**
|
||||
* Execute service command by delegating to appropriate application runner
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function executeServiceCommand(): int
|
||||
{
|
||||
if (\class_exists(\Support\App::class)) {
|
||||
\Support\App::run();
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
Application::run();
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('connections', 'Get worker connections')]
|
||||
class ConnectionsCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['connections_desc'] ?? 'Get worker connections');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
#[AsCommand('fix-disable-functions', 'Fix disbale_functions in php.ini')]
|
||||
class FixDisbaleFunctionsCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$php_ini_file = php_ini_loaded_file();
|
||||
if (!$php_ini_file) {
|
||||
$output->writeln($this->msg('no_ini'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$output->writeln($this->msg('location', ['{path}' => $php_ini_file]));
|
||||
$disable_functions_str = ini_get("disable_functions");
|
||||
if (!$disable_functions_str) {
|
||||
$output->writeln($this->msg('ok'));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$functions_required = [
|
||||
"stream_socket_server",
|
||||
"stream_socket_accept",
|
||||
"stream_socket_client",
|
||||
"pcntl_signal_dispatch",
|
||||
"pcntl_signal",
|
||||
"pcntl_alarm",
|
||||
"pcntl_fork",
|
||||
"posix_getuid",
|
||||
"posix_getpwuid",
|
||||
"posix_kill",
|
||||
"posix_setsid",
|
||||
"posix_getpid",
|
||||
"posix_getpwnam",
|
||||
"posix_getgrnam",
|
||||
"posix_getgid",
|
||||
"posix_setgid",
|
||||
"posix_initgroups",
|
||||
"posix_setuid",
|
||||
"posix_isatty",
|
||||
"proc_open",
|
||||
"proc_get_status",
|
||||
"proc_close",
|
||||
"shell_exec",
|
||||
"exec",
|
||||
];
|
||||
|
||||
$disable_functions = explode(",", $disable_functions_str);
|
||||
$disable_functions_removed = [];
|
||||
foreach ($disable_functions as $index => $func) {
|
||||
$func = trim($func);
|
||||
foreach ($functions_required as $func_prefix) {
|
||||
if (strpos($func, $func_prefix) === 0) {
|
||||
$disable_functions_removed[$func] = $func;
|
||||
unset($disable_functions[$index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$php_ini_content = file_get_contents($php_ini_file);
|
||||
if (!is_string($php_ini_content) || $php_ini_content === '') {
|
||||
$output->writeln($this->msg('ini_empty', ['{path}' => $php_ini_file]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$disable_functions = array_values(array_filter(array_map('trim', $disable_functions), static fn($v) => $v !== ''));
|
||||
$new_disable_functions_str = implode(",", $disable_functions);
|
||||
|
||||
// Replace existing line if present, otherwise append.
|
||||
$pattern = '/(^|\\R)\\s*disable_functions\\s*=.*$/m';
|
||||
if (preg_match($pattern, $php_ini_content)) {
|
||||
$php_ini_content = preg_replace(
|
||||
$pattern,
|
||||
'$1disable_functions = ' . $new_disable_functions_str,
|
||||
$php_ini_content,
|
||||
1
|
||||
);
|
||||
} else {
|
||||
$php_ini_content = rtrim($php_ini_content) . PHP_EOL . 'disable_functions = ' . $new_disable_functions_str . PHP_EOL;
|
||||
}
|
||||
|
||||
file_put_contents($php_ini_file, $php_ini_content);
|
||||
|
||||
foreach ($disable_functions_removed as $func) {
|
||||
$output->writeln($this->msg('enabled', ['{func}' => $func]));
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('success'));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getFixDisableFunctionsMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
#[AsCommand('install', 'Execute webman installation script')]
|
||||
class InstallCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln($this->msg('install_title'));
|
||||
$install_function = "\\Webman\\Install::install";
|
||||
if (is_callable($install_function)) {
|
||||
$install_function();
|
||||
$output->writeln($this->msg('done'));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$output->writeln($this->msg('require_version'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getInstallMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
|
||||
#[AsCommand('make:bootstrap', 'Make a bootstrap.')]
|
||||
class MakeBootstrapCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, $this->msg('arg_name'));
|
||||
$this->addArgument('enable', InputArgument::OPTIONAL, $this->msg('arg_enable'));
|
||||
$this->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, $this->msg('opt_plugin'));
|
||||
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED, $this->msg('opt_path'));
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, $this->msg('opt_force'));
|
||||
|
||||
$this->setHelp($this->buildHelpText());
|
||||
|
||||
$this->addUsage('MyBootstrap');
|
||||
$this->addUsage('MyBootstrap no');
|
||||
$this->addUsage('MyBootstrap -p admin');
|
||||
$this->addUsage('MyBootstrap -P plugin/admin/app/bootstrap');
|
||||
$this->addUsage('MyBootstrap -f');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = Util::nameToClass((string)$input->getArgument('name'));
|
||||
$enable = $this->parseEnableArgument($input->getArgument('enable'));
|
||||
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
|
||||
$path = $this->normalizeOptionValue($input->getOption('path'));
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
|
||||
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($plugin && !$this->assertPluginExists($plugin, $output)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$name = str_replace('\\', '/', $name);
|
||||
|
||||
if (!$path && $input->isInteractive()) {
|
||||
$pathDefault = $plugin ? Util::getDefaultAppRelativePath('bootstrap', $plugin) : Util::getDefaultAppRelativePath('bootstrap');
|
||||
$path = $this->promptForPathWithDefault($input, $output, 'bootstrap', $pathDefault);
|
||||
}
|
||||
|
||||
if ($plugin || $path) {
|
||||
$resolved = $this->resolveTargetByPluginOrPath(
|
||||
$name,
|
||||
$plugin,
|
||||
$path,
|
||||
$output,
|
||||
fn(string $p) => Util::getDefaultAppRelativePath('bootstrap', $p),
|
||||
fn(string $key, array $replace = []) => $this->msg($key, $replace)
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$class, $namespace, $file] = $resolved;
|
||||
} else {
|
||||
[$class, $namespace, $file] = $this->resolveAppBootstrapTarget($name);
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('make_bootstrap', ['{name}' => $class]));
|
||||
|
||||
if (is_file($file) && !$force) {
|
||||
$relative = $this->toRelativePath($file);
|
||||
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
|
||||
$question = new ConfirmationQuestion($prompt, true);
|
||||
if (!$this->askOrAbort($input, $output, $question)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->createBootstrap($class, $namespace, $file);
|
||||
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
|
||||
|
||||
if ($enable) {
|
||||
$bootstrapClass = "{$namespace}\\{$class}";
|
||||
$configFile = $plugin
|
||||
? base_path('plugin' . DIRECTORY_SEPARATOR . $plugin . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'bootstrap.php')
|
||||
: (config_path() . '/bootstrap.php');
|
||||
|
||||
$this->addClassToFlatClassListConfig($configFile, $bootstrapClass);
|
||||
$output->writeln($this->msg('enabled', ['{class}' => $bootstrapClass]));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bootstrap namespace/file path under app/ (backward compatible).
|
||||
*
|
||||
* @param string $name
|
||||
* @return array{0:string,1:string,2:string} [class, namespace, file]
|
||||
*/
|
||||
protected function resolveAppBootstrapTarget(string $name): array
|
||||
{
|
||||
$bootstrapStr = Util::getDefaultAppPath('bootstrap');
|
||||
$bootstrapRelPath = Util::getDefaultAppRelativePath('bootstrap');
|
||||
$upper = strtolower($bootstrapStr[0]) !== $bootstrapStr[0];
|
||||
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$class = ucfirst($name);
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . $bootstrapStr . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = Util::pathToNamespace($bootstrapRelPath);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
$dirPart = substr($name, 0, $pos);
|
||||
$realDirPart = Util::guessPath(app_path(), $dirPart);
|
||||
if ($realDirPart) {
|
||||
$dirPart = str_replace(DIRECTORY_SEPARATOR, '/', $realDirPart);
|
||||
} else if ($upper) {
|
||||
$dirPart = preg_replace_callback('/\/([a-z])/', static function ($matches) {
|
||||
return '/' . strtoupper($matches[1]);
|
||||
}, ucfirst($dirPart));
|
||||
}
|
||||
|
||||
$appDirName = Util::detectAppDirName();
|
||||
$path = "{$bootstrapStr}/{$dirPart}";
|
||||
$class = ucfirst(substr($name, $pos + 1));
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path) . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = str_replace('/', '\\', $appDirName . '/' . $path);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app bootstrap relative path.
|
||||
*/
|
||||
protected function getAppBootstrapRelativePath(): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('bootstrap');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string relative path
|
||||
*/
|
||||
protected function getPluginBootstrapRelativePath(string $plugin): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('bootstrap', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for path (question style, input on new line). Reuses enter_path_prompt from make:crud.
|
||||
*/
|
||||
protected function promptForPathWithDefault(InputInterface $input, OutputInterface $output, string $labelKey, string $defaultPath): string
|
||||
{
|
||||
$defaultPath = $this->normalizeRelativePath($defaultPath);
|
||||
$label = Util::selectLocaleMessages(Messages::getTypeLabels())[$labelKey] ?? $labelKey;
|
||||
$promptText = Util::selectLocaleMessages(Messages::getMakeCrudMessages())['enter_path_prompt']
|
||||
?? 'Enter {label} path (Enter for default: {default}): ';
|
||||
$promptText = strtr($promptText, ['{label}' => $label, '{default}' => $defaultPath]);
|
||||
$promptText = '<question>' . trim($promptText) . "</question>\n";
|
||||
$question = new Question($promptText, $defaultPath);
|
||||
$path = $this->askOrAbort($input, $output, $question);
|
||||
$path = is_string($path) ? $path : $defaultPath;
|
||||
return $this->normalizeRelativePath($path ?: $defaultPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $namespace
|
||||
* @param $file
|
||||
* @return void
|
||||
*/
|
||||
protected function createBootstrap($name, $namespace, $file)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$bootstrap_content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace $namespace;
|
||||
|
||||
use Webman\Bootstrap;
|
||||
|
||||
class $name implements Bootstrap
|
||||
{
|
||||
public static function start(\$worker)
|
||||
{
|
||||
// Is it console environment ?
|
||||
\$is_console = !\$worker;
|
||||
if (\$is_console) {
|
||||
// If you do not want to execute this in console, just return.
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $bootstrap_content);
|
||||
}
|
||||
|
||||
public function addConfig($class, $config_file)
|
||||
{
|
||||
$config = include $config_file;
|
||||
if(!in_array($class, $config ?? [])) {
|
||||
$config_file_content = file_get_contents($config_file);
|
||||
$config_file_content = preg_replace('/\];/', " $class::class,\n];", $config_file_content);
|
||||
file_put_contents($config_file, $config_file_content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse positional `enable` argument (backward compatible).
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
protected function parseEnableArgument(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
$v = strtolower(trim((string)$value));
|
||||
return !in_array($v, ['no', '0', 'false', 'n', 'off', 'disable', 'disabled'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (multilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getMakeBootstrapHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded CLI messages (bilingual) without translation module.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getMakeBootstrapMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
|
||||
#[AsCommand('make:command', 'Make command')]
|
||||
class MakeCommandCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, 'Command name');
|
||||
$this->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'Plugin name under plugin/. e.g. admin');
|
||||
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED, 'Target directory (relative to base path). e.g. plugin/admin/app/command');
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Override existing file without confirmation.');
|
||||
|
||||
$this->setHelp($this->buildHelpText());
|
||||
|
||||
$this->addUsage('user:list');
|
||||
$this->addUsage('user:list -p admin');
|
||||
$this->addUsage('user:list -P plugin/admin/app/command');
|
||||
$this->addUsage('user:list -f');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$command = trim((string)$input->getArgument('name'));
|
||||
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
|
||||
$path = $this->normalizeOptionValue($input->getOption('path'));
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
|
||||
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($plugin && !$this->assertPluginExists($plugin, $output)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// make:command 不支持子目录(不允许 / 或 \)
|
||||
$command = str_replace(['\\', '/'], '', $command);
|
||||
if ($command === '') {
|
||||
$output->writeln($this->msg('invalid_command'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$class = $this->commandToClassName($command);
|
||||
|
||||
if (!$path && $input->isInteractive()) {
|
||||
$pathDefault = $plugin ? Util::getDefaultAppRelativePath('command', $plugin) : Util::getDefaultAppRelativePath('command');
|
||||
$path = $this->promptForPathWithDefault($input, $output, 'command', $pathDefault);
|
||||
}
|
||||
|
||||
if ($plugin || $path) {
|
||||
$resolved = $this->resolveTargetByPluginOrPath(
|
||||
$class,
|
||||
$plugin,
|
||||
$path,
|
||||
$output,
|
||||
fn(string $p) => Util::getDefaultAppRelativePath('command', $p),
|
||||
fn(string $key, array $replace = []) => $this->msg($key, $replace)
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$class, $namespace, $file] = $resolved;
|
||||
} else {
|
||||
[$class, $namespace, $file] = $this->resolveAppCommandTarget($class);
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('make_command', ['{name}' => $command]));
|
||||
|
||||
if (is_file($file) && !$force) {
|
||||
$relative = $this->toRelativePath($file);
|
||||
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
|
||||
$question = new ConfirmationQuestion($prompt, true);
|
||||
if (!$this->askOrAbort($input, $output, $question)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->createCommand($class, $namespace, $file, $command);
|
||||
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a console command name (like "user:list") to a PHP class name (like "UserList").
|
||||
*
|
||||
* @param string $command
|
||||
* @return string
|
||||
*/
|
||||
protected function commandToClassName(string $command): string
|
||||
{
|
||||
$items = array_values(array_filter(explode(':', $command), static fn($v) => $v !== ''));
|
||||
$name = '';
|
||||
foreach ($items as $item) {
|
||||
// Support kebab/snake: foo-bar => FooBar
|
||||
$tmp = Util::nameToClass(str_replace('-', '_', $item));
|
||||
$tmp = str_replace(['\\', '/'], '', $tmp);
|
||||
$name .= ucfirst($tmp);
|
||||
}
|
||||
return $name ?: 'ConsoleCommand';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve command namespace/file path under app/ (backward compatible).
|
||||
*
|
||||
* @param string $class
|
||||
* @return array{0:string,1:string,2:string} [class, namespace, file]
|
||||
*/
|
||||
protected function resolveAppCommandTarget(string $class): array
|
||||
{
|
||||
$commandStr = Util::getDefaultAppPath('command');
|
||||
$commandRelPath = Util::getDefaultAppRelativePath('command');
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . $commandStr . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = Util::pathToNamespace($commandRelPath);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app command relative path.
|
||||
*/
|
||||
protected function getAppCommandRelativePath(): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string relative path
|
||||
*/
|
||||
protected function getPluginCommandRelativePath(string $plugin): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('command', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for path (question style, input on new line). Reuses enter_path_prompt from make:crud.
|
||||
*/
|
||||
protected function promptForPathWithDefault(InputInterface $input, OutputInterface $output, string $labelKey, string $defaultPath): string
|
||||
{
|
||||
$defaultPath = $this->normalizeRelativePath($defaultPath);
|
||||
$label = Util::selectLocaleMessages(Messages::getTypeLabels())[$labelKey] ?? $labelKey;
|
||||
$promptText = Util::selectLocaleMessages(Messages::getMakeCrudMessages())['enter_path_prompt']
|
||||
?? 'Enter {label} path (Enter for default: {default}): ';
|
||||
$promptText = strtr($promptText, ['{label}' => $label, '{default}' => $defaultPath]);
|
||||
$promptText = '<question>' . trim($promptText) . "</question>\n";
|
||||
$question = new Question($promptText, $defaultPath);
|
||||
$path = $this->askOrAbort($input, $output, $question);
|
||||
$path = is_string($path) ? $path : $defaultPath;
|
||||
return $this->normalizeRelativePath($path ?: $defaultPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $namespace
|
||||
* @param $path
|
||||
* @return void
|
||||
*/
|
||||
protected function createCommand($name, $namespace, $file, $command)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$desc = str_replace(':', ' ', $command);
|
||||
$command_content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace $namespace;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('$command', '$desc')]
|
||||
class $name extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
}
|
||||
|
||||
protected function execute(InputInterface \$input, OutputInterface \$output): int
|
||||
{
|
||||
\$output->writeln('<info>Hello</info> <comment>' . \$this->getName() . '</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $command_content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (multilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getMakeCommandHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded CLI messages (bilingual) without translation module.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getMakeCommandMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
#[AsCommand('make:controller', 'Make controller')]
|
||||
class MakeControllerCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, 'Controller name');
|
||||
$this->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'Plugin name under plugin/. e.g. admin');
|
||||
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED, 'Target directory (relative to base path). e.g. plugin/admin/app/controller');
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Override existing file without confirmation.');
|
||||
|
||||
$this->setHelp($this->buildHelpText());
|
||||
|
||||
$this->addUsage('User');
|
||||
$this->addUsage('User -p admin');
|
||||
$this->addUsage('User -P plugin/admin/app/controller');
|
||||
$this->addUsage('admin/User -f');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
$name = Util::nameToClass((string)$input->getArgument('name'));
|
||||
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
|
||||
$path = $this->normalizeOptionValue($input->getOption('path'));
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
|
||||
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($plugin && !$this->assertPluginExists($plugin, $output)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// When "-p/--plugin" is provided, controller suffix should come from the plugin config,
|
||||
// not from the main project config.
|
||||
if ($plugin) {
|
||||
$suffix = (string)config("plugin.$plugin.app.controller_suffix", 'Controller');
|
||||
} else {
|
||||
$suffix = (string)config('app.controller_suffix', 'Controller');
|
||||
}
|
||||
$name = str_replace('\\', '/', $name);
|
||||
$name = $this->applySuffixToLastSegment($name, $suffix);
|
||||
|
||||
// When path is not provided: in interactive mode prompt for path (same UX as make:crud).
|
||||
if (!$path && $input->isInteractive()) {
|
||||
$pathDefault = Util::getDefaultAppRelativePath('controller', $plugin ?: null);
|
||||
$path = $this->promptForControllerPath($input, $output, $pathDefault);
|
||||
}
|
||||
|
||||
if ($plugin || $path) {
|
||||
$resolved = $this->resolveTargetByPluginOrPath(
|
||||
$name,
|
||||
$plugin,
|
||||
$path,
|
||||
$output,
|
||||
fn(string $p) => Util::getDefaultAppRelativePath('controller', $p),
|
||||
fn(string $key, array $replace = []) => $this->msg($key, $replace)
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$class, $namespace, $file] = $resolved;
|
||||
|
||||
if (!$this->isControllerNamespace($namespace)) {
|
||||
$output->writeln(Util::selectByLocale(Messages::getPathMissingControllerMessage()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
[$class, $namespace, $file] = $this->resolveAppControllerTarget($name);
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('make_controller', ['{name}' => $class]));
|
||||
|
||||
if (is_file($file) && !$force) {
|
||||
$relative = $this->toRelativePath($file);
|
||||
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
|
||||
$question = new ConfirmationQuestion($prompt, true);
|
||||
$yes = (bool)$this->askOrAbort($input, $output, $question);
|
||||
if (!$yes) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->createController($class, $namespace, $file);
|
||||
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $namespace
|
||||
* @param $file
|
||||
* @return void
|
||||
*/
|
||||
protected function createController($name, $namespace, $file)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$controller_content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace $namespace;
|
||||
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class $name
|
||||
{
|
||||
public function index(Request \$request): Response
|
||||
{
|
||||
return response('Hello, $name');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $controller_content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if namespace contains a "controller" segment (case-insensitive).
|
||||
*/
|
||||
protected function isControllerNamespace(string $namespace): bool
|
||||
{
|
||||
$segments = preg_split('/[\\\\\/]/', strtolower($namespace));
|
||||
return in_array('controller', $segments, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply controller suffix to the last segment only.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $suffix
|
||||
* @return string
|
||||
*/
|
||||
protected function applySuffixToLastSegment(string $name, string $suffix): string
|
||||
{
|
||||
$suffix = trim($suffix);
|
||||
if ($suffix === '') {
|
||||
return $name;
|
||||
}
|
||||
$pos = strrpos($name, '/');
|
||||
if ($pos === false) {
|
||||
return str_ends_with($name, $suffix) ? $name : ($name . $suffix);
|
||||
}
|
||||
$prefix = substr($name, 0, $pos + 1);
|
||||
$last = substr($name, $pos + 1);
|
||||
if (!str_ends_with($last, $suffix)) {
|
||||
$last .= $suffix;
|
||||
}
|
||||
return $prefix . $last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve controller namespace/file path under app/ (backward compatible).
|
||||
*
|
||||
* @param string $name
|
||||
* @return array{0:string,1:string,2:string} [class, namespace, file]
|
||||
*/
|
||||
protected function resolveAppControllerTarget(string $name): array
|
||||
{
|
||||
$controllerRelPath = Util::getDefaultAppRelativePath('controller');
|
||||
$controllerStr = Util::getDefaultAppPath('controller');
|
||||
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$class = ucfirst($name);
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . $controllerStr . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = Util::pathToNamespace($controllerRelPath);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
$nameStr = substr($name, 0, $pos);
|
||||
$realNameStr = null;
|
||||
if ($tmp = Util::guessPath(app_path(), $nameStr)) {
|
||||
$realNameStr = $tmp;
|
||||
$nameStr = $tmp;
|
||||
} else if ($realSectionName = Util::guessPath(app_path(), strstr($nameStr, '/', true))) {
|
||||
$upper = strtolower($realSectionName[0]) !== $realSectionName[0];
|
||||
} else {
|
||||
$upper = strtolower($controllerStr[0]) !== $controllerStr[0];
|
||||
}
|
||||
$upper = $upper ?? strtolower($nameStr[0]) !== $nameStr[0];
|
||||
|
||||
if ($upper && !$realNameStr) {
|
||||
$nameStr = preg_replace_callback('/\/([a-z])/', static function ($matches) {
|
||||
return '/' . strtoupper($matches[1]);
|
||||
}, ucfirst($nameStr));
|
||||
}
|
||||
|
||||
$appDirName = Util::detectAppDirName();
|
||||
$path = "{$nameStr}/{$controllerStr}";
|
||||
$class = ucfirst(substr($name, $pos + 1));
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path) . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = str_replace('/', '\\', $appDirName . '/' . $path);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string relative path
|
||||
*/
|
||||
protected function getPluginControllerRelativePath(string $plugin): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('controller', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for controller path (interactive). Reuses enter_path_prompt from make:crud messages.
|
||||
*
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @param string $defaultPath
|
||||
* @return string
|
||||
*/
|
||||
protected function promptForControllerPath(InputInterface $input, OutputInterface $output, string $defaultPath): string
|
||||
{
|
||||
$defaultPath = $this->normalizeRelativePath($defaultPath);
|
||||
$label = Util::selectLocaleMessages(Messages::getTypeLabels())['controller'] ?? 'Controller';
|
||||
$promptText = Util::selectLocaleMessages(Messages::getMakeCrudMessages())['enter_path_prompt']
|
||||
?? 'Enter {label} path (Enter for default: {default}): ';
|
||||
$promptText = strtr($promptText, ['{label}' => $label, '{default}' => $defaultPath]);
|
||||
$promptText = '<question>' . trim($promptText) . "</question>\n";
|
||||
$question = new Question($promptText, $defaultPath);
|
||||
$path = $this->askOrAbort($input, $output, $question);
|
||||
$path = is_string($path) ? $path : $defaultPath;
|
||||
return $this->normalizeRelativePath($path ?: $defaultPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (multilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getMakeControllerHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded CLI messages (bilingual) without translation module.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getMakeControllerMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
}
|
||||
+1303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
|
||||
#[AsCommand('make:middleware', 'Make middleware')]
|
||||
class MakeMiddlewareCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('name', InputArgument::REQUIRED, 'Middleware name');
|
||||
$this->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'Plugin name under plugin/. e.g. admin');
|
||||
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED, 'Target directory (relative to base path). e.g. plugin/admin/app/middleware');
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Override existing file without confirmation.');
|
||||
|
||||
$this->setHelp($this->buildHelpText());
|
||||
|
||||
$this->addUsage('Auth');
|
||||
$this->addUsage('Auth -p admin');
|
||||
$this->addUsage('Auth -P plugin/admin/app/middleware');
|
||||
$this->addUsage('Api/Auth -f');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = Util::nameToClass((string)$input->getArgument('name'));
|
||||
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
|
||||
$path = $this->normalizeOptionValue($input->getOption('path'));
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
|
||||
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($plugin && !$this->assertPluginExists($plugin, $output)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$name = str_replace('\\', '/', $name);
|
||||
|
||||
if (!$path && $input->isInteractive()) {
|
||||
$pathDefault = $plugin ? Util::getDefaultAppRelativePath('middleware', $plugin) : Util::getDefaultAppRelativePath('middleware');
|
||||
$path = $this->promptForPathWithDefault($input, $output, 'middleware', $pathDefault);
|
||||
}
|
||||
|
||||
if ($plugin || $path) {
|
||||
$resolved = $this->resolveTargetByPluginOrPath(
|
||||
$name,
|
||||
$plugin,
|
||||
$path,
|
||||
$output,
|
||||
fn(string $p) => Util::getDefaultAppRelativePath('middleware', $p),
|
||||
fn(string $key, array $replace = []) => $this->msg($key, $replace)
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$class, $namespace, $file] = $resolved;
|
||||
} else {
|
||||
[$class, $namespace, $file] = $this->resolveAppMiddlewareTarget($name);
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('make_middleware', ['{name}' => $class]));
|
||||
|
||||
if (is_file($file) && !$force) {
|
||||
$relative = $this->toRelativePath($file);
|
||||
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
|
||||
$question = new ConfirmationQuestion($prompt, true);
|
||||
if (!$this->askOrAbort($input, $output, $question)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->createMiddleware($class, $namespace, $file);
|
||||
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
|
||||
|
||||
$middlewareClass = "{$namespace}\\{$class}";
|
||||
$configFile = $plugin
|
||||
? base_path('plugin' . DIRECTORY_SEPARATOR . $plugin . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'middleware.php')
|
||||
: (config_path() . '/middleware.php');
|
||||
$this->addClassToMiddlewareConfig($configFile, $middlewareClass);
|
||||
$output->writeln($this->msg('configured', ['{class}' => $middlewareClass, '{file}' => $this->toRelativePath($configFile)]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve middleware namespace/file path under app/ (backward compatible).
|
||||
*
|
||||
* @param string $name
|
||||
* @return array{0:string,1:string,2:string} [class, namespace, file]
|
||||
*/
|
||||
protected function resolveAppMiddlewareTarget(string $name): array
|
||||
{
|
||||
$middlewareStr = Util::getDefaultAppPath('middleware');
|
||||
$middlewareRelPath = Util::getDefaultAppRelativePath('middleware');
|
||||
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$class = ucfirst($name);
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . $middlewareStr . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = Util::pathToNamespace($middlewareRelPath);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
$upper = strtolower($middlewareStr[0]) !== $middlewareStr[0];
|
||||
$dirPart = substr($name, 0, $pos);
|
||||
$realDirPart = Util::guessPath(app_path(), $dirPart);
|
||||
if ($realDirPart) {
|
||||
$dirPart = str_replace(DIRECTORY_SEPARATOR, '/', $realDirPart);
|
||||
} else if ($upper) {
|
||||
$dirPart = preg_replace_callback('/\/([a-z])/', static function ($matches) {
|
||||
return '/' . strtoupper($matches[1]);
|
||||
}, ucfirst($dirPart));
|
||||
}
|
||||
|
||||
$appDirName = Util::detectAppDirName();
|
||||
$path = "{$middlewareStr}/{$dirPart}";
|
||||
$class = ucfirst(substr($name, $pos + 1));
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path) . DIRECTORY_SEPARATOR . "{$class}.php";
|
||||
$namespace = str_replace('/', '\\', $appDirName . '/' . $path);
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Default app middleware relative path.
|
||||
*/
|
||||
protected function getAppMiddlewareRelativePath(): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('middleware');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string relative path
|
||||
*/
|
||||
protected function getPluginMiddlewareRelativePath(string $plugin): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('middleware', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for path (question style, input on new line). Reuses enter_path_prompt from make:crud.
|
||||
*/
|
||||
protected function promptForPathWithDefault(InputInterface $input, OutputInterface $output, string $labelKey, string $defaultPath): string
|
||||
{
|
||||
$defaultPath = $this->normalizeRelativePath($defaultPath);
|
||||
$label = Util::selectLocaleMessages(Messages::getTypeLabels())[$labelKey] ?? $labelKey;
|
||||
$promptText = Util::selectLocaleMessages(Messages::getMakeCrudMessages())['enter_path_prompt']
|
||||
?? 'Enter {label} path (Enter for default: {default}): ';
|
||||
$promptText = strtr($promptText, ['{label}' => $label, '{default}' => $defaultPath]);
|
||||
$promptText = '<question>' . trim($promptText) . "</question>\n";
|
||||
$question = new Question($promptText, $defaultPath);
|
||||
$path = $this->askOrAbort($input, $output, $question);
|
||||
$path = is_string($path) ? $path : $defaultPath;
|
||||
return $this->normalizeRelativePath($path ?: $defaultPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $namespace
|
||||
* @param $path
|
||||
* @return void
|
||||
*/
|
||||
protected function createMiddleware($name, $namespace, $file)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$middleware_content = <<<EOF
|
||||
<?php
|
||||
namespace $namespace;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class $name implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request \$request, callable \$handler) : Response
|
||||
{
|
||||
return \$handler(\$request);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $middleware_content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded CLI messages (bilingual) without translation module.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getMakeMiddlewareMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (multilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getMakeMiddlewareHelpText());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Doctrine\Inflector\InflectorFactory;
|
||||
use support\Db;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Webman\Console\OrmType;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\MakeCommandHelpers;
|
||||
use Webman\Console\Commands\Concerns\OrmTableCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
|
||||
#[AsCommand('make:model', 'Make model')]
|
||||
class MakeModelCommand extends Command
|
||||
{
|
||||
use MakeCommandHelpers;
|
||||
use OrmTableCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
/**
|
||||
* Usage quick notes:
|
||||
* - Prefer explicit `--table/-t` when table name does not follow conventions.
|
||||
* - Interactive table picker is available only when terminal supports input; press Enter for more, 0 for empty model, /keyword to filter.
|
||||
*
|
||||
* 常用提示:
|
||||
* - 表名不符合约定时建议显式使用 `--table/-t`。
|
||||
* - 交互式选表仅在支持输入的终端下启用:回车=更多,0=空模型,/关键字=过滤。
|
||||
*/
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, 'Model name (optional in interactive mode)');
|
||||
$this->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'Plugin name under plugin/. e.g. admin');
|
||||
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED, 'Target directory (relative to base path). e.g. plugin/admin/app/model');
|
||||
$this->addOption('table', 't', InputOption::VALUE_REQUIRED, 'Specify table name. e.g. wa_users');
|
||||
$this->addOption('orm', 'o', InputOption::VALUE_REQUIRED, 'Select orm: laravel|thinkorm');
|
||||
$this->addOption('database', 'd', InputOption::VALUE_OPTIONAL, 'Select database connection.');
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Override existing file without confirmation.');
|
||||
|
||||
// Symfony Console built-in help:
|
||||
// - php webman make:model --help
|
||||
// - php webman help make:model
|
||||
$this->setHelp($this->buildHelpText());
|
||||
|
||||
// Display examples in synopsis (shown in --help).
|
||||
$this->addUsage('');
|
||||
$this->addUsage('User');
|
||||
$this->addUsage('User -p admin');
|
||||
$this->addUsage('User -P plugin/admin/app/model');
|
||||
$this->addUsage('User -t wa_users -o laravel');
|
||||
$this->addUsage('User -t=wa_users -o=thinkorm -f');
|
||||
$this->addUsage('admin/User --table=wa_admin_users --orm=laravel --database=mysql');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizeOptionValue($input->getArgument('name'));
|
||||
$plugin = $this->normalizeOptionValue($input->getOption('plugin'));
|
||||
$pathOption = $this->normalizeOptionValue($input->getOption('path'));
|
||||
$orm = $this->normalizeOptionValue($input->getOption('orm'));
|
||||
$databaseOption = $this->normalizeOptionValue($input->getOption('database'));
|
||||
$connection = $databaseOption;
|
||||
$table = $this->normalizeOptionValue($input->getOption('table'));
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
if ($plugin && (str_contains($plugin, '/') || str_contains($plugin, '\\'))) {
|
||||
$output->writeln($this->msg('invalid_plugin', ['{plugin}' => $plugin]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($plugin && !$this->assertPluginExists($plugin, $output)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$type = $this->resolveOrm($orm);
|
||||
// Resolve & validate DB connection:
|
||||
// - When --plugin/-p is provided and --database/-d is provided, use the plugin's connection config.
|
||||
// e.g. "-p admin -d mysql" => "plugin.admin.mysql"
|
||||
// - When no plugin is provided, validate that the given --database/-d exists.
|
||||
// - When --database/-d is omitted, prefer plugin default connection when plugin has DB config.
|
||||
[$ok, $connection] = $this->resolveAndValidateConnection($type, $plugin, $connection, $output);
|
||||
if (!$ok) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// New interactive flow: when no model name is provided, go table -> name -> path.
|
||||
if (!$nameArg) {
|
||||
if (!$this->isTerminalInteractive($input)) {
|
||||
$output->writeln($this->msg('name_required_non_interactive'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$table) {
|
||||
$table = $this->promptForTable($input, $output, $type, $connection, 'Model', $plugin, $databaseOption) ?: null;
|
||||
}
|
||||
|
||||
$nameDefault = $table ? $this->suggestModelNameFromTable($table, $type, $connection, $plugin, $databaseOption) : 'Model';
|
||||
$nameInput = $this->promptForModelNameWithDefault($input, $output, $nameDefault);
|
||||
if (!$nameInput) {
|
||||
$output->writeln($this->msg('invalid_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameNormalized = $this->normalizeClassLikeName($nameInput);
|
||||
|
||||
$defaultPath = $plugin ? Util::getDefaultAppRelativePath('model', $plugin) : Util::getDefaultAppRelativePath('model');
|
||||
$pathFinal = $pathOption;
|
||||
if (!$pathFinal) {
|
||||
$pathFinal = $this->promptForModelPathWithDefault($input, $output, $defaultPath);
|
||||
}
|
||||
$pathFinal = $this->normalizeRelativePath($pathFinal ?: $defaultPath);
|
||||
|
||||
// Resolve namespace/file by --plugin/--path rules:
|
||||
// - If --path is explicitly provided, keep the strict plugin/path conflict check.
|
||||
// - If --path is not provided, allow user to override the default path even when --plugin is set.
|
||||
if ($plugin && $pathOption) {
|
||||
$resolved = $this->resolveModelTargetByPluginOrPath($nameNormalized, $plugin, $pathOption, $output);
|
||||
} else if ($plugin && !$pathOption) {
|
||||
$expected = $this->getPluginModelRelativePath($plugin);
|
||||
$resolved = $this->pathsEqual($expected, $pathFinal)
|
||||
? $this->resolveModelTargetByPluginOrPath($nameNormalized, $plugin, null, $output)
|
||||
: $this->resolveModelTargetByPluginOrPath($nameNormalized, null, $pathFinal, $output);
|
||||
} else {
|
||||
$resolved = $this->resolveModelTargetByPluginOrPath($nameNormalized, null, $pathFinal, $output);
|
||||
}
|
||||
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$name, $namespace, $file] = $resolved;
|
||||
} else {
|
||||
// Backward compatible behavior when model name is explicitly provided.
|
||||
$name = Util::nameToClass($nameArg);
|
||||
// Table selection before path selection.
|
||||
if ($nameArg && !$table) {
|
||||
$table = $this->promptForTableIfNeeded($input, $output, $type, $connection, $name, $plugin, $databaseOption) ?: null;
|
||||
}
|
||||
// When path is not provided and interactive: prompt for path after table.
|
||||
if (!$pathOption && $input->isInteractive()) {
|
||||
$pathDefault = $plugin ? Util::getDefaultAppRelativePath('model', $plugin) : Util::getDefaultAppRelativePath('model');
|
||||
$pathOption = $this->promptForModelPathWithDefault($input, $output, $pathDefault);
|
||||
}
|
||||
if ($plugin || $pathOption) {
|
||||
$resolved = $this->resolveModelTargetByPluginOrPath($name, $plugin, $pathOption, $output);
|
||||
if ($resolved === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
[$name, $namespace, $file] = $resolved;
|
||||
} else {
|
||||
// Original behavior for app models (backward compatible)
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$name = ucfirst($name);
|
||||
$model_str = Util::getDefaultAppPath('model');
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . $model_str . DIRECTORY_SEPARATOR . "$name.php";
|
||||
$namespace = Util::pathToNamespace(Util::getDefaultAppRelativePath('model'));
|
||||
} else {
|
||||
$name_str = substr($name, 0, $pos);
|
||||
if ($real_name_str = Util::guessPath(app_path(), $name_str)) {
|
||||
$name_str = $real_name_str;
|
||||
} else if ($real_section_name = Util::guessPath(app_path(), strstr($name_str, '/', true))) {
|
||||
$upper = strtolower($real_section_name[0]) !== $real_section_name[0];
|
||||
} else {
|
||||
$model_str_check = Util::getDefaultAppPath('model');
|
||||
$upper = strtolower($model_str_check[0]) !== $model_str_check[0];
|
||||
}
|
||||
$upper = $upper ?? strtolower($name_str[0]) !== $name_str[0];
|
||||
if ($upper && !$real_name_str) {
|
||||
$name_str = preg_replace_callback('/\/([a-z])/', function ($matches) {
|
||||
return '/' . strtoupper($matches[1]);
|
||||
}, ucfirst($name_str));
|
||||
}
|
||||
$appDirName = Util::detectAppDirName();
|
||||
$model_dir = Util::getDefaultAppPath('model');
|
||||
$path_for_file = "$name_str/" . $model_dir;
|
||||
$name = ucfirst(substr($name, $pos + 1));
|
||||
$file = app_path() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path_for_file) . DIRECTORY_SEPARATOR . "$name.php";
|
||||
$namespace = str_replace('/', '\\', $appDirName . '/' . $path_for_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('make_model', ['{name}' => $name]));
|
||||
|
||||
if (is_file($file) && !$force) {
|
||||
$relative = $this->toRelativePath($file);
|
||||
$prompt = $this->msg('override_prompt', ['{path}' => $relative]);
|
||||
$question = new ConfirmationQuestion($prompt, true);
|
||||
if (!$this->askOrAbort($input, $output, $question)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === OrmType::THINKORM) {
|
||||
$this->createTpModel($name, $namespace, $file, $connection, $table, $output);
|
||||
} else {
|
||||
$this->createModel($name, $namespace, $file, $connection, $table, $output);
|
||||
}
|
||||
|
||||
$output->writeln($this->msg('created', ['{path}' => $this->toRelativePath($file)]));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for model name with a default value.
|
||||
*/
|
||||
protected function promptForModelNameWithDefault(InputInterface $input, OutputInterface $output, string $default): string
|
||||
{
|
||||
$default = $this->normalizeClassLikeName($default ?: 'Model');
|
||||
$question = new Question($this->msg('enter_model_name_prompt', ['{default}' => $default]), $default);
|
||||
$answer = $this->askOrAbort($input, $output, $question);
|
||||
$answer = is_string($answer) ? trim($answer) : '';
|
||||
$answer = $answer !== '' ? $answer : $default;
|
||||
return $this->normalizeClassLikeName($answer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for model path with a default value.
|
||||
*/
|
||||
protected function promptForModelPathWithDefault(InputInterface $input, OutputInterface $output, string $default): string
|
||||
{
|
||||
$default = $this->normalizeRelativePath($default ?: 'app/model');
|
||||
$question = new Question($this->msg('enter_model_path_prompt', ['{default}' => $default]), $default);
|
||||
$answer = $this->askOrAbort($input, $output, $question);
|
||||
$answer = is_string($answer) ? trim($answer) : '';
|
||||
$answer = $answer !== '' ? $answer : $default;
|
||||
return $this->normalizeRelativePath($answer ?: $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize "class-like" names with optional sub paths, e.g.:
|
||||
* - "admin/user_profile" => "admin/UserProfile"
|
||||
* - "Admin\\User" => "admin/User"
|
||||
*/
|
||||
protected function normalizeClassLikeName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '';
|
||||
}
|
||||
$name = str_replace('\\', '/', $name);
|
||||
$name = trim($name, '/');
|
||||
$segments = array_values(array_filter(explode('/', $name), static fn(string $s): bool => $s !== ''));
|
||||
$segments = array_map(static fn(string $seg): string => Util::nameToClass($seg), $segments);
|
||||
return implode('/', $segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app model relative path. Prefer existing "Model/model" directory.
|
||||
*/
|
||||
protected function getAppModelRelativePath(): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('model');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $class
|
||||
* @param $namespace
|
||||
* @param $file
|
||||
* @param string|null $connection
|
||||
* @param string|null $table
|
||||
* @param OutputInterface|null $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createModel($class, $namespace, $file, $connection = null, $table = null, ?OutputInterface $output = null)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$table_base = Util::classToName($class);
|
||||
$table_val = 'null';
|
||||
$meta_table = null;
|
||||
$pk = 'id';
|
||||
$properties = '';
|
||||
$connection = $connection ?: config('database.default');
|
||||
$timestamps = 'false';
|
||||
$hasCreatedAt = false;
|
||||
$hasUpdatedAt = false;
|
||||
$dbFailed = false;
|
||||
$dbFailed = false;
|
||||
try {
|
||||
$connectionConfig = $this->getLaravelConnectionConfig((string)$connection);
|
||||
$prefix = (string)($connectionConfig['prefix'] ?? '');
|
||||
$database = (string)($connectionConfig['database'] ?? '');
|
||||
$driver = (string)($connectionConfig['driver'] ?? 'mysql');
|
||||
$inflector = InflectorFactory::create()->build();
|
||||
$table_plura = $inflector->pluralize($inflector->tableize($class));
|
||||
$con = Db::connection($connection);
|
||||
|
||||
// Table resolve
|
||||
if ($table) {
|
||||
$table = ltrim(trim((string)$table), '=');
|
||||
$table_for_model = ($prefix && str_starts_with($table, $prefix)) ? substr($table, strlen($prefix)) : $table;
|
||||
$table_val = "'" . $table_for_model . "'";
|
||||
$meta_table = $prefix . $table_for_model;
|
||||
} else {
|
||||
// 检查表是否存在(兼容MySQL和PostgreSQL)
|
||||
if ($driver === 'pgsql') {
|
||||
$schema = (string)($connectionConfig['schema'] ?? 'public');
|
||||
$exists_plura = $con->select("SELECT to_regclass('{$schema}.{$prefix}{$table_plura}') as table_exists");
|
||||
$exists = $con->select("SELECT to_regclass('{$schema}.{$prefix}{$table_base}') as table_exists");
|
||||
|
||||
if (!empty($exists_plura[0]->table_exists)) {
|
||||
$table_val = "'$table_plura'";
|
||||
$meta_table = "{$prefix}{$table_plura}";
|
||||
} else if (!empty($exists[0]->table_exists)) {
|
||||
$table_val = "'$table_base'";
|
||||
$meta_table = "{$prefix}{$table_base}";
|
||||
}
|
||||
} else {
|
||||
if ($con->select("show tables like '{$prefix}{$table_plura}'")) {
|
||||
$table_val = "'$table_plura'";
|
||||
$meta_table = "{$prefix}{$table_plura}";
|
||||
} else if ($con->select("show tables like '{$prefix}{$table_base}'")) {
|
||||
$table_val = "'$table_base'";
|
||||
$meta_table = "{$prefix}{$table_base}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表注释和列信息(兼容MySQL和PostgreSQL)
|
||||
if ($meta_table) {
|
||||
if ($driver === 'pgsql') {
|
||||
$schema = (string)($connectionConfig['schema'] ?? 'public');
|
||||
$tableComment = $con->select("SELECT obj_description('{$schema}.{$meta_table}'::regclass) as table_comment");
|
||||
if (!empty($tableComment) && !empty($tableComment[0]->table_comment)) {
|
||||
$comments = $tableComment[0]->table_comment;
|
||||
$properties .= " * {$meta_table} {$comments}" . PHP_EOL;
|
||||
}
|
||||
|
||||
$columns = $con->select("
|
||||
SELECT
|
||||
a.attname as column_name,
|
||||
format_type(a.atttypid, a.atttypmod) as data_type,
|
||||
CASE WHEN con.contype = 'p' THEN 'PRI' ELSE '' END as column_key,
|
||||
d.description as column_comment
|
||||
FROM pg_catalog.pg_attribute a
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum
|
||||
LEFT JOIN pg_catalog.pg_constraint con ON con.conrelid = a.attrelid AND a.attnum = ANY(con.conkey) AND con.contype = 'p'
|
||||
WHERE a.attrelid = '{$schema}.{$meta_table}'::regclass
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum
|
||||
");
|
||||
|
||||
foreach ($columns as $item) {
|
||||
if ($item->column_key === 'PRI') {
|
||||
$pk = $item->column_name;
|
||||
$item->column_comment = ($item->column_comment ? $item->column_comment . ' ' : '') . "(主键)";
|
||||
}
|
||||
$type = $this->getType($item->data_type);
|
||||
if ($item->column_name === 'created_at') {
|
||||
$hasCreatedAt = true;
|
||||
}
|
||||
if ($item->column_name === 'updated_at') {
|
||||
$hasUpdatedAt = true;
|
||||
}
|
||||
$properties .= " * @property $type \${$item->column_name} " . ($item->column_comment ?? '') . "\n";
|
||||
}
|
||||
|
||||
} else {
|
||||
$tableComment = $con->select('SELECT table_comment FROM information_schema.`TABLES` WHERE table_schema = ? AND table_name = ?', [$database, $meta_table]);
|
||||
if (!empty($tableComment)) {
|
||||
$comments = $tableComment[0]->table_comment ?? $tableComment[0]->TABLE_COMMENT;
|
||||
$properties .= " * {$meta_table} {$comments}" . PHP_EOL;
|
||||
}
|
||||
|
||||
foreach ($con->select("select COLUMN_NAME,DATA_TYPE,COLUMN_KEY,COLUMN_COMMENT from INFORMATION_SCHEMA.COLUMNS where table_name = '$meta_table' and table_schema = '$database' ORDER BY ordinal_position") as $item) {
|
||||
if ($item->COLUMN_KEY === 'PRI') {
|
||||
$pk = $item->COLUMN_NAME;
|
||||
$item->COLUMN_COMMENT .= "(主键)";
|
||||
}
|
||||
$type = $this->getType($item->DATA_TYPE);
|
||||
if ($item->COLUMN_NAME === 'created_at') {
|
||||
$hasCreatedAt = true;
|
||||
}
|
||||
if ($item->COLUMN_NAME === 'updated_at') {
|
||||
$hasUpdatedAt = true;
|
||||
}
|
||||
$properties .= " * @property $type \${$item->COLUMN_NAME} {$item->COLUMN_COMMENT}\n";
|
||||
}
|
||||
}
|
||||
} else if ($table) {
|
||||
$output?->writeln($this->msg('table_not_found_schema', ['{table}' => (string)$table]));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dbFailed = true;
|
||||
$this->reportException($e, $output);
|
||||
}
|
||||
if (!$table && !$meta_table) {
|
||||
$output?->writeln($dbFailed ? $this->msg('table_not_found_empty_db_failed') : $this->msg('table_not_found_empty'));
|
||||
}
|
||||
$properties = rtrim($properties) ?: ' *';
|
||||
$timestamps = $hasCreatedAt && $hasUpdatedAt ? 'true' : 'false';
|
||||
$model_content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace $namespace;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
$properties
|
||||
*/
|
||||
class $class extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected \$connection = '$connection';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected \$table = $table_val;
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected \$primaryKey = '$pk';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public \$timestamps = $timestamps;
|
||||
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $model_content);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param $class
|
||||
* @param $namespace
|
||||
* @param $file
|
||||
* @param string|null $connection
|
||||
* @param string|null $table
|
||||
* @param OutputInterface|null $output
|
||||
* @return void
|
||||
*/
|
||||
protected function createTpModel($class, $namespace, $file, $connection = null, $table = null, ?OutputInterface $output = null)
|
||||
{
|
||||
$path = pathinfo($file, PATHINFO_DIRNAME);
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
$table_base = Util::classToName($class);
|
||||
$is_thinkorm_v2 = class_exists(\support\think\Db::class);
|
||||
$table_val = 'null';
|
||||
$meta_table = null;
|
||||
$pk = 'id';
|
||||
$properties = '';
|
||||
try {
|
||||
$config_name = $is_thinkorm_v2 ? 'think-orm' : 'thinkorm';
|
||||
$connection = $connection ?: (config("$config_name.default") ?: 'mysql');
|
||||
$connectionConfig = $this->getThinkOrmConnectionConfig($config_name, (string)$connection);
|
||||
$prefix = (string)($connectionConfig['prefix'] ?? '');
|
||||
$database = (string)($connectionConfig['database'] ?? '');
|
||||
$driver = (string)($connectionConfig['type'] ?? 'mysql');
|
||||
$inflector = InflectorFactory::create()->build();
|
||||
$table_plural = $inflector->pluralize($inflector->tableize($class));
|
||||
|
||||
if ($is_thinkorm_v2) {
|
||||
$con = \support\think\Db::connect($connection);
|
||||
} else {
|
||||
$con = \think\facade\Db::connect($connection);
|
||||
}
|
||||
|
||||
// Table resolve (ThinkORM Model `$table` will be treated as an exact table name and will NOT apply prefix automatically)
|
||||
if ($table) {
|
||||
$table = ltrim(trim((string)$table), '=');
|
||||
if ($prefix && !str_starts_with($table, $prefix)) {
|
||||
$table = $prefix . $table;
|
||||
}
|
||||
$table_val = "'" . $table . "'";
|
||||
$meta_table = $table;
|
||||
} else {
|
||||
// 检查表是否存在(兼容MySQL和PostgreSQL)
|
||||
if ($driver === 'pgsql') {
|
||||
$schema = (string)($connectionConfig['schema'] ?? 'public');
|
||||
$exists_plural = $con->query("SELECT to_regclass('{$schema}.{$prefix}{$table_plural}') as table_exists");
|
||||
$exists = $con->query("SELECT to_regclass('{$schema}.{$prefix}{$table_base}') as table_exists");
|
||||
|
||||
if (!empty($exists_plural[0]['table_exists'])) {
|
||||
$meta_table = "{$prefix}{$table_plural}";
|
||||
$table_val = "'" . $meta_table . "'";
|
||||
} else if (!empty($exists[0]['table_exists'])) {
|
||||
$meta_table = "{$prefix}{$table_base}";
|
||||
$table_val = "'" . $meta_table . "'";
|
||||
}
|
||||
} else {
|
||||
if ($con->query("show tables like '{$prefix}{$table_plural}'")) {
|
||||
$meta_table = "{$prefix}{$table_plural}";
|
||||
$table_val = "'" . $meta_table . "'";
|
||||
} else if ($con->query("show tables like '{$prefix}{$table_base}'")) {
|
||||
$meta_table = "{$prefix}{$table_base}";
|
||||
$table_val = "'" . $meta_table . "'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表注释和列信息(兼容MySQL和PostgreSQL)
|
||||
if ($meta_table) {
|
||||
if ($driver === 'pgsql') {
|
||||
$schema = (string)($connectionConfig['schema'] ?? 'public');
|
||||
$tableComment = $con->query("SELECT obj_description('{$schema}.{$meta_table}'::regclass) as table_comment");
|
||||
if (!empty($tableComment) && !empty($tableComment[0]['table_comment'])) {
|
||||
$comments = $tableComment[0]['table_comment'];
|
||||
$properties .= " * {$meta_table} {$comments}" . PHP_EOL;
|
||||
}
|
||||
|
||||
$columns = $con->query("
|
||||
SELECT
|
||||
a.attname as column_name,
|
||||
format_type(a.atttypid, a.atttypmod) as data_type,
|
||||
CASE WHEN con.contype = 'p' THEN 'PRI' ELSE '' END as column_key,
|
||||
d.description as column_comment
|
||||
FROM pg_catalog.pg_attribute a
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum
|
||||
LEFT JOIN pg_catalog.pg_constraint con ON con.conrelid = a.attrelid AND a.attnum = ANY(con.conkey) AND con.contype = 'p'
|
||||
WHERE a.attrelid = '{$schema}.{$meta_table}'::regclass
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum
|
||||
");
|
||||
|
||||
foreach ($columns as $item) {
|
||||
if ($item['column_key'] === 'PRI') {
|
||||
$pk = $item['column_name'];
|
||||
$item['column_comment'] = ($item['column_comment'] ? $item['column_comment'] . ' ' : '') . "(主键)";
|
||||
}
|
||||
$type = $this->getType($item['data_type']);
|
||||
$properties .= " * @property $type \${$item['column_name']} " . ($item['column_comment'] ?? '') . "\n";
|
||||
}
|
||||
} else {
|
||||
$tableComment = $con->query('SELECT table_comment FROM information_schema.`TABLES` WHERE table_schema = ? AND table_name = ?', [$database, $meta_table]);
|
||||
if (!empty($tableComment)) {
|
||||
$comments = $tableComment[0]['table_comment'] ?? $tableComment[0]['TABLE_COMMENT'];
|
||||
$properties .= " * {$meta_table} {$comments}" . PHP_EOL;
|
||||
}
|
||||
|
||||
foreach ($con->query("select COLUMN_NAME,DATA_TYPE,COLUMN_KEY,COLUMN_COMMENT from INFORMATION_SCHEMA.COLUMNS where table_name = '$meta_table' and table_schema = '$database' ORDER BY ordinal_position") as $item) {
|
||||
if ($item['COLUMN_KEY'] === 'PRI') {
|
||||
$pk = $item['COLUMN_NAME'];
|
||||
$item['COLUMN_COMMENT'] .= "(主键)";
|
||||
}
|
||||
$type = $this->getType($item['DATA_TYPE']);
|
||||
$properties .= " * @property $type \${$item['COLUMN_NAME']} {$item['COLUMN_COMMENT']}\n";
|
||||
}
|
||||
}
|
||||
} else if ($table) {
|
||||
$output?->writeln($this->msg('table_not_found_schema', ['{table}' => (string)$table]));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dbFailed = true;
|
||||
$this->reportException($e, $output);
|
||||
}
|
||||
if (!$table && !$meta_table) {
|
||||
$output?->writeln($dbFailed ? $this->msg('table_not_found_empty_db_failed') : $this->msg('table_not_found_empty'));
|
||||
}
|
||||
$properties = rtrim($properties) ?: ' *';
|
||||
$modelNamespace = $is_thinkorm_v2 ? 'support\think\Model' : 'think\Model';
|
||||
$model_content = <<<EOF
|
||||
<?php
|
||||
|
||||
namespace $namespace;
|
||||
|
||||
use $modelNamespace;
|
||||
|
||||
/**
|
||||
$properties
|
||||
*/
|
||||
class $class extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected \$connection = '$connection';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected \$table = $table_val;
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected \$pk = '$pk';
|
||||
|
||||
|
||||
}
|
||||
|
||||
EOF;
|
||||
file_put_contents($file, $model_content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Throwable $e
|
||||
* @param OutputInterface|null $output
|
||||
* @return void
|
||||
*/
|
||||
protected function reportException(\Throwable $e, ?OutputInterface $output = null): void
|
||||
{
|
||||
if ($output) {
|
||||
$this->writeDbNativeErrorOnce($output, $e, 'comment');
|
||||
return;
|
||||
}
|
||||
echo $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model namespace/file path by --plugin/-p or --path/-P.
|
||||
* - --plugin/-p: generate under plugin/<plugin>/app/model by default.
|
||||
* - --path/-P: generate under the specified directory (relative to base path).
|
||||
* - If both are provided, they must point to the same directory, otherwise it's an error.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $plugin
|
||||
* @param string|null $path
|
||||
* @param OutputInterface $output
|
||||
* @return array{0:string,1:string,2:string}|null [class, namespace, file]
|
||||
*/
|
||||
protected function resolveModelTargetByPluginOrPath(string $name, ?string $plugin, ?string $path, OutputInterface $output): ?array
|
||||
{
|
||||
$pathNorm = $path ? $this->normalizeRelativePath($path) : null;
|
||||
if ($pathNorm !== null && $this->isAbsolutePath($pathNorm)) {
|
||||
$output->writeln($this->msg('invalid_path', ['{path}' => $path]));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate plugin existence (from --plugin/-p or inferred from --path/-P).
|
||||
$pluginToCheck = $this->normalizeOptionValue($plugin) ?: $this->extractPluginNameFromRelativePath($pathNorm);
|
||||
if ($pluginToCheck && !$this->assertPluginExists($pluginToCheck, $output)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expected = null;
|
||||
if ($plugin) {
|
||||
$expected = $this->getPluginModelRelativePath($plugin);
|
||||
}
|
||||
|
||||
if ($plugin && $pathNorm) {
|
||||
$pluginPrefix = 'plugin/' . $plugin . '/';
|
||||
if (!str_starts_with(strtolower($pathNorm), strtolower($pluginPrefix))) {
|
||||
$output->writeln(strtr(
|
||||
Util::selectByLocale(Messages::getPluginPathConflictMessage()),
|
||||
['{plugin}' => $plugin, '{path}' => $pathNorm]
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$targetRel = $pathNorm ?: $expected;
|
||||
if (!$targetRel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetDir = base_path($targetRel);
|
||||
$namespaceRoot = trim(str_replace('/', '\\', $targetRel), '\\');
|
||||
|
||||
if (!($pos = strrpos($name, '/'))) {
|
||||
$class = ucfirst($name);
|
||||
$subPath = '';
|
||||
} else {
|
||||
$subPath = substr($name, 0, $pos);
|
||||
$class = ucfirst(substr($name, $pos + 1));
|
||||
}
|
||||
|
||||
$subDir = $subPath ? str_replace('/', DIRECTORY_SEPARATOR, $subPath) . DIRECTORY_SEPARATOR : '';
|
||||
$file = $targetDir . DIRECTORY_SEPARATOR . $subDir . $class . '.php';
|
||||
$namespace = $namespaceRoot . ($subPath ? '\\' . str_replace('/', '\\', $subPath) : '');
|
||||
|
||||
return [$class, $namespace, $file];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin
|
||||
* @return string relative path
|
||||
*/
|
||||
protected function getPluginModelRelativePath(string $plugin): string
|
||||
{
|
||||
return Util::getDefaultAppRelativePath('model', $plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded CLI messages without translation module.
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $replace
|
||||
* @return string
|
||||
*/
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
return strtr(Util::selectLocaleMessages(Messages::getMakeModelMessages())[$key] ?? $key, $replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (multilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(Messages::getMakeModelHelpText());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @return string
|
||||
*/
|
||||
protected function getType(string $type)
|
||||
{
|
||||
if (strpos($type, 'int') !== false) {
|
||||
return 'integer';
|
||||
}
|
||||
|
||||
if (strpos($type, 'character varying') !== false || strpos($type, 'varchar') !== false) {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
if (strpos($type, 'timestamp') !== false) {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'varchar':
|
||||
case 'string':
|
||||
case 'text':
|
||||
case 'date':
|
||||
case 'time':
|
||||
case 'guid':
|
||||
case 'datetimetz':
|
||||
case 'datetime':
|
||||
case 'decimal':
|
||||
case 'enum':
|
||||
case 'character': // PostgreSQL类型
|
||||
case 'char': // PostgreSQL类型
|
||||
case 'json': // PostgreSQL类型
|
||||
case 'jsonb': // PostgreSQL类型
|
||||
case 'uuid': // PostgreSQL类型
|
||||
case 'timestamptz': // PostgreSQL类型
|
||||
case 'citext': // PostgreSQL类型
|
||||
return 'string';
|
||||
case 'boolean':
|
||||
case 'bool': // PostgreSQL类型
|
||||
return 'bool';
|
||||
case 'float':
|
||||
case 'float4': // PostgreSQL类型 (real)
|
||||
case 'float8': // PostgreSQL类型 (double precision)
|
||||
return 'float';
|
||||
case 'numeric': // PostgreSQL类型
|
||||
return 'string';
|
||||
default:
|
||||
return 'mixed';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
|
||||
#[AsCommand('plugin:create', 'Create plugin')]
|
||||
class PluginCreateCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin');
|
||||
$this->addUsage('--name foo/my-admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('create_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$namespace = Util::nameToNamespace($nameRaw);
|
||||
|
||||
// Create dir config/plugin/$name
|
||||
if (is_dir($plugin_config_path = config_path()."/plugin/$nameRaw")) {
|
||||
$output->writeln($this->pluginMsg('dir_exists', ['{path}' => $this->toRelativePath($plugin_config_path)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (is_dir($plugin_path = base_path()."/vendor/$nameRaw")) {
|
||||
$output->writeln($this->pluginMsg('dir_exists', ['{path}' => $this->toRelativePath($plugin_path)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Add psr-4
|
||||
$output->writeln($this->pluginMsg('step_psr4', [
|
||||
'{key}' => rtrim($namespace, '\\') . '\\',
|
||||
'{path}' => "vendor/{$nameRaw}/src",
|
||||
]));
|
||||
if ($err = $this->addAutoloadToComposerJson($nameRaw, $namespace)) {
|
||||
$output->writeln($this->pluginMsg('psr4_failed', ['{error}' => $err]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln($this->pluginMsg('psr4_ok'));
|
||||
|
||||
$output->writeln($this->pluginMsg('step_config', ['{path}' => $this->toRelativePath($plugin_config_path)]));
|
||||
if ($err = $this->createConfigFiles($plugin_config_path)) {
|
||||
$output->writeln($this->pluginMsg('create_failed', ['{error}' => $err]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln($this->pluginMsg('created', ['{path}' => $this->toRelativePath($plugin_config_path . '/app.php')]));
|
||||
|
||||
$output->writeln($this->pluginMsg('step_vendor', ['{path}' => $this->toRelativePath($plugin_path)]));
|
||||
if ($err = $this->createVendorFiles($nameRaw, $namespace, $plugin_path, $output)) {
|
||||
$output->writeln($this->pluginMsg('create_failed', ['{error}' => $err]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('done', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param string $namespace
|
||||
* @return string|null error message
|
||||
*/
|
||||
protected function addAutoloadToComposerJson(string $name, string $namespace): ?string
|
||||
{
|
||||
if (!is_file($composer_json_file = base_path()."/composer.json")) {
|
||||
return "$composer_json_file not exists";
|
||||
}
|
||||
$composer_json_str = file_get_contents($composer_json_file);
|
||||
if (!is_string($composer_json_str) || $composer_json_str === '') {
|
||||
return "Bad $composer_json_file";
|
||||
}
|
||||
$composer_json = json_decode($composer_json_str, true);
|
||||
if (!$composer_json) {
|
||||
return "Bad $composer_json_file";
|
||||
}
|
||||
|
||||
$psr4Key = rtrim($namespace, '\\') . "\\";
|
||||
$psr4Path = "vendor/$name/src";
|
||||
|
||||
if (isset($composer_json['autoload']['psr-4'][$psr4Key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer surgical insertion to avoid rewriting whole composer.json format.
|
||||
$line = json_encode($psr4Key, JSON_UNESCAPED_SLASHES) . ': ' . json_encode($psr4Path, JSON_UNESCAPED_SLASHES) . ",\n";
|
||||
$pattern = '/("psr-4"\s*:\s*\{\s*\n)(\s*)"/';
|
||||
if (preg_match($pattern, $composer_json_str)) {
|
||||
$patched = preg_replace_callback($pattern, static function ($m) use ($line) {
|
||||
return $m[1] . $m[2] . $line . $m[2] . '"';
|
||||
}, $composer_json_str, 1);
|
||||
if (is_string($patched) && json_decode($patched, true)) {
|
||||
file_put_contents($composer_json_file, $patched);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: rewrite composer.json (stable, but may change formatting).
|
||||
if (!isset($composer_json['autoload']) || !is_array($composer_json['autoload'])) {
|
||||
$composer_json['autoload'] = [];
|
||||
}
|
||||
if (!isset($composer_json['autoload']['psr-4']) || !is_array($composer_json['autoload']['psr-4'])) {
|
||||
$composer_json['autoload']['psr-4'] = [];
|
||||
}
|
||||
$composer_json['autoload']['psr-4'] = [$psr4Key => $psr4Path] + $composer_json['autoload']['psr-4'];
|
||||
$encoded = json_encode($composer_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($encoded) || $encoded === '') {
|
||||
return "Bad $composer_json_file";
|
||||
}
|
||||
file_put_contents($composer_json_file, $encoded . "\n");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $plugin_config_path
|
||||
* @return string|null error message
|
||||
*/
|
||||
protected function createConfigFiles(string $plugin_config_path): ?string
|
||||
{
|
||||
if (!mkdir($plugin_config_path, 0777, true) && !is_dir($plugin_config_path)) {
|
||||
return "Unable to create directory {$plugin_config_path}";
|
||||
}
|
||||
$app_str = <<<EOF
|
||||
<?php
|
||||
return [
|
||||
'enable' => true,
|
||||
];
|
||||
EOF;
|
||||
$ret = file_put_contents("$plugin_config_path/app.php", $app_str);
|
||||
if ($ret === false) {
|
||||
return "Unable to write file {$plugin_config_path}/app.php";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param string $namespace
|
||||
* @param string $plugin_path
|
||||
* @param OutputInterface $output
|
||||
* @return string|null error message
|
||||
*/
|
||||
protected function createVendorFiles(string $name, string $namespace, string $plugin_path, OutputInterface $output): ?string
|
||||
{
|
||||
if (!mkdir("$plugin_path/src", 0777, true) && !is_dir("$plugin_path/src")) {
|
||||
return "Unable to create directory {$plugin_path}/src";
|
||||
}
|
||||
if (!$this->createComposerJson($name, $namespace, $plugin_path)) {
|
||||
return "Unable to write file {$plugin_path}/composer.json";
|
||||
}
|
||||
$output->writeln($this->pluginMsg('created', ['{path}' => $this->toRelativePath($plugin_path . '/composer.json')]));
|
||||
|
||||
$baseCmd = "composer dumpautoload";
|
||||
$result = Util::exec("$baseCmd --no-interaction");
|
||||
if ($result === null) {
|
||||
$output->writeln($this->pluginMsg('dumpautoload_manual', ['{cmd}' => $baseCmd]));
|
||||
return null;
|
||||
}
|
||||
if ($result['exit_code'] !== 0) {
|
||||
$output->writeln($this->pluginMsg('dumpautoload_failed', ['{cmd}' => $baseCmd]));
|
||||
return null;
|
||||
}
|
||||
$output->writeln($this->pluginMsg('dumpautoload_ok'));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @param string $namespace
|
||||
* @param string $dest
|
||||
* @return bool
|
||||
*/
|
||||
protected function createComposerJson(string $name, string $namespace, string $dest): bool
|
||||
{
|
||||
$namespace = str_replace('\\', '\\\\', $namespace);
|
||||
$composer_json_content = <<<EOT
|
||||
{
|
||||
"name": "$name",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"description": "Webman plugin $name",
|
||||
"require": {
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"$namespace\\\\": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOT;
|
||||
return file_put_contents("$dest/composer.json", $composer_json_content) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command help text (bilingual).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginCreateHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('plugin:disable', 'Disable plugin by name')]
|
||||
class PluginDisableCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin');
|
||||
$this->addUsage('--name foo/my-admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('disable_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$configFile = config_path() . "/plugin/{$nameRaw}/app.php";
|
||||
$output->writeln($this->pluginMsg('config_file', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
|
||||
$res = $this->setPluginEnableFlag($configFile, false);
|
||||
if (!$res['ok']) {
|
||||
$output->writeln($this->pluginMsg('update_failed', ['{error}' => (string)$res['error']]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($res['missingFile']) {
|
||||
$output->writeln($this->pluginMsg('config_missing', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
// Disable is idempotent: missing config means nothing to disable.
|
||||
$output->writeln($this->pluginMsg('disabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($res['missingKey']) {
|
||||
$output->writeln($this->pluginMsg('enable_key_missing', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
$output->writeln($this->pluginMsg('disabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($res['already']) {
|
||||
$output->writeln($this->pluginMsg('already_disabled'));
|
||||
$output->writeln($this->pluginMsg('disabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('updated_ok', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
$output->writeln($this->pluginMsg('disabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginDisableHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('plugin:enable', 'Enable plugin by name')]
|
||||
class PluginEnableCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin');
|
||||
$this->addUsage('--name foo/my-admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('enable_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$configFile = config_path() . "/plugin/{$nameRaw}/app.php";
|
||||
$output->writeln($this->pluginMsg('config_file', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
|
||||
$res = $this->setPluginEnableFlag($configFile, true);
|
||||
if (!$res['ok']) {
|
||||
$output->writeln($this->pluginMsg('update_failed', ['{error}' => (string)$res['error']]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($res['missingFile']) {
|
||||
$output->writeln($this->pluginMsg('config_missing', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($res['missingKey']) {
|
||||
$output->writeln($this->pluginMsg('config_key_missing', ['{key}' => 'enable', '{path}' => $this->toRelativePath($configFile)]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if ($res['already']) {
|
||||
$output->writeln($this->pluginMsg('already_enabled'));
|
||||
$output->writeln($this->pluginMsg('enabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('updated_ok', ['{path}' => $this->toRelativePath($configFile)]));
|
||||
$output->writeln($this->pluginMsg('enabled_ok', ['{name}' => $nameRaw]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginEnableHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
|
||||
#[AsCommand('plugin:export', 'Plugin export')]
|
||||
class PluginExportCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->addOption('source', 's', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, $this->pluginMsg('description_source'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin --source app --source config');
|
||||
$this->addUsage('--name foo/my-admin --source app --source config');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('export_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$namespace = Util::nameToNamespace($nameRaw);
|
||||
|
||||
$pathRelations = $input->getOption('source');
|
||||
$pathRelations = is_array($pathRelations) ? $pathRelations : [];
|
||||
$pathRelations = array_values(array_filter(array_map('trim', $pathRelations), static fn($v) => $v !== ''));
|
||||
|
||||
$pluginConfigDir = "config/plugin/{$nameRaw}";
|
||||
if (!in_array($pluginConfigDir, $pathRelations, true) && is_dir($pluginConfigDir)) {
|
||||
$pathRelations[] = $pluginConfigDir;
|
||||
}
|
||||
|
||||
$originalDest = base_path() . "/vendor/{$nameRaw}";
|
||||
$dest = $originalDest . '/src';
|
||||
|
||||
$this->writeInstallFile($namespace, $pathRelations, $dest);
|
||||
$output->writeln($this->pluginMsg('export_install_created', ['{path}' => $this->toRelativePath($dest . '/Install.php')]));
|
||||
|
||||
foreach ($pathRelations as $source) {
|
||||
$source = $this->normalizeRelativePath((string)$source);
|
||||
if ($source === '' || (!is_dir($source) && !is_file($source))) {
|
||||
$output->writeln($this->pluginMsg('export_skip_missing', ['{path}' => $source]));
|
||||
continue;
|
||||
}
|
||||
$basePath = pathinfo("{$dest}/{$source}", PATHINFO_DIRNAME);
|
||||
if (!is_dir($basePath)) {
|
||||
mkdir($basePath, 0777, true);
|
||||
}
|
||||
$output->writeln($this->pluginMsg('export_copy', [
|
||||
'{src}' => $source,
|
||||
'{dest}' => $this->toRelativePath("{$dest}/{$source}"),
|
||||
]));
|
||||
copy_dir($source, "{$dest}/{$source}");
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('export_saved', [
|
||||
'{name}' => $nameRaw,
|
||||
'{dest}' => $this->toRelativePath($originalDest),
|
||||
]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $namespace
|
||||
* @param $path_relations
|
||||
* @param $dest_dir
|
||||
* @return void
|
||||
*/
|
||||
protected function writeInstallFile($namespace, $path_relations, $dest_dir)
|
||||
{
|
||||
if (!is_dir($dest_dir)) {
|
||||
mkdir($dest_dir, 0777, true);
|
||||
}
|
||||
$relations = [];
|
||||
foreach($path_relations as $relation) {
|
||||
$relations[$relation] = $relation;
|
||||
}
|
||||
$relations = var_export($relations, true);
|
||||
$install_php_content = <<<EOT
|
||||
<?php
|
||||
namespace $namespace;
|
||||
|
||||
class Install
|
||||
{
|
||||
const WEBMAN_PLUGIN = true;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected static \$pathRelation = $relations;
|
||||
|
||||
/**
|
||||
* Install
|
||||
* @return void
|
||||
*/
|
||||
public static function install()
|
||||
{
|
||||
static::installByRelation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall
|
||||
* @return void
|
||||
*/
|
||||
public static function uninstall()
|
||||
{
|
||||
self::uninstallByRelation();
|
||||
}
|
||||
|
||||
/**
|
||||
* installByRelation
|
||||
* @return void
|
||||
*/
|
||||
public static function installByRelation()
|
||||
{
|
||||
foreach (static::\$pathRelation as \$source => \$dest) {
|
||||
if (\$pos = strrpos(\$dest, '/')) {
|
||||
\$parent_dir = base_path().'/'.substr(\$dest, 0, \$pos);
|
||||
if (!is_dir(\$parent_dir)) {
|
||||
mkdir(\$parent_dir, 0777, true);
|
||||
}
|
||||
}
|
||||
//symlink(__DIR__ . "/\$source", base_path()."/\$dest");
|
||||
copy_dir(__DIR__ . "/\$source", base_path()."/\$dest");
|
||||
echo "Create \$dest\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* uninstallByRelation
|
||||
* @return void
|
||||
*/
|
||||
public static function uninstallByRelation()
|
||||
{
|
||||
foreach (static::\$pathRelation as \$source => \$dest) {
|
||||
\$path = base_path()."/\$dest";
|
||||
if (!is_dir(\$path) && !is_file(\$path)) {
|
||||
continue;
|
||||
}
|
||||
echo "Remove \$dest\r\n";
|
||||
if (is_file(\$path) || is_link(\$path)) {
|
||||
unlink(\$path);
|
||||
continue;
|
||||
}
|
||||
remove_dir(\$path);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
EOT;
|
||||
file_put_contents("$dest_dir/Install.php", $install_php_content);
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginExportHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
|
||||
#[AsCommand('plugin:install', 'Execute plugin installation script')]
|
||||
class PluginInstallCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin');
|
||||
$this->addUsage('--name foo/my-admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->pluginPackageExists($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('plugin_not_found', [
|
||||
'{name}' => $nameRaw,
|
||||
'{path}' => "vendor/{$nameRaw}",
|
||||
]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('install_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$namespace = Util::nameToNamespace($nameRaw);
|
||||
$installFunction = "\\{$namespace}\\Install::install";
|
||||
$pluginConst = "\\{$namespace}\\Install::WEBMAN_PLUGIN";
|
||||
if (!defined($pluginConst) || !is_callable($installFunction)) {
|
||||
$output->writeln($this->pluginMsg('script_missing'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$installFunction();
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln($this->pluginMsg('script_failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('script_ok'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginInstallHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Commands\Concerns\PluginCommandHelpers;
|
||||
|
||||
#[AsCommand('plugin:uninstall', 'Execute plugin uninstall script')]
|
||||
class PluginUninstallCommand extends Command
|
||||
{
|
||||
use PluginCommandHelpers;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
// Do NOT use "-n": Symfony Console already reserves "-n" for "--no-interaction".
|
||||
$this->addArgument('name', InputArgument::OPTIONAL, $this->pluginMsg('description_name'));
|
||||
$this->addOption('name', null, InputOption::VALUE_REQUIRED, $this->pluginMsg('description_name'));
|
||||
$this->setHelp($this->buildHelpText());
|
||||
$this->addUsage('foo/my-admin');
|
||||
$this->addUsage('--name foo/my-admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$nameArg = $this->normalizePluginName($input->getArgument('name'));
|
||||
$nameOpt = $this->normalizePluginName($input->getOption('name'));
|
||||
if ($nameArg && $nameOpt && $nameArg !== $nameOpt) {
|
||||
$output->writeln($this->pluginMsg('name_conflict', ['{arg}' => $nameArg, '{opt}' => $nameOpt]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$nameRaw = $nameOpt ?: $nameArg;
|
||||
if (!$nameRaw) {
|
||||
$output->writeln($this->pluginMsg('missing_name'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!$this->isValidComposerPackageName($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('bad_name', ['{name}' => (string)$nameRaw]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->pluginPackageExists($nameRaw)) {
|
||||
$output->writeln($this->pluginMsg('plugin_not_found', [
|
||||
'{name}' => $nameRaw,
|
||||
'{path}' => "vendor/{$nameRaw}",
|
||||
]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('uninstall_title', ['{name}' => $nameRaw]));
|
||||
|
||||
$namespace = Util::nameToNamespace($nameRaw);
|
||||
$uninstallFunction = "\\{$namespace}\\Install::uninstall";
|
||||
$pluginConst = "\\{$namespace}\\Install::WEBMAN_PLUGIN";
|
||||
if (!defined($pluginConst) || !is_callable($uninstallFunction)) {
|
||||
$output->writeln($this->pluginMsg('script_missing'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$uninstallFunction();
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln($this->pluginMsg('script_failed', ['{error}' => $e->getMessage()]));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln($this->pluginMsg('script_ok'));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function buildHelpText(): string
|
||||
{
|
||||
return Util::selectByLocale(\Webman\Console\Messages::getPluginUninstallHelpText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('restart', 'Restart workers. Use mode -d to start in DAEMON mode. Use mode -g to stop gracefully.')]
|
||||
class ReStartCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure() : void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['restart_desc']);
|
||||
$this
|
||||
->addOption('daemon', 'd', InputOption::VALUE_NONE, $messages['daemon_option'])
|
||||
->addOption('graceful', 'g', InputOption::VALUE_NONE, $messages['graceful_stop']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('reload', 'Reload codes. Use mode -g to reload gracefully.')]
|
||||
class ReloadCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure() : void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['reload_desc']);
|
||||
$this->addOption('graceful', 'g', InputOption::VALUE_NONE, $messages['graceful_reload']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Webman\Console\Commands\Concerns\BaseCommandHelpers;
|
||||
use Webman\Console\Util;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Route;
|
||||
|
||||
#[AsCommand('route:list', 'Route list')]
|
||||
class RouteListCommand extends Command
|
||||
{
|
||||
use BaseCommandHelpers;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$desc = $this->msg('desc');
|
||||
$this->setDescription($desc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln($this->msg('title'));
|
||||
$headers = $this->msg('headers');
|
||||
$closureLabel = $this->msg('closure_label');
|
||||
$rows = [];
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
foreach ($route->getMethods() as $method) {
|
||||
$cb = $route->getCallback();
|
||||
$cb = $cb instanceof \Closure
|
||||
? $closureLabel
|
||||
: (is_array($cb) ? json_encode($cb) : var_export($cb, 1));
|
||||
$rows[] = [$route->getPath(), $method, $cb, json_encode($route->getMiddleware() ?: null), $route->getName()];
|
||||
}
|
||||
}
|
||||
|
||||
$table = new Table($output);
|
||||
$table->setHeaders($headers);
|
||||
$table->setRows($rows);
|
||||
$table->render();
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function msg(string $key, array $replace = []): mixed
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getRouteListMessages());
|
||||
return $this->getLocalizedMessage($messages, $key, $replace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('start', 'Start worker in DEBUG mode. Use mode -d to start in DAEMON mode.')]
|
||||
class StartCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure() : void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['start_desc']);
|
||||
$this->addOption('daemon', 'd', InputOption::VALUE_NONE, $messages['daemon_option']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('status', 'Get worker status. Use mode -d to show live status.')]
|
||||
class StatusCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure() : void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['status_desc']);
|
||||
$this->addOption('live', 'd', InputOption::VALUE_NONE, $messages['live_status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Webman\Console\Commands\Concerns\ServiceCommandExecutor;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('stop', 'Stop worker. Use mode -g to stop gracefully.')]
|
||||
class StopCommand extends Command
|
||||
{
|
||||
use ServiceCommandExecutor;
|
||||
|
||||
protected function configure() : void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getServiceMessages());
|
||||
$this->setDescription($messages['stop_desc']);
|
||||
$this->addOption('graceful', 'g',InputOption::VALUE_NONE, $messages['graceful_stop']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->executeServiceCommand();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console\Commands;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Webman\Console\Commands\Concerns\BaseCommandHelpers;
|
||||
use Webman\Console\Messages;
|
||||
use Webman\Console\Util;
|
||||
|
||||
#[AsCommand('version', 'Show webman version')]
|
||||
class VersionCommand extends Command
|
||||
{
|
||||
use BaseCommandHelpers;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getVersionMessages());
|
||||
$this->setDescription($messages['desc']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$version_info = [];
|
||||
$installed_file = base_path() . '/vendor/composer/installed.php';
|
||||
if (is_file($installed_file)) {
|
||||
$version_info = include $installed_file;
|
||||
}
|
||||
$webman_framework_version = $version_info['versions']['workerman/webman-framework']['pretty_version'] ?? null;
|
||||
$webman_framework_version = is_string($webman_framework_version) ? trim($webman_framework_version) : '';
|
||||
if ($webman_framework_version === '') {
|
||||
$output->writeln($this->msg('not_found'));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln($this->msg('version', ['{version}' => $webman_framework_version]));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function msg(string $key, array $replace = []): string
|
||||
{
|
||||
$messages = Util::selectLocaleMessages(Messages::getVersionMessages());
|
||||
return $this->getLocalizedMessage($messages, $key, $replace);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
namespace Webman\Console;
|
||||
|
||||
class Install
|
||||
{
|
||||
const WEBMAN_PLUGIN = true;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected static $pathRelation = [
|
||||
'config/plugin/webman/console' => 'config/plugin/webman/console',
|
||||
];
|
||||
|
||||
/**
|
||||
* Install
|
||||
* @return void
|
||||
*/
|
||||
public static function install()
|
||||
{
|
||||
$dest = base_path() . "/webman";
|
||||
if (is_dir($dest)) {
|
||||
echo "Installation failed, please remove directory $dest\n";
|
||||
return;
|
||||
}
|
||||
copy(__DIR__ . "/webman", $dest);
|
||||
chmod(base_path() . "/webman", 0755);
|
||||
|
||||
static::installByRelation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall
|
||||
* @return void
|
||||
*/
|
||||
public static function uninstall()
|
||||
{
|
||||
if (is_file(base_path()."/webman")) {
|
||||
unlink(base_path() . "/webman");
|
||||
}
|
||||
self::uninstallByRelation();
|
||||
}
|
||||
|
||||
/**
|
||||
* installByRelation
|
||||
* @return void
|
||||
*/
|
||||
public static function installByRelation()
|
||||
{
|
||||
foreach (static::$pathRelation as $source => $dest) {
|
||||
if ($pos = strrpos($dest, '/')) {
|
||||
$parent_dir = base_path().'/'.substr($dest, 0, $pos);
|
||||
if (!is_dir($parent_dir)) {
|
||||
mkdir($parent_dir, 0777, true);
|
||||
}
|
||||
}
|
||||
copy_dir(__DIR__ . "/$source", base_path()."/$dest");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* uninstallByRelation
|
||||
* @return void
|
||||
*/
|
||||
public static function uninstallByRelation()
|
||||
{
|
||||
foreach (static::$pathRelation as $source => $dest) {
|
||||
$path = base_path()."/$dest";
|
||||
if (!is_dir($path) && !is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
remove_dir($path);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+3758
File diff suppressed because it is too large
Load Diff
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Webman\Console;
|
||||
|
||||
final class OrmType
|
||||
{
|
||||
public const LARAVEL = 'laravel';
|
||||
public const THINKORM = 'thinkorm';
|
||||
}
|
||||
Vendored
+495
@@ -0,0 +1,495 @@
|
||||
<?php
|
||||
namespace Webman\Console;
|
||||
|
||||
use Doctrine\Inflector\InflectorFactory;
|
||||
|
||||
class Util
|
||||
{
|
||||
/**
|
||||
* Get current locale for CLI messages. Default is en.
|
||||
* All commands can use this to get the current language for prompts.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getLocale(): string
|
||||
{
|
||||
$locale = 'en';
|
||||
if (function_exists('config')) {
|
||||
$value = config('translation.locale', 'en');
|
||||
$value = is_string($value) ? trim($value) : '';
|
||||
if ($value !== '') {
|
||||
$locale = $value;
|
||||
}
|
||||
}
|
||||
return $locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select message map by current locale. Fallback: exact -> language prefix -> en -> zh_CN -> first.
|
||||
*
|
||||
* @param array<string, array<string, string>> $localeToMessages e.g. ['zh_CN' => ['key' => '...'], 'en' => [...]]
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function selectLocaleMessages(array $localeToMessages): array
|
||||
{
|
||||
$locale = self::getLocale();
|
||||
if (isset($localeToMessages[$locale])) {
|
||||
return $localeToMessages[$locale];
|
||||
}
|
||||
$lang = explode('_', $locale)[0] ?? '';
|
||||
if ($lang !== '' && isset($localeToMessages[$lang])) {
|
||||
return $localeToMessages[$lang];
|
||||
}
|
||||
// Use configured fallback locales if available, otherwise default to ['en'].
|
||||
$fallbacks = ['en'];
|
||||
if (function_exists('config')) {
|
||||
$cfg = config('translation.fallback_locale', $fallbacks);
|
||||
if (is_string($cfg)) {
|
||||
$cfg = [$cfg];
|
||||
}
|
||||
if (is_array($cfg) && !empty($cfg)) {
|
||||
$fallbacks = $cfg;
|
||||
}
|
||||
}
|
||||
foreach ($fallbacks as $fb) {
|
||||
if (isset($localeToMessages[$fb])) {
|
||||
return $localeToMessages[$fb];
|
||||
}
|
||||
}
|
||||
$first = reset($localeToMessages);
|
||||
return is_array($first) ? $first : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one value by current locale. Fallback: exact -> language prefix -> en -> zh_CN -> first.
|
||||
*
|
||||
* @param array<string, string> $localeToValue e.g. ['zh_CN' => '简体', 'en' => 'English']
|
||||
* @return string
|
||||
*/
|
||||
public static function selectByLocale(array $localeToValue): string
|
||||
{
|
||||
$locale = self::getLocale();
|
||||
if (isset($localeToValue[$locale])) {
|
||||
return $localeToValue[$locale];
|
||||
}
|
||||
$lang = explode('_', $locale)[0] ?? '';
|
||||
if ($lang !== '' && isset($localeToValue[$lang])) {
|
||||
return $localeToValue[$lang];
|
||||
}
|
||||
// Use configured fallback locales if available, otherwise default to ['en'].
|
||||
$fallbacks = ['en'];
|
||||
if (function_exists('config')) {
|
||||
$cfg = config('translation.fallback_locale', $fallbacks);
|
||||
if (is_string($cfg)) {
|
||||
$cfg = [$cfg];
|
||||
}
|
||||
if (is_array($cfg) && !empty($cfg)) {
|
||||
$fallbacks = $cfg;
|
||||
}
|
||||
}
|
||||
foreach ($fallbacks as $fb) {
|
||||
if (isset($localeToValue[$fb])) {
|
||||
return $localeToValue[$fb];
|
||||
}
|
||||
}
|
||||
$first = reset($localeToValue);
|
||||
return is_string($first) ? $first : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an array by current locale (e.g. table headers). Fallback: exact -> language prefix -> en -> zh_CN -> first.
|
||||
*
|
||||
* @param array<string, array> $localeToArray e.g. ['zh_CN' => ['A','B'], 'en' => ['A','B']]
|
||||
* @return array
|
||||
*/
|
||||
public static function selectLocaleArray(array $localeToArray): array
|
||||
{
|
||||
$locale = self::getLocale();
|
||||
if (isset($localeToArray[$locale])) {
|
||||
return $localeToArray[$locale];
|
||||
}
|
||||
$lang = explode('_', $locale)[0] ?? '';
|
||||
if ($lang !== '' && isset($localeToArray[$lang])) {
|
||||
return $localeToArray[$lang];
|
||||
}
|
||||
// Use configured fallback locales if available, otherwise default to ['en'].
|
||||
$fallbacks = ['en'];
|
||||
if (function_exists('config')) {
|
||||
$cfg = config('translation.fallback_locale', $fallbacks);
|
||||
if (is_string($cfg)) {
|
||||
$cfg = [$cfg];
|
||||
}
|
||||
if (is_array($cfg) && !empty($cfg)) {
|
||||
$fallbacks = $cfg;
|
||||
}
|
||||
}
|
||||
foreach ($fallbacks as $fb) {
|
||||
if (isset($localeToArray[$fb])) {
|
||||
return $localeToArray[$fb];
|
||||
}
|
||||
}
|
||||
$first = reset($localeToArray);
|
||||
return is_array($first) ? $first : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an external shell command using the best available PHP function.
|
||||
*
|
||||
* Non-interactive (default): captures output, stdin from null device to prevent blocking.
|
||||
* Tries: proc_open -> exec -> system -> passthru -> popen
|
||||
*
|
||||
* Interactive ($interactive = true): passes I/O through to the terminal so the user
|
||||
* can see prompts and respond. Output is not captured (returned as empty string).
|
||||
* Tries: passthru -> system -> proc_open
|
||||
* If no interactive-capable function is available, returns null.
|
||||
*
|
||||
* @param string $command
|
||||
* @param bool $interactive
|
||||
* @return array{output: string, exit_code: int}|null null if no suitable function is available
|
||||
*/
|
||||
public static function exec(string $command, bool $interactive = false): ?array
|
||||
{
|
||||
return $interactive
|
||||
? static::execInteractive($command)
|
||||
: static::execCapture($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with terminal passthrough — user can see output and respond to prompts.
|
||||
*/
|
||||
protected static function execInteractive(string $command): ?array
|
||||
{
|
||||
if (static::isCallable('passthru')) {
|
||||
$code = 0;
|
||||
passthru($command, $code);
|
||||
return ['output' => '', 'exit_code' => $code];
|
||||
}
|
||||
|
||||
if (static::isCallable('system')) {
|
||||
$code = 0;
|
||||
system($command, $code);
|
||||
return ['output' => '', 'exit_code' => $code];
|
||||
}
|
||||
|
||||
if (static::isCallable('proc_open') && defined('STDIN')) {
|
||||
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes);
|
||||
if (is_resource($process)) {
|
||||
return ['output' => '', 'exit_code' => proc_close($process)];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command non-interactively — capture output, stdin from null device to prevent blocking.
|
||||
*/
|
||||
protected static function execCapture(string $command): ?array
|
||||
{
|
||||
$nullDevice = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
|
||||
|
||||
if (static::isCallable('proc_open')) {
|
||||
$process = proc_open($command, [
|
||||
0 => ['file', $nullDevice, 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
if (is_resource($process)) {
|
||||
$stdout = (string)stream_get_contents($pipes[1]);
|
||||
$stderr = (string)stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
return ['output' => trim($stdout . $stderr), 'exit_code' => $exitCode];
|
||||
}
|
||||
}
|
||||
|
||||
$cmd = "$command < $nullDevice 2>&1";
|
||||
|
||||
if (static::isCallable('exec')) {
|
||||
$lines = [];
|
||||
$code = 0;
|
||||
exec($cmd, $lines, $code);
|
||||
return ['output' => implode("\n", $lines), 'exit_code' => $code];
|
||||
}
|
||||
|
||||
if (static::isCallable('system')) {
|
||||
ob_start();
|
||||
$code = 0;
|
||||
system($cmd, $code);
|
||||
return ['output' => trim((string)ob_get_clean()), 'exit_code' => $code];
|
||||
}
|
||||
|
||||
if (static::isCallable('passthru')) {
|
||||
ob_start();
|
||||
$code = 0;
|
||||
passthru($cmd, $code);
|
||||
return ['output' => trim((string)ob_get_clean()), 'exit_code' => $code];
|
||||
}
|
||||
|
||||
if (static::isCallable('popen')) {
|
||||
$handle = popen($cmd, 'r');
|
||||
if ($handle !== false) {
|
||||
$output = (string)stream_get_contents($handle);
|
||||
$exitCode = pclose($handle);
|
||||
return ['output' => trim($output), 'exit_code' => $exitCode];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a function exists and is not disabled.
|
||||
*
|
||||
* @param string $function
|
||||
* @return bool
|
||||
*/
|
||||
public static function isCallable(string $function): bool
|
||||
{
|
||||
return function_exists($function) && is_callable($function);
|
||||
}
|
||||
|
||||
public static function nameToNamespace($name)
|
||||
{
|
||||
$namespace = ucfirst($name);
|
||||
$namespace = preg_replace_callback(['/-([a-zA-Z])/', '/(\/[a-zA-Z])/'], function ($matches) {
|
||||
return strtoupper($matches[1]);
|
||||
}, $namespace);
|
||||
return str_replace('/', '\\' ,ucfirst($namespace));
|
||||
}
|
||||
|
||||
public static function classToName($class)
|
||||
{
|
||||
$class = lcfirst($class);
|
||||
return preg_replace_callback(['/([A-Z])/'], function ($matches) {
|
||||
return '_' . strtolower($matches[1]);
|
||||
}, $class);
|
||||
}
|
||||
|
||||
public static function nameToClass($class)
|
||||
{
|
||||
$class = preg_replace_callback(['/-([a-zA-Z])/', '/_([a-zA-Z])/'], function ($matches) {
|
||||
return strtoupper($matches[1]);
|
||||
}, $class);
|
||||
|
||||
if (!($pos = strrpos($class, '/'))) {
|
||||
$class = ucfirst($class);
|
||||
} else {
|
||||
$path = substr($class, 0, $pos);
|
||||
$class = ucfirst(substr($class, $pos + 1));
|
||||
$class = "$path/$class";
|
||||
}
|
||||
return $class;
|
||||
}
|
||||
|
||||
public static function guessPath($base_path, $name, $return_full_path = false)
|
||||
{
|
||||
if (!is_dir($base_path)) {
|
||||
return false;
|
||||
}
|
||||
$names = explode('/', trim(strtolower($name), '/'));
|
||||
$realname = [];
|
||||
$path = $base_path;
|
||||
foreach ($names as $name) {
|
||||
$finded = false;
|
||||
foreach (scandir($path) ?: [] as $tmp_name) {
|
||||
if (strtolower($tmp_name) === $name && is_dir("$path/$tmp_name")) {
|
||||
$path = "$path/$tmp_name";
|
||||
$realname[] = $tmp_name;
|
||||
$finded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$finded) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$realname = implode(DIRECTORY_SEPARATOR, $realname);
|
||||
return $return_full_path ? get_realpath($base_path . DIRECTORY_SEPARATOR . $realname) : $realname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default directory name for a given type (controller, model, etc.)
|
||||
* by detecting the actual local directory structure.
|
||||
*
|
||||
* Priority when multiple matches exist:
|
||||
* 1. All lowercase (e.g. "controller")
|
||||
* 2. First-letter uppercase (e.g. "Controller")
|
||||
* 3. Any other casing found
|
||||
*
|
||||
* @param string $type Directory type, e.g. "controller", "model", "middleware", "bootstrap", "command", "process", "validation"
|
||||
* @param string|null $plugin Plugin name, e.g. "admin"
|
||||
* @return string The directory name with correct casing, e.g. "controller" or "Controller"
|
||||
*/
|
||||
public static function getDefaultAppPath(string $type, ?string $plugin = null): string
|
||||
{
|
||||
$type = strtolower(trim($type));
|
||||
if ($type === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Determine the base app directory.
|
||||
if ($plugin) {
|
||||
$appDirName = self::detectAppDirName($plugin);
|
||||
$baseDir = base_path('plugin' . DIRECTORY_SEPARATOR . trim($plugin) . DIRECTORY_SEPARATOR . $appDirName);
|
||||
} else {
|
||||
$baseDir = app_path();
|
||||
}
|
||||
|
||||
// Try to find the exact directory on disk (case-insensitive).
|
||||
if (is_dir($baseDir)) {
|
||||
$matches = [];
|
||||
foreach (scandir($baseDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (strtolower($entry) === $type && is_dir($baseDir . DIRECTORY_SEPARATOR . $entry)) {
|
||||
$matches[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
// Priority: all lowercase > first-letter uppercase > others
|
||||
foreach ($matches as $m) {
|
||||
if ($m === $type) {
|
||||
return $m; // all lowercase
|
||||
}
|
||||
}
|
||||
$ucfirst = ucfirst($type);
|
||||
foreach ($matches as $m) {
|
||||
if ($m === $ucfirst) {
|
||||
return $m; // first-letter uppercase
|
||||
}
|
||||
}
|
||||
return $matches[0]; // first found
|
||||
}
|
||||
}
|
||||
|
||||
// No match found. Determine casing convention from existing siblings.
|
||||
return self::guessAppDirCase($type, $baseDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full relative path for a type directory with correct casing.
|
||||
*
|
||||
* Examples:
|
||||
* - getDefaultAppRelativePath('controller') => "app/controller" or "app/Controller"
|
||||
* - getDefaultAppRelativePath('model', 'admin') => "plugin/admin/app/model" or "plugin/admin/app/Model"
|
||||
*
|
||||
* @param string $type
|
||||
* @param string|null $plugin
|
||||
* @return string
|
||||
*/
|
||||
public static function getDefaultAppRelativePath(string $type, ?string $plugin = null): string
|
||||
{
|
||||
$dirName = self::getDefaultAppPath($type, $plugin);
|
||||
if ($plugin) {
|
||||
$appDirName = self::detectAppDirName($plugin);
|
||||
return 'plugin/' . trim($plugin) . '/' . $appDirName . '/' . $dirName;
|
||||
}
|
||||
|
||||
// Detect the actual "app" directory name casing.
|
||||
$appDirName = self::detectAppDirName($plugin);
|
||||
return $appDirName . '/' . $dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a relative path to a PHP namespace, preserving the original casing.
|
||||
*
|
||||
* Examples:
|
||||
* - "app/controller" => "app\controller"
|
||||
* - "App/Controller" => "App\Controller"
|
||||
* - "plugin/admin/app/controller" => "plugin\admin\app\controller"
|
||||
*
|
||||
* @param string $relativePath
|
||||
* @return string
|
||||
*/
|
||||
public static function pathToNamespace(string $relativePath): string
|
||||
{
|
||||
$path = trim(str_replace('\\', '/', $relativePath), '/');
|
||||
return str_replace('/', '\\', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the actual casing of the "app" directory under base_path().
|
||||
*
|
||||
* @param string|null $plugin
|
||||
* @return string "app" or "App" depending on what exists on disk
|
||||
*/
|
||||
public static function detectAppDirName(?string $plugin = null): string
|
||||
{
|
||||
if ($plugin) {
|
||||
$parentDir = base_path('plugin' . DIRECTORY_SEPARATOR . trim($plugin));
|
||||
} else {
|
||||
$parentDir = base_path();
|
||||
}
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
return 'app';
|
||||
}
|
||||
|
||||
foreach (scandir($parentDir) ?: [] as $entry) {
|
||||
if (strtolower($entry) === 'app' && is_dir($parentDir . DIRECTORY_SEPARATOR . $entry)) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return 'app';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess the directory name casing for a type that doesn't yet exist,
|
||||
* based on sibling directories' naming convention.
|
||||
*
|
||||
* Priority: if any siblings are all-lowercase, use lowercase.
|
||||
* If siblings are first-letter uppercase, use ucfirst.
|
||||
* Fallback: lowercase.
|
||||
*
|
||||
* @param string $type All-lowercase type name, e.g. "model"
|
||||
* @param string $baseDir The app directory to scan for siblings
|
||||
* @return string The type name with guessed casing
|
||||
*/
|
||||
public static function guessAppDirCase(string $type, string $baseDir): string
|
||||
{
|
||||
$knownTypes = ['controller', 'model', 'middleware', 'bootstrap', 'command', 'process', 'validation', 'view', 'functions'];
|
||||
|
||||
if (!is_dir($baseDir)) {
|
||||
return $type;
|
||||
}
|
||||
|
||||
$hasLower = false;
|
||||
$hasUpper = false;
|
||||
|
||||
foreach (scandir($baseDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (!is_dir($baseDir . DIRECTORY_SEPARATOR . $entry)) {
|
||||
continue;
|
||||
}
|
||||
$lower = strtolower($entry);
|
||||
if (!in_array($lower, $knownTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($entry === $lower) {
|
||||
$hasLower = true;
|
||||
} else if ($entry === ucfirst($lower)) {
|
||||
$hasUpper = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: lowercase > ucfirst > default lowercase
|
||||
if ($hasLower) {
|
||||
return $type;
|
||||
}
|
||||
if ($hasUpper) {
|
||||
return ucfirst($type);
|
||||
}
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
return [
|
||||
'enable' => true,
|
||||
|
||||
'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build',
|
||||
|
||||
'phar_filename' => 'webman.phar',
|
||||
|
||||
'phar_format' => Phar::PHAR, // Phar archive format: Phar::PHAR, Phar::TAR, Phar::ZIP
|
||||
|
||||
'phar_compression' => Phar::NONE, // Compression method for Phar archive: Phar::NONE, Phar::GZ, Phar::BZ2
|
||||
|
||||
'bin_filename' => 'webman.bin',
|
||||
|
||||
'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL.
|
||||
|
||||
'private_key_file' => '', // The file path for certificate or OpenSSL private key file.
|
||||
|
||||
'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#',
|
||||
|
||||
'exclude_files' => [
|
||||
'.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin'
|
||||
],
|
||||
|
||||
'custom_ini' => '
|
||||
memory_limit = 256M
|
||||
',
|
||||
];
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Webman\Config;
|
||||
use Webman\Console\Command;
|
||||
use Webman\Console\Util;
|
||||
use support\Container;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
if (!Phar::running()) {
|
||||
chdir(__DIR__);
|
||||
}
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
if (!$appConfigFile = config_path('app.php')) {
|
||||
throw new RuntimeException('Config file not found: app.php');
|
||||
}
|
||||
|
||||
if (class_exists(Dotenv::class) && file_exists(run_path('.env'))) {
|
||||
if (method_exists(Dotenv::class, 'createUnsafeImmutable')) {
|
||||
Dotenv::createUnsafeImmutable(run_path())->load();
|
||||
} else {
|
||||
Dotenv::createMutable(run_path())->load();
|
||||
}
|
||||
}
|
||||
|
||||
$appConfig = require $appConfigFile;
|
||||
if ($timezone = $appConfig['default_timezone'] ?? '') {
|
||||
date_default_timezone_set($timezone);
|
||||
}
|
||||
|
||||
if ($errorReporting = $appConfig['error_reporting'] ?? '') {
|
||||
error_reporting($errorReporting);
|
||||
}
|
||||
|
||||
if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) {
|
||||
require_once __DIR__ . '/support/bootstrap.php';
|
||||
} else {
|
||||
if (class_exists('Support\App')) {
|
||||
Support\App::loadAllConfig(['route']);
|
||||
} else {
|
||||
Config::reload(config_path(), ['route', 'container']);
|
||||
}
|
||||
}
|
||||
|
||||
$cli = new Command();
|
||||
$cli->setName('webman cli');
|
||||
$cli->installInternalCommands();
|
||||
if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) {
|
||||
$cli->installCommands($command_path);
|
||||
}
|
||||
|
||||
foreach (config('plugin', []) as $firm => $projects) {
|
||||
if (isset($projects['app'])) {
|
||||
foreach (['', '/app'] as $app) {
|
||||
if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) {
|
||||
$command_path = base_path() . "/plugin/$firm{$app}/$command_str";
|
||||
$cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($projects as $name => $project) {
|
||||
if (!is_array($project)) {
|
||||
continue;
|
||||
}
|
||||
$project['command'] ??= [];
|
||||
array_walk($project['command'], [$cli, 'createCommandInstance']);
|
||||
}
|
||||
}
|
||||
|
||||
$cli->run();
|
||||
Reference in New Issue
Block a user