diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index 7059e3b..3ed8279 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -6,8 +6,8 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Saucebase\Installer\Environments\Contracts\Environment; use Saucebase\Installer\Environments\DockerEnvironment; +use Saucebase\Installer\Environments\Environment; use Saucebase\Installer\Environments\NativeEnvironment; use Symfony\Component\Process\Process; @@ -62,8 +62,6 @@ public function handle(): int return self::FAILURE; } - $this->promptForModules(); - return $driver->run($this); } @@ -83,15 +81,16 @@ protected function resolveDriver(): Environment $name = $this->option('driver') ?? select( label: 'How would you like to run Saucebase?', options: [ - 'native' => 'Native PHP - minimal setup, ideal for exploring', 'docker' => 'Docker - recommended for real projects: MySQL, Redis, Mailpit, HTTPS', + 'native' => 'Native PHP - minimal setup, ideal for exploring', ], - default: 'native', + default: 'docker', ); return match ($name) { 'docker' => new DockerEnvironment, - default => new NativeEnvironment, + 'native' => new NativeEnvironment, + default => throw new \InvalidArgumentException("Unknown driver: {$name}"), }; } @@ -120,7 +119,7 @@ protected function runStack(): void } } - protected function promptForModules(): void + public function promptForModules(): void { if ($this->option('all-modules') || $this->option('modules') !== null || $this->option('dev') || $this->isCI()) { return; @@ -218,9 +217,9 @@ public function install(): int $this->runStack(); $this->setupModules(); + $this->rewriteCrossModuleImports(); $this->createStorageLink(); $this->clearCaches(); - $this->displaySuccess(); return self::SUCCESS; } @@ -298,6 +297,16 @@ protected function setupDatabase(): bool protected function setupModules(): void { + // Fast path: skip Packagist discovery when all requested names are fully qualified + if ($opt = $this->option('modules')) { + $names = array_values(array_filter(array_map(fn ($n) => strtolower(trim($n)), explode(',', $opt)))); + if ($names && ! array_filter($names, fn ($n) => ! str_contains($n, '/'))) { + $this->doInstallModules($names); + + return; + } + } + $available = $this->fetchAvailableModules(); if (empty($available)) { @@ -312,6 +321,11 @@ protected function setupModules(): void return; } + $this->doInstallModules($selected); + } + + protected function doInstallModules(array $selected): void + { $this->newLine(); // Phase 1: require all selected packages in one Composer run @@ -353,6 +367,10 @@ protected function setupModules(): void foreach ($selected as $package) { $name = Str::after($package, '/'); + if (! $this->moduleHasSeeder($name)) { + continue; + } + $this->components->task("Seeding {$name}", function () use ($name) { $process = new Process([PHP_BINARY, base_path('artisan'), 'db:seed', "--module={$name}", '--force']); $process->setTimeout(60); @@ -363,6 +381,41 @@ protected function setupModules(): void } } + public function rewriteCrossModuleImports(): void + { + $frameworks = ['vue', 'react', 'svelte']; + $pattern = implode('|', array_map(fn ($f) => preg_quote($f, '#'), $frameworks)); + $extensions = ['vue', 'ts', 'tsx', 'js']; + $moduleDirs = glob(base_path('modules/*/resources/js'), GLOB_ONLYDIR) ?: []; + + foreach ($moduleDirs as $jsRoot) { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($jsRoot)); + foreach ($iterator as $file) { + if (! $file->isFile() || ! in_array($file->getExtension(), $extensions, true)) { + continue; + } + $path = $file->getPathname(); + $content = file_get_contents($path); + $rewritten = preg_replace( + "#(@modules/[^/]+/resources/js/)({$pattern})/#", + '$1', + $content + ); + if ($rewritten !== $content) { + file_put_contents($path, $rewritten); + } + } + } + } + + public function moduleHasSeeder(string $name): bool + { + $seederFile = 'database/seeders/DatabaseSeeder.php'; + + return file_exists(base_path('modules/'.strtolower($name).'/'.$seederFile)) + || file_exists(base_path('vendor/saucebase/'.strtolower($name).'/'.$seederFile)); + } + public function applyModulePatches(array $modules): void { foreach ($modules as $package) { @@ -525,16 +578,18 @@ protected function displayTagline(): void $this->newLine(); } - protected function displaySuccess(): void + public function displaySuccess(array $steps = []): void { $this->newLine(); $this->info('Installation complete!'); $this->newLine(); - $this->line('Next steps:'); - $this->line(' 1. Ensure APP_URL is set correctly in .env'); - $this->line(' 2. Start the dev server: '.($this->option('driver') === 'docker' ? 'npm run dev' : 'php artisan serve or composer dev').''); - $this->line(' 3. Open your app in the browser: '.config('app.url').''); - $this->newLine(); + if ($steps) { + $this->line('Next steps:'); + foreach (array_values($steps) as $i => $step) { + $this->line(' '.($i + 1).'. '.$step); + } + $this->newLine(); + } $this->line('Learn more: https://github.com/saucebase-dev/saucebase'); } } diff --git a/src/Console/Commands/StackCommand.php b/src/Console/Commands/StackCommand.php index 1a6ec13..4dcb0e0 100644 --- a/src/Console/Commands/StackCommand.php +++ b/src/Console/Commands/StackCommand.php @@ -100,9 +100,8 @@ private function runInstallMode(string $framework): int $this->files->deleteDirectory($this->jsRoot.'/react'); $this->files->deleteDirectory($this->basePath.'/stubs/saucebase/stack'); $this->deployModuleFiles($framework); - $this->rewriteCrossModuleImports($framework); $this->flattenRecipeStubs($framework); - $this->info("Framework set to {$framework}. Run: npm install && composer dev"); + $this->info("Framework set to {$framework}. Run: npm install to install dependencies."); return self::SUCCESS; } @@ -289,42 +288,6 @@ private function deployModuleFiles(string $framework): void } } - private function rewriteCrossModuleImports(string $framework): void - { - $moduleDirs = glob($this->basePath.'/modules/*/', GLOB_ONLYDIR); - - if (! $moduleDirs) { - return; - } - - $extensions = ['vue', 'ts', 'tsx', 'js']; - - foreach ($moduleDirs as $moduleDir) { - $jsRoot = $moduleDir.'resources/js'; - - if (! $this->files->isDirectory($jsRoot)) { - continue; - } - - foreach ($this->files->allFiles($jsRoot) as $file) { - if (! in_array($file->getExtension(), $extensions)) { - continue; - } - - $content = $this->files->get($file->getPathname()); - $rewritten = preg_replace( - '#(@modules/[^/]+/resources/js/)'.preg_quote($framework, '#').'/#', - '$1', - $content - ); - - if ($rewritten !== $content) { - $this->files->put($file->getPathname(), $rewritten); - } - } - } - } - private function flattenRecipeStubs(string $framework): void { $others = array_diff(self::SUPPORTED, [$framework]); diff --git a/src/Environments/Contracts/Environment.php b/src/Environments/Contracts/Environment.php deleted file mode 100644 index 425d634..0000000 --- a/src/Environments/Contracts/Environment.php +++ /dev/null @@ -1,17 +0,0 @@ - Human-readable error messages for each unmet prerequisite; empty means all good. */ - public function missingPrerequisites(): array; - - public function run(InstallCommand $command): int; -} diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index c0ef0bb..6595b75 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -5,12 +5,11 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Str; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Environments\Contracts\Environment; use Symfony\Component\Process\Process; use function Laravel\Prompts\confirm; -class DockerEnvironment implements Environment +class DockerEnvironment extends Environment { protected bool $ssl = true; @@ -41,17 +40,22 @@ public function missingPrerequisites(): array return $missing; } - public function run(InstallCommand $command): int + protected function beforePrompts(InstallCommand $command): ?int { $this->promptForSsl($command); if ($this->ssl && ! $this->commandExists('mkcert')) { $command->error('mkcert is required for SSL. Install it with: brew install mkcert'); - $command->line('Then re-run: php artisan saucebase:install --driver=docker'); + $command->info('Official mkcert installation instructions: https://github.com/FiloSottile/mkcert'); return InstallCommand::FAILURE; } + return null; + } + + protected function boot(InstallCommand $command): int + { $this->publishStubs($command); $this->generateSsl($command); @@ -78,10 +82,14 @@ public function run(InstallCommand $command): int } $this->runStack($command); - $this->installModules($command); + + if (! $this->installModules($command)) { + return InstallCommand::FAILURE; + } + + $command->rewriteCrossModuleImports(); $this->createStorageLink($command); $this->clearCaches($command); - $this->reloadDocker($command); return InstallCommand::SUCCESS; } @@ -103,10 +111,13 @@ protected function publishStubs(InstallCommand $command): void Artisan::call('vendor:publish', ['--tag' => 'saucebase-docker', '--no-interaction' => true]); if (! $this->ssl) { - copy( + $copied = copy( __DIR__.'/../../../stubs/docker/docker/nginx-no-ssl.conf', base_path('docker/nginx.conf'), ); + if (! $copied) { + $command->warn('Failed to write nginx.conf (no-SSL). Check that Docker stubs were published first.'); + } } } @@ -123,12 +134,6 @@ protected function generateSsl(InstallCommand $command): void return; } - if (! $this->commandExists('mkcert')) { - $command->warn('mkcert not found — skipping SSL generation. HTTPS may not work.'); - - return; - } - $command->info('Generating SSL certificates...'); @mkdir(dirname($certFile), 0755, true); @@ -158,6 +163,7 @@ protected function startDocker(InstallCommand $command): bool $up = new Process(['docker', 'compose', 'up', '-d', '--wait', '--build']); $up->setTimeout(30 * 60); // 30 minutes — first run pulls images + builds layers $up->run(fn ($_type, $buffer) => $command->line(trim($buffer))); + $command->newLine(); if (! $up->isSuccessful()) { $command->error('Docker failed to start: '.$up->getErrorOutput()); @@ -194,6 +200,10 @@ protected function execInContainer(InstallCommand $command, array $args, int $ti protected function generateAppKey(InstallCommand $command): bool { $command->info('Generating application key...'); + $env = @file_get_contents(base_path('.env')); + if ($env !== false && preg_match('/^APP_KEY=base64:.+$/m', $env)) { + return true; + } return $this->execInContainer($command, ['php', 'artisan', 'key:generate', '--force']); } @@ -226,12 +236,12 @@ protected function runStack(InstallCommand $command): void $this->execInContainer($command, $args); } - protected function installModules(InstallCommand $command): void + protected function installModules(InstallCommand $command): bool { $modules = $this->resolveModules($command); if (empty($modules)) { - return; + return true; } $command->info('Installing modules...'); @@ -245,7 +255,7 @@ protected function installModules(InstallCommand $command): void if (! $ok) { $command->warn('Failed to install one or more modules — skipping patches, sync, and migrations.'); - return; + return false; } $command->applyModulePatches($modules); @@ -254,25 +264,39 @@ protected function installModules(InstallCommand $command): void foreach ($modules as $package) { $name = Str::after($package, '/'); + + if (! $command->moduleHasSeeder($name)) { + continue; + } + $this->execInContainer($command, ['php', 'artisan', 'db:seed', "--module={$name}", '--force']); } + + return true; } - protected function resolveModules(InstallCommand $command): array + protected function nextSteps(InstallCommand $command): array { - if ($command->option('all-modules')) { - $available = $command->fetchAvailableModules(); + $appUrl = $this->readEnvValue('APP_URL') ?? ($this->ssl ? 'https://localhost' : 'http://localhost'); - return $command->getSelectedStack() - ? $command->filterModulesByFramework($available, $command->getSelectedStack()) - : $available; - } + return [ + 'Compile frontend assets: npm install && npm run dev', + 'Open your app: '.$appUrl.'', + 'Email testing (Mailpit): http://localhost:8025', + ]; + } - if ($raw = $command->option('modules')) { - return array_values(array_filter(array_map('trim', explode(',', $raw)))); + private function readEnvValue(string $key): ?string + { + $env = @file_get_contents(base_path('.env')); + if ($env === false) { + return null; + } + if (preg_match('/^'.preg_quote($key, '/').'=(.+)$/m', $env, $m)) { + return trim($m[1], "\"'"); } - return $command->getSelectedModules(); + return null; } protected function createStorageLink(InstallCommand $command): void @@ -287,14 +311,6 @@ protected function clearCaches(InstallCommand $command): void $this->execInContainer($command, ['php', 'artisan', 'optimize:clear']); } - protected function reloadDocker(InstallCommand $command): void - { - $command->info('Reloading container...'); - $process = new Process(['docker', 'compose', 'up', '-d', '--wait']); - $process->setTimeout(60); - $process->run(); - } - protected function setDockerEnvDefaults(InstallCommand $command): void { $path = base_path('.env'); @@ -370,9 +386,4 @@ protected function dockerComposeAvailable(): bool { return (bool) shell_exec('docker compose version 2>/dev/null'); } - - protected function commandExists(string $name): bool - { - return (bool) shell_exec("which {$name} 2>/dev/null"); - } } diff --git a/src/Environments/Environment.php b/src/Environments/Environment.php new file mode 100644 index 0000000..0eb2ab8 --- /dev/null +++ b/src/Environments/Environment.php @@ -0,0 +1,70 @@ + Human-readable error messages for each unmet prerequisite; empty means all good. */ + abstract public function missingPrerequisites(): array; + + public function run(InstallCommand $command): int + { + if (($result = $this->beforePrompts($command)) !== null) { + return $result; + } + + $command->promptForModules(); + + $result = $this->boot($command); + + if ($result === InstallCommand::SUCCESS) { + $command->displaySuccess($this->nextSteps($command)); + } + + return $result; + } + + /** Hook: perform driver-specific steps before the module prompt. Return an exit code to abort, null to continue. */ + protected function beforePrompts(InstallCommand $command): ?int + { + return null; + } + + abstract protected function boot(InstallCommand $command): int; + + /** @return string[] Fully-qualified package names to install (e.g. ['saucebase/auth', 'saucebase/billing']). */ + protected function resolveModules(InstallCommand $command): array + { + if ($command->option('all-modules')) { + $available = $command->fetchAvailableModules(); + + return $command->getSelectedStack() + ? $command->filterModulesByFramework($available, $command->getSelectedStack()) + : $available; + } + + if ($raw = $command->option('modules')) { + return array_values(array_filter(array_map(function (string $name): string { + $name = strtolower(trim($name)); + + return $name !== '' ? (str_contains($name, '/') ? $name : "saucebase/{$name}") : ''; + }, explode(',', $raw)))); + } + + return $command->getSelectedModules(); + } + + /** @return string[] */ + abstract protected function nextSteps(InstallCommand $command): array; + + protected function commandExists(string $name): bool + { + return (bool) shell_exec("which {$name} 2>/dev/null"); + } +} diff --git a/src/Environments/NativeEnvironment.php b/src/Environments/NativeEnvironment.php index 3bb34b3..24837ce 100644 --- a/src/Environments/NativeEnvironment.php +++ b/src/Environments/NativeEnvironment.php @@ -3,9 +3,8 @@ namespace Saucebase\Installer\Environments; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Environments\Contracts\Environment; -class NativeEnvironment implements Environment +class NativeEnvironment extends Environment { public function name(): string { @@ -28,13 +27,16 @@ public function missingPrerequisites(): array return $missing; } - public function run(InstallCommand $command): int + protected function boot(InstallCommand $command): int { return $command->install(); } - protected function commandExists(string $name): bool + protected function nextSteps(InstallCommand $command): array { - return (bool) shell_exec("which {$name} 2>/dev/null"); + return [ + 'Start the dev server: composer dev', + 'Open your app: '.config('app.url').'', + ]; } } diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index 980d377..c94e686 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -4,8 +4,8 @@ use Illuminate\Console\Command; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Environments\Contracts\Environment; use Saucebase\Installer\Environments\DockerEnvironment; +use Saucebase\Installer\Environments\Environment; use Saucebase\Installer\Tests\TestCase; class DockerEnvironmentTest extends TestCase @@ -20,7 +20,7 @@ public function test_label_is_set(): void $this->assertNotEmpty((new DockerEnvironment)->label()); } - public function test_implements_environment_contract(): void + public function test_extends_environment_base(): void { $this->assertInstanceOf(Environment::class, new DockerEnvironment); } @@ -50,6 +50,13 @@ public function test_resolve_modules_returns_empty_when_nothing_selected(): void $this->assertSame([], $modules); } + public function test_resolve_modules_normalizes_short_names_to_saucebase_vendor(): void + { + $modules = $this->resolveModules(options: ['modules' => 'auth, billing']); + + $this->assertSame(['saucebase/auth', 'saucebase/billing'], $modules); + } + // ------------------------------------------------------------------------- // missingPrerequisites // ------------------------------------------------------------------------- @@ -295,6 +302,145 @@ protected function runInstallInContainer(InstallCommand $command): void $this->assertFalse($spy->installCalled, 'in-container install must not run when composer install fails'); } + // ------------------------------------------------------------------------- + // generateAppKey idempotency + // ------------------------------------------------------------------------- + + public function test_generate_app_key_skips_when_key_already_set(): void + { + $spy = (object) ['execCalled' => false]; + $envPath = base_path('.env'); + file_put_contents($envPath, "APP_KEY=base64:abc123==\n"); + + try { + $env = new class($spy) extends DockerEnvironment + { + public function __construct(private object $spy) {} + + protected function execInContainer(InstallCommand $command, array $args, int $timeout = 120): bool + { + $this->spy->execCalled = true; + + return true; + } + + public function exposedGenerateAppKey(InstallCommand $command): bool + { + return $this->generateAppKey($command); + } + }; + + $result = $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], [])); + + $this->assertTrue($result); + $this->assertFalse($spy->execCalled, 'key:generate must not run when APP_KEY is already set'); + } finally { + @unlink($envPath); + } + } + + public function test_generate_app_key_runs_when_key_missing(): void + { + $spy = (object) ['execCalled' => false]; + $envPath = base_path('.env'); + file_put_contents($envPath, "APP_NAME=Test\n"); + + try { + $env = new class($spy) extends DockerEnvironment + { + public function __construct(private object $spy) {} + + protected function execInContainer(InstallCommand $command, array $args, int $timeout = 120): bool + { + $this->spy->execCalled = true; + + return true; + } + + public function exposedGenerateAppKey(InstallCommand $command): bool + { + return $this->generateAppKey($command); + } + }; + + $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], [])); + + $this->assertTrue($spy->execCalled, 'key:generate must run when APP_KEY is not set'); + } finally { + @unlink($envPath); + } + } + + // ------------------------------------------------------------------------- + // installModules failure propagation + // ------------------------------------------------------------------------- + + public function test_boot_returns_failure_when_install_modules_fails(): void + { + $env = new class extends DockerEnvironment + { + protected function beforePrompts(InstallCommand $command): ?int + { + return null; + } + + protected function publishStubs(InstallCommand $command): void {} + + protected function generateSsl(InstallCommand $command): void {} + + protected function setDockerEnvDefaults(InstallCommand $command): void {} + + protected function startDocker(InstallCommand $command): bool + { + return true; + } + + protected function runComposerInContainer(InstallCommand $command): bool + { + return true; + } + + protected function generateAppKey(InstallCommand $command): bool + { + return true; + } + + protected function runMigrations(InstallCommand $command): bool + { + return true; + } + + protected function runStack(InstallCommand $command): void {} + + protected function installModules(InstallCommand $command): bool + { + return false; + } + }; + + $command = new class extends FakeInstallCommand + { + public function __construct() + { + parent::__construct(null, [], []); + } + + public function ensureEnvFile(): bool + { + return true; + } + + public function promptForModules(): void {} + + public function displaySuccess(array $steps = []): void {} + + public function rewriteCrossModuleImports(): void {} + }; + + $result = $env->run($command); + $this->assertSame(Command::FAILURE, $result); + } + // ------------------------------------------------------------------------- // applyDockerEnvDefaults // ------------------------------------------------------------------------- diff --git a/tests/Feature/Environments/NativeEnvironmentTest.php b/tests/Feature/Environments/NativeEnvironmentTest.php index 3f585b2..c3ede33 100644 --- a/tests/Feature/Environments/NativeEnvironmentTest.php +++ b/tests/Feature/Environments/NativeEnvironmentTest.php @@ -4,7 +4,7 @@ use Illuminate\Console\Command; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Environments\Contracts\Environment; +use Saucebase\Installer\Environments\Environment; use Saucebase\Installer\Environments\NativeEnvironment; use Saucebase\Installer\Tests\TestCase; @@ -20,7 +20,7 @@ public function test_label_is_set(): void $this->assertNotEmpty((new NativeEnvironment)->label()); } - public function test_implements_environment_contract(): void + public function test_extends_environment_base(): void { $this->assertInstanceOf(Environment::class, new NativeEnvironment); } @@ -70,6 +70,10 @@ public function test_run_delegates_to_install_and_returns_success(): void { public function __construct(public object $spy) {} + public function promptForModules(): void {} + + public function displaySuccess(array $steps = []): void {} + public function install(): int { $this->spy->installCalled = true; @@ -92,6 +96,10 @@ public function test_run_passes_through_failure_from_install(): void app()->bind(InstallCommand::class, function () { return new class extends InstallCommand { + public function promptForModules(): void {} + + public function displaySuccess(array $steps = []): void {} + public function install(): int { return Command::FAILURE; diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 8860f96..3426e6f 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -5,7 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Environments\Contracts\Environment; +use Saucebase\Installer\Environments\Environment; use Saucebase\Installer\Environments\NativeEnvironment; use Saucebase\Installer\Tests\TestCase; @@ -214,7 +214,7 @@ protected function createStorageLink(): void {} protected function clearCaches(): void {} - protected function displaySuccess(): void {} + public function displaySuccess(array $steps = []): void {} protected function resolveDriver(): Environment { @@ -288,7 +288,7 @@ protected function createStorageLink(): void {} protected function clearCaches(): void {} - protected function displaySuccess(): void {} + public function displaySuccess(array $steps = []): void {} }; $cmd->spy = $spy; @@ -329,7 +329,7 @@ protected function createStorageLink(): void {} protected function clearCaches(): void {} - protected function displaySuccess(): void {} + public function displaySuccess(array $steps = []): void {} }; }); @@ -361,7 +361,7 @@ protected function resolveDriver(): Environment { $spy = $this->spy; - return new class($spy) implements Environment + return new class($spy) extends Environment { public function __construct(private object $spy) {} @@ -386,6 +386,16 @@ public function run(InstallCommand $command): int return Command::SUCCESS; } + + protected function boot(InstallCommand $command): int + { + return Command::SUCCESS; + } + + protected function nextSteps(InstallCommand $command): array + { + return []; + } }; } }; @@ -458,7 +468,7 @@ protected function createStorageLink(): void {} protected function clearCaches(): void {} - protected function displaySuccess(): void {} + public function displaySuccess(array $steps = []): void {} protected function resolveDriver(): Environment { @@ -564,6 +574,121 @@ public function test_resolve_matches_multiple_full_package_names(): void $this->assertSame(['saucebase/auth', 'saucebase/billing'], $result); } + // ------------------------------------------------------------------------- + // moduleHasSeeder — vendor/ path + // ------------------------------------------------------------------------- + + public function test_module_has_seeder_checks_vendor_path(): void + { + $name = 'sb-test-seeder-'.uniqid(); + $vendorSeederDir = base_path("vendor/saucebase/{$name}/database/seeders"); + @mkdir($vendorSeederDir, 0755, true); + file_put_contents($vendorSeederDir.'/DatabaseSeeder.php', 'assertFalse( + file_exists(base_path("modules/{$name}/database/seeders/DatabaseSeeder.php")), + 'modules/ path must not exist for this test to be valid' + ); + $this->assertTrue($cmd->moduleHasSeeder($name), 'moduleHasSeeder() must detect seeder in vendor/saucebase/'); + } finally { + @unlink($vendorSeederDir.'/DatabaseSeeder.php'); + @rmdir($vendorSeederDir); + @rmdir(base_path("vendor/saucebase/{$name}/database")); + @rmdir(base_path("vendor/saucebase/{$name}")); + } + } + + // ------------------------------------------------------------------------- + // displaySuccess — sequential step numbering + // ------------------------------------------------------------------------- + + public function test_display_success_numbers_steps_sequentially(): void + { + $cmd = new class extends TestableInstallCommand + { + public array $capturedLines = []; + + public function line($string, $style = null, $verbosity = null): void + { + $this->capturedLines[] = $string; + } + + public function info($string, $verbosity = null): void {} + + public function newLine($count = 1): static + { + return $this; + } + }; + + $cmd->displaySuccess([5 => 'first step', 10 => 'second step']); + + $stepLines = implode(' ', array_filter( + $cmd->capturedLines, + fn ($l) => (bool) preg_match('/\d+\./', $l), + )); + + $this->assertStringContainsString('1. first step', $stepLines); + $this->assertStringContainsString('2. second step', $stepLines); + $this->assertStringNotContainsString('6. first step', $stepLines); + $this->assertStringNotContainsString('11. second step', $stepLines); + } + + // ------------------------------------------------------------------------- + // setupModules — Packagist fast-path + // ------------------------------------------------------------------------- + + public function test_setup_modules_skips_packagist_when_fully_qualified_names_given(): void + { + $cmd = new class extends TestableInstallCommand + { + public bool $fetchCalled = false; + + public function fetchAvailableModules(): array + { + $this->fetchCalled = true; + + return []; + } + }; + $cmd->fakeOptions = ['modules' => 'saucebase/auth']; + $cmd->exposedSetupModules(); + + $this->assertFalse($cmd->fetchCalled, 'Packagist must not be fetched when all names are fully qualified'); + } + + // ------------------------------------------------------------------------- + // rewriteCrossModuleImports + // ------------------------------------------------------------------------- + + public function test_rewrite_cross_module_imports_strips_all_framework_segments(): void + { + $jsRoot = base_path('modules/sb-test-rewrite/resources/js'); + @mkdir($jsRoot, 0755, true); + file_put_contents($jsRoot.'/app.ts', implode("\n", [ + "import Foo from '@modules/other/resources/js/vue/components/Foo.vue';", + "import Bar from '@modules/other/resources/js/react/Bar.tsx';", + ])); + + try { + $cmd = new TestableInstallCommand; + $cmd->rewriteCrossModuleImports(); + + $result = file_get_contents($jsRoot.'/app.ts'); + $this->assertStringContainsString('@modules/other/resources/js/components/Foo.vue', $result); + $this->assertStringContainsString('@modules/other/resources/js/Bar.tsx', $result); + $this->assertStringNotContainsString('/vue/', $result); + $this->assertStringNotContainsString('/react/', $result); + } finally { + @unlink($jsRoot.'/app.ts'); + @rmdir($jsRoot); + @rmdir(base_path('modules/sb-test-rewrite/resources')); + @rmdir(base_path('modules/sb-test-rewrite')); + } + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -635,6 +760,16 @@ public function exposedResolveModuleSelection(array $available): array return $this->resolveModuleSelection($available); } + public function exposedSetupModules(): void + { + $this->setupModules(); + } + + protected function doInstallModules(array $selected): void + { + // no-op — prevents composer require from running in unit tests + } + protected function fetchPackageFrameworks(string $package): array { if (isset($this->frameworkFixtures[$package])) { diff --git a/tests/Feature/StackCommandTest.php b/tests/Feature/StackCommandTest.php index 2cfd8ce..3fea826 100644 --- a/tests/Feature/StackCommandTest.php +++ b/tests/Feature/StackCommandTest.php @@ -380,25 +380,6 @@ public function test_install_mode_removes_stack_stubs_directory(): void $this->assertDirectoryDoesNotExist($this->tmpDir.'/stubs/saucebase/stack'); } - public function test_install_mode_rewrites_cross_module_framework_imports(): void - { - $this->seedFakeStubs('vue'); - $this->seedFakeSourceDir('vue'); - - $consumerDir = $this->tmpDir.'/modules/consumer/resources/js/vue/pages'; - $this->files->ensureDirectoryExists($consumerDir); - file_put_contents( - $consumerDir.'/Index.vue', - "import Foo from '@modules/other/resources/js/vue/components/Foo.vue';\n" - ); - - $this->artisan('saucebase:stack vue')->assertSuccessful(); - - $content = file_get_contents($this->tmpDir.'/modules/consumer/resources/js/pages/Index.vue'); - $this->assertStringNotContainsString('/vue/', $content); - $this->assertStringContainsString('@modules/other/resources/js/components/Foo.vue', $content); - } - public function test_install_mode_removes_framework_subdirs_from_modules(): void { $this->seedFakeStubs('vue');