This commit is contained in:
2026-05-01 23:40:14 +08:00
commit b8f599a617
3867 changed files with 478663 additions and 0 deletions
+115
View File
@@ -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
View File
@@ -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();
}
}
+153
View File
@@ -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;
}
}
+224
View File
@@ -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);
}
}
+40
View File
@@ -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);
}
}
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());
}
}
+785
View File
@@ -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());
}
}
+37
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}
+59
View File
@@ -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);
}
}
+35
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}
+51
View File
@@ -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
View File
@@ -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);
}
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Webman\Console;
final class OrmType
{
public const LARAVEL = 'laravel';
public const THINKORM = 'thinkorm';
}
+495
View File
@@ -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
View File
@@ -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();