feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<?php namespace System\Console;
use InvalidArgumentException;
use Winter\Storm\Parse\PHP\ArrayFile;
use Winter\Storm\Scaffold\GeneratorCommand;
abstract class BaseScaffoldCommand extends GeneratorCommand
{
use Traits\HasPluginArgument;
/**
* Prepare variables for stubs.
*/
protected function prepareVars(): array
{
/*
* Extract the author and name from the plugin code
*/
$pluginCode = $this->getPluginIdentifier();
$parts = explode('.', $pluginCode);
if (count($parts) !== 2) {
throw new InvalidArgumentException("Invalid plugin name, either too many dots or not enough. Example: Author.PluginName");
}
$pluginName = array_pop($parts);
$authorName = array_pop($parts);
return [
'name' => $this->getNameInput(),
'plugin' => $pluginName,
'author' => $authorName,
];
}
/**
* Converts all variables to available modifier and case formats and adds plugin helpers
*/
protected function processVars(array $vars): array
{
$vars = parent::processVars($vars);
$vars['plugin_id'] = "{$vars['lower_author']}.{$vars['lower_plugin']}";
$vars['plugin_code'] = "{$vars['studly_author']}.{$vars['studly_plugin']}";
$vars['plugin_url'] = "{$vars['lower_author']}/{$vars['lower_plugin']}";
$vars['plugin_folder'] = "{$vars['lower_author']}/{$vars['lower_plugin']}";
$vars['plugin_namespace'] = "{$vars['studly_author']}\\{$vars['studly_plugin']}";
return $vars;
}
/**
* Get the base path to output generated stubs to
*/
protected function getDestinationPath(): string
{
$plugin = $this->getPlugin();
if ($plugin) {
return $plugin->getPluginPath();
}
$parts = explode('.', $this->getPluginIdentifier());
$name = array_pop($parts);
$author = array_pop($parts);
return plugins_path(strtolower($author) . '/' . strtolower($name));
}
/**
* Make all stubs.
*/
public function makeStubs(): void
{
parent::makeStubs();
// Get the language keys to be set
$langKeys = $this->getLangKeys();
if (empty($langKeys)) {
return;
}
// Generate the path to the localization file to modify
$langFilePath = plugins_path(
$this->vars['plugin_folder']
. DIRECTORY_SEPARATOR
. 'lang'
. DIRECTORY_SEPARATOR
. $this->laravel->getLocale()
. DIRECTORY_SEPARATOR
. 'lang.php'
);
if (!file_exists($langFilePath)) {
$this->makeDirectory($langFilePath);
$comment = '<fg=green>File generated:</> ' . str_replace(base_path(), '', $langFilePath);
} else {
$comment = '<fg=yellow>File updated:</> ' . str_replace(base_path(), '', $langFilePath);
}
// Store the localization messages to the determined file path
ArrayFile::open($langFilePath)->set($langKeys)->write();
// Inform the user
$this->output->writeLn($comment);
}
/**
* Gets the localization keys and values to be stored in the plugin's localization files
* Can reference $this->vars and $this->laravel->getLocale() internally
*/
protected function getLangKeys(): array
{
return [];
}
}

View File

@@ -0,0 +1,77 @@
<?php namespace System\Console;
use InvalidArgumentException;
use System\Console\BaseScaffoldCommand;
class CreateCommand extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:command';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:command
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{name : The name of the command to generate. <info>(eg: ImportPosts)</info>}
{--command= : The terminal command that should be assigned. <info>(eg: blog:importposts)</info>}
{--description= : The command description displayed in help.}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new console command.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:command',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Command';
/**
* @var array A mapping of stubs to generated files.
*/
protected $stubs = [
'scaffold/command/command.stub' => 'console/{{studly_name}}.php',
];
/**
* Prepare variables for stubs.
*/
protected function prepareVars(): array
{
$parts = explode('.', $this->getPluginIdentifier());
$plugin = array_pop($parts);
$author = array_pop($parts);
$name = $this->getNameInput();
$command = trim($this->option('command') ?? strtolower("{$plugin}:{$name}"));
$description = trim($this->option('description') ?? 'No description provided yet...');
// More strict than the base Symfony validateName()
// method, make a PR if it's a problem for you
// - Plugin and command names can contain a number, but they can't start with it.
// - Command name can contain a dash (-), but it can't start with it.
if (preg_match('/^[a-z][\w]++(:[a-z][\w\-]++)*$/', $command) !== 1) {
throw new InvalidArgumentException(sprintf('Command name "%s" is invalid.', $command));
}
return [
'name' => $name,
'command' => $command,
'author' => $author,
'plugin' => $plugin,
'description' => $description,
];
}
}

View File

@@ -0,0 +1,61 @@
<?php namespace System\Console;
use System\Console\BaseScaffoldCommand;
class CreateFactory extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:factory';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:factory
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{factory : The name of the factory to generate. <info>(eg: PostFactory)</info>}
{--m|model= : The name of the model. <info>(eg: Post)</info>}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new factory.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:factory',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Factory';
/**
* @var string The argument that the generated class name comes from
*/
protected $nameFrom = 'factory';
/**
* @var array A mapping of stubs to generated files.
*/
protected $stubs = [
'scaffold/factory/factory.stub' => 'database/factories/{{studly_name}}.php',
];
protected function processVars($vars): array
{
$vars = parent::processVars($vars);
$vars['model'] = $this->option('model');
return $vars;
}
}

View File

@@ -0,0 +1,71 @@
<?php namespace System\Console;
use System\Console\BaseScaffoldCommand;
class CreateJob extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:job';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:job
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{name : The name of the job class to generate. <info>(eg: ImportPosts)</info>}
{--b|batchable : Generates a batchable queue job.}
{--s|sync : Generates a non-queueable job.}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new job class.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:job',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Job';
/**
* @var array A mapping of stubs to generated files.
*/
protected $jobStubs = [
'sync' => [
'scaffold/job/job.stub' => 'jobs/{{studly_name}}.php',
],
'batched' => [
'scaffold/job/job.batched.stub' => 'jobs/{{studly_name}}.php',
],
'queued' => [
'scaffold/job/job.queued.stub' => 'jobs/{{studly_name}}.php',
],
];
/**
* @inheritDoc
*/
public function prepareVars(): array
{
if ($this->option('sync')) {
$this->stubs = $this->jobStubs['sync'];
} elseif ($this->option('batchable')) {
$this->stubs = $this->jobStubs['batched'];
} else {
$this->stubs = $this->jobStubs['queued'];
}
return parent::prepareVars();
}
}

View File

@@ -0,0 +1,358 @@
<?php namespace System\Console;
use File;
use InvalidArgumentException;
use System\Classes\VersionManager;
use System\Console\BaseScaffoldCommand;
use Winter\Storm\Database\Model;
use Winter\Storm\Support\Str;
use Yaml;
/**
* Scaffolds a new migration file
*
* @TODO:
* - Add flag to either create a new version automatically in version.yaml or
* add the migration to a specific version, would also put the migration in
* a version specific folder
*/
class CreateMigration extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:migration';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:migration
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{--name= : The name of the migration}
{--model= : The model to create a migration for. <info>(eg: Post)</info>}
{--table= : The table to migrate, defaults to autogenerated from the provided model. <info>(eg: winter_blog_posts)</info>}
{--for-version= : Generate a migration for a specific version}
{--c|create : Generate a migration that creates the specified table}
{--u|update : Generate a migration that updates the specified table}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new migration.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:migration',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Migration';
/**
* @var bool Validate the provided plugin input against the PluginManager.
*/
protected $validatePluginInput = true;
/**
* @var array Available migration scaffolds and their types
*/
protected $migrationScaffolds = [
'create' => [
'scaffold/migration/migration.create.stub' => 'updates/{{version}}/{{snake_name}}.php',
],
'update' => [
'scaffold/migration/migration.update.stub' => 'updates/{{version}}/{{snake_name}}.php',
],
'migrate' => [
'scaffold/migration/migration.stub' => 'updates/{{version}}/{{snake_name}}.php',
],
];
/**
* Make all stubs.
*/
public function makeStubs(): void
{
parent::makeStubs();
$plugin = $this->getPlugin();
// Identify the changes to insert into the version.yaml file
$changes = [$this->vars['name']];
$stubs = array_keys($this->stubs);
foreach ($stubs as $stub) {
$changes[] = Str::after($this->getDestinationForStub($stub), $plugin->getPluginPath() . '/updates/');
}
// Identify the version to insert these changes into
$versions = $plugin->getPluginVersions();
$firstVersion = array_keys($versions)[0];
$usesV = Str::startsWith($firstVersion, 'v');
$version = $usesV ? $this->vars['version'] : Str::after($this->vars['version'], 'v');
// Insert these changes into the identified version
$changes = array_merge($versions[$version] ?? [], $changes);
$versions[$version] = $changes;
// Render and save the updated version.yaml file
$destinationFile = $plugin->getPluginPath() . '/updates/version.yaml';
$this->files->put($destinationFile, Yaml::render($versions));
$this->comment('File updated: ' . str_replace(base_path(), '', $destinationFile));
}
/**
* Get the desired class name from the input.
*/
protected function getNameInput(): string
{
$name = trim($this->option($this->nameFrom));
if (empty($name)) {
if ($this->option('create')) {
$template = 'Create {ResourceName} Table';
} elseif ($this->option('update')) {
$template = 'Update {ResourceName} Table';
} else {
$template = now()->format('Y_m_d_His');
}
$resourceName = (
$this->option('model')
? Str::plural($this->option('model'))
: null
)
?? (
$this->option('table')
? Str::replace('_', ' ', $this->option('table'))
: ''
);
$name = Str::replace('{ResourceName}', $resourceName, $template);
}
return $name;
}
/**
* Prepare variables for stubs.
*/
protected function prepareVars(): array
{
$parts = explode('.', $this->getPluginIdentifier());
$plugin = array_pop($parts);
$author = array_pop($parts);
$name = $this->getNameInput();
$table = $this->option('table');
$model = $this->option('model');
if (empty($table) && !empty($model)) {
$modelClass = "\\{$author}\\{$plugin}\Models\\{$model}";
if (class_exists($modelClass)) {
$table = (new $modelClass)->getTable();
} else {
throw new InvalidArgumentException("The model [{$modelClass}] does not exist.");
}
}
if ($this->option('create') && $this->option('update')) {
throw new InvalidArgumentException('The create & update options cannot both be set at the same time');
}
if ($this->option('create')) {
$scaffold = 'create';
} elseif ($this->option('update')) {
$scaffold = 'update';
} else {
$scaffold = 'migrate';
}
if (in_array($scaffold, ['create', 'update']) && empty($table)) {
throw new InvalidArgumentException('The table or model options are required when using the create or update options');
}
if (($table || $model) && !in_array($scaffold, ['create', 'update'])) {
throw new InvalidArgumentException('One of create or update option is required when using the model or table options');
}
$this->stubs = $this->migrationScaffolds[$scaffold];
if (!empty($this->option('for-version'))) {
$version = $this->option('for-version');
} else {
$currentVersion = $this->getPlugin()->getPluginVersion();
if ($currentVersion === VersionManager::NO_VERSION_VALUE) {
throw new InvalidArgumentException('The plugin [' . $this->getPluginIdentifier() . '] does not have a version set and no --version option was provided. Please set a version in the plugin\'s updates/version.yaml file.');
}
$version = $this->getNextVersion($currentVersion);
}
$vars = [
'name' => $name,
'author' => $author,
'plugin' => $plugin,
'version' => $version,
];
if (!empty($model)) {
$vars['model'] = $model;
}
if (!empty($table)) {
$vars['table'] = $table;
}
return $vars;
}
/**
* Create vars for model fields mappings so they can be used in update/create stubs
*/
protected function processVars(array $vars): array
{
$vars = parent::processVars($vars);
// --model option needed below
if (empty($vars['model'])) {
return $vars;
}
$vars['fields'] = [];
$fields_path = plugins_path($vars['plugin_url'] . '/models/' . $vars['lower_model'] . '/fields.yaml');
$fields = [];
if (file_exists($fields_path)) {
$fields = Yaml::parseFile(($fields_path));
}
$modelName = $vars['plugin_namespace'] . '\\Models\\' . $vars['model'];
$vars['model'] = $model = new $modelName();
foreach (['fields', 'tabs', 'secondaryTabs'] as $type) {
if (!isset($fields[$type])) {
continue;
}
if ($type === 'fields') {
$fieldList = $fields[$type];
} else {
$fieldList = $fields[$type]['fields'];
}
foreach ($fieldList as $field => $config) {
if (str_contains($field, '@')) {
list($field, $context) = explode('@', $field);
}
$type = $config['type'] ?? 'text';
if (str_starts_with($field, '_')
|| $field === $model->getKeyName()
|| str_contains($field, '[')
|| in_array($type, ['fileupload', 'relation', 'relationmanager', 'repeater', 'section', 'hint'])
|| in_array($field, $model->purgeable ?? [])
|| $model->getRelationType($field)
) {
continue;
}
$vars['fields'][$field] = $this->mapFieldType($field, $config, $model);
}
}
foreach ($model->getRelationDefinitions() as $relationType => $definitions) {
if (in_array($relationType, ['belongsTo', 'hasOne'])) {
foreach (array_keys($definitions) as $relation) {
$vars['fields'][$relation . '_id'] = [
'type' => 'foreignId',
'index' => true,
'required' => true,
];
}
}
}
if ($model->methodExists('getSortOrderColumn')) {
$field = $model->getSortOrderColumn();
$vars['fields'][$field] = [
'type' => 'unsignedinteger',
'required' => false,
'index' => true,
];
}
$vars['primaryKey'] = $model->getKeyName();
$vars['jsonable'] = $model->getJsonable();
$vars['timestamps'] = $model->timestamps;
if ($morphable = $model->morphTo) {
$vars['morphable'] = array_keys($morphable);
}
return $vars;
}
/**
* Get the next version number based on the current number.
*/
protected function getNextVersion($currentVersion): string
{
$currentVersion = ltrim($currentVersion, 'v');
$parts = explode('.', $currentVersion);
$parts[count($parts) - 1] = (int) $parts[count($parts) - 1] + 1;
return 'v' . implode('.', $parts);
}
/**
* Maps model fields config to DB Schema column types.
*/
protected function mapFieldType(string $fieldName, array $fieldConfig, ?Model $model = null) : array
{
switch ($fieldConfig['type'] ?? 'text') {
case 'checkbox':
case 'switch':
$dbType = 'boolean';
break;
case 'number':
$dbType = 'double';
if (isset($fieldConfig['step']) && is_int($fieldConfig['step'])) {
$dbType = 'integer';
}
if ($dbType === 'integer' && isset($fieldConfig['min']) && $fieldConfig['min'] >= 0) {
$dbType = 'unsignedInteger';
}
break;
case 'range':
$dbType = 'unsignedInteger';
break;
case 'datepicker':
$dbType = $fieldConfig['mode'] ?? 'datetime';
break;
case 'markdown':
$dbType = 'mediumText';
break;
case 'textarea':
$dbType = 'text';
break;
default:
$dbType = 'string';
}
if ($model) {
$rule = array_get($model->rules ?? [], $fieldName, '');
$rule = is_array($rule) ? implode(',', $rule) : $rule;
$required = str_contains($rule, 'required') ? true : $fieldConfig['required'] ?? false;
} else {
$required = $fieldConfig['required'] ?? false;
}
return [
'type' => $dbType,
'required' => $required,
'index' => in_array($fieldName, ["slug"]) or str_ends_with($fieldName, "_id"),
];
}
}

View File

@@ -0,0 +1,177 @@
<?php namespace System\Console;
use Winter\Storm\Support\Str;
use System\Console\BaseScaffoldCommand;
class CreateModel extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:model';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:model
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{model : The name of the model to generate. <info>(eg: Post)</info>}
{--f|force : Overwrite existing files with generated files.}
{--a|all : Generate a controller, migration, & seeder for the model}
{--c|controller : Create a new controller for the model}
{--s|seed : Create a new seeder for the model}
{--F|factory : Create a new factory for the model}
{--p|pivot : Indicates if the generated model should be a custom intermediate table model}
{--no-migration : Don\'t create a migration file for the model}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new model.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:model',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Model';
/**
* @var string The argument that the generated class name comes from
*/
protected $nameFrom = 'model';
/**
* @var array A mapping of stubs to generated files.
*/
protected $stubs = [
'scaffold/model/model.stub' => 'models/{{studly_name}}.php',
'scaffold/model/fields.stub' => 'models/{{lower_name}}/fields.yaml',
'scaffold/model/columns.stub' => 'models/{{lower_name}}/columns.yaml',
];
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
if (parent::handle() === false && !$this->option('force')) {
return false;
}
if ($this->option('all')) {
$this->input->setOption('controller', true);
$this->input->setOption('seed', true);
$this->input->setOption('factory', true);
}
if ($this->option('controller')) {
$this->createController();
}
if ($this->option('seed')) {
$this->createSeeder();
}
if (!$this->option('no-migration')) {
$this->createMigration();
}
if ($this->option('factory')) {
$this->createFactory();
}
}
/**
* Adds controller & model lang helpers to the vars
*/
protected function processVars($vars): array
{
$vars = parent::processVars($vars);
$vars['table_name'] = "{$vars['lower_author']}_{$vars['lower_plugin']}_{$vars['snake_plural_name']}";
return $vars;
}
/**
* Gets the localization keys and values to be stored in the plugin's localization files
* Can reference $this->vars and $this->laravel->getLocale() internally
*/
protected function getLangKeys(): array
{
return [
'models.general.id' => 'ID',
'models.general.created_at' => 'Created At',
'models.general.updated_at' => 'Updated At',
];
}
/**
* Create a migration for the model.
*/
public function createMigration()
{
$this->call('create:migration', [
'plugin' => $this->getPluginIdentifier(),
'--model' => $this->getNameInput(),
'--create' => true,
'--force' => $this->option('force'),
'--uninspiring' => $this->option('uninspiring'),
]);
}
/**
* Create a seeder for the model.
*/
public function createSeeder()
{
// @TODO: Implement this
return;
$this->call('create:seeder', [
'plugin' => $this->getPluginIdentifier(),
'model' => $this->getNameInput(),
'--force' => $this->option('force'),
'--uninspiring' => $this->option('uninspiring'),
]);
}
/**
* Create a controller for the model.
*/
public function createController()
{
$this->call('create:controller', [
'plugin' => $this->getPluginIdentifier(),
'controller' => Str::plural($this->argument('model')),
'--model' => $this->getNameInput(),
'--force' => $this->option('force'),
'--uninspiring' => $this->option('uninspiring'),
]);
}
/**
* Create a factory class for the model.
*/
public function createFactory(): void
{
$this->call('create:factory', [
'plugin' => $this->getPluginIdentifier(),
'factory' => "{$this->getNameInput()}Factory",
'--model' => $this->getNameInput(),
'--force' => $this->option('force'),
'--uninspiring' => $this->option('uninspiring'),
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php namespace System\Console;
use System\Console\BaseScaffoldCommand;
class CreatePlugin extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:plugin';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:plugin
{plugin : The name of the plugin to create. <info>(eg: Winter.Blog)</info>}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new plugin.';
/**
* @var string The type of class being generated.
*/
protected $type = 'Plugin';
/**
* @var string The argument that the generated class name comes from
*/
protected $nameFrom = 'plugin';
/**
* @var array A mapping of stubs to generated files.
*/
protected $stubs = [
'scaffold/plugin/plugin.stub' => 'Plugin.php',
'scaffold/plugin/version.stub' => 'updates/version.yaml',
];
/**
* @var bool Validate the provided plugin input against the PluginManager, default true.
*/
protected $validatePluginInput = false;
/**
* Get the desired class name from the input.
*/
protected function getNameInput(): string
{
return explode('.', $this->getPluginIdentifier())[1];
}
/**
* Gets the localization keys and values to be stored in the plugin's localization files
* Can reference $this->vars and $this->laravel->getLocale() internally
*/
protected function getLangKeys(): array
{
return [
'plugin.name' => $this->vars['name'],
'plugin.description' => 'No description provided yet...',
'permissions.some_permission' => 'Some permission',
];
}
}

View File

@@ -0,0 +1,52 @@
<?php namespace System\Console;
use System\Console\BaseScaffoldCommand;
class CreateSettings extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:settings';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:settings
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{settings? : The name of the settings model to generate. <info>(eg: BlogSettings)</info>}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new settings model.';
/**
* @var string The type of class being generated.
*/
protected $type = 'Settings Model';
/**
* @var string The argument that the generated class name comes from
*/
protected $nameFrom = 'settings';
/**
* @var array A mapping of stubs to generated files.
*/
protected $stubs = [
'scaffold/settings/model.stub' => 'models/{{studly_name}}.php',
'scaffold/settings/fields.stub' => 'models/{{lower_name}}/fields.yaml'
];
/**
* Get the desired class name from the input.
*/
protected function getNameInput(): string
{
return parent::getNameInput() ?: 'Settings';
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace System\Console;
use System\Console\BaseScaffoldCommand;
use Winter\Storm\Support\Str;
class CreateTest extends BaseScaffoldCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'create:test';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'create:test
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
{name : The name of the test class to generate. Test will be automatically added to the end. Can also be a relative path to a class to generate a test for. <info>(eg: Components\Posts)</info>}
{--u|unit : Generate a Unit test (defaults to generating Feature tests).}
{--p|pest : Generate a Pest PHP test (defaults to generating PHPUnit tests).}
{--f|force : Overwrite existing files with generated files.}
{--uninspiring : Disable inspirational quotes}
';
/**
* @var string The console command description.
*/
protected $description = 'Creates a new test class.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'make:test',
];
/**
* @var string The type of class being generated.
*/
protected $type = 'Test';
/**
* @var array Stub files to make a plugin testable
*/
protected $pluginStubs = [
'scaffold/test/test.plugin.stub' => 'tests/Unit/PluginTest.php',
'scaffold/test/phpunit.stub' => 'phpunit.xml',
];
/**
* Adds controller & model lang helpers to the vars
*/
protected function processVars($vars): array
{
$vars = parent::processVars($vars);
// Enable testing on the plugin if it isn't already
if (!$this->files->exists($this->getDestinationPath() . '/phpunit.xml')) {
$this->stubs = array_merge($this->pluginStubs, $this->stubs);
}
// Populate Pest.php if it doesn't exist
$isPest = $this->option('pest');
if ($isPest && !$this->files->exists($this->getDestinationPath() . '/tests/Pest.php')) {
$this->stubs = array_merge($this->stubs, [
'scaffold/test/pest.init.stub' => 'tests/Pest.php',
]);
}
$prefix = $isPest ? 'pest' : 'test';
$type = $this->option('unit') ? 'Unit' : 'Feature';
$suffix = $this->option('unit') ? '.unit.stub' : '.stub';
$name = $this->argument('name');
$class = $vars['plugin_namespace'] . '\\' . $name;
// provided name is a class in the plugin
if (class_exists($class)) {
// Get the public methods to stub out tests for
$reflection = new \ReflectionClass($class);
$publicMethods = [];
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC) ?: [];
foreach ($methods as $method) {
if ($method->class === $class) {
$publicMethods[] = $method->name;
}
}
$vars['public_methods'] = $publicMethods;
// Generate the necessary stub variables
$namePieces = explode('\\', $name);
$vars['tested_class_full'] = $class;
$vars['tested_class'] = array_pop($namePieces);
$testClass = $vars['tested_class'] . 'Test';
$suffix = '.class.stub';
if (count($namePieces)) {
$type .= '\\' . implode('\\', $namePieces);
}
// sometimes a name is just a name. Move on.
} else {
$testClass = $name . 'Test';
}
// Just in case :)
$testClass = Str::replace('TestTest', 'Test', $testClass);
$folder = str_replace('\\', '/', $type);
$vars['test_class'] = $testClass;
$vars['test_namespace'] = "{$vars['plugin_namespace']}\\Tests\\$type";
$this->stubs["scaffold/test/{$prefix}{$suffix}"] = "tests/$folder/$testClass.php";
return array_merge($vars, [
'test_namespace' => "{$vars['plugin_namespace']}\\Tests\\$type",
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php namespace System\Console;
use Winter\Storm\Console\Command;
use System\Classes\PluginManager;
use System\Models\PluginVersion;
/**
* Console command to disable a plugin.
*
* @package winter\wn-system-module
* @author Lucas Zamora
*/
class PluginDisable extends Command
{
use Traits\HasPluginArgument;
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:disable';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:disable
{plugin : The plugin to disable. <info>(eg: Winter.Blog)</info>}';
/**
* @var string The console command description.
*/
protected $description = 'Disable an existing plugin.';
/**
* Execute the console command.
* @return void
*/
public function handle()
{
$pluginName = $this->getPluginIdentifier();
$pluginManager = PluginManager::instance();
// Disable this plugin
$pluginManager->disablePlugin($pluginName);
$this->output->writeln(sprintf('<info>%s:</info> disabled.', $pluginName));
}
}

View File

@@ -0,0 +1,52 @@
<?php namespace System\Console;
use Winter\Storm\Console\Command;
use System\Classes\PluginManager;
use System\Models\PluginVersion;
/**
* Console command to enable a plugin.
*
* @package winter\wn-system-module
* @author Lucas Zamora
*/
class PluginEnable extends Command
{
use Traits\HasPluginArgument;
/**
* @var string Only suggest plugins that are disabled
*/
protected $hasPluginsFilter = 'disabled';
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:enable';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:enable
{plugin : The plugin to disable. <info>(eg: Winter.Blog)</info>}';
/**
* @var string The console command description.
*/
protected $description = 'Enable an existing plugin.';
/**
* Execute the console command.
* @return void
*/
public function handle()
{
$pluginName = $this->getPluginIdentifier();
$pluginManager = PluginManager::instance();
// Enable this plugin
$pluginManager->enablePlugin($pluginName);
$this->output->writeln(sprintf('<info>%s:</info> enabled.', $pluginName));
}
}

View File

@@ -0,0 +1,67 @@
<?php namespace System\Console;
use Winter\Storm\Console\Command;
use System\Classes\UpdateManager;
use System\Classes\PluginManager;
/**
* Console command to install a new plugin.
*
* This adds a new plugin by requesting it from the Winter marketplace.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class PluginInstall extends Command
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:install';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:install
{plugin : The plugin to install. <info>(eg: Winter.Blog)</info>}';
/**
* @var string The console command description.
*/
protected $description = 'Install a plugin from the Winter marketplace.';
/**
* Execute the console command.
* @return void
*/
public function handle()
{
$pluginName = $this->argument('plugin');
$manager = UpdateManager::instance()->setNotesOutput($this->output);
$pluginDetails = $manager->requestPluginDetails($pluginName);
$code = array_get($pluginDetails, 'code');
$hash = array_get($pluginDetails, 'hash');
$this->output->writeln(sprintf('<info>Downloading plugin: %s</info>', $code));
$manager->downloadPlugin($code, $hash, true);
$this->output->writeln(sprintf('<info>Unpacking plugin: %s</info>', $code));
$manager->extractPlugin($code, $hash);
/*
* Make sure plugin is registered
*/
$pluginManager = PluginManager::instance();
$pluginManager->loadPlugins();
$plugin = $pluginManager->findByIdentifier($code);
$pluginManager->registerPlugin($plugin, $code);
/*
* Migrate plugin
*/
$this->output->writeln(sprintf('<info>Migrating plugin...</info>', $code));
$manager->updatePlugin($code);
}
}

View File

@@ -0,0 +1,57 @@
<?php namespace System\Console;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use System\Models\PluginVersion;
use Winter\Storm\Console\Command;
/**
* Console command to list existing plugins.
*
* @package winter\wn-system-module
* @author Lucas Zamora
*/
class PluginList extends Command
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:list';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:list';
/**
* @var string The console command description.
*/
protected $description = 'List existing plugins.';
/**
* Execute the console command.
* @return void
*/
public function handle()
{
$allPlugins = PluginVersion::all();
$pluginsCount = count($allPlugins);
if ($pluginsCount <= 0) {
$this->info('No plugin found');
return;
}
$rows = [];
foreach ($allPlugins as $plugin) {
$rows[] = [
$plugin->code,
$plugin->version,
(!$plugin->is_frozen) ? '<info>Yes</info>': '<fg=red>No</>',
(!$plugin->is_disabled) ? '<info>Yes</info>': '<fg=red>No</>',
];
}
$this->table(['Plugin name', 'Version', 'Updates enabled', 'Plugin enabled'], $rows);
}
}

View File

@@ -0,0 +1,63 @@
<?php namespace System\Console;
use Winter\Storm\Console\Command;
use System\Classes\UpdateManager;
/**
* Console command to refresh a plugin.
*
* This destroys all database tables for a specific plugin, then builds them up again.
* It is a great way for developers to debug and develop new plugins.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class PluginRefresh extends Command
{
use \Winter\Storm\Console\Traits\ConfirmsWithInput;
use Traits\HasPluginArgument;
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:refresh';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:refresh
{plugin : The plugin to refresh. <info>(eg: Winter.Blog)</info>}
{--f|force : Force the operation to run and ignore production warnings and confirmation questions.}';
/**
* @var string The console command description.
*/
protected $description = 'Removes and re-adds an existing plugin.';
/**
* Execute the console command.
*/
public function handle(): int
{
$pluginName = $this->getPluginIdentifier();
if (!$this->confirmWithInput(
"This will completely remove and reinstall $pluginName. This may result in potential data loss.",
$pluginName
)) {
return 1;
}
// Set the UpdateManager output stream to the CLI
$manager = UpdateManager::instance()->setNotesOutput($this->output);
// Rollback the plugin
$manager->rollbackPlugin($pluginName);
// Reinstall the plugin
$this->output->writeln('<info>Reinstalling plugin...</info>');
$manager->updatePlugin($pluginName);
return 0;
}
}

View File

@@ -0,0 +1,92 @@
<?php namespace System\Console;
use File;
use Winter\Storm\Console\Command;
use System\Classes\UpdateManager;
use System\Classes\PluginManager;
use System\Classes\VersionManager;
/**
* Console command to remove a plugin.
*
* This completely deletes an existing plugin, including database tables, files
* and directories.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class PluginRemove extends Command
{
use \Winter\Storm\Console\Traits\ConfirmsWithInput;
use Traits\HasPluginArgument;
/**
* @var string Suggest all plugins
*/
protected $hasPluginsFilter = 'all';
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:remove';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:remove
{plugin : The plugin to remove. <info>(eg: Winter.Blog)</info>}
{--f|force : Force the operation to run and ignore production warnings and confirmation questions.}
{--r|no-rollback : Skip the rollback of the plugin migrations.}';
/**
* @var string The console command description.
*/
protected $description = 'Removes an existing plugin.';
/**
* Execute the console command.
*/
public function handle(): int
{
$pluginName = $this->getPluginIdentifier();
$pluginManager = PluginManager::instance();
if (
!$pluginManager->hasPlugin($pluginName)
&& !VersionManager::instance()->getDatabaseHistory($pluginName)
) {
$this->error(sprintf('Plugin "%s" could not be found.', $pluginName));
return 1;
}
$confirmQuestion = sprintf('This will remove the files for the "%s" plugin.', $pluginName);
if (!$this->option('no-rollback')) {
$confirmQuestion = sprintf('This will remove the database tables and files for the "%s" plugin.', $pluginName);
}
if (!$this->confirmWithInput(
$confirmQuestion,
$pluginName
)) {
return 1;
}
if (!$this->option('no-rollback')) {
/*
* Rollback plugin
*/
$manager = UpdateManager::instance()->setNotesOutput($this->output);
$manager->rollbackPlugin($pluginName);
}
/*
* Delete from file system
*/
if ($pluginPath = $pluginManager->getPluginPath($pluginName)) {
File::deleteDirectory($pluginPath);
$this->output->writeln(sprintf('<info>Deleted: %s</info>', $pluginPath));
}
return 0;
}
}

View File

@@ -0,0 +1,95 @@
<?php namespace System\Console;
use InvalidArgumentException;
use Winter\Storm\Console\Command;
use System\Classes\UpdateManager;
use System\Classes\VersionManager;
/**
* Console command to rollback a plugin.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class PluginRollback extends Command
{
use \Winter\Storm\Console\Traits\ConfirmsWithInput;
use Traits\HasPluginArgument;
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'plugin:rollback';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'plugin:rollback
{plugin : The plugin to disable. <info>(eg: Winter.Blog)</info>}
{version? : If this parameter is not specified the plugin will be completely rolled back; otherwise it will stop on the specified version. <info>(eg: 1.3.9)</info>}
{--f|force : Force the operation to run and ignore production warnings and confirmation questions.}';
/**
* @var string The console command description.
*/
protected $description = 'Rollback an existing plugin.';
/**
* Execute the console command.
* @throws Exception if the UpdateManager is unable to rollback the requested plugin to the requested version
* @throws InvalidArgumentException if the requested rollback version can't be found
*/
public function handle(): int
{
$pluginName = $this->getPluginIdentifier();
$stopOnVersion = ltrim(($this->argument('version') ?: null), 'v');
if ($stopOnVersion) {
if (!VersionManager::instance()->hasDatabaseVersion($pluginName, $stopOnVersion)) {
throw new InvalidArgumentException('Plugin version not found');
}
$confirmQuestion = "This will revert $pluginName to version $stopOnVersion - changes to the database and potential data loss may occur.";
} else {
$confirmQuestion = "This will completely rollback $pluginName. This may result in potential data loss.";
}
if (!$this->confirmWithInput(
$confirmQuestion,
$pluginName
)) {
return 1;
}
$manager = UpdateManager::instance()->setNotesOutput($this->output);
try {
$manager->rollbackPlugin($pluginName, $stopOnVersion);
} catch (\Exception $exception) {
$lastVersion = VersionManager::instance()->getCurrentVersion($pluginName);
$this->output->writeln(sprintf("<comment>An exception occurred during the rollback and the process has been stopped. %s was rolled back to version v%s.</comment>", $pluginName, $lastVersion));
throw $exception;
}
return 0;
}
/**
* Suggest values for the optional version argument
*/
public function suggestVersionValues(?string $value, array $allInput): array
{
// Get the currently selected plugin
$pluginName = $this->getPluginIdentifier($allInput['arguments']['plugin']);
// Get that plugin's versions from the database
$history = VersionManager::instance()->getDatabaseHistory($pluginName);
// Compile a list of available versions to rollback to
$availableVersions = [];
foreach ($history as $record) {
$availableVersions[] = $record->version;
}
return $availableVersions;
}
}

View File

@@ -0,0 +1,63 @@
<?php namespace System\Console;
use Winter\Storm\Console\Command;
use System\Classes\UpdateManager;
/**
* Console command to tear down the database.
*
* This destroys all database tables that are registered for Winter and all plugins.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterDown extends Command
{
use \Winter\Storm\Console\Traits\ConfirmsWithInput;
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'winter:down';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'winter:down
{--f|force : Force the operation to run and ignore production warnings and confirmation questionss.}';
/**
* @var string The console command description.
*/
protected $description = 'Destroys all database tables for Winter and all plugins.';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October & Laravel
$this->setAliases(['october:down', 'migrate:reset']);
}
/**
* Execute the console command.
*/
public function handle(): int
{
if (!$this->confirmWithInput(
"This will completely delete all database tables in use with your Winter installation.",
"DELETE"
)) {
return 1;
}
UpdateManager::instance()
->setNotesOutput($this->output)
->uninstall();
return 0;
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace System\Console;
use App;
use Winter\Storm\Parse\EnvFile;
use Winter\Storm\Console\Command;
use Winter\Storm\Parse\PHP\ArrayFile;
/**
* Console command to convert configuration to use .env files.
*
* This creates an .env file with some default configuration values, it also converts
* the existing PHP-based configuration files to use the `env` function for values.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterEnv extends Command
{
use \Illuminate\Console\ConfirmableTrait;
/**
* The console command name.
*/
protected $name = 'winter:env';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'winter:env
{--force : Force the operation to run when in production}';
/**
* The console command description.
*/
protected $description = 'Creates .env file with default configuration values.';
/**
* @var array The env keys that need to have their original values removed from the config files
*/
protected $protectedKeys = [
'APP_KEY',
'DB_USERNAME',
'DB_PASSWORD',
'MAIL_USERNAME',
'MAIL_PASSWORD',
'REDIS_PASSWORD',
];
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:env']);
}
/**
* Execute the console command.
*/
public function handle(): int
{
if (
!$this->confirmToProceed(
'The .env file already exists. Proceeding may overwrite some values!',
function () {
return file_exists($this->laravel->environmentFilePath()) && $this->getLaravel()->environment() === 'production';
}
)
) {
return 1;
}
$this->updateEnvFile();
$this->updateConfigFiles();
$this->info('.env configuration file has been created.');
return 0;
}
/**
* Get the full path of a config file
* @param string $config
* @return string
*/
protected function getConfigPath(string $config): string
{
return rtrim(App::make('path.config'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $config . '.php';
}
/**
* Set env keys to their config values within the EnvFile object
*/
protected function updateEnvFile(): void
{
$env = EnvFile::open($this->laravel->environmentFilePath());
foreach ($this->config() as $config => $items) {
foreach ($items as $envKey => $configKey) {
$env->set($envKey, config($config . '.' . $configKey));
if ($config === 'database' && $envKey === 'DB_CONNECTION') {
$default = config('database.default');
$dbConfig = $this->dbConfig()[$default] ?? [];
foreach ($dbConfig as $dbEnvKey => $dbConfigKey) {
$value = config(join('.', [$config, 'connections', $default, $dbConfigKey]));
// Fix for https://github.com/wintercms/winter/issues/1242#issuecomment-2515344385
if ($dbEnvKey === 'DB_DATABASE' && PHP_OS_FAMILY === 'Windows' && str_contains($value, '\\')) {
$value = str_replace('\\', '\\\\', $value);
}
$env->set($dbEnvKey, $value);
}
}
if ($config === 'mail' && $envKey === 'MAIL_MAILER') {
$default = config('mail.default');
$mailConfig = $this->mailConfig()[$default] ?? [];
foreach ($mailConfig as $mailEnvKey => $mailConfigKey) {
$env->set($mailEnvKey, config(join('.', [$config, 'mailers', $default, $mailConfigKey])));
}
}
}
$env->addEmptyLine();
}
$env->write();
}
/**
* Update config files with env function calls
*/
protected function updateConfigFiles(): void
{
foreach ($this->config() as $config => $items) {
$arrayFile = ArrayFile::open($this->getConfigPath($config));
foreach ($items as $envKey => $configKey) {
$arrayFile->set(
$configKey,
$arrayFile->function('env', $this->getKeyValuePair($envKey, $config . '.' . $configKey))
);
if ($config === 'database' && $envKey === 'DB_CONNECTION') {
foreach ($this->dbConfig() as $connection => $keys) {
foreach ($keys as $dbEnvKey => $dbConfigKey) {
$path = sprintf('connections.%s.%s', $connection, $dbConfigKey);
$arrayFile->set(
$path,
$arrayFile->function('env', $this->getKeyValuePair($dbEnvKey, $config . '.' . $path))
);
}
}
}
if ($config === 'mail' && $envKey === 'MAIL_MAILER') {
foreach ($this->mailConfig() as $mailer => $keys) {
foreach ($keys as $mailEnvKey => $mailConfigKey) {
$path = sprintf('mailers.%s.%s', $mailer, $mailConfigKey);
$arrayFile->set(
$path,
$arrayFile->function('env', $this->getKeyValuePair($mailEnvKey, $config . '.' . $path))
);
}
}
}
}
$arrayFile->write();
}
}
/**
* Returns an array containing the key as the first element and the value
* as the second if the key is not a protected key; otherwise the value
* will be an empty string
*/
protected function getKeyValuePair(string $envKey, string $configKey): array
{
$return = [$envKey, in_array($envKey, $this->protectedKeys) ? '' : config($configKey)];
return $return;
}
/**
* Returns a map of env keys to php config keys for db configs
* @return array
*/
protected function config(): array
{
return [
'app' => [
'APP_DEBUG' => 'debug',
'APP_URL' => 'url',
'APP_KEY' => 'key',
],
'database' => [
'DB_CONNECTION' => 'default',
],
'cache' => [
'CACHE_DRIVER' => 'default',
],
'session' => [
'SESSION_DRIVER' => 'driver',
],
'queue' => [
'QUEUE_CONNECTION' => 'default',
],
'mail' => [
'MAIL_MAILER' => 'default',
],
'cms' => [
'ROUTES_CACHE' => 'enableRoutesCache',
'ASSET_CACHE' => 'enableAssetCache',
'LINK_POLICY' => 'linkPolicy',
'ENABLE_CSRF' => 'enableCsrfProtection',
'DATABASE_TEMPLATES' => 'databaseTemplates',
],
];
}
/**
* Returns a map of env keys to php config keys for db configs
* @return array
*/
protected function dbConfig(): array
{
return [
'sqlite' => [
'DB_DATABASE' => 'database',
],
'mysql' => [
'DB_HOST' => 'host',
'DB_PORT' => 'port',
'DB_DATABASE' => 'database',
'DB_USERNAME' => 'username',
'DB_PASSWORD' => 'password',
],
'pgsql' => [
'DB_HOST' => 'host',
'DB_PORT' => 'port',
'DB_DATABASE' => 'database',
'DB_USERNAME' => 'username',
'DB_PASSWORD' => 'password',
],
'redis' => [
'REDIS_HOST' => 'host',
'REDIS_PASSWORD' => 'password',
'REDIS_PORT' => 'port',
],
];
}
/**
* Returns a map of env keys to php config keys for mail configs
* @return array
*/
protected function mailConfig(): array
{
return [
'smtp' => [
'MAIL_ENCRYPTION' => 'encryption',
'MAIL_HOST' => 'host',
'MAIL_PASSWORD' => 'password',
'MAIL_PORT' => 'port',
'MAIL_USERNAME' => 'username',
],
'sendmail' => [
'MAIL_SENDMAIL_PATH' => 'path',
],
'log' => [
'MAIL_LOG_CHANNEL' => 'channel',
],
];
}
}

View File

@@ -0,0 +1,86 @@
<?php namespace System\Console;
use File;
use Artisan;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
/**
* Console command to remove boilerplate.
*
* This removes the demo theme and plugin. A great way to start a fresh project!
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterFresh extends Command
{
use \Illuminate\Console\ConfirmableTrait;
/**
* The console command name.
*/
protected $name = 'winter:fresh';
/**
* The console command description.
*/
protected $description = 'Removes the demo theme and plugin.';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:fresh']);
}
/**
* Execute the console command.
*/
public function handle()
{
if (!$this->confirmToProceed('Are you sure?')) {
return;
}
$themeRemoved = false;
$pluginRemoved = false;
$demoThemePath = themes_path().'/demo';
if (File::exists($demoThemePath)) {
File::deleteDirectory($demoThemePath);
$themeRemoved = true;
}
$demoPluginPath = plugins_path().'/winter/demo';
if (File::exists($demoPluginPath)) {
Artisan::call('plugin:remove', ['plugin' => 'Winter.Demo', '--force' => true]);
$pluginRemoved = true;
}
if ($themeRemoved && $pluginRemoved) {
$this->info('Demo theme and plugin have been removed! Enjoy a fresh start.');
} elseif ($themeRemoved) {
$this->info('Demo theme has been removed! Enjoy a fresh start.');
} elseif ($pluginRemoved) {
$this->info('Demo plugin has been removed! Enjoy a fresh start.');
} else {
$this->info('Demo theme and plugin have already been removed.');
}
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['force', null, InputOption::VALUE_NONE, 'Force the operation to run.'],
];
}
}

View File

@@ -0,0 +1,463 @@
<?php namespace System\Console;
use Backend\Database\Seeds\SeedSetupAdmin;
use Config;
use Db;
use Exception;
use File;
use Illuminate\Encryption\Encrypter;
use PDO;
use Str;
use Symfony\Component\Console\Input\InputOption;
use System\Classes\UpdateManager;
use Winter\Storm\Config\ConfigWriter;
use Winter\Storm\Console\Command;
/**
* Console command to install Winter.
*
* This sets up Winter for the first time. It will prompt the user for several
* configuration items, including application URL and database config, and then
* perform a database migration.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterInstall extends Command
{
use \Illuminate\Console\ConfirmableTrait;
/**
* The console command name.
*/
protected $name = 'winter:install';
/**
* The console command description.
*/
protected $description = 'Set up Winter for the first time.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'october:install',
];
/**
* @var Winter\Storm\Config\ConfigWriter
*/
protected $configWriter;
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
$this->configWriter = new ConfigWriter;
}
/**
* Execute the console command.
*/
public function handle()
{
$this->displayIntro();
if (
$this->laravel->hasDatabase() &&
!$this->confirm('Application appears to be installed already. Continue anyway?', false)
) {
return;
}
$this->setupDatabaseConfig();
$this->setupAdminUser();
$this->setupCommonValues();
$chosenToInstall = [];
if ($this->confirm('Would you like to change the backend options? (URL, default locale, default timezone)')) {
$this->setupBackendValues();
}
if ($this->confirm('Configure advanced options?', false)) {
$this->setupEncryptionKey();
$this->setupAdvancedValues();
$chosenToInstall = $this->askToInstallPlugins();
}
else {
$this->setupEncryptionKey(true);
}
$this->setupMigrateDatabase();
foreach ($chosenToInstall as $pluginCode) {
$this->output->writeln('<info>Installing plugin ' . $pluginCode . '</info>');
$this->callSilent('plugin:install', [
'plugin' => $pluginCode
]);
$this->output->writeln('<info>' . $pluginCode . ' installed successfully.</info>');
}
$this->displayOutro();
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['force', null, InputOption::VALUE_NONE, 'Force the operation to run.'],
];
}
//
// Misc
//
protected function setupCommonValues()
{
$url = $this->ask('Application URL', Config::get('app.url'));
$this->writeToConfig('app', ['url' => $url]);
}
protected function setupBackendValues()
{
// cms.backendUri
$backendUri = $this->ask('Backend URL', Config::get('cms.backendUri'));
$this->writeToConfig('cms', ['backendUri' => $backendUri]);
// app.locale
$defaultLocale = Config::get('app.locale');
try {
$availableLocales = (new \Backend\Models\Preference)->getLocaleOptions();
$localesByName = [];
$i = $defaultLocaleIndex = 0;
foreach ($availableLocales as $locale => $name) {
$localesByName[$name[0]] = $locale;
if ($locale === $defaultLocale) {
$defaultLocaleIndex = $i;
}
$i++;
}
$localeName = $this->choice('Default Backend Locale', array_keys($localesByName), $defaultLocaleIndex);
$locale = $localesByName[$localeName];
} catch (\Exception $e) {
// Installation failed halfway through, recover gracefully
$locale = $this->ask('Default Backend Locale', $defaultLocale);
}
$this->writeToConfig('app', ['locale' => $locale]);
// cms.backendTimezone
$defaultTimezone = Config::get('cms.backendTimezone');
try {
$availableTimezones = (new \Backend\Models\Preference)->getTimezoneOptions();
$longestTimezone = max(array_map('strlen', $availableTimezones));
$padTo = $longestTimezone - 13 + 1; // (UTC +10:00)
$timezonesByName = [];
foreach ($availableTimezones as $timezone => $name) {
$nameParts = explode(') ', $name);
$nameParts[0] .= ')';
$name = str_pad($nameParts[1], $padTo) . $nameParts[0];
$timezonesByName[$name] = $timezone;
}
$timezonesByContinent = [];
$defaultTimezoneIndex = 0;
$defaultTimezoneGroupIndex = 0;
foreach ($timezonesByName as $name => $zone) {
$zone = explode('/', $zone)[0];
$timezonesByContinent[$zone][$name] = $zone;
if ($zone === $defaultTimezone) {
$defaultTimezoneGroupIndex = count(array_keys($timezonesByContinent)) - 1;
$defaultTimezoneIndex = count(array_keys($timezonesByContinent[$zone])) - 1;
}
}
$timezoneGroup = $this->choice('Timezone Continent', array_keys($timezonesByContinent), $defaultTimezoneGroupIndex);
$timezoneName = $this->choice('Default Backend Timezone', array_keys($timezonesByContinent[$timezoneGroup]), $defaultTimezoneIndex);
$timezone = $timezonesByName[$timezoneName];
} catch (\Exception $e) {
// Installation failed halfway through, recover gracefully
$timezone = $this->ask('Default Backend Timezone', $defaultTimezone);
}
$this->writeToConfig('cms', ['backendTimezone' => $timezone]);
}
protected function setupAdvancedValues()
{
$defaultMask = $this->ask('File Permission Mask', Config::get('cms.defaultMask.file') ?: '777');
$this->writeToConfig('cms', ['defaultMask.file' => $defaultMask]);
$defaultMask = $this->ask('Folder Permission Mask', Config::get('cms.defaultMask.folder') ?: '777');
$this->writeToConfig('cms', ['defaultMask.folder' => $defaultMask]);
$debug = (bool) $this->confirm('Enable Debug Mode?', true);
$this->writeToConfig('app', ['debug' => $debug]);
}
protected function askToInstallPlugins()
{
$chosenToInstall = [];
if ($this->confirm('Install the Winter.Builder plugin?', false)) {
$chosenToInstall[] = 'Winter.Builder';
}
return $chosenToInstall;
}
//
// Encryption key
//
protected function setupEncryptionKey($force = false)
{
$validKey = false;
$cipher = Config::get('app.cipher');
$keyLength = $this->getKeyLength($cipher);
$randomKey = $this->getRandomKey($cipher);
if ($force) {
$key = $randomKey;
}
else {
$this->line(sprintf('Enter a new value of %s characters, or press ENTER to use the generated key', $keyLength));
while (!$validKey) {
$key = $this->ask('Application key', $randomKey);
$validKey = Encrypter::supported($key, $cipher);
if (!$validKey) {
$this->error(sprintf('[ERROR] Invalid key length for "%s" cipher. Supplied key must be %s characters in length.', $cipher, $keyLength));
}
}
}
$this->writeToConfig('app', ['key' => $key]);
$this->info(sprintf('Application key [%s] set successfully.', $key));
}
/**
* Generate a random key for the application.
*
* @param string $cipher
* @return string
*/
protected function getRandomKey($cipher)
{
return Str::random($this->getKeyLength($cipher));
}
/**
* Returns the supported length of a key for a cipher.
*
* @param string $cipher
* @return int
*/
protected function getKeyLength($cipher)
{
return $cipher === 'AES-128-CBC' ? 16 : 32;
}
//
// Database config
//
protected function setupDatabaseConfig()
{
$type = $this->choice('Database type', ['MySQL', 'Postgres', 'SQLite', 'SQL Server'], 'SQLite');
$typeMap = [
'SQLite' => 'sqlite',
'MySQL' => 'mysql',
'Postgres' => 'pgsql',
'SQL Server' => 'sqlsrv',
];
$driver = array_get($typeMap, $type, 'sqlite');
$method = 'setupDatabase'.Str::studly($driver);
$newConfig = $this->$method();
$this->writeToConfig('database', ['default' => $driver]);
foreach ($newConfig as $config => $value) {
$this->writeToConfig('database', ['connections.'.$driver.'.'.$config => $value]);
}
}
protected function setupDatabaseMysql()
{
$result = [];
$result['host'] = $this->ask('MySQL Host', Config::get('database.connections.mysql.host'));
$result['port'] = $this->output->ask('MySQL Port', Config::get('database.connections.mysql.port') ?: false) ?: '';
$result['database'] = $this->ask('Database Name', Config::get('database.connections.mysql.database'));
$result['username'] = $this->ask('MySQL Login', Config::get('database.connections.mysql.username'));
$result['password'] = $this->ask('MySQL Password', Config::get('database.connections.mysql.password') ?: false) ?: '';
return $result;
}
protected function setupDatabasePgsql()
{
$result = [];
$result['host'] = $this->ask('Postgres Host', Config::get('database.connections.pgsql.host'));
$result['port'] = $this->ask('Postgres Port', Config::get('database.connections.pgsql.port') ?: false) ?: '';
$result['database'] = $this->ask('Database Name', Config::get('database.connections.pgsql.database'));
$result['username'] = $this->ask('Postgres Login', Config::get('database.connections.pgsql.username'));
$result['password'] = $this->ask('Postgres Password', Config::get('database.connections.pgsql.password') ?: false) ?: '';
return $result;
}
protected function setupDatabaseSqlite()
{
$filename = $this->ask('Database path', Config::get('database.connections.sqlite.database'));
try {
if (!file_exists($filename)) {
$directory = dirname($filename);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
new PDO('sqlite:'.$filename);
}
}
catch (Exception $ex) {
$this->error($ex->getMessage());
$this->setupDatabaseSqlite();
}
return ['database' => Str::after($filename, base_path() . DIRECTORY_SEPARATOR)];
}
protected function setupDatabaseSqlsrv()
{
$result = [];
$result['host'] = $this->ask('SQL Host', Config::get('database.connections.sqlsrv.host'));
$result['port'] = $this->ask('SQL Port', Config::get('database.connections.sqlsrv.port') ?: false) ?: '';
$result['database'] = $this->ask('Database Name', Config::get('database.connections.sqlsrv.database'));
$result['username'] = $this->ask('SQL Login', Config::get('database.connections.sqlsrv.username'));
$result['password'] = $this->ask('SQL Password', Config::get('database.connections.sqlsrv.password') ?: false) ?: '';
return $result;
}
//
// Migration
//
protected function setupAdminUser()
{
$this->line('Enter a new value, or press ENTER for the default');
SeedSetupAdmin::$firstName = $this->ask('First Name', SeedSetupAdmin::$firstName);
SeedSetupAdmin::$lastName = $this->ask('Last Name', SeedSetupAdmin::$lastName);
SeedSetupAdmin::$email = $this->ask('Email Address', SeedSetupAdmin::$email);
SeedSetupAdmin::$login = $this->ask('Admin Login', SeedSetupAdmin::$login);
SeedSetupAdmin::$password = $this->ask('Admin Password', Str::random(22));
if (!$this->confirm('Is the information correct?', true)) {
$this->setupAdminUser();
}
}
protected function setupMigrateDatabase()
{
$this->line('Migrating application and plugins...');
try {
Db::purge();
UpdateManager::instance()
->setNotesOutput($this->output)
->update()
;
}
catch (Exception $ex) {
$this->error($ex->getMessage());
$this->setupDatabaseConfig();
$this->setupMigrateDatabase();
}
}
//
// Helpers
//
protected function displayIntro()
{
$message = [
".========================================================================.",
" ",
" db d8b db d888888b d8b db d888888b d88888b d8888b. \033[1;34m...\033[0m ",
" 88 I8I 88 `88' 888o 88 `~~88~~' 88' 88 `8D \033[1;34m... ..... ...\033[0m ",
" 88 I8I 88 88 88V8o 88 88 88ooooo 88oobY' \033[1;34m.. ... ..\033[0m ",
" Y8 I8I 88 88 88 V8o88 88 88~~~~~ 88`8b \033[1;34m.. ... ..\033[0m ",
" `8b d8'8b d8' .88. 88 V888 88 88. 88 `88. \033[1;34m... ..... ...\033[0m ",
" `8b8' `8d8' Y888888P VP V8P YP Y88888P 88 YD \033[1;34m...\033[0m ",
" ",
"`============================= INSTALLATION =============================",
"",
];
$this->line($message);
}
protected function displayOutro()
{
$message = [
// Sourced from https://www.asciiart.eu/holiday-and-events/christmas/snowmen
".===========================================================.",
" * * *. * . * . ",
" . . __ * . * . * ",
" * * . . _|__|_ * __ . * ",
" /\ /\ ('') * _|__|_ . ",
" / \ * / \ * . <( . )> * . ('') * * ",
" / \ / \ . _(__.__)_ _ ,--<( . )> . . ",
"/ \ / \ * | | )),` ( . ) * ",
" `||` .. `||` . *... ==========='` ... '--`-` ... * jb .",
"`================== INSTALLATION COMPLETE =================='",
"",
];
$this->line($message);
}
protected function writeToConfig($file, $values)
{
$configFile = $this->getConfigFile($file);
foreach ($values as $key => $value) {
Config::set($file.'.'.$key, $value);
}
$this->configWriter->toFile($configFile, $values);
}
/**
* Get a config file and contents.
*
* @return array
*/
protected function getConfigFile($name = 'app')
{
$env = $this->option('env') ? $this->option('env').'/' : '';
$name .= '.php';
$contents = File::get($path = $this->laravel['path.config']."/{$env}{$name}");
return $path;
}
}

View File

@@ -0,0 +1,240 @@
<?php namespace System\Console;
use ApplicationException;
use Http;
use ZipArchive;
use System\Classes\FileManifest;
use System\Classes\SourceManifest;
/**
* Console command to generate a release/tag manifest for Winter CMS version checks.
*
* @package winter\wn-system-module
* @author Ben Thomson
* @author Winter CMS
*/
class WinterManifest extends \Illuminate\Console\Command
{
/**
* @var string The console command description.
*/
protected $description = 'Generates a build manifest of Winter CMS builds.';
/**
* @var string The name and signature of the console command.
*/
protected $signature = 'winter:manifest
{target : Specifies the target file for the build manifest.}
{--token= : Specifies a GitHub token, to get around rate limits.}
{--minBuild= : Specifies the minimum build number to retrieve from the source.}
{--maxBuild= : Specifies the maximum build number to retreive from the source.}';
/**
* @var bool Indicates whether the command should be shown in the Artisan command list.
*/
protected $hidden = true;
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:manifest']);
}
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
$minBuild = $this->getVersionInt($this->option('minBuild') ?? '1.0.420');
$maxBuild = $this->getVersionInt($this->option('maxBuild') ?? '1.999.999');
$targetFile = (substr($this->argument('target'), 0, 1) === '/')
? $this->argument('target')
: getcwd() . '/' . $this->argument('target');
if (empty($targetFile)) {
throw new ApplicationException(
'A target argument must be specified for the generated manifest file.'
);
}
if ($minBuild > $maxBuild) {
throw new ApplicationException(
'Minimum build specified is larger than the maximum build specified.'
);
}
if (file_exists($targetFile)) {
$manifest = new SourceManifest($targetFile);
} else {
$manifest = new SourceManifest('', null, false);
}
// Create temporary directory to hold builds
$buildDir = storage_path('temp/builds/');
if (!is_dir($buildDir)) {
mkdir($buildDir, 0775, true);
}
// Find all released builds
$page = 0;
$sourceBuilds = [];
while (true) {
++$page;
if ($this->option('token')) {
$url = 'https://' . $this->option('token') . '@api.github.com/repos/wintercms/winter/tags?per_page=100&page=' . $page;
} else {
$url = 'https://api.github.com/repos/wintercms/winter/tags?per_page=100&page=' . $page;
}
$builds = Http::get($url, function ($http) {
$http->header('User-Agent', 'Winter CMS');
$http->header('Accept', 'application/vnd.github.v3+json');
});
if ($builds->code !== 200) {
break;
}
$builds = json_decode($builds->body);
if (empty($builds)) {
break;
}
foreach ($builds as $build) {
$version = preg_replace('/[^0-9\.]+/', '', $build->name);
$versionInt = $this->getVersionInt($version);
if ($versionInt >= $minBuild && $versionInt <= $maxBuild) {
$sourceBuilds[] = [
'version' => $version,
'download' => $build->zipball_url
];
}
}
}
// Sort by version number
$sourceBuilds = array_sort($sourceBuilds, function ($item) {
return $this->getVersionInt($item['version']);
});
foreach ($sourceBuilds as $sourceBuild) {
$build = $sourceBuild['version'];
// Download version from GitHub
$this->comment('Processing build ' . $build);
$this->line(' - Downloading...');
if (file_exists($buildDir . 'build-' . $build . '.zip') || is_dir($buildDir . $build . '/')) {
$this->info(' - Already downloaded.');
} else {
Http::get($sourceBuild['download'], function ($http) use ($buildDir, $build) {
$http->header('User-Agent', 'Winter CMS');
$http->toFile($buildDir . 'build-' . $build . '.zip');
});
$zipFile = @file_get_contents($buildDir . 'build-' . $build . '.zip');
if (empty($zipFile)) {
$this->error(' - Not found (' . $sourceBuild['download'] . ').');
break;
}
$this->info(' - Downloaded.');
}
// Extract version
$this->line(' - Extracting...');
if (is_dir($buildDir . $build . '/')) {
$this->info(' - Already extracted.');
} else {
$zip = new ZipArchive;
if ($zip->open($buildDir . 'build-' . $build . '.zip')) {
$rootFolder = substr($zip->statIndex(0)['name'], 0, -1);
$toExtract = [];
$paths = [
$rootFolder . '/modules/backend/',
$rootFolder . '/modules/cms/',
$rootFolder . '/modules/system/',
];
// Only get necessary files from the modules directory
for ($i = 1; $i < $zip->numFiles; ++$i) {
$filename = $zip->statIndex($i)['name'];
foreach ($paths as $path) {
if (strpos($filename, $path) === 0) {
$toExtract[] = $filename;
break;
}
}
}
if (!count($toExtract)) {
$this->error(' - Unable to get valid files for extraction. Cancelled.');
exit(1);
}
$zip->extractTo($buildDir . $build . '/', $toExtract);
$zip->close();
// Rename root folder
rename($buildDir . $build . '/' . $rootFolder, $buildDir . $build . '/winter-' . $build);
// Remove ZIP file
unlink($buildDir . 'build-' . $build . '.zip');
} else {
$this->error(' - Unable to extract zip file. Cancelled.');
exit(1);
}
$this->info(' - Extracted.');
}
// Add build to manifest
$this->line(' - Adding to manifest...');
$buildManifest = new FileManifest($buildDir . $build . '/winter-' . $build);
$manifest->addBuild($build, $buildManifest);
$this->info(' - Added.');
}
// Generate manifest
$this->comment('Generating manifest...');
file_put_contents($targetFile, $manifest->generate());
$this->comment('Completed.');
}
/**
* Converts a version string into an integer for comparison.
*
* @param string $version
* @throws ApplicationException if a version string does not match the format "major.minor.path"
* @return int
*/
protected function getVersionInt(string $version)
{
// Get major.minor.patch versions
if (!preg_match('/^v?([0-9]+)\.([0-9]+)\.([0-9]+)/', $version, $versionParts)) {
throw new ApplicationException('Invalid version string - must be of the format "major.minor.path"');
}
$int = $versionParts[1] * 1000000;
$int += $versionParts[2] * 1000;
$int += $versionParts[3];
return $int;
}
}

View File

@@ -0,0 +1,272 @@
<?php namespace System\Console;
use File;
use Event;
use StdClass;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
/**
* Console command to implement a "public" folder.
*
* This command will create symbolic links to files and directories
* that are commonly required to be publicly available.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterMirror extends Command
{
/**
* The console command name.
*/
protected $name = 'winter:mirror';
/**
* The console command description.
*/
protected $description = 'Generates a mirrored public folder using symbolic links.';
/**
* @var array Files that should be mirrored
*/
protected $files = [
'.htaccess',
'.user.ini',
'index.php',
'favicon.ico',
'robots.txt',
'humans.txt',
'sitemap.xml',
'llms.txt',
];
/**
* @var array Directories that should be mirrored
*/
protected $directories = [
'storage/app/uploads/public',
'storage/app/media',
'storage/app/resized',
'storage/temp/public',
];
/**
* @var array Wildcard paths that should be mirrored
*/
protected $wildcards = [
'modules/*/assets',
'modules/*/resources',
'modules/*/behaviors/*/assets',
'modules/*/behaviors/*/resources',
'modules/*/components/*/assets',
'modules/*/components/*/resources',
'modules/*/widgets/*/assets',
'modules/*/widgets/*/resources',
'modules/*/formwidgets/*/assets',
'modules/*/formwidgets/*/resources',
'modules/*/reportwidgets/*/assets',
'modules/*/reportwidgets/*/resources',
'plugins/*/*/assets',
'plugins/*/*/resources',
'plugins/*/*/behaviors/*/assets',
'plugins/*/*/behaviors/*/resources',
'plugins/*/*/components/*/assets',
'plugins/*/*/components/*/resources',
'plugins/*/*/reportwidgets/*/assets',
'plugins/*/*/reportwidgets/*/resources',
'plugins/*/*/formwidgets/*/assets',
'plugins/*/*/formwidgets/*/resources',
'plugins/*/*/widgets/*/assets',
'plugins/*/*/widgets/*/resources',
'themes/*/assets',
'themes/*/resources',
];
/**
* @var string|null Local cache of the mirror destination path
*/
protected $destinationPath;
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:mirror']);
}
/**
* Execute the console command.
*/
public function handle()
{
$this->getDestinationPath();
$paths = new StdClass();
$paths->files = $this->files;
$paths->directories = $this->directories;
$paths->wildcards = $this->wildcards;
/**
* @event system.console.mirror.extendPaths
* Enables extending the `php artisan winter:mirror` command
*
* You will have access to a $paths stdClass with `files`, `directories`, `wildcards` properties available for modifying.
*
* Example usage:
*
* Event::listen('system.console.mirror.extendPaths', function ($paths) {
* $paths->directories = array_merge($paths->directories, ['plugins/myauthor/myplugin/public']);
* });
*
*/
Event::fire('system.console.mirror.extendPaths', [$paths]);
foreach ($paths->files as $file) {
$this->mirrorFile($file);
}
foreach ($paths->directories as $directory) {
$this->mirrorDirectory($directory);
}
foreach ($paths->wildcards as $wildcard) {
$this->mirrorWildcard($wildcard);
}
$this->output->writeln('<info>Mirror complete!</info>');
}
protected function mirrorFile($file)
{
$this->output->writeln(sprintf('<info> - Mirroring: %s</info>', $file));
$src = base_path().'/'.$file;
$dest = $this->getDestinationPath().'/'.$file;
if (!File::isFile($src) || File::isFile($dest)) {
return false;
}
$this->mirror($src, $dest);
}
protected function mirrorDirectory($directory)
{
$this->output->writeln(sprintf('<info> - Mirroring: %s</info>', $directory));
$src = base_path().'/'.$directory;
$dest = $this->getDestinationPath().'/'.$directory;
if (!File::isDirectory($src) || File::isDirectory($dest)) {
return false;
}
if (!File::isDirectory(dirname($dest))) {
File::makeDirectory(dirname($dest), 0755, true);
}
$this->mirror($src, $dest);
}
protected function mirrorWildcard($wildcard)
{
if (strpos($wildcard, '*') === false) {
return $this->mirrorDirectory($wildcard);
}
list($start, $end) = explode('*', $wildcard, 2);
$startDir = base_path().'/'.$start;
if (!File::isDirectory($startDir)) {
return false;
}
foreach (File::directories($startDir) as $directory) {
$this->mirrorWildcard($start.basename($directory).$end);
}
}
protected function mirror($src, $dest)
{
if ($this->option('relative') && PHP_OS_FAMILY !== 'Windows') {
$src = $this->getRelativePath($dest, $src);
if (strpos($src, '../') === 0) {
$src = rtrim(substr($src, 3), '/');
}
}
File::link($src, $dest);
}
protected function getDestinationPath()
{
if ($this->destinationPath !== null) {
return $this->destinationPath;
}
$destPath = $this->argument('destination');
if (realpath($destPath) === false) {
$destPath = base_path() . '/' . $destPath;
}
if (!File::isDirectory($destPath)) {
File::makeDirectory($destPath, 0755, true);
}
$destPath = realpath($destPath);
$this->output->writeln(sprintf('<info>Destination: %s</info>', $destPath));
return $this->destinationPath = $destPath;
}
protected function getRelativePath($from, $to)
{
$from = str_replace('\\', '/', $from);
$to = str_replace('\\', '/', $to);
$dir = explode('/', is_file($from) ? dirname($from) : rtrim($from, '/'));
$file = explode('/', $to);
while ($dir && $file && ($dir[0] == $file[0])) {
array_shift($dir);
array_shift($file);
}
return str_repeat('../', count($dir)) . implode('/', $file);
}
/**
* Get the console command arguments.
* @return array
*/
protected function getArguments()
{
return [
['destination', InputArgument::REQUIRED, 'The destination path relative to the current directory. Eg: public/'],
];
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['relative', null, InputOption::VALUE_NONE, 'Create symlinks relative to the public directory.'],
];
}
}

View File

@@ -0,0 +1,275 @@
<?php namespace System\Console;
use Config;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
use System\Classes\PluginManager;
use Winter\Storm\Console\Command;
use Winter\Storm\Exception\ApplicationException;
use Winter\Storm\Filesystem\PathResolver;
use Winter\Storm\Support\Str;
/**
* Console command to run tests for plugins and modules.
*
* If a plugin is provided, this command will search for a `phpunit.xml` file inside the plugin's directory and run its tests.
*
* @package winter\wn-system-module
*/
class WinterTest extends Command
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'winter:test';
/**
* @var string The console command name.
*/
protected $name = 'winter:test';
/**
* @var string The console command signature as ignoreValidationErrors causes options not to be registered.
*/
protected $signature = 'winter:test
{phpunitArgs?* : Arguments to pass through to PHPUnit}
{?--c|configuration= : A specific phpunit xml file}
{?--b|bootstrap= : A custom PHPUnit bootstrap file}
{?--p|plugin=* : List of plugins to test}
{?--m|module=* : List of modules to test}
';
/**
* @var string The console command description.
*/
protected $description = 'Run tests for the Winter CMS core or an existing plugin.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'test',
];
/**
* @var ?string Path to phpunit binary
*/
protected $phpUnitExec = null;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
/**
* Ignore validation errors as option proxying is used by this command
* @see https://github.com/nunomaduro/collision/blob/stable/src/Adapters/Laravel/Commands/TestCommand.php
*/
$this->ignoreValidationErrors();
}
/**
* Determines if Pest is being used.
*/
protected function usingPest(): bool
{
return class_exists(\Pest\Laravel\PestServiceProvider::class);
}
/**
* Execute the console command.
*
* @throws ApplicationException
* @return int|void
*/
public function handle()
{
$arguments = $this->argument('phpunitArgs');
if (($config = $this->option('configuration')) && file_exists($config)) {
return $this->execPhpUnit($config, $arguments);
}
$configs = $this->getPhpUnitConfigs();
$exitCode = null;
// loop over arguments and run specified tests
foreach (['module', 'plugin'] as $type) {
if ($this->option($type)) {
foreach ($this->option($type) as $target) {
$target = strtolower($target);
if (!isset($configs[$type . 's'][$target])) {
throw new ApplicationException(sprintf(
'Unable to find %s %s\'s phpunit.xml file',
$type,
$target
));
}
$this->info(sprintf('Running tests for %s: %s', $type, $target));
$exit = $this->execPhpUnit($configs[$type . 's'][$target], $arguments);
// keep non 0 exit codes for return
$exitCode = !$exitCode ? $exit : $exitCode;
}
}
}
// if we ran a specific test above we should have an exit code
if (!is_null($exitCode)) {
return $exitCode;
}
// default to running all defined configs found
foreach (['modules', 'plugins'] as $type) {
foreach ($configs[$type] as $name => $config) {
$this->info(
$type === 'plugins'
? 'Running tests for plugin: ' . PluginManager::instance()->normalizeIdentifier($name)
: 'Running tests for module: ' . $name
);
$exit = $this->execPhpUnit($config, $arguments);
// keep non 0 exit codes for return
$exitCode = !$exitCode ? $exit : $exitCode;
}
}
return $exitCode ?? 0;
}
/**
* Execute a phpunit test
*
* @param string $config Path to configuration file
* @param array $args Array of params for PHPUnit
* @return int Exit code from process
*/
protected function execPhpUnit(string $config, array $args): int
{
// Find and bind the phpunit executable
if (!$this->phpUnitExec) {
$bin = $this->usingPest() ? 'pest' : 'phpunit';
$this->phpUnitExec = (new ExecutableFinder())
->find($bin, base_path("vendor/bin/$bin"), [base_path('vendor')]);
}
// Resolve the configuration path based on the current working directory
$configPath = realpath($config);
$bootstrapPath = (string) simplexml_load_file($configPath)['bootstrap'];
// Use a default bootstrap path if none is specified in the config
if (empty($bootstrapPath)) {
$bootstrapPath = base_path('modules/system/tests/bootstrap/app.php');
} elseif ($this->option('bootstrap')) {
$bootstrapPath = $this->option('bootstrap');
} else {
// Temporarily switch the working directory to the config path to account for relative paths.
$cwd = getcwd();
chdir(dirname($config));
$bootstrapPath = PathResolver::resolve($bootstrapPath);
chdir($cwd);
}
if (!is_file($bootstrapPath)) {
throw new ApplicationException(sprintf(
'Unable to find the bootstrap file "%s"',
$bootstrapPath,
));
}
$testDirectory = Str::after(dirname($config), base_path() . DIRECTORY_SEPARATOR) . '/tests';
$generatedArgs = [
$this->phpUnitExec,
'--configuration=' . $config,
'--bootstrap=' . $bootstrapPath,
];
if ($this->usingPest()) {
$generatedArgs[] = '--test-directory=' . $testDirectory;
}
$process = new Process(
array_merge($generatedArgs, $args),
base_path(),
[
'APP_ENV' => 'testing',
'CACHE_DRIVER' => 'array',
'SESSION_DRIVER' => 'array',
],
null
);
// Set an unlimited timeout
$process->setTimeout(0);
// Attempt to set tty mode, catch and warn with the exception message if unsupported
try {
$process->setTty(true);
} catch (\Throwable $e) {
$this->warn($e->getMessage());
}
try {
return $process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
return 1;
}
}
/**
* Find all PHPUnit config files (core, lib, plugins)
*/
protected function getPhpUnitConfigs(): array
{
$configs = [
'modules' => [],
'plugins' => []
];
foreach (Config::get('cms.loadModules', ['System', 'Cms', 'Backend']) as $module) {
$module = strtolower($module);
if ($path = $this->getPhpUnitXmlFile(base_path('modules/' . $module))) {
$configs['modules'][$module] = $path;
}
}
foreach (PluginManager::instance()->getPlugins() as $plugin) {
if ($path = $this->getPhpUnitXmlFile($plugin->getPluginPath())) {
$configs['plugins'][strtolower($plugin->getPluginIdentifier())] = $path;
}
}
return $configs;
}
/**
* Search for the config file to use.
* Priority order is: phpunit.xml, phpunit.xml.dist
*/
protected function getPhpUnitXmlFile(string $path): ?string
{
// If a phpunit.xml file exists, returns its path
$configFilePath = $path . DIRECTORY_SEPARATOR . 'phpunit.xml';
if (file_exists($configFilePath)) {
return $configFilePath;
}
// Fallback to phpunit.xml.dist file path if it exists
$distFilePath = $path . DIRECTORY_SEPARATOR . 'phpunit.xml.dist';
if (file_exists($distFilePath)) {
return $distFilePath;
}
return null;
}
}

View File

@@ -0,0 +1,52 @@
<?php namespace System\Console;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
use System\Classes\UpdateManager;
/**
* Console command to migrate the database.
*
* This builds up all database tables that are registered for Winter and all plugins.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterUp extends Command implements Isolatable
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'winter:up
{--seed : Included for compatibility with Laravel default signature, no effect at this time}';
/**
* The console command description.
*/
protected $description = 'Builds database tables for Winter and all plugins.';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:up', 'migrate']);
}
/**
* Execute the console command.
*/
public function handle()
{
$this->output->writeln('<info>Migrating application and plugins...</info>');
UpdateManager::instance()
->setNotesOutput($this->output)
->update();
}
}

View File

@@ -0,0 +1,127 @@
<?php namespace System\Console;
use Str;
use Illuminate\Console\Command;
use System\Classes\UpdateManager;
use Symfony\Component\Console\Input\InputOption;
/**
* Console command to perform a system update.
*
* This updates Winter CMS and all plugins, database and files. It uses the
* Winter gateway to receive the files via a package manager, then saves
* the latest build number to the system.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterUpdate extends Command
{
/**
* The console command name.
*/
protected $name = 'winter:update';
/**
* The console command description.
*/
protected $description = 'Updates Winter CMS and all plugins, database and files.';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:update']);
}
/**
* Execute the console command.
*/
public function handle()
{
$this->output->writeln('<info>Updating Winter...</info>');
$manager = UpdateManager::instance()->setNotesOutput($this->output);
$forceUpdate = $this->option('force');
/*
* Check for disabilities
*/
$disableCore = $disablePlugins = $disableThemes = false;
if ($this->option('plugins')) {
$disableCore = true;
$disableThemes = true;
}
if ($this->option('core')) {
$disablePlugins = true;
$disableThemes = true;
}
/*
* Perform update
*/
$updateList = $manager->requestUpdateList($forceUpdate);
$updates = (int) array_get($updateList, 'update', 0);
if ($updates == 0) {
$this->output->writeln('<info>No new updates found</info>');
return;
}
$this->output->writeln(sprintf('<info>Found %s new %s!</info>', $updates, Str::plural('update', $updates)));
$coreHash = $disableCore ? null : array_get($updateList, 'core.hash');
$coreBuild = array_get($updateList, 'core.build');
if ($coreHash) {
$this->output->writeln('<info>Downloading application files</info>');
$manager->downloadCore($coreHash);
}
$plugins = $disablePlugins ? [] : array_get($updateList, 'plugins');
foreach ($plugins as $code => $plugin) {
$pluginName = array_get($plugin, 'name');
$pluginHash = array_get($plugin, 'hash');
$this->output->writeln(sprintf('<info>Downloading plugin: %s</info>', $pluginName));
$manager->downloadPlugin($code, $pluginHash);
}
if ($coreHash) {
$this->output->writeln('<info>Unpacking application files</info>');
$manager->extractCore();
$manager->setBuild($coreBuild, $coreHash);
}
foreach ($plugins as $code => $plugin) {
$pluginName = array_get($plugin, 'name');
$pluginHash = array_get($plugin, 'hash');
$this->output->writeln(sprintf('<info>Unpacking plugin: %s</info>', $pluginName));
$manager->extractPlugin($code, $pluginHash);
}
/*
* Run migrations
*/
$this->call('winter:up');
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['force', null, InputOption::VALUE_NONE, 'Force updates.'],
['core', null, InputOption::VALUE_NONE, 'Update core application files only.'],
['plugins', null, InputOption::VALUE_NONE, 'Update plugin files only.'],
];
}
}

View File

@@ -0,0 +1,560 @@
<?php namespace System\Console;
use Lang;
use File;
use Config;
use DirectoryIterator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File as Filesystem;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
use System\Classes\UpdateManager;
use System\Classes\CombineAssets;
use System\Models\Parameter;
use System\Models\File as FileModel;
use Winter\Storm\Filesystem\Zip;
use Winter\Storm\Network\Http as NetworkHttp;
use Winter\Storm\Support\Facades\Http;
/**
* Console command for other utility commands.
*
* This provides functionality that doesn't quite deserve its own dedicated
* console class. It is used mostly developer tools and maintenance tasks.
*
* Currently supported commands:
*
* - purge resized: Deletes all files in the resized directory.
* - purge thumbs: Deletes all thumbnail files in the uploads directory.
* - purge uploads: Deletes files in the uploads directory that do not exist in the "system_files" table.
* - git pull: Perform "git pull" on all plugins and themes.
* - compile assets: Compile registered Language, LESS and JS files.
* - compile js: Compile registered JS files only.
* - compile less: Compile registered LESS files only.
* - compile scss: Compile registered SCSS files only.
* - compile lang: Compile registered Language files only.
* - set project --projectId=<id>: Set the projectId for this winter instance.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class WinterUtil extends Command
{
use \Illuminate\Console\ConfirmableTrait;
/**
* The console command name.
*/
protected $name = 'winter:util';
/**
* The console command description.
*/
protected $description = 'Utility commands for Winter';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:util']);
}
/**
* Execute the console command.
*/
public function handle()
{
$command = implode(' ', (array) $this->argument('name'));
$method = 'util'.studly_case($command);
$methods = preg_grep('/^util/', get_class_methods(get_called_class()));
$list = array_map(function ($item) {
return "winter:".snake_case($item, " ");
}, $methods);
if (!$this->argument('name')) {
$message = 'There are no commands defined in the "util" namespace.';
if (1 == count($list)) {
$message .= "\n\nDid you mean this?\n ";
} else {
$message .= "\n\nDid you mean one of these?\n ";
}
$message .= implode("\n ", $list);
throw new \InvalidArgumentException($message);
}
if (!method_exists($this, $method)) {
$this->error(sprintf('Utility command "%s" does not exist!', $command));
return;
}
$this->$method();
}
/**
* Get the console command arguments.
* @return array
*/
protected function getArguments()
{
return [
['name', InputArgument::IS_ARRAY, 'The utility command to perform, For more info, see "https://wintercms.com/docs/v1.2/docs/console/utilities#utility-runner".'],
];
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production.'],
['debug', null, InputOption::VALUE_NONE, 'Run the operation in debug / development mode.'],
['projectId', null, InputOption::VALUE_REQUIRED, 'Specify a projectId for set project'],
['missing-files', null, InputOption::VALUE_NONE, 'Purge system_files records for missing storage files'],
];
}
//
// Utilties
//
protected function utilSetBuild()
{
$this->comment('NOTE: This command is now deprecated. Please use "php artisan winter:version" instead.');
$this->comment('');
return $this->call('winter:version');
}
protected function utilCompileJs()
{
$this->utilCompileAssets('js');
}
protected function utilCompileLess()
{
$this->utilCompileAssets('less');
}
protected function utilCompileScss()
{
$this->utilCompileAssets('scss');
}
protected function utilCompileAssets($type = null)
{
// Download Font Awesome icons if they are missing and LESS files are being compiled
if (
(
!is_dir(base_path('node_modules/@fortawesome/fontawesome-free'))
|| !is_file(base_path('node_modules/@fortawesome/fontawesome-free/less/_variables.less'))
)
&& ($type === 'less' || $type === null)
) {
$this->comment('Downloading Font Awesome icons...');
$releases = Http::get('https://api.github.com/repos/FortAwesome/Font-Awesome/releases/191042913', function (NetworkHttp $http) {
$http->header('Accept', 'application/json');
$http->header('User-Agent', 'Winter CMS');
});
if (!$releases->ok) {
$this->error('Failed to download Font Awesome icons');
return;
}
$releases = json_decode($releases->body, true);
$releaseName = null;
$zipUrl = null;
foreach ($releases['assets'] as $asset) {
if (
str_starts_with($asset['name'], 'fontawesome-free-')
&& str_ends_with($asset['name'], 'web.zip')
) {
$zipUrl = $asset['browser_download_url'];
$releaseName = pathinfo($asset['name'], PATHINFO_FILENAME);
}
}
if (is_null($zipUrl)) {
$this->error('Failed to find Font Awesome icons download URL');
return;
}
Http::get($zipUrl, function (NetworkHttp $http) {
$http->header('User-Agent', 'Winter CMS');
$http->toFile(storage_path('temp/fontawesome.zip'));
});
// Extract Font Awesome files
if (is_dir(storage_path('temp/fontawesome'))) {
$this->rimraf(storage_path('temp/fontawesome'));
}
Zip::extract(storage_path('temp/fontawesome.zip'), storage_path('temp/fontawesome'));
Filesystem::delete(storage_path('temp/fontawesome.zip'));
// Move Font Awesome LESS and font files into place
Filesystem::makeDirectory(base_path('node_modules/@fortawesome/fontawesome-free/less'), 0755, true);
Filesystem::moveDirectory(storage_path('temp/fontawesome/' . $releaseName . '/less'), base_path('node_modules/@fortawesome/fontawesome-free/less'));
Filesystem::copyDirectory(storage_path('temp/fontawesome/' . $releaseName . '/webfonts'), base_path('modules/system/assets/ui/font'));
// Remove remaining files
$this->rimraf(storage_path('temp/fontawesome'));
}
$this->comment('Compiling registered asset bundles...');
Config::set('cms.enableAssetMinify', !$this->option('debug'));
$combiner = CombineAssets::instance();
$bundles = $combiner->getBundles($type);
if (!$bundles) {
$this->comment('Nothing to compile!');
return;
}
if ($type) {
$bundles = [$bundles];
}
foreach ($bundles as $bundleType) {
foreach ($bundleType as $destination => $assets) {
$destination = File::symbolizePath($destination);
$publicDest = File::localToPublic(realpath(dirname($destination))) . '/' . basename($destination);
$combiner->combineToFile($assets, $destination);
$shortAssets = implode(', ', array_map('basename', $assets));
$this->comment($shortAssets);
$this->comment(sprintf(' -> %s', $publicDest));
}
}
if ($type === null) {
$this->utilCompileLang();
}
}
protected function utilCompileLang()
{
if (!$locales = Lang::get('system::lang.locale')) {
return;
}
$this->comment('Compiling client-side language files...');
$locales = array_keys($locales);
$stub = base_path() . '/modules/system/assets/js/lang/lang.stub';
foreach ($locales as $locale) {
/*
* Generate messages
*/
$fallbackPath = base_path() . '/modules/system/lang/en/client.php';
$srcPath = base_path() . '/modules/system/lang/'.$locale.'/client.php';
$messages = require $fallbackPath;
if (File::isFile($srcPath) && $fallbackPath != $srcPath) {
$messages = array_replace_recursive($messages, require $srcPath);
}
/*
* Load possible replacements from /lang
*/
$overrides = [];
$parentOverrides = [];
$overridePath = base_path() . '/lang/'.$locale.'/system/client.php';
if (File::isFile($overridePath)) {
$overrides = require $overridePath;
}
if (str_contains($locale, '-')) {
list($parentLocale, $country) = explode('-', $locale);
$parentOverridePath = base_path() . '/lang/'.$parentLocale.'/system/client.php';
if (File::isFile($parentOverridePath)) {
$parentOverrides = require $parentOverridePath;
}
}
$messages = array_replace_recursive($messages, $parentOverrides, $overrides);
/*
* Compile from stub and save file
*/
$destPath = base_path() . '/modules/system/assets/js/lang/lang.'.$locale.'.js';
$contents = str_replace(
['{{locale}}', '{{messages}}'],
[$locale, json_encode($messages)],
File::get($stub)
);
/*
* Include the moment localization data
*/
$momentPath = base_path() . '/modules/system/assets/ui/vendor/moment/locale/'.$locale.'.js';
if (File::exists($momentPath)) {
$contents .= PHP_EOL.PHP_EOL.File::get($momentPath).PHP_EOL;
}
File::put($destPath, $contents);
/*
* Output notes
*/
$publicDest = File::localToPublic(realpath(dirname($destPath))) . '/' . basename($destPath);
$this->comment($locale.'/'.basename($srcPath));
$this->comment(sprintf(' -> %s', $publicDest));
}
}
protected function utilPurgeResized()
{
if (!$this->confirmToProceed('This will PERMANENTLY DELETE all files in the resized directory.')) {
return;
}
$resizedDisk = Config::get('cms.storage.resized.disk', 'local');
$resizedFolder = Config::get('cms.storage.resized.folder', 'resized');
$totalCount = count(Storage::disk($resizedDisk)->allFiles($resizedFolder));
foreach (Storage::disk($resizedDisk)->directories($resizedFolder, false) as $directory) {
Storage::disk($resizedDisk)->deleteDirectory($directory);
}
if ($totalCount > 0) {
$this->comment(sprintf('Successfully deleted %d file(s)', $totalCount));
} else {
$this->comment('No files found to purge.');
}
}
protected function utilPurgeThumbs()
{
if (!$this->confirmToProceed('This will PERMANENTLY DELETE all thumbs in the uploads directory.')) {
return;
}
$totalCount = 0;
$uploadsPath = Config::get('filesystems.disks.local.root', storage_path('app'));
$uploadsPath .= '/uploads';
/*
* Recursive function to scan the directory for files beginning
* with "thumb_" and repeat itself on directories.
*/
$purgeFunc = function ($targetDir) use (&$purgeFunc, &$totalCount) {
if ($files = File::glob($targetDir.'/thumb_*')) {
foreach ($files as $file) {
$this->info('Purged: '. basename($file));
$totalCount++;
@unlink($file);
}
}
if ($dirs = File::directories($targetDir)) {
foreach ($dirs as $dir) {
$purgeFunc($dir);
}
}
};
$purgeFunc($uploadsPath);
if ($totalCount > 0) {
$this->comment(sprintf('Successfully deleted %s thumbs', $totalCount));
}
else {
$this->comment('No thumbs found to delete');
}
}
protected function utilPurgeUploads()
{
if (!$this->confirmToProceed('This will PERMANENTLY DELETE files in the uploads directory that do not exist in the "system_files" table.')) {
return;
}
$uploadsDisk = Config::get('cms.storage.uploads.disk', 'local');
$uploadsFolder = Config::get('cms.storage.uploads.folder', 'uploads');
$totalCount = 0;
$validFiles = FileModel::pluck('disk_name')->all();
foreach (Storage::disk($uploadsDisk)->allFiles($uploadsFolder) as $filePath) {
$fileName = basename($filePath);
// Skip .gitignore files
if ($fileName === '.gitignore') {
continue;
}
// Purge invalid files
if (!in_array($fileName, $validFiles)) {
// Purge invalid upload file
Storage::disk($uploadsDisk)->delete($filePath);
$this->info('Purged: ' . $filePath);
// Purge parent directories
$currentDir = dirname($filePath);
while ($currentDir !== $uploadsFolder) {
// Get parent directory children
$children = Storage::disk($uploadsDisk)->allFiles($currentDir);
// Parent directory is empty
if (count($children) === 0) {
Storage::disk($uploadsDisk)->deleteDirectory($currentDir);
$this->info('Removed folder: ' . $currentDir);
} else {
// Parent directory is not empty
// stop the iteration
break;
}
$currentDir = dirname($currentDir);
}
$totalCount++;
}
}
if ($totalCount > 0) {
$this->comment(sprintf('Successfully deleted %d invalid file(s), leaving %d valid files', $totalCount, count($validFiles)));
} else {
$this->comment('No files found to purge.');
}
}
protected function utilPurgeOrphans()
{
if (!$this->confirmToProceed('This will PERMANENTLY DELETE files in "system_files" that do not belong to any other model.')) {
return;
}
$isDebug = $this->option('debug');
$orphanedFiles = 0;
$isLocalStorage = Config::get('cms.storage.uploads.disk', 'local') === 'local';
$files = FileModel::whereDoesntHaveMorph('attachment', '*')
->orWhereNull('attachment_id')
->orWhereNull('attachment_type')
->get();
foreach ($files as $file) {
if (!$isDebug) {
$file->delete();
}
$orphanedFiles += 1;
}
if ($this->option('missing-files') && $isLocalStorage) {
foreach (FileModel::all() as $file) {
if (!File::exists($file->getLocalPath())) {
if (!$isDebug) {
$file->delete();
}
$orphanedFiles += 1;
}
}
}
if ($orphanedFiles > 0) {
$this->comment(sprintf('Successfully deleted %d orphaned record(s).', $orphanedFiles));
} else {
$this->comment('No records to purge.');
}
}
/**
* This command requires the git binary to be installed.
*/
protected function utilGitPull()
{
foreach (File::directories(plugins_path()) as $authorDir) {
foreach (File::directories($authorDir) as $pluginDir) {
if (!File::exists($pluginDir.'/.git')) {
continue;
}
$exec = 'cd ' . $pluginDir . ' && ';
$exec .= 'git pull 2>&1';
echo 'Updating plugin: '. basename(dirname($pluginDir)) .'.'. basename($pluginDir) . PHP_EOL;
echo shell_exec($exec);
}
}
foreach (File::directories(themes_path()) as $themeDir) {
if (!File::exists($themeDir.'/.git')) {
continue;
}
$exec = 'cd ' . $themeDir . ' && ';
$exec .= 'git pull 2>&1';
echo 'Updating theme: '. basename($themeDir) . PHP_EOL;
echo shell_exec($exec);
}
}
protected function utilSetProject()
{
$projectId = $this->option('projectId');
if (empty($projectId)) {
$this->error("No projectId defined, use --projectId=<id> to set a projectId");
return;
}
$manager = UpdateManager::instance();
$result = $manager->requestProjectDetails($projectId);
Parameter::set([
'system::project.id' => $projectId,
'system::project.name' => $result['name'],
'system::project.owner' => $result['owner'],
]);
}
/**
* PHP-based "rm -rf" command.
*
* Recursively removes a directory and all files and subdirectories within.
*/
protected function rimraf(string $path): void
{
if (!file_exists($path)) {
return;
}
if (is_file($path)) {
@unlink($path);
return;
}
$dir = new DirectoryIterator($path);
foreach ($dir as $item) {
if ($item->isDot()) {
continue;
}
if ($item->isDir()) {
$this->rimraf($item->getPathname());
}
@unlink($item->getPathname());
}
@rmdir($path);
}
}

View File

@@ -0,0 +1,110 @@
<?php namespace System\Console;
use System\Classes\UpdateManager;
/**
* Detects the version of Winter CMS installed.
*
* This checks against a central manifest on Winter CMS's GitHub account to determine the version. If any files have
* been modified, this will be indicated when detecting the version.
*
* To get a list of modified files, simply add the "--changes" parameter.
*
* @package winter\wn-system-module
* @author Ben Thomson
* @author Winter CMS
*/
class WinterVersion extends \Winter\Storm\Console\Command
{
/**
* @var string The console command description.
*/
protected $description = 'Detects the build number (version) of this Winter CMS instance.';
/**
* @var string The name and signature of the console command.
*/
protected $signature = 'winter:version
{--changes : Include the list of changes between this install and the expected files for the detected build.}
{--o|only-version : Return only the build version number.}
';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
// Register aliases for backwards compatibility with October
$this->setAliases(['october:version']);
}
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
if (!$this->option('only-version')) {
$this->comment('*** Detecting Winter CMS build...');
}
if (!$this->laravel->hasDatabase()) {
$build = UpdateManager::instance()->getBuildNumberManually($this->option('changes'));
// Skip setting the build number if no database is detected to set it within
if (!$this->option('only-version')) {
$this->comment('*** No database detected - skipping setting the build number.');
}
} else {
$build = UpdateManager::instance()->setBuildNumberManually($this->option('changes'));
}
if ($this->option('only-version')) {
$this->line($build['build']);
return 0;
}
if (!$build['confident']) {
$this->warn('*** We could not accurately determine your Winter CMS build due to the number of modifications. The closest detected build is Winter CMS build ' . $build['build'] . '.');
} elseif ($build['modified']) {
$this->info('*** Detected a modified version of Winter CMS build ' . $build['build'] . '.');
} else {
$this->info('*** Detected Winter CMS build ' . $build['build'] . '.');
}
if (!empty($build['changes']) && $this->option('changes')) {
$this->line('');
$this->comment('We have detected the following modifications:');
if (count($build['changes']['added'] ?? [])) {
$this->line('');
$this->info('Files added:');
foreach (array_keys($build['changes']['added']) as $file) {
$this->line(' - ' . $file);
}
}
if (count($build['changes']['modified'] ?? [])) {
$this->line('');
$this->info('Files modified:');
foreach (array_keys($build['changes']['modified']) as $file) {
$this->line(' - ' . $file);
}
}
if (count($build['changes']['removed'] ?? [])) {
$this->line('');
$this->info('Files removed:');
foreach ($build['changes']['removed'] as $file) {
$this->line(' - ' . $file);
}
}
}
}
}

View File

@@ -0,0 +1,272 @@
<?php
namespace System\Console\Asset;
use Symfony\Component\Process\Process;
use System\Classes\Asset\PackageManager;
use System\Classes\Asset\PackageJson;
use Winter\Storm\Console\Command;
use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Str;
abstract class AssetCompile extends Command
{
/**
* @var string The console command description.
*/
protected $description = 'Mix and compile assets';
/**
* PackageJson object holding the contents of the active package.json
*/
protected PackageJson $packageJson;
/**
* Name of config file i.e. mix.webpack.js, vite.config.js
*/
protected string $configFile;
/**
* File path being watched, used for cleanup by mix:watch
*/
protected string $watchingFilePath;
public function compileHandle(string $type): int
{
// Exit early if node_modules isn't available yet
if (!File::exists(base_path('node_modules'))) {
$this->error(sprintf(
'The Node dependencies are not available, try running %s:install first.',
$type
));
return 1;
}
$compilableAssets = PackageManager::instance();
$compilableAssets->fireCallbacks();
$registeredPackages = $compilableAssets->getPackages($type);
$requestedPackages = $this->option('package') ?: [];
// Calling commands in unit tests can cause the option casting to not work correctly,
// ensure that the option value is always an array
if (is_string($requestedPackages)) {
$requestedPackages = [$requestedPackages];
}
// Normalize the requestedPackages option
if (count($requestedPackages)) {
foreach ($requestedPackages as &$name) {
$name = strtolower($name);
}
unset($name);
}
// Filter the registered packages to only include requested packages
if (count($requestedPackages) && count($registeredPackages)) {
// Get an updated list of packages including any newly added packages
$registeredPackages = $compilableAssets->getPackages($type);
// Filter the registered packages to only deal with the requested packages
foreach (array_keys($registeredPackages) as $name) {
if (!in_array($name, $requestedPackages)) {
unset($registeredPackages[$name]);
}
}
}
if (!count($registeredPackages)) {
if (count($requestedPackages)) {
$this->error('No registered packages matched the requested packages for compilation.');
return 1;
} else {
$this->info('No packages registered for mixing.');
return 0;
}
}
$exits = [];
foreach ($registeredPackages as $name => $package) {
$relativeMixJsPath = $package['config'];
if (!$this->isPackageWithinWorkspace($relativeMixJsPath)) {
$this->error(sprintf(
'Unable to compile "%s", %s was not found in the package.json\'s workspaces.packages property.'
. ' Try running %s:install first.',
$name,
$relativeMixJsPath,
$type
));
continue;
}
if (!$this->option('silent')) {
$this->info(sprintf('Compiling package "%s"', $name));
}
$exitCode = $this->executeProcess(base_path($relativeMixJsPath));
if ($exitCode > 0) {
$this->error(sprintf('Unable to compile package "%s"', $name));
}
if ($this->option('stop-on-error') && $exitCode > 0) {
return $exitCode;
}
$exits[] = $exitCode;
}
return (int) !empty(array_filter($exits));
}
public function watchHandle(string $type): int
{
$compilableAssets = PackageManager::instance();
$compilableAssets->fireCallbacks();
$packages = $compilableAssets->getPackages($type);
$name = $this->argument('package');
$nameLower = strtolower($name);
if (!in_array($nameLower, array_keys($packages))) {
$this->error(
sprintf('Package "%s" is not a registered package.', $name)
);
return 1;
}
$package = $packages[$nameLower];
$relativeConfigPath = $package['config'];
if (!$this->isPackageWithinWorkspace($relativeConfigPath)) {
$this->error(sprintf(
'Unable to watch "%s", %s was not found in the package.json\'s workspaces.packages property. Try running %s:install first.',
$name,
$relativeConfigPath,
$type
));
return 1;
}
if (!$this->option('silent')) {
$this->info(sprintf('Watching package "%s" for changes', $name));
}
$this->watchingFilePath = $relativeConfigPath;
if ($this->executeProcess(base_path($relativeConfigPath)) !== 0) {
$this->error(sprintf('Unable to compile package "%s"', $name));
return 1;
}
return 0;
}
/**
* Get the package path for the provided winter.mix.js file
*/
protected function getPackagePath(string $path): string
{
return pathinfo($path, PATHINFO_DIRNAME);
}
/**
* Get the path to the mix.webpack.js file for the provided winter.mix.js file
*/
protected function getJsConfigPath(string $path): string
{
return $this->getPackagePath($path) . DIRECTORY_SEPARATOR . $this->configFile;
}
/**
* Check if Mix is able to compile the provided winter.mix.js file
*/
protected function isPackageWithinWorkspace(string $mixJsPath): bool
{
if (!isset($this->packageJson)) {
// Load the main package.json for the project
$this->packageJson = $this->getNpmPackageManifest();
}
return $this->packageJson->hasWorkspace(
Str::replace(DIRECTORY_SEPARATOR, '/', $this->getPackagePath($mixJsPath))
);
}
/**
* Read the package.json file for the project, path configurable with the
* `--manifest` option
*/
protected function getNpmPackageManifest(): PackageJson
{
return new PackageJson(base_path($this->option('manifest') ?? 'package.json'));
}
/**
* Run the mix command against the provided package
*/
protected function executeProcess(string $configPath): int
{
$this->beforeExecution($configPath);
$command = $this->createCommand($configPath);
$commandEnv = $this->createCommandEnv($configPath);
$process = new Process(
$command,
$this->getPackagePath($configPath),
[
'NODE_ENV' => $this->option('production', false) ? 'production' : 'development',
...$commandEnv
],
null,
null
);
if (!$this->option('disable-tty')) {
try {
$process->setTty(true);
} catch (\Throwable $e) {
// This will fail on unsupported systems
}
}
$exitCode = $process->run(function ($status, $stdout) {
if (!$this->option('silent')) {
$this->getOutput()->write($stdout);
}
});
$this->afterExecution($configPath);
return $exitCode;
}
/**
* Ran before dispatching the compile process, use for setting up
*/
protected function beforeExecution(string $configPath): void
{
// do nothing
}
/**
* Ran after dispatching the compile process, use for tearing down
*/
protected function afterExecution(string $configPath): void
{
// do nothing
}
/**
* Create the command array to create a Process object with
*/
abstract protected function createCommand(string $configPath): array;
/**
* Return values to append to the command env
*/
protected function createCommandEnv(string $configPath): array
{
return [];
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace System\Console\Asset;
use Closure;
use Cms\Classes\Theme;
use Symfony\Component\Console\Input\InputOption;
use System\Classes\Asset\BundleManager;
use System\Classes\Asset\PackageJson;
use System\Classes\Asset\PackageManager;
use System\Classes\PluginManager;
use Winter\Storm\Console\Command;
use Winter\Storm\Support\Facades\File;
abstract class AssetCreate extends Command
{
protected const TYPE_MODULE = 'module';
protected const TYPE_PLUGIN = 'plugin';
protected const TYPE_THEME = 'theme';
/**
* @var string The console command description.
*/
protected $description = 'Creates the compiler configuration files for the provided package and optionally installs the necessary dependencies for any selected asset bundles.';
/**
* Local cache of fixture path
*/
private string $fixturePath;
/**
* The type of compilable to configure
*/
protected string $assetType;
/**
* The name of the config file
*/
protected string $configFile;
/**
* Dynamically generate options for all available bundles
*/
public function __construct()
{
parent::__construct();
foreach (BundleManager::instance()->getBundles() as $bundle) {
$this->addOption($bundle, null, InputOption::VALUE_NONE, 'Create ' . $bundle . ' configuration');
}
}
/**
* Execute the console command.
*/
public function handle(): int
{
$package = $this->argument('packageName');
$this->fixturePath = __DIR__ . '/fixtures/config';
$compilableAssets = PackageManager::instance();
$compilableAssets->fireCallbacks();
$packages = $compilableAssets->getPackages($this->assetType, true);
if (
// We have the package already
isset($packages[$package])
// If the user has requested `force` & `no-interaction` then we do not ask for confirmation and continue
&& !($this->option('force') && $this->option('no-interaction'))
// If the user has forced but with interaction, then we do not ask for confirmation, else we do
&& !($this->option('force') || $this->confirm('Package `' . $package . '` has already been configured, are you sure you wish to continue?'))
) {
return 0;
}
[$path, $type] = $this->getPackagePathType($package);
if (is_null($path) || is_null($type)) {
$this->error('Package `' . $package . '` could not be resolved');
return 1;
}
$packageJson = new PackageJson($path . '/package.json');
if (!$packageJson->getName()) {
$packageJson->setName(strtolower(str_replace('.', '-', $package)));
}
$this->installConfigs($packageJson, $package, $type, $path);
$verb = File::exists($packageJson->getPath()) ? 'updated' : 'generated';
$packageJson->save();
if (!$this->option('silent')) {
$this->warn("File $verb: " . str_after($packageJson->getPath(), base_path()));
$this->info(ucfirst($this->assetType) . ' configuration complete.');
}
$this->afterExecution();
return 0;
}
/**
* Resolve the path and type of the package by name
*/
protected function getPackagePathType(string $package): array
{
if (str_starts_with($package, 'theme-')) {
if (($theme = Theme::load(str_after($package, 'theme-'))) && File::exists($theme->getPath())) {
return [$theme->getPath(), static::TYPE_THEME];
}
return [null, null];
}
if (str_starts_with($package, 'module-')) {
if (
($modulePath = base_path('modules') . '/' . str_after($package, 'module-'))
&& File::exists($modulePath)
) {
return [$modulePath, static::TYPE_MODULE];
}
return [null, null];
}
if ($plugin = PluginManager::instance()->findByIdentifier($package)) {
return [$plugin->getPluginPath(), static::TYPE_PLUGIN];
}
return [null, null];
}
/**
* Write out config files based on assetType and the requested options
*/
protected function installConfigs(
PackageJson $packageJson,
string $packageName,
string $packageType,
string $packagePath
): void {
// Normalize package name
$packageName = $this->makePackageName($packageName);
// Bind the bundleManager instance
$bundleManager = BundleManager::instance();
// Get the default config
$config = $this->getFixture(
$this->assetType . '/' . pathinfo($this->configFile, PATHINFO_BASENAME) . '.fixture'
);
// For each bundle offered by node packages
foreach ($bundleManager->getBundles() as $bundle) {
// If the bundle was not selected exit
if (!$this->option($bundle)) {
continue;
}
// Require all packages specified by the bundle
foreach ($bundleManager->getBundlePackages($bundle, $this->assetType) as $dependency => $version) {
$packageJson->addDependency($dependency, $version, dev: true);
}
// Fire any setup handlers required
$setupHandler = $bundleManager->getSetupHandler($bundle);
if ($setupHandler) {
Closure::bind($setupHandler, $this)->call($this, $packagePath, $packageType);
}
// Loop through all the scaffold handlers to build configs / stubs
$scaffoldHandler = $bundleManager->getScaffoldHandler($bundle);
if ($scaffoldHandler) {
// Generate the config
$config = Closure::bind($scaffoldHandler, $this)->call($this, $config, $this->assetType);
// Generate stub files if required
if (!$this->option('no-stubs')) {
$css = Closure::bind($scaffoldHandler, $this)->call($this, $css ?? '', 'css');
$js = Closure::bind($scaffoldHandler, $this)->call($this, $js ?? '', 'js');
}
}
}
// Create stub files if required
if (!$this->option('no-stubs')) {
foreach (['css', 'js'] as $asset) {
// Create asset dist dir so laravel-vite-plugin doesn't complain
if (!File::exists($packagePath . '/assets/dist')) {
File::makeDirectory($packagePath . '/assets/dist/', recursive: true);
}
// Create asset src dirs for stubs
if (!File::exists($packagePath . '/assets/src/' . $asset)) {
File::makeDirectory($packagePath . '/assets/src/' . $asset, recursive: true);
}
$this->writeFile(
sprintf('%1$s/assets/src/%2$s/%3$s.%2$s', $packagePath, $asset, $packageName),
$$asset ?? null ? $$asset : $this->getFixture(sprintf('%1$s/default.%1$s.fixture', $asset))
);
}
}
// Write out the config file
$this->writeFile($packagePath . '/' . $this->configFile, str_replace(
'{{packageName}}',
$packageName,
$config
));
}
/**
* Write a file but ask for conformation before overwriting
*/
protected function writeFile(string $path, string $content): int
{
if (
// If forced, ignore file existing and overwrite
(!$this->option('force') && File::exists($path))
&& (
// If no interaction requested, then skip the confirm check and return
$this->option('no-interaction')
// else ask the user if they want overwriting
|| !$this->confirm(sprintf('%s already exists, overwrite?', basename($path)))
)
) {
return 0;
}
File::ensureDirectoryExists(pathinfo($path, PATHINFO_DIRNAME));
$result = File::put($path, $content);
if (!$this->option('silent')) {
$this->warn('File generated: ' . str_after($path, base_path()));
}
return $result;
}
/**
* Helper method for loading fixtures from the default library
*/
protected function getFixture(string $path): string
{
return File::get($this->fixturePath . '/' . $path);
}
/**
* Converts the user supplied package name into a consistent internal format
*/
protected function makePackageName(string $package): string
{
return strtolower(str_replace('.', '-', $package));
}
/**
* Ran after configuration is complete, use for tearing down / reporting
*/
protected function afterExecution(): void
{
// do nothing
}
}

View File

@@ -0,0 +1,409 @@
<?php
namespace System\Console\Asset;
use Cms\Classes\Theme;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
use System\Classes\Asset\PackageJson;
use System\Classes\Asset\PackageManager;
use System\Classes\PluginManager;
use Winter\Storm\Console\Command;
use Winter\Storm\Exception\SystemException;
use Winter\Storm\Support\Facades\Config;
use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Str;
abstract class AssetInstall extends Command
{
/**
* The path to the "npm" executable.
*/
protected string $npmPath = 'npm';
/**
* Terms used in messages.
*/
protected array $terms = [
'complete' => 'install',
'completed' => 'installed',
];
/**
* Path to package json, if null use base_path.
*/
protected ?string $packageJsonPath = null;
/**
* Type of asset to be installed, @see PackageManager
*/
protected string $assetType;
/**
* The asset config file
*/
protected string $configFile;
/**
* The required dependencies for this compiler
*/
protected array $requiredDependencies = [];
/**
* Execute the console command.
*/
public function handle(): int
{
if ($npmPath = $this->option('npm')) {
if (!File::exists($npmPath) || !is_executable($npmPath)) {
$this->error('The supplied --npm path does not exist or is not executable.');
return 1;
}
$this->npmPath = $npmPath;
}
if (!version_compare($this->getNpmVersion(), '7', '>')) {
$this->error('"npm" version 7 or above must be installed to run this command.');
return 1;
}
// If a custom path is passed, then validate it
if ($packageJsonPath = $this->option('package-json')) {
// If this is not an absolute path, then make it relative
if (!str_starts_with($packageJsonPath, '/')) {
$packageJsonPath = base_path($packageJsonPath);
}
if (!File::exists($packageJsonPath)) {
$this->error('The supplied --package-json path does not exist.');
return 1;
}
$this->packageJsonPath = $packageJsonPath;
}
// Get any packages the user has requested
$requestedPackages = $this->argument('assetPackage') ?: [];
$registeredPackages = $this->getRegisteredPackages($requestedPackages);
if (!$registeredPackages) {
if ($requestedPackages) {
$this->error('No registered packages matched the requested packages for installation.');
return 1;
}
$this->info('No packages registered for mixing.');
return 0;
}
// Get base package.json
$packageJson = new PackageJson($this->packageJsonPath ?? base_path('package.json'));
// Ensure asset compiling packages are set in package.json, then save
$this->validateRequireDependenciesPresent($packageJson)
->save();
// Process compilable asset packages, then save
$this->processPackages($registeredPackages, $packageJson)
->save();
if (!$this->option('no-install')) {
// Ensure separation between package.json modification messages and rest of output
$this->info('');
if ($this->runNpmInstall() !== 0) {
$this->error("Unable to {$this->terms['complete']} dependencies.");
return 1;
}
$this->info("Dependencies successfully {$this->terms['completed']}!");
}
return 0;
}
/**
* Returns all packages registered by the system filtered by requestedPackages if defined
* @throws SystemException
*/
protected function getRegisteredPackages(array $requestedPackages = []): array
{
$packageManager = $this->getPackageManager();
$registeredPackages = $packageManager->getPackages($this->assetType, true);
// Normalize the requestedPackages option
$requestedPackages = array_map(fn ($name) => strtolower($name), $requestedPackages);
// Filter the registered packages to only include requested packages
if (count($requestedPackages) && count($registeredPackages)) {
$cmsEnabled = in_array('Cms', Config::get('cms.loadModules'));
// Autogenerate config files for packages that don't exist but can be autodiscovered
foreach ($requestedPackages as $package) {
// Check if the package is already registered
if (isset($registeredPackages[$package])) {
continue;
}
switch ($packageManager->getPackageTypeFromName($package)) {
case PackageManager::TYPE_MODULE:
$packageManager->registerPackage(
$package,
base_path('modules/' . Str::after($package, 'module-') . '/' . $this->configFile),
$this->assetType
);
break;
case PackageManager::TYPE_THEME:
if (!$cmsEnabled) {
break;
}
$theme = Theme::load(Str::after($package, 'theme-'));
$packageManager->registerPackage(
$package,
$theme->getPath() . '/' . $this->configFile,
$this->assetType
);
break;
case PackageManager::TYPE_PLUGIN:
$packageManager->registerPackage(
$package,
PluginManager::instance()->getPluginPath($package) . '/' . $this->configFile,
$this->assetType
);
break;
case null:
throw new SystemException(sprintf(
'PackageNotFoundException: The package `%s` does not exist.',
$package
));
}
}
// Get an updated list of packages including any newly added packages
$registeredPackages = $packageManager->getPackages($this->assetType, true);
// Filter the registered packages to only deal with the requested packages
foreach (array_keys($registeredPackages) as $name) {
if (!in_array($name, $requestedPackages)) {
unset($registeredPackages[$name]);
}
}
}
return $registeredPackages;
}
/**
* Checks if the package.json of a package has the dependencies required for this command and asks the user if
* they want to install them if not present.
*/
protected function validateRequireDependenciesPresent(PackageJson $packageJson): PackageJson
{
// Check to see if required packages are already present as a dependency
foreach ($this->requiredDependencies as $dependency => $version) {
if (
!$packageJson->hasDependency($dependency)
&& $this->confirm($dependency . ' was not found as a dependency in package.json, would you like to add it?', true)
) {
$packageJson->addDependency($dependency, $version, dev: true);
}
}
return $packageJson;
}
/**
* Validates if the packages passed can be installed and if possible, installs them.
* @throws SystemException
* @throws PackageNotFoundException
*/
protected function processPackages(array $registeredPackages, PackageJson $packageJson): PackageJson
{
// Check if the user requested a specific package for install
if ($requestedPackages = array_map(fn ($name) => strtolower($name), $this->argument('assetPackage'))) {
$packageManager = $this->getPackageManager();
foreach ($requestedPackages as $requestedPackage) {
// We did not find the package, exit
if (!isset($registeredPackages[$requestedPackage])) {
if ($detected = $packageManager->getPackage($requestedPackage, true)) {
switch (count($detected)) {
case 1:
if ($detected[0]['type'] !== $this->assetType) {
throw new SystemException(sprintf(
'PackageNotConfiguredException: The requested package `%s` is only configured for %s. Run `php artisan %s:create %1$s`',
$requestedPackage,
$detected[0]['type'],
$this->assetType
));
}
if ($detected[0]['ignored']) {
throw new SystemException(sprintf(
'PackageIgnoredException: The requested package `%s` is ignored, remove it from package.json to continue',
$requestedPackage,
));
}
break;
case 2:
default:
if (($detected[0]['ignored'] ?? false) || ($detected[1]['ignored'] ?? false)) {
throw new SystemException(sprintf(
'PackageIgnoredException: The requested package `%s` is ignored, remove it from package.json to continue',
$requestedPackage,
));
}
break;
}
}
throw new SystemException(sprintf(
'PackageNotFoundException: The requested package `%s` could not be found.',
$requestedPackage,
));
}
$this->processPackage($packageJson, $requestedPackage, $registeredPackages[$requestedPackage], true);
}
return $packageJson;
}
// Process each found package
foreach ($registeredPackages as $name => $package) {
$this->processPackage($packageJson, $name, $package);
}
return $packageJson;
}
/**
* Adds a package to the project workspace or mark it as ignored based on user input
*/
protected function processPackage(PackageJson $packageJson, string $name, array $package, bool $force = false): bool
{
// Normalize package path across OS types
$packagePath = Str::replace(DIRECTORY_SEPARATOR, '/', $package['path']);
// Nicely report if the package is already in the workspace
if ($packageJson->hasWorkspace($packagePath)) {
$this->warn(sprintf(
'Package %s (%s) is already included in workspaces.packages.',
$name,
$packagePath
));
return true;
}
if ($packageJson->hasIgnoredPackage($packagePath)) {
$this->warn(sprintf(
'The requested package %s (%s) is ignored, remove it from package.json to continue.',
$name,
$packagePath
));
return true;
}
// Add the package path to the instance's package.json->workspaces->packages property if not present
if (!$packageJson->hasWorkspace($packagePath) && !$packageJson->hasIgnoredPackage($packagePath)) {
if (
$force
|| $this->confirm(
sprintf(
"Detected %s (%s), would you like to add it to package.json to include it in your project workspace? Answer no to ignore it.",
$name,
$packagePath
),
true
)
) {
$packageJson->addWorkspace($packagePath);
$this->info(sprintf(
'Adding %s (%s) to the workspaces.packages property in package.json',
$name,
$packagePath
));
} else {
$packageJson->addIgnoredPackage($packagePath);
$this->warn(
sprintf('Ignoring %s (%s)', $name, $packagePath)
);
}
}
// Detect missing config files and provide feedback
if (!File::exists($package['config'])) {
$this->info(sprintf(
'No config file found for %s, you should run %s:config',
$name,
$this->assetType
));
return false;
}
return true;
}
/**
* Installs the dependencies for the given package.
*/
protected function runNpmInstall(): int
{
$process = new Process(
command: [$this->npmPath, 'install'],
cwd: $this->packageJsonPath ? dirname($this->packageJsonPath) : base_path(),
timeout: null
);
if (!$this->option('disable-tty')) {
try {
$process->setTty(true);
} catch (\Throwable $e) {
// This will fail on unsupported systems
}
}
try {
return $process->run(function ($status, $stdout) {
if (!$this->option('silent')) {
$this->getOutput()->write($stdout);
}
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
return 1;
}
}
/**
* Returns the root package.json as a PackageManager object
*/
protected function getPackageManager(): PackageManager
{
// Flush the instance
$packageManager = PackageManager::instance()->fireCallbacks();
// Ensure the instance follows any custom package.json
if ($this->packageJsonPath) {
$packageManager->setPackageJsonPath($this->packageJsonPath);
}
return $packageManager;
}
/**
* Gets the installed NPM version.
*/
protected function getNpmVersion(): string
{
$process = new Process([$this->npmPath, '--version']);
$process->run();
return $process->getOutput();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace System\Console\Asset;
use System\Classes\Asset\PackageManager;
use Winter\Storm\Console\Command;
use Winter\Storm\Support\Facades\File;
abstract class AssetList extends Command
{
protected string $assetType;
public function handle(): int
{
$compilableAssets = PackageManager::instance();
$compilableAssets->fireCallbacks();
$packages = $compilableAssets->getPackages($this->assetType, true);
if (count($packages) === 0) {
$this->info('No packages have been registered.');
return 0;
}
$errors = [];
$rows = [];
foreach ($packages as $name => $package) {
$rows[] = [
'name' => $name,
'active' => !$package['ignored'],
'path' => $package['path'],
'configuration' => $package['config'],
];
if (!File::exists($package['config'])) {
$errors[] = "The config file for $name doesn't exist, try running artisan $this->assetType:install";
}
}
if ($this->option('json')) {
$this->line(json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->line('');
$this->info('Packages registered:');
$this->line('');
$this->table(['Name', 'Active', 'Path', 'Configuration'], array_map(function ($row) {
$row['active'] = ($row['active']) ? '<info>Yes</info>' : '<fg=red>No</>';
return $row;
}, $rows));
$this->line('');
}
if (!empty($errors)) {
foreach ($errors as $error) {
$this->error($error);
}
}
return 0;
}
}

View File

@@ -0,0 +1,3 @@
/**
* Css here
*/

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
console.log('hello world!');

View File

@@ -0,0 +1,8 @@
import { createApp } from "vue";
// import Example from "./components/Example.vue";
//
// const app = createApp({
// components: {Example}
// });
//
// app.mount("#example");

View File

@@ -0,0 +1,4 @@
const mix = require('laravel-mix');
mix.setPublicPath(__dirname);
mix.js('assets/src/js/{{packageName}}.js', 'assets/dist/js/{{packageName}}.js');

View File

@@ -0,0 +1,7 @@
import React from "react";
const App = () => {
return <h1>Hello from React in Winter CMS!</h1>;
};
export default App;

View File

@@ -0,0 +1 @@
import('./{{packageName}}.jsx');

View File

@@ -0,0 +1,5 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./components/App.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
tailwindcss: {},
},
};

View File

@@ -0,0 +1,15 @@
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./assets/src/js/**/*.{js,jsx}',
'./blocks/**/*.block',
'./components/**/*.{htm,php}',
'./controllers/**/*.{htm,php}',
'./formwidgets/**/*.{htm,php}',
'./widgets/**/*.{htm,php}',
],
plugins: [forms],
};

View File

@@ -0,0 +1,15 @@
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./assets/src/js/**/*.{js,jsx}',
'./blocks/**/*.block',
'./layouts/**/*.htm',
'./pages/**/*.htm',
'./partials/**/*.htm',
'./content/**/*.htm',
],
plugins: [forms],
};

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
const defaultOutDir = 'assets/dist';
export default defineConfig({
base: `${process.env.VITE_BASE}/${defaultOutDir}`,
build: {
outDir: defaultOutDir,
assetsDir: '',
},
plugins: [
laravel({
publicDirectory: defaultOutDir,
input: [
'assets/src/css/{{packageName}}.css',
'assets/src/js/{{packageName}}.js',
],
refresh: {
paths: [
'./**/*.htm',
'./**/*.block',
'assets/src/**/*.css',
'assets/src/**/*.js',
'assets/src/**/*.jsx',
]
},
}),
],
});

View File

@@ -0,0 +1,87 @@
const basePath = '%base%';
const { assertSupportedNodeVersion } = require(basePath + '/node_modules/laravel-mix/src/Engine');
module.exports = async () => {
assertSupportedNodeVersion();
const mix = require(basePath + '/node_modules/laravel-mix/src/Mix').primary;
mix.listen('init', function (_mix) {
// Setup Winter path aliases
_mix._api.alias({
'$': '%pluginsPath%',
'~': '%appPath%',
});
// disable notifications if not in watch
%notificationInject%
// define options
_mix._api.options({
processCssUrls: false,
clearConsole: false,
cssNano: {
discardComments: {removeAll: true},
}
});
// enable source maps for dev builds
if (!_mix._api.inProduction()) {
_mix._api.webpackConfig({
devtool: 'inline-source-map'
});
_mix._api.sourceMaps();
}
// Disable default manifest, allow for custom manifest
if (_mix.config.manifest === 'mix-manifest.json') {
mix.manifest.refresh = _ => void 0;
} else {
if (_mix.config.manifest === true) {
_mix.config.manifest = 'mix-manifest.json';
}
}
});
// override default mix output
mix.listen("loading-plugins", function (plugins) {
plugins.forEach(function (plugin, index) {
switch (plugin.constructor.name) {
case "BuildOutputPlugin":
plugins[index].apply = function (compiler) {
compiler.hooks.done.tap('BuildOutputPlugin', stats => {
if (stats.hasErrors()) {
return false;
}
if (this.options.clearConsole) {
this.clearConsole();
}
let data = stats.toJson({
assets: true,
builtAt: true,
hash: true,
performance: true,
relatedAssets: this.options.showRelated
});
if (data.assets.length && !%silent%) {
console.log(this.statsTable(data));
}
});
};
break;
case "WebpackBarPlugin":
if (%silent% || %noProgress%) {
plugins[index].apply = _ => void 0;
}
break;
}
});
});
require('%mixConfigPath%');
await mix.installDependencies();
await mix.init();
return mix.build();
};

View File

@@ -0,0 +1,4 @@
const mix = require('laravel-mix');
mix.setPublicPath(__dirname + '/assets');
// Your mix configuration below

View File

@@ -0,0 +1,103 @@
<?php
namespace System\Console\Asset\Mix;
use System\Console\Asset\AssetCompile;
use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Str;
class MixCompile extends AssetCompile
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'mix:compile';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'mix:compile
{webpackArgs?* : Arguments to pass through to the Webpack CLI}
{--f|production : Runs compilation in "production" mode}
{--s|silent : Enables silent mode, no output will be shown.}
{--d|disable-tty : Disable tty mode}
{--e|stop-on-error : Exit once an error is encountered}
{--m|manifest= : Defines package.json to use for compile}
{--p|package=* : Defines one or more packages to compile}
{--no-progress : Do not show mix progress}';
/**
* @var string The console command description.
*/
protected $description = 'Mix and compile assets';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'mix:build'
];
/**
* Name of config file i.e. mix.webpack.js, vite.config.js
*/
protected string $configFile = 'mix.webpack.js';
/**
* Call the AssetCompile::compileHandle with the mix type
*/
public function handle(): int
{
return $this->compileHandle('mix');
}
/**
* Create the command array to create a Process object with
*/
protected function createCommand(string $configPath): array
{
$basePath = base_path();
$command = $this->argument('webpackArgs') ?? [];
array_unshift(
$command,
$basePath . sprintf('%1$snode_modules%1$s.bin%1$swebpack', DIRECTORY_SEPARATOR),
'build',
$this->option('silent') ? '--stats=none' : '--progress',
'--config=' . $this->getJsConfigPath($configPath)
);
return $command;
}
/**
* Create the temporary mix.webpack.js config file to run webpack with
*/
protected function beforeExecution(string $configPath): void
{
$basePath = base_path();
$fixture = File::get(__DIR__ . '/../fixtures/mix.webpack.js.fixture');
$config = Str::swap([
'%base%' => addslashes($basePath),
'%notificationInject%' => 'mix._api.disableNotifications();',
'%mixConfigPath%' => addslashes($configPath),
'%pluginsPath%' => addslashes(plugins_path()),
'%appPath%' => addslashes(base_path()),
'%silent%' => (int) $this->option('silent'),
'%noProgress%' => (int) $this->option('no-progress')
], $fixture);
File::put($this->getJsConfigPath($configPath), $config);
}
/**
* Remove the temporary mix.webpack.js file
*/
protected function afterExecution(string $configPath): void
{
$webpackConfigPath = $this->getJsConfigPath($configPath);
if (File::exists($webpackConfigPath) && File::isFile($webpackConfigPath)) {
File::delete($webpackConfigPath);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php namespace System\Console\Asset\Mix;
use System\Console\Asset\AssetCreate;
class MixCreate extends AssetCreate
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'mix:create';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'mix:create
{packageName : The package name to add configuration for}
{--no-stubs : Disable stub file generation}
{--s|silent : Enables silent mode, no output will be shown.}
{--f|force : Force file overwrites}';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'mix:config',
];
/**
* The type of compilable to configure
*/
protected string $assetType = 'mix';
/**
* The name of the config file
*/
protected string $configFile = 'winter.mix.js';
}

View File

@@ -0,0 +1,44 @@
<?php namespace System\Console\Asset\Mix;
use System\Console\Asset\AssetInstall;
class MixInstall extends AssetInstall
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'mix:install';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'mix:install
{assetPackage?* : The asset package name to install.}
{--no-install : Tells Winter not to run npm install after config update.}
{--npm= : Defines a custom path to the "npm" binary.}
{--d|disable-tty : Disable tty mode.}
{--s|silent : Enables silent mode, no output will be shown.}
{--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}';
/**
* @var string The console command description.
*/
protected $description = 'Install Node.js dependencies required for mixed assets';
/**
* The asset compiler being used
*/
protected string $assetType = 'mix';
/**
* The asset config file
*/
protected string $configFile = 'winter.mix.js';
/**
* The required packages for this compiler
*/
protected array $requiredDependencies = [
'laravel-mix' => '^6.0.41',
];
}

View File

@@ -0,0 +1,29 @@
<?php
namespace System\Console\Asset\Mix;
use System\Console\Asset\AssetList;
class MixList extends AssetList
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'mix:list';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'mix:list
{--json : Output as JSON}';
/**
* @var string The console command description.
*/
protected $description = 'List all registered Mix packages in this project.';
/**
* The asset compiler being used
*/
protected string $assetType = 'mix';
}

View File

@@ -0,0 +1,66 @@
<?php
namespace System\Console\Asset\Mix;
class MixWatch extends MixCompile
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'mix:watch';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'mix:watch
{package : Defines the package to watch for changes}
{webpackArgs?* : Arguments to pass through to the Webpack CLI}
{--f|production : Runs compilation in "production" mode}
{--m|manifest= : Defines package.json to use for compile}
{--s|silent : Enables silent mode, no output will be shown.}
{--d|disable-tty : Disable tty mode}
{--no-progress : Do not show mix progress}';
/**
* @var string The console command description.
*/
protected $description = 'Mix and compile assets on-the-fly as changes are made.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'mix:dev'
];
/**
* Call the AssetCompile::watchHandle with the mix type
*/
public function handle(): int
{
return $this->watchHandle('mix');
}
/**
* Create the command array to create a Process object with
*/
protected function createCommand(string $configPath): array
{
$command = parent::createCommand($configPath);
// @TODO: Detect Homestead running on Windows to switch to watch-poll-options instead, see https://laravel-mix.com/docs/6.0/cli#polling
$command[] = '--watch';
return $command;
}
/**
* Handle the cleanup of this command if a termination signal is received
*/
public function handleCleanup(): void
{
$this->newLine();
$this->info('Cleaning up: ' . $this->getPackagePath($this->watchingFilePath));
$this->afterExecution(base_path($this->watchingFilePath));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace System\Console\Asset\Npm;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
use System\Classes\Asset\PackageJson;
use System\Classes\Asset\PackageManager;
use Winter\Storm\Console\Command;
use Winter\Storm\Exception\SystemException;
abstract class NpmCommand extends Command
{
/**
* Gets a package config and its PackageJson file based on the `package` argument.
* @throws SystemException
* @throws \JsonException
*/
protected function getPackage(): ?array
{
$compilableAssets = PackageManager::instance();
$compilableAssets->fireCallbacks();
$name = $this->argument('package');
if (!$name) {
return null;
}
if (!$compilableAssets->hasPackage($name, true)) {
throw new SystemException(sprintf('Package "%s" is not a registered package.', $name));
}
$package = $compilableAssets->getPackage($name, true)[0] ?? [];
// Assume that packages with matching names have matching package.json files
$packageJson = new PackageJson($package['package'] ?? null);
return [$package, $packageJson];
}
/**
* Starts a npm process with the command and cwd provided
*/
protected function npmRun(array $command, string $path): int
{
$process = new Process(
$command,
base_path($path),
['NODE_ENV' => $this->getNodeEnv()],
null,
null
);
if (!$this->option('disable-tty') && !$this->option('silent')) {
try {
$process->setTty(true);
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
return 1;
} catch (\Throwable $e) {
// This will fail on unsupported systems
}
}
return $process->run(function ($status, $stdout) {
if (!$this->option('silent')) {
$this->getOutput()->write($stdout);
}
});
}
/**
* Get the env env to provide to node
*/
protected function getNodeEnv(): string
{
if (!$this->hasOption('production')) {
return 'development';
}
return $this->option('production', false) ? 'production' : 'development';
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace System\Console\Asset\Npm;
use System\Console\Asset\Npm\NpmCommand;
use Winter\Storm\Exception\SystemException;
class NpmInstall extends NpmCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'npm:install';
/**
* @inheritDoc
*/
protected $description = 'Install Node.js dependencies for a package';
/**
* @inheritDoc
*/
protected $signature = 'npm:install
{package? : The package name to add configuration for}
{npmArgs?* : Arguments to pass through to the "npm" binary}
{--npm= : Defines a custom path to the "npm" binary}
{--d|dev : Install packages in devDependencies}
{--s|silent : Silent mode.}
{--disable-tty : Disable tty mode}';
/**
* Execute the console command.
*/
public function handle(): int
{
$command = ($this->argument('npmArgs')) ?? [];
try {
[$package, $packageJson] = $this->getPackage();
} catch (SystemException $e) {
if (!str_contains($e->getMessage(), 'is not a registered package.')) {
throw $e;
}
array_unshift($command, $this->argument('package'));
}
array_unshift($command, 'npm', 'install');
if ($this->option('dev')) {
$command[] = '--save-dev';
}
return $this->npmRun($command, $package['path'] ?? '');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace System\Console\Asset\Npm;
use System\Console\Asset\Npm\NpmCommand;
class NpmRun extends NpmCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'npm:run';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'npm:run
{package : Defines the package where the script is located.}
{script : The name of the script to run, as defined in the package.json "scripts" config.}
{additionalArgs?* : Arguments to pass through to the script being run.}
{--f|production : Runs the script in "production" mode.}
{--s|silent : Silent mode.}
{--disable-tty : Disable tty mode}';
/**
* @var string The console command description.
*/
protected $description = 'Runs a script in a given package.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'mix:run'
];
/**
* Execute the console command.
*/
public function handle(): int
{
[$package, $packageJson] = $this->getPackage();
$script = $this->argument('script');
if (!$packageJson->hasScript($script)) {
$this->error(
sprintf('Script "%s" is not defined in package "%s".', $script, $this->argument('package'))
);
return 1;
}
if (!$this->option('silent')) {
$this->info(sprintf('Running script "%s" in package "%s"', $script, $this->argument('package')));
}
$command = ($this->argument('additionalArgs')) ?? [];
if (count($command)) {
array_unshift($command, 'npm', 'run', $script, '--');
} else {
array_unshift($command, 'npm', 'run', $script);
}
return $this->npmRun($command, $package['path']);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace System\Console\Asset\Npm;
use System\Console\Asset\Npm\NpmCommand;
use Winter\Storm\Exception\SystemException;
class NpmUpdate extends NpmCommand
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'npm:update';
/**
* @inheritDoc
*/
protected $description = 'Update Node.js dependencies required for mixed assets';
/**
* @inheritDoc
*/
protected $signature = 'npm:update
{package? : The package name to add configuration for.}
{npmArgs?* : Arguments to pass through to the "npm" binary.}
{--npm= : Defines a custom path to the "npm" binary.}
{--a|save : Tell npm to update package.json.}
{--s|silent : Silent mode.}
{--disable-tty : Disable tty mode}';
/**
* @inheritDoc
*/
public $replaces = [
'mix:update'
];
/**
* Execute the console command.
*/
public function handle(): int
{
$command = ($this->argument('npmArgs')) ?? [];
try {
[$package, $packageJson] = $this->getPackage();
} catch (SystemException $e) {
if (!str_contains($e->getMessage(), 'is not a registered package.')) {
throw $e;
}
array_unshift($command, $this->argument('package'));
}
$args = ['npm', 'update'];
if ($this->option('save')) {
$args[] = '--save';
}
if (count($command)) {
$args[] = '--';
}
array_unshift($command, ...$args);
return $this->npmRun($command, $package['path'] ?? '');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace System\Console\Asset\Npm;
use Symfony\Component\Process\Process;
use System\Console\Asset\Npm\NpmCommand;
class NpmVersion extends NpmCommand
{
const NPM_MINIMUM_SUPPORTED_VERSION = '7.0';
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'npm:version';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'npm:version
{--c|compatible : Report compatible version via exit code.}
{--s|silent : Silent mode.}
{--disable-tty : Disable tty mode}';
/**
* @var string The console command description.
*/
protected $description = 'Runs a script in a given package.';
/**
* Execute the console command.
*/
public function handle(): int
{
$process = new Process(
['npm', '--version'],
base_path(),
['NODE_ENV' => $this->getNodeEnv()],
null,
null
);
$output = '';
$exit = $process->run(function ($status, $stdout) use (&$output) {
$output .= $stdout;
});
$output = trim($output);
// Npm failed for some reason, report to user
if ($exit !== 0) {
$this->error('NPM exited with error: ' . $output);
return $exit;
}
// Report the version to user
if (!$this->option('silent')) {
$this->info($output);
}
// If the user has not requested a compatibility check, then return 0
if (!$this->option('compatible')) {
return 0;
}
// If the version of npm is less than the required minimum, then return fail
if (version_compare($output, static::NPM_MINIMUM_SUPPORTED_VERSION, '<')) {
return 1;
}
return 0;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace System\Console\Asset\Vite;
use System\Console\Asset\AssetCompile;
use Winter\Storm\Support\Str;
class ViteCompile extends AssetCompile
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'vite:compile';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'vite:compile
{viteArgs?* : Arguments to pass through to the Vite CLI}
{--f|production : Runs compilation in "production" mode}
{--s|silent : Enables silent mode, no output will be shown.}
{--d|disable-tty : Disable tty mode}
{--e|stop-on-error : Exit once an error is encountered}
{--m|manifest= : Defines package.json to use for compile}
{--p|package=* : Defines one or more packages to compile}
';
/**
* @var string The console command description.
*/
protected $description = 'Vite and compile assets';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'vite:build'
];
/**
* Name of config file i.e. mix.webpack.js, vite.config.js
*/
protected string $configFile = 'vite.config.mjs';
/**
* Call the AssetCompile::compileHandle with the vite type
*/
public function handle(): int
{
return $this->compileHandle('vite');
}
/**
* Create the command array to create a Process object with
*/
protected function createCommand(string $configPath): array
{
$basePath = base_path();
$command = $this->argument('viteArgs') ?? [];
array_unshift(
$command,
$basePath . sprintf('%1$snode_modules%1$s.bin%1$svite', DIRECTORY_SEPARATOR),
'build',
$this->option('silent') ? '--logLevel=silent' : '',
);
return $command;
}
/**
* Return values to append to the command env
*/
protected function createCommandEnv(string $configPath): array
{
return [
'VITE_BASE' => Str::after($this->getPackagePath($configPath), base_path()),
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace System\Console\Asset\Vite;
use System\Console\Asset\AssetCreate;
class ViteCreate extends AssetCreate
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'vite:create';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'vite:create
{packageName : The package name to add configuration for}
{--no-stubs : Disable stub file generation}
{--s|silent : Enables silent mode, no output will be shown.}
{--f|force : Force file overwrites}';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'vite:config',
];
/**
* The type of compilable to configure
*/
protected string $assetType = 'vite';
/**
* The name of the config file
*/
protected string $configFile = 'vite.config.mjs';
/**
* Output a helpful message with the twig code to set up vite after config generation is complete
*/
public function afterExecution(): void
{
if ($this->option('silent')) {
return;
}
$packageName = $this->makePackageName($this->argument('packageName'));
$this->output->writeln('');
$this->info('Add the following to your twig to enable asset loading:');
if ($this->option('react')) {
$this->output->writeln(sprintf(
'<fg=blue>{{ viteReactRefresh(\'%1$s\') }}</>',
strtolower($this->argument('packageName'))
));
}
$this->output->writeln(sprintf(
'<fg=blue>{{ vite([\'assets/src/css/%1$s.css\', \'assets/src/js/%1$s.js\'], \'%2$s\') }}</>',
$packageName,
strtolower($this->argument('packageName'))
));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace System\Console\Asset\Vite;
use System\Console\Asset\AssetInstall;
class ViteInstall extends AssetInstall
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'vite:install';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'vite:install
{assetPackage?* : The asset package name to install.}
{--no-install : Tells Winter not to run npm install after config update.}
{--npm= : Defines a custom path to the "npm" binary.}
{--d|disable-tty : Disable tty mode.}
{--s|silent : Enables silent mode, no output will be shown.}
{--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}';
/**
* @var string The console command description.
*/
protected $description = 'Install Node.js dependencies required for vite assets';
/**
* The type of compilable to configure
*/
protected string $assetType = 'vite';
/**
* The asset config file
*/
protected string $configFile = 'vite.config.mjs';
/**
* The required packages for this compiler
*/
protected array $requiredDependencies = [
'vite' => '^6.0.0',
'laravel-vite-plugin' => '^1.1.0',
];
}

View File

@@ -0,0 +1,29 @@
<?php
namespace System\Console\Asset\Vite;
use System\Console\Asset\AssetList;
class ViteList extends AssetList
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'vite:list';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'vite:list
{--json : Output as JSON}';
/**
* @var string The console command description.
*/
protected $description = 'List all registered Vite packages in this project.';
/**
* The asset compiler being used
*/
protected string $assetType = 'vite';
}

View File

@@ -0,0 +1,72 @@
<?php
namespace System\Console\Asset\Vite;
use Winter\Storm\Support\Facades\File;
class ViteWatch extends ViteCompile
{
/**
* @var string|null The default command name for lazy loading.
*/
protected static $defaultName = 'vite:watch';
/**
* @var string The name and signature of this command.
*/
protected $signature = 'vite:watch
{package : The package to watch for changes (ex. author.plugin, theme-code, etc)}
{viteArgs?* : Arguments to pass through to the Webpack CLI}
{--f|production : Runs compilation in "production" mode}
{--m|manifest= : Defines package.json to use for compile}
{--s|silent : Enables silent mode, no output will be shown.}
{--d|disable-tty : Disable tty mode}
{--no-progress : Do not show mix progress}';
/**
* @var string The console command description.
*/
protected $description = 'Vite and compile assets on-the-fly as changes are made.';
/**
* @var array List of commands that this command replaces (aliases)
*/
protected $replaces = [
'vite:dev'
];
/**
* Call the AssetCompile::watchHandle with the vite type
*/
public function handle(): int
{
return $this->watchHandle('vite');
}
/**
* Create the command array to create a Process object with
*/
protected function createCommand(string $configPath): array
{
$command = parent::createCommand($configPath);
$key = array_search('build', $command);
unset($command[$key]);
$command[] = '--host';
return array_values($command);
}
/**
* Handle the cleanup of this command if a termination signal is received
*/
public function handleCleanup(): void
{
$this->newLine();
$this->info('Running compile to ensure files exist after exit');
$this->call('vite:compile', [
'--package' => $this->argument('package'),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace {{ plugin_namespace }}\Console;
use Winter\Storm\Console\Command;
class {{studly_name}} extends Command
{
/**
* @var string The console command name.
*/
protected static $defaultName = '{{ lower_command }}';
/**
* @var string The name and signature of this command.
*/
protected $signature = '{{ lower_command }}
{myCustomArgument : Example argument. <info>Additional information</info>}
{--f|force : Force the operation to run and ignore production warnings and confirmation questions.}';
/**
* @var string The console command description.
*/
protected $description = '{{ description }}';
/**
* Execute the console command.
* @return void
*/
public function handle()
{
$this->output->writeln('Hello world!');
}
/**
* Provide autocomplete suggestions for the "myCustomArgument" argument
*/
// public function suggestMyCustomArgumentValues(): array
// {
// return ['value', 'another'];
// }
}

View File

@@ -0,0 +1,27 @@
<?php
namespace {{ plugin_namespace }}\Database\Factories;
use Winter\Storm\Database\Factories\Factory;
/**
* {{ name }} Factory
*
{% if model %}
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\{{ plugin_namespace }}\Models\{{ model }}>
{% endif %}
*/
class {{ studly_name }} extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
//
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace {{ plugin_namespace }}\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
use Illuminate\Queue\SerializesModels;
class {{studly_name}} implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
//
}
/**
* Get the middleware the job should pass through.
*/
public function middleware()
{
return []; // Remove this line to activate
return [
// Instruct Laravel to not process the job if
// its corresponding batch has been cancelled
new SkipIfBatchCancelled()
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace {{ plugin_namespace }}\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class {{studly_name}} implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
//
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace {{ plugin_namespace }}\Jobs;
use Illuminate\Foundation\Bus\Dispatchable;
class {{studly_name}}
{
use Dispatchable;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
//
}
}

View File

@@ -0,0 +1,46 @@
<?php
use Winter\Storm\Database\Schema\Blueprint;
use Winter\Storm\Database\Updates\Migration;
use Winter\Storm\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('{{ table }}', function (Blueprint $table) {
{% if primaryKey %}
$table->increments('{{ primaryKey }}');
{% else %}
$table->id();
{% endif %}
{% for field,config in fields %}
$table->{{ config.type }}('{{ field }}'){{ config.required == false ? '->nullable()' }}{{ config.index ? '->index()' }};
{% endfor %}
{% for field in jsonable %}
$table->mediumText('{{ field }}')->nullable();
{% endfor %}
{% for field in morphable %}
$table->nullableMorphs('{{ field }}', 'morphable_index');
{% endfor %}
{% if not model or timestamps %}
$table->timestamps();
{% endif %}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('{{ table }}');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Winter\Storm\Database\Schema\Blueprint;
use Winter\Storm\Database\Updates\Migration;
use Winter\Storm\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
//
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Winter\Storm\Database\Schema\Blueprint;
use Winter\Storm\Database\Updates\Migration;
use Winter\Storm\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('{{ table }}', function (Blueprint $table) {
{% for field,config in fields %}
$table->{{ config.type }}('{{ field }}'){{ config.required == false ? '->nullable()' }}{{ config.index ? '->index()' }};
{% endfor %}
{% for field in jsonable %}
$table->mediumText('{{ field }}')->nullable();
{% endfor %}
{% for field in morphable %}
$table->nullableMorphs('{{ field }}', 'morphable_index');
{% endfor %}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('{{ table }}', function (Blueprint $table) {
{% for field,config in fields %}
$table->dropColumn('{{ field }}');
{% endfor %}
{% for field in jsonable %}
$table->dropColumn('{{ field }}');
{% endfor %}
{% for field in morphable %}
$table->dropColumn('{{ field }}');
{% endfor %}
});
}
};

View File

@@ -0,0 +1,19 @@
# ===================================
# List Column Definitions
# ===================================
columns:
id:
label: '{{ plugin_id }}::lang.models.general.id'
searchable: true
created_at:
label: '{{ plugin_id }}::lang.models.general.created_at'
type: datetime
searchable: true
sortable: true
invisible: true
updated_at:
label: '{{ plugin_id }}::lang.models.general.updated_at'
type: datetime
searchable: true
sortable: true

View File

@@ -0,0 +1,21 @@
<?php
use Winter\Storm\Database\Schema\Blueprint;
use Winter\Storm\Database\Updates\Migration;
return new class extends Migration
{
public function up()
{
Schema::create('{{ table_name }}', function (Blueprint $table) {
$table->engine = 'InnoDB';
$table->increments('id');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('{{ table_name }}');
}
};

View File

@@ -0,0 +1,8 @@
# ===================================
# Form Field Definitions
# ===================================
fields:
id:
label: '{{ plugin_id }}::lang.models.general.id'
disabled: true

View File

@@ -0,0 +1,76 @@
<?php
namespace {{ plugin_namespace }}\Models;
use Winter\Storm\Database\Model;
/**
* {{ name }} Model
*/
class {{ studly_name }} extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var string The database table used by the model.
*/
public $table = '{{ table_name }}';
/**
* @var array Guarded fields
*/
protected $guarded = ['*'];
/**
* @var array Fillable fields
*/
protected $fillable = [];
/**
* @var array Validation rules for attributes
*/
public $rules = [];
/**
* @var array Attributes to be cast to native types
*/
protected $casts = [];
/**
* @var array Attributes to be cast to JSON
*/
protected $jsonable = [];
/**
* @var array Attributes to be appended to the API representation of the model (ex. toArray())
*/
protected $appends = [];
/**
* @var array Attributes to be removed from the API representation of the model (ex. toArray())
*/
protected $hidden = [];
/**
* @var array Attributes to be cast to Argon (Carbon) instances
*/
protected $dates = [
'created_at',
'updated_at',
];
/**
* @var array Relations
*/
public $hasOne = [];
public $hasMany = [];
public $hasOneThrough = [];
public $hasManyThrough = [];
public $belongsTo = [];
public $belongsToMany = [];
public $morphTo = [];
public $morphOne = [];
public $morphMany = [];
public $attachOne = [];
public $attachMany = [];
}

View File

@@ -0,0 +1,88 @@
<?php
namespace {{ plugin_namespace }};
use Backend\Facades\Backend;
use Backend\Models\UserRole;
use System\Classes\PluginBase;
/**
* {{ name }} Plugin Information File
*/
class Plugin extends PluginBase
{
/**
* Returns information about this plugin.
*/
public function pluginDetails(): array
{
return [
'name' => '{{ plugin_id }}::lang.plugin.name',
'description' => '{{ plugin_id }}::lang.plugin.description',
'author' => '{{ author }}',
'icon' => 'icon-leaf'
];
}
/**
* Register method, called when the plugin is first registered.
*/
public function register(): void
{
}
/**
* Boot method, called right before the request route.
*/
public function boot(): void
{
}
/**
* Registers any frontend components implemented in this plugin.
*/
public function registerComponents(): array
{
return []; // Remove this line to activate
return [
\{{ plugin_namespace }}\Components\MyComponent::class => 'myComponent',
];
}
/**
* Registers any backend permissions used by this plugin.
*/
public function registerPermissions(): array
{
return []; // Remove this line to activate
return [
'{{ plugin_id }}.some_permission' => [
'tab' => '{{ plugin_id }}::lang.plugin.name',
'label' => '{{ plugin_id }}::lang.permissions.some_permission',
'roles' => [UserRole::CODE_DEVELOPER, UserRole::CODE_PUBLISHER],
],
];
}
/**
* Registers backend navigation items for this plugin.
*/
public function registerNavigation(): array
{
return []; // Remove this line to activate
return [
'{{ lower_name }}' => [
'label' => '{{ plugin_id }}::lang.plugin.name',
'url' => Backend::url('{{ plugin_url }}/mycontroller'),
'icon' => 'icon-leaf',
'permissions' => ['{{ plugin_id }}.*'],
'order' => 500,
],
];
}
}

View File

@@ -0,0 +1,2 @@
'1.0.0':
- 'First version of {{ name }}'

View File

@@ -0,0 +1,7 @@
# ===================================
# Form Field Definitions
# ===================================
fields:
settings_option:
label: This is a sample settings field used by {{author}}.{{plugin}}

View File

@@ -0,0 +1,33 @@
<?php
namespace {{ plugin_namespace }}\Models;
use Model;
/**
* {{name}} Model
*/
class {{studly_name}} extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var array Behaviors implemented by this model.
*/
public $implement = [\System\Behaviors\SettingsModel::class];
/**
* @var string Unique code
*/
public $settingsCode = '{{lower_author}}_{{lower_plugin}}_{{lower_name}}';
/**
* @var mixed Settings form field definitions
*/
public $settingsFields = 'fields.yaml';
/**
* @var array Validation rules
*/
public $rules = [];
}

View File

@@ -0,0 +1,13 @@
<?php
use {{ tested_class_full }};
beforeEach(function () {
$this->instance = $this->app->make({{ tested_class }}::class);
});
{% for method in public_methods %}
it('the {{ method }} method does something', function () {
// $this->assertTrue(method_exists($this->instance, '{{ method }}'));
});
{% endfor %}

View File

@@ -0,0 +1,45 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
uses(\System\Tests\Bootstrap\TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
// expect()->extend('toBeOne', function () {
// return $this->toBe(1);
// });
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
// function something()
// {
// // ..
// }

View File

@@ -0,0 +1,7 @@
<?php
test('example', function () {
$response = $this->get('/');
$response->assertStatus(200);
});

View File

@@ -0,0 +1,5 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="../../../modules/system/tests/bootstrap/app.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
>
<testsuites>
<testsuite name="Unit">
<directory>./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>./tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>

View File

@@ -0,0 +1,34 @@
<?php
namespace {{ test_namespace }};
use {{ tested_class_full }};
use System\Tests\Bootstrap\TestCase as WinterTestCase;
class {{ test_class }} extends WinterTestCase
{
/**
* An instance of the class being tested
*/
protected ?{{ tested_class }} $instance = null;
/**
* Initialize the {{ tested_class }}
*/
public function setUp(): void
{
parent::setUp();
$this->instance = $this->app->make({{ tested_class }}::class);
}
{% for method in public_methods %}
/**
* Test for the {{ method }} method
*/
public function test_{{ method }}()
{
$this->assertTrue(method_exists($this->instance, '{{ method }}'));
}
{% endfor %}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace {{ plugin_namespace }}\Tests\Unit;
use {{ plugin_namespace }}\Plugin;
use System\Classes\PluginBase;
use System\Tests\Bootstrap\PluginTestCase;
class PluginTest extends PluginTestCase
{
protected PluginBase $plugin;
public function setUp(): void
{
$this->plugin = new Plugin($this->createApplication());
}
public function testPluginDetails()
{
$details = $this->plugin->pluginDetails();
$this->assertIsArray($details);
$this->assertArrayHasKey('name', $details);
$this->assertArrayHasKey('description', $details);
$this->assertArrayHasKey('icon', $details);
$this->assertArrayHasKey('author', $details);
$this->assertEquals('{{ author }}', $details['author']);
}
public function testRegisterPermissions()
{
$permissions = $this->plugin->registerPermissions();
$this->assertIsArray($permissions);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace {{ test_namespace }};
use System\Tests\Bootstrap\PluginTestCase;
class {{ test_class }} extends PluginTestCase
{
/**
* A basic feature test example.
*/
public function test_example(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace {{ test_namespace }};
use PHPUnit\Framework\TestCase;
class {{ test_class }} extends TestCase
{
/**
* A basic unit test example.
*/
public function test_example(): void
{
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,79 @@
<?php namespace System\Console\Traits;
use InvalidArgumentException;
use System\Classes\PluginBase;
use System\Classes\PluginManager;
/**
* Console Command Trait that provides autocompletion for the "plugin" argument
*
* @package winter\wn-system-module
* @author Luke Towers
*/
trait HasPluginArgument
{
/**
* @var string What type of plugins to suggest in the CLI autocompletion. Valid values: "enabled", "disabled", "all"
*/
// protected $hasPluginsFilter = 'enabled';
/**
* @var bool Validate the provided plugin input against the PluginManager, default true.
*/
// protected $validatePluginInput = true;
/**
* Return available plugins for autocompletion of the "plugin" argument
*/
public function suggestPluginValues()
{
$manager = PluginManager::instance();
$plugins = array_keys($manager->getAllPlugins());
$filter = $this->hasPluginsFilter ?? 'enabled';
// Apply the hasPluginsFilter on the list of plugins to return
if ($filter !== 'all') {
foreach ($plugins as $i => $identifier) {
$disabled = $manager->isDisabled($identifier);
if (
(!$disabled && $filter === 'disabled')
|| ($disabled && $filter === 'enabled')
) {
unset($plugins[$i]);
}
}
}
return $plugins;
}
/**
* Get the desired plugin name from the input.
* @throws InvalidArgumentException if the provided plugin name is invalid
*/
public function getPluginIdentifier($identifier = null): string
{
$pluginManager = PluginManager::instance();
$pluginName = $identifier ?? $this->argument('plugin');
$pluginName = $pluginManager->normalizeIdentifier($pluginName);
if (
(isset($this->validatePluginInput) && $this->validatePluginInput !== false)
&& !$pluginManager->hasPlugin($pluginName)
) {
throw new InvalidArgumentException(sprintf('Plugin "%s" could not be found.', $pluginName));
}
return $pluginName;
}
/**
* Get the plugin instance for the input.
* @throws InvalidArgumentException if the provided plugin name is invalid
*/
public function getPlugin($identifier = null): ?PluginBase
{
return PluginManager::instance()->findByIdentifier($this->getPluginIdentifier($identifier));
}
}