From 5d8864be44e82920a31833c120b787bb2ccc1099 Mon Sep 17 00:00:00 2001 From: Kin Basco Date: Thu, 19 Feb 2026 02:45:29 +0800 Subject: [PATCH 1/2] Add alias support and configurable generation options --- config/make-action.php | 50 +++++- src/Commands/MakeActionCommand.php | 155 +++++++++++++++-- tests/MakeActionCommandTest.php | 268 +++++++++++++++++++++++++++-- 3 files changed, 449 insertions(+), 24 deletions(-) diff --git a/config/make-action.php b/config/make-action.php index 6fb9f71..8c29361 100644 --- a/config/make-action.php +++ b/config/make-action.php @@ -2,5 +2,53 @@ // config for Webteractive/MakeAction return [ - 'method_name' => 'handle', + + /* + |-------------------------------------------------------------------------- + | Default generation settings + |-------------------------------------------------------------------------- + | Used when no --target / --path / --namespace / --method are provided. + | + | Examples: + | php artisan make:action HealTheWorldAction + | -> app/Actions/HealTheWorldAction.php + | -> namespace App\Actions + | -> method handle() + */ + 'default' => [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ], + + /* + |-------------------------------------------------------------------------- + | Target aliases + |-------------------------------------------------------------------------- + | Targets allow you to avoid typing deep paths repeatedly (monorepo-friendly). + | + | Usage: + | php artisan make:action HealTheWorldAction --target=billing + | + | Precedence (highest -> lowest): + | 1) CLI options: --path, --namespace, --method + | 2) Target alias config (targets.) + | 3) Default config (default.*) + */ + 'targets' => [ + + // Example: default app target + 'app' => [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + // 'method' => 'handle', // optional override + ], + + // Example: monorepo plugin target + // 'billing' => [ + // 'path' => base_path('plugins/Billing/src/Actions'), + // 'namespace' => 'Plugins\\Billing\\Actions', + // 'method' => 'execute', // optional override per target + // ], + ], ]; diff --git a/src/Commands/MakeActionCommand.php b/src/Commands/MakeActionCommand.php index a339fb2..41ced3a 100644 --- a/src/Commands/MakeActionCommand.php +++ b/src/Commands/MakeActionCommand.php @@ -2,16 +2,21 @@ namespace Webteractive\MakeAction\Commands; -use Illuminate\Support\Str; use Illuminate\Console\Command; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; use function Laravel\Prompts\text; -use Illuminate\Filesystem\Filesystem; - class MakeActionCommand extends Command { - public $signature = 'make:action {name?}'; + public $signature = 'make:action + {name? : The name of the action class} + {--target= : Target alias defined in config/make-action.php (targets.)} + {--method= : The action method name (overrides config)} + {--path= : The directory where the action should be generated (overrides config)} + {--namespace= : The namespace for the action class (overrides config)}'; public $description = 'Create a new action class'; @@ -20,6 +25,7 @@ class MakeActionCommand extends Command public function __construct(Filesystem $files) { parent::__construct(); + $this->files = $files; } @@ -33,22 +39,57 @@ public function handle(): int $className = Str::studly($name); - $path = app_path('Actions/'.$className.'.php'); - $namespace = 'App\\Actions'; + if (! $this->isValidClassName($className)) { + $this->error("Invalid action class name [{$name}]. Use a valid PHP class name."); + return self::FAILURE; + } + + $resolved = $this->resolveTargetConfig(); + + if ($resolved === null) { + return self::FAILURE; + } + + $method = $resolved['method']; + $namespace = $resolved['namespace']; + $directory = $resolved['path']; + + if (! $this->isValidMethodName($method)) { + $this->error("Invalid method name [{$method}]."); + return self::FAILURE; + } + if (! $this->isValidNamespace($namespace)) { + $this->error("Invalid namespace [{$namespace}]."); + return self::FAILURE; + } + + $path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $className . '.php'; + + // 🚫 No force support — always fail if exists if ($this->files->exists($path)) { - $this->error('Action already exists!'); + $this->error('Action already exists.'); + $this->line("Path: {$path}"); + return self::FAILURE; + } + if (! $this->ensureDirectoryExists($directory)) { + $this->error("Unable to create directory: {$directory}"); return self::FAILURE; } - $this->ensureDirectoryExists(app_path('Actions')); + $stubPath = __DIR__ . '/../../stubs/action.stub'; - $stub = $this->files->get(__DIR__.'/../../stubs/action.stub'); + if (! $this->files->exists($stubPath)) { + $this->error("Stub file not found: {$stubPath}"); + return self::FAILURE; + } + + $stub = $this->files->get($stubPath); $stub = str_replace( ['{{ namespace }}', '{{ class }}', '{{ method }}'], - [$namespace, $className, config('make-action.method_name', 'handle')], + [$namespace, $className, $method], $stub ); @@ -60,10 +101,98 @@ public function handle(): int return self::SUCCESS; } - protected function ensureDirectoryExists($path) + protected function resolveTargetConfig(): ?array + { + $config = (array) config('make-action', []); + $defaults = (array) Arr::get($config, 'default', []); + + $resolved = [ + 'path' => $defaults['path'] ?? app_path('Actions'), + 'namespace' => $defaults['namespace'] ?? 'App\\Actions', + 'method' => $defaults['method'] ?? 'handle', + ]; + + if ($alias = $this->option('target')) { + $targets = (array) Arr::get($config, 'targets', []); + + if (! array_key_exists($alias, $targets)) { + $this->error("Unknown target alias [{$alias}]."); + + $available = array_keys($targets); + if ($available) { + $this->line('Available targets: ' . implode(', ', $available)); + } + + return null; + } + + $resolved = array_replace( + $resolved, + Arr::only((array) $targets[$alias], ['path', 'namespace', 'method']) + ); + } + + if ($this->option('path')) { + $resolved['path'] = $this->normalizePath((string) $this->option('path')); + } + + if ($this->option('namespace')) { + $resolved['namespace'] = trim((string) $this->option('namespace'), '\\'); + } + + if ($this->option('method')) { + $resolved['method'] = (string) $this->option('method'); + } + + $resolved['path'] = rtrim((string) $resolved['path'], '/\\'); + $resolved['namespace'] = trim((string) $resolved['namespace'], '\\'); + $resolved['method'] = (string) $resolved['method']; + + return $resolved; + } + + protected function normalizePath(string $path): string { - if (! $this->files->isDirectory($path)) { - $this->files->makeDirectory($path, 0755, true); + $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) + || (bool) preg_match('/^[A-Z]:\\\\/i', $path); + + if ($isAbsolute) { + return rtrim($path, '/\\'); } + + return base_path(trim($path, '/\\')); + } + + protected function ensureDirectoryExists(string $path): bool + { + if ($this->files->isDirectory($path)) { + return true; + } + + try { + return $this->files->makeDirectory($path, 0755, true); + } catch (\Throwable) { + return false; + } + } + + protected function isValidClassName(string $class): bool + { + return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $class); + } + + protected function isValidMethodName(string $name): bool + { + return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name); + } + + protected function isValidNamespace(string $namespace): bool + { + $namespace = trim($namespace, '\\'); + + return $namespace !== '' && (bool) preg_match( + '/^(?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', + $namespace + ); } } diff --git a/tests/MakeActionCommandTest.php b/tests/MakeActionCommandTest.php index bfb0e0f..f51dbb3 100644 --- a/tests/MakeActionCommandTest.php +++ b/tests/MakeActionCommandTest.php @@ -2,29 +2,128 @@ namespace Webteractive\MakeAction\Tests; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\Test; -use Illuminate\Support\Facades\Artisan; class MakeActionCommandTest extends TestCase { #[Test] - public function it_can_create_an_action_class_with_default_method() + public function it_formats_the_class_name_and_creates_the_action_successfully(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + $inputName = 'bad-action'; // not valid class name as-is + $expectedClassName = 'BadAction'; + $expectedPath = app_path("Actions/{$expectedClassName}.php"); + + File::ensureDirectoryExists(app_path('Actions')); + + if (File::exists($expectedPath)) { + File::delete($expectedPath); + } + + $exitCode = Artisan::call('make:action', [ + 'name' => $inputName, + ]); + + $output = Artisan::output(); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('Action created successfully.', $output); + $this->assertStringContainsString("Action path: {$expectedPath}", $output); + + $this->assertTrue(File::exists($expectedPath)); + + $content = File::get($expectedPath); + + $this->assertStringContainsString('namespace App\\Actions;', $content); + $this->assertStringContainsString("class {$expectedClassName}", $content); + $this->assertStringContainsString('public function handle()', $content); + + File::delete($expectedPath); + } + + + #[Test] + public function it_can_create_an_action_class_using_custom_path_and_namespace_overrides(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + $actionName = 'CustomLocationAction'; + + // Use a deep path override (relative -> base_path()) + $relativeDir = 'plugins/Custom/src/Actions'; + $absoluteDir = base_path($relativeDir); + $actionPath = $absoluteDir.'/'.$actionName.'.php'; + + // ensure clean + File::ensureDirectoryExists($absoluteDir); + if (File::exists($actionPath)) { + File::delete($actionPath); + } + + $exitCode = Artisan::call('make:action', [ + 'name' => $actionName, + '--path' => $relativeDir, + '--namespace' => 'Plugins\\Custom\\Actions', + ]); + + $output = Artisan::output(); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('Action created successfully.', $output); + $this->assertStringContainsString("Action path: {$actionPath}", $output); + + $this->assertTrue(File::exists($actionPath)); + + $content = File::get($actionPath); + $this->assertStringContainsString('namespace Plugins\\Custom\\Actions;', $content); + $this->assertStringContainsString("class {$actionName}", $content); + $this->assertStringContainsString('public function handle()', $content); + + File::delete($actionPath); + + // best-effort cleanup + @rmdir(base_path('plugins/Custom/src/Actions')); + @rmdir(base_path('plugins/Custom/src')); + @rmdir(base_path('plugins/Custom')); + @rmdir(base_path('plugins')); + } + + #[Test] + public function it_can_create_an_action_class_with_default_settings(): void { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + $actionName = 'TestAction'; $actionPath = app_path('Actions/'.$actionName.'.php'); - // Ensure the file doesn't exist before running the command + File::ensureDirectoryExists(app_path('Actions')); + if (File::exists($actionPath)) { File::delete($actionPath); } - Artisan::call('make:action', ['name' => $actionName]); + $exitCode = Artisan::call('make:action', ['name' => $actionName]); + $this->assertSame(0, $exitCode); $this->assertTrue(File::exists($actionPath)); $content = File::get($actionPath); - $this->assertStringContainsString('namespace App\Actions;', $content); + $this->assertStringContainsString('namespace App\\Actions;', $content); $this->assertStringContainsString('class TestAction', $content); $this->assertStringContainsString('public function handle()', $content); @@ -32,27 +131,176 @@ public function it_can_create_an_action_class_with_default_method() } #[Test] - public function it_can_create_an_action_class_with_custom_method() + public function it_can_create_an_action_class_with_custom_method_option(): void { - config()->set('make-action.method_name', 'execute'); + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); $actionName = 'AnotherAction'; $actionPath = app_path('Actions/'.$actionName.'.php'); - // Ensure the file doesn't exist before running the command + File::ensureDirectoryExists(app_path('Actions')); + if (File::exists($actionPath)) { File::delete($actionPath); } - Artisan::call('make:action', ['name' => $actionName]); + $exitCode = Artisan::call('make:action', [ + 'name' => $actionName, + '--method' => 'execute', + ]); + $this->assertSame(0, $exitCode); $this->assertTrue(File::exists($actionPath)); $content = File::get($actionPath); - $this->assertStringContainsString('namespace App\Actions;', $content); + $this->assertStringContainsString('namespace App\\Actions;', $content); $this->assertStringContainsString('class AnotherAction', $content); $this->assertStringContainsString('public function execute()', $content); File::delete($actionPath); } + + #[Test] + public function it_can_create_an_action_class_using_target_alias(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + config()->set('make-action.targets', [ + 'billing' => [ + 'path' => base_path('plugins/Billing/src/Actions'), + 'namespace' => 'Plugins\\Billing\\Actions', + 'method' => 'execute', + ], + ]); + + $actionName = 'BillingAction'; + $actionDir = base_path('plugins/Billing/src/Actions'); + $actionPath = $actionDir.'/'.$actionName.'.php'; + + File::ensureDirectoryExists($actionDir); + + if (File::exists($actionPath)) { + File::delete($actionPath); + } + + $exitCode = Artisan::call('make:action', [ + 'name' => $actionName, + '--target' => 'billing', + ]); + + $this->assertSame(0, $exitCode); + $this->assertTrue(File::exists($actionPath)); + + $content = File::get($actionPath); + $this->assertStringContainsString('namespace Plugins\\Billing\\Actions;', $content); + $this->assertStringContainsString('class BillingAction', $content); + $this->assertStringContainsString('public function execute()', $content); + + File::delete($actionPath); + + // clean up created directories if empty (best-effort) + @rmdir(base_path('plugins/Billing/src/Actions')); + @rmdir(base_path('plugins/Billing/src')); + @rmdir(base_path('plugins/Billing')); + @rmdir(base_path('plugins')); + } + + #[Test] + public function it_fails_when_target_alias_is_unknown(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + config()->set('make-action.targets', [ + 'billing' => [ + 'path' => base_path('plugins/Billing/src/Actions'), + 'namespace' => 'Plugins\\Billing\\Actions', + 'method' => 'execute', + ], + ]); + + $actionName = 'UnknownTargetAction'; + $defaultPath = app_path('Actions/'.$actionName.'.php'); + $billingPath = base_path('plugins/Billing/src/Actions/'.$actionName.'.php'); + + if (File::exists($defaultPath)) { + File::delete($defaultPath); + } + if (File::exists($billingPath)) { + File::delete($billingPath); + } + + $exitCode = Artisan::call('make:action', [ + 'name' => $actionName, + '--target' => 'nope', + ]); + + $this->assertSame(1, $exitCode); + $this->assertFalse(File::exists($defaultPath)); + $this->assertFalse(File::exists($billingPath)); + } + + #[Test] + public function it_fails_when_method_name_is_invalid(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + $actionName = 'InvalidMethodAction'; + $actionPath = app_path('Actions/'.$actionName.'.php'); + + File::ensureDirectoryExists(app_path('Actions')); + + if (File::exists($actionPath)) { + File::delete($actionPath); + } + + $exitCode = Artisan::call('make:action', [ + 'name' => $actionName, + '--method' => '123bad', + ]); + + $this->assertSame(1, $exitCode); + $this->assertFalse(File::exists($actionPath)); + } + + #[Test] + public function it_fails_when_action_already_exists(): void + { + config()->set('make-action.default', [ + 'path' => app_path('Actions'), + 'namespace' => 'App\\Actions', + 'method' => 'handle', + ]); + + $actionName = 'DuplicateAction'; + $actionPath = app_path('Actions/'.$actionName.'.php'); + + File::ensureDirectoryExists(app_path('Actions')); + + // Create existing file + File::put($actionPath, ' $actionName]); + + $this->assertSame(1, $exitCode); + $this->assertTrue(File::exists($actionPath)); + $this->assertSame(' Date: Thu, 19 Feb 2026 03:08:59 +0800 Subject: [PATCH 2/2] Format with pint --- src/Commands/MakeActionCommand.php | 17 ++++++++++++----- tests/MakeActionCommandTest.php | 3 +-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Commands/MakeActionCommand.php b/src/Commands/MakeActionCommand.php index 41ced3a..3d864fc 100644 --- a/src/Commands/MakeActionCommand.php +++ b/src/Commands/MakeActionCommand.php @@ -2,13 +2,14 @@ namespace Webteractive\MakeAction\Commands; -use Illuminate\Console\Command; -use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Console\Command; use function Laravel\Prompts\text; +use Illuminate\Filesystem\Filesystem; + class MakeActionCommand extends Command { public $signature = 'make:action @@ -41,6 +42,7 @@ public function handle(): int if (! $this->isValidClassName($className)) { $this->error("Invalid action class name [{$name}]. Use a valid PHP class name."); + return self::FAILURE; } @@ -56,32 +58,37 @@ public function handle(): int if (! $this->isValidMethodName($method)) { $this->error("Invalid method name [{$method}]."); + return self::FAILURE; } if (! $this->isValidNamespace($namespace)) { $this->error("Invalid namespace [{$namespace}]."); + return self::FAILURE; } - $path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $className . '.php'; + $path = rtrim($directory, '/\\').DIRECTORY_SEPARATOR.$className.'.php'; // 🚫 No force support — always fail if exists if ($this->files->exists($path)) { $this->error('Action already exists.'); $this->line("Path: {$path}"); + return self::FAILURE; } if (! $this->ensureDirectoryExists($directory)) { $this->error("Unable to create directory: {$directory}"); + return self::FAILURE; } - $stubPath = __DIR__ . '/../../stubs/action.stub'; + $stubPath = __DIR__.'/../../stubs/action.stub'; if (! $this->files->exists($stubPath)) { $this->error("Stub file not found: {$stubPath}"); + return self::FAILURE; } @@ -120,7 +127,7 @@ protected function resolveTargetConfig(): ?array $available = array_keys($targets); if ($available) { - $this->line('Available targets: ' . implode(', ', $available)); + $this->line('Available targets: '.implode(', ', $available)); } return null; diff --git a/tests/MakeActionCommandTest.php b/tests/MakeActionCommandTest.php index f51dbb3..29ee2dc 100644 --- a/tests/MakeActionCommandTest.php +++ b/tests/MakeActionCommandTest.php @@ -2,9 +2,9 @@ namespace Webteractive\MakeAction\Tests; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\Test; +use Illuminate\Support\Facades\Artisan; class MakeActionCommandTest extends TestCase { @@ -48,7 +48,6 @@ public function it_formats_the_class_name_and_creates_the_action_successfully(): File::delete($expectedPath); } - #[Test] public function it_can_create_an_action_class_using_custom_path_and_namespace_overrides(): void {