Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `saucebase new <name>` — creates a new project (via `laravel/installer`) and runs the full install flow against it.
- `saucebase install` — runs the install flow against an existing Saucebase app in the current directory (used internally by `new`, and available standalone).
- `saucebase stack <vue|react>` — selects/switches the frontend framework in an app directory.
- `saucebase docker:publish` — publishes the Docker stubs into an app directory (`--force` overwrites, `--ssl=no` picks the plain-HTTP nginx config).

It is **not** a Laravel package — there is no service provider and no package discovery. It runs standalone via a minimal `Illuminate\Console\Application` (see `src/Console/Application.php`). Local PHP + Composer are the only universal prerequisites (same as `laravel/installer` itself). Docker stubs (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `php.ini`, `xdebug.ini`) live in `stubs/docker/` and are copied directly into the target app by `DockerEnvironment::publishStubs()` (no `vendor:publish`).

Expand Down Expand Up @@ -58,7 +59,7 @@ composer install
**Docker flow** (`DockerEnvironment::boot()`):
1. `promptForSsl()` — `--ssl=yes|no` if given, else `--force` ⇒ on, else prompt (requires mkcert)
2. SSL gate: requested but no `mkcert` → FAILURE with install hint
3. `publishStubs()` — **copies `stubs/docker/*` directly** into the target app (skips files that already exist); if SSL off, overwrites `docker/nginx.conf` with `nginx-no-ssl.conf`
3. `publishStubs()` — delegates to the `docker:publish` command (non-interactively, so existing files are kept); if SSL off it publishes `nginx-no-ssl.conf` as `docker/nginx.conf`
4. `generateSsl()` — mkcert for `*.localhost` (no-op if disabled or certs exist)
5. `ensureEnvFile()` → `setDockerEnvDefaults()` → `applyDockerEnvDefaults()`: `DB_CONNECTION=mysql`, MySQL creds, `MAIL_MAILER=smtp`, `APP_URL=https://localhost` (or `http://` if SSL off)
6. `startDocker()` — `docker compose restart` + `up -d --wait --build` (30 min timeout, streaming), cwd = target
Expand Down
2 changes: 2 additions & 0 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Facades\Facade;
use Saucebase\Installer\Console\Commands\InstallCommand;
use Saucebase\Installer\Console\Commands\NewCommand;
use Saucebase\Installer\Console\Commands\PublishDockerCommand;
use Saucebase\Installer\Console\Commands\StackCommand;

class Application
Expand All @@ -27,6 +28,7 @@ public static function make(): ConsoleApplication
NewCommand::class,
InstallCommand::class,
StackCommand::class,
PublishDockerCommand::class,
]);

return $console;
Expand Down
60 changes: 60 additions & 0 deletions src/Console/Commands/PublishDockerCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Saucebase\Installer\Console\Commands;

use Illuminate\Console\Command;

class PublishDockerCommand extends Command
{
protected $signature = 'docker:publish
{--path= : The Saucebase application directory (defaults to the current directory)}
{--ssl= : Publish the SSL or the plain-HTTP nginx config (yes/no, default yes)}
{--force : Overwrite existing files without asking}';

protected $description = 'Publish the Saucebase Docker files (docker-compose.yml, Dockerfile, nginx/php config) into an application';

private const FILES = [
'docker-compose.yml',
'docker/Dockerfile',
'docker/nginx.conf',
'docker/php.ini',
'docker/xdebug.ini',
];

public function handle(): int
{
$stubs = dirname(__DIR__, 3).'/stubs/docker';
$base = $this->option('path') ?: getcwd();
$force = (bool) $this->option('force');

foreach (self::FILES as $file) {
$destination = $base.'/'.$file;

if (file_exists($destination) && ! $force && ! $this->confirm("{$file} already exists. Overwrite it?", false)) {
$this->line(" Skipped {$file}");

continue;
}

@mkdir(dirname($destination), 0755, true);

if (! copy($stubs.'/'.$file, $destination)) {
$this->warn("Failed to publish {$file}.");

continue;
}

$this->line(" Published {$file}");
}

$ssl = $this->option('ssl');

if ($ssl !== null && $ssl !== '' && ! filter_var($ssl, FILTER_VALIDATE_BOOLEAN)) {
if (! copy($stubs.'/docker/nginx-no-ssl.conf', $base.'/docker/nginx.conf')) {
$this->warn('Failed to write nginx.conf (no-SSL).');
}
}

return self::SUCCESS;
}
}
36 changes: 5 additions & 31 deletions src/Environments/DockerEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,37 +149,11 @@ protected function publishStubs(InstallCommand $command): void
{
$command->info('Publishing Docker stubs...');

$stubs = dirname(__DIR__, 2).'/stubs/docker';

foreach ([
'docker-compose.yml',
'docker/Dockerfile',
'docker/nginx.conf',
'docker/php.ini',
'docker/xdebug.ini',
] as $file) {
$destination = $command->path($file);

if (file_exists($destination)) {
continue;
}

@mkdir(dirname($destination), 0755, true);

if (! copy($stubs.'/'.$file, $destination)) {
$command->warn("Failed to publish {$file}.");
}
}

if (! $this->ssl) {
$copied = copy(
$stubs.'/docker/nginx-no-ssl.conf',
$command->path('docker/nginx.conf'),
);
if (! $copied) {
$command->warn('Failed to write nginx.conf (no-SSL). Check that Docker stubs were published first.');
}
}
// Non-interactive: existing files are kept (the confirm defaults to "no").
$command->call('docker:publish', [
'--path' => $command->path(),
'--ssl' => $this->ssl ? 'yes' : 'no',
]);
}

protected function generateSsl(InstallCommand $command): void
Expand Down
72 changes: 72 additions & 0 deletions tests/Feature/PublishDockerCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Saucebase\Installer\Tests\Feature;

use Saucebase\Installer\Tests\TestCase;

class PublishDockerCommandTest extends TestCase
{
private string $tmp;

protected function setUp(): void
{
parent::setUp();
$this->tmp = sys_get_temp_dir().'/sb-docker-publish-'.uniqid();
mkdir($this->tmp, 0755, true);
}

protected function tearDown(): void
{
exec('rm -rf '.escapeshellarg($this->tmp));
parent::tearDown();
}

public function test_it_publishes_the_docker_files(): void
{
$this->artisan("docker:publish --path={$this->tmp}")->assertSuccessful();

foreach (['docker-compose.yml', 'docker/Dockerfile', 'docker/nginx.conf', 'docker/php.ini', 'docker/xdebug.ini'] as $file) {
$this->assertFileExists($this->tmp.'/'.$file);
}
}

public function test_it_keeps_existing_files_when_not_forced(): void
{
file_put_contents($this->tmp.'/docker-compose.yml', 'mine');

$this->artisan("docker:publish --path={$this->tmp}")
->assertSuccessful()
->expectsOutputToContain('Skipped docker-compose.yml');

$this->assertSame('mine', file_get_contents($this->tmp.'/docker-compose.yml'));
}

public function test_force_overwrites_existing_files(): void
{
file_put_contents($this->tmp.'/docker-compose.yml', 'mine');

$this->artisan("docker:publish --path={$this->tmp} --force")->assertSuccessful();

$this->assertNotSame('mine', file_get_contents($this->tmp.'/docker-compose.yml'));
}

public function test_ssl_no_publishes_the_plain_http_nginx_config(): void
{
$this->artisan("docker:publish --path={$this->tmp} --ssl=no")->assertSuccessful();

$this->assertSame(
file_get_contents(dirname(__DIR__, 2).'/stubs/docker/docker/nginx-no-ssl.conf'),
file_get_contents($this->tmp.'/docker/nginx.conf'),
);
}

public function test_ssl_yes_publishes_the_ssl_nginx_config(): void
{
$this->artisan("docker:publish --path={$this->tmp} --ssl=yes")->assertSuccessful();

$this->assertSame(
file_get_contents(dirname(__DIR__, 2).'/stubs/docker/docker/nginx.conf'),
file_get_contents($this->tmp.'/docker/nginx.conf'),
);
}
}