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
44 changes: 22 additions & 22 deletions .phpstorm.meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -1478,7 +1478,7 @@
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
'filesystems.disks.s3.throw' => 'boolean',
'filesystems.disks.s3.report' => 'boolean',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
'fortify.guard' => 'string',
'fortify.middleware' => 'array',
'fortify.auth_middleware' => 'string',
Expand Down Expand Up @@ -2096,7 +2096,7 @@
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
'filesystems.disks.s3.throw' => 'boolean',
'filesystems.disks.s3.report' => 'boolean',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
'fortify.guard' => 'string',
'fortify.middleware' => 'array',
'fortify.auth_middleware' => 'string',
Expand Down Expand Up @@ -2714,7 +2714,7 @@
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
'filesystems.disks.s3.throw' => 'boolean',
'filesystems.disks.s3.report' => 'boolean',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
'fortify.guard' => 'string',
'fortify.middleware' => 'array',
'fortify.auth_middleware' => 'string',
Expand Down Expand Up @@ -3145,7 +3145,7 @@
'filesystems.disks.public.root','filesystems.disks.public.url','filesystems.disks.public.visibility','filesystems.disks.public.throw','filesystems.disks.public.report',
'filesystems.disks.s3.driver','filesystems.disks.s3.key','filesystems.disks.s3.secret','filesystems.disks.s3.region','filesystems.disks.s3.bucket',
'filesystems.disks.s3.url','filesystems.disks.s3.endpoint','filesystems.disks.s3.use_path_style_endpoint','filesystems.disks.s3.throw','filesystems.disks.s3.report',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage','fortify.guard','fortify.middleware','fortify.auth_middleware','fortify.passwords',
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage','fortify.guard','fortify.middleware','fortify.auth_middleware','fortify.passwords',
'fortify.username','fortify.email','fortify.views','fortify.home','fortify.prefix',
'fortify.domain','fortify.lowercase_usernames','fortify.limiters.login','fortify.limiters.two-factor','fortify.limiters.passkeys',
'fortify.paths.login','fortify.paths.logout','fortify.paths.password.request','fortify.paths.password.reset','fortify.paths.password.email',
Expand Down Expand Up @@ -3226,17 +3226,17 @@
'passkey.store','passkey.destroy','default-livewire.update','livewire.upload-file','livewire.preview-file','home','dashboard',
'profile.edit','appearance.edit','security.edit','storage.local','storage.local.upload',);
registerArgumentsSet('views',
'4a433aca7e9281f2536d3caad73ce93e::app-logo','4a433aca7e9281f2536d3caad73ce93e::app-logo-icon','4a433aca7e9281f2536d3caad73ce93e::auth-header','4a433aca7e9281f2536d3caad73ce93e::auth-session-status','4a433aca7e9281f2536d3caad73ce93e::desktop-user-menu',
'4a433aca7e9281f2536d3caad73ce93e::passkey-registration','4a433aca7e9281f2536d3caad73ce93e::passkey-verify','4a433aca7e9281f2536d3caad73ce93e::placeholder-pattern','4a433aca7e9281f2536d3caad73ce93e::settings.layout','app-logo',
'app-logo-icon','auth-header','auth-session-status','auth.confirm-password','auth.forgot-password',
'auth.login','auth.register','auth.reset-password','auth.two-factor-challenge','auth.verify-email',
'components.app-logo','components.app-logo-icon','components.auth-header','components.auth-session-status','components.desktop-user-menu',
'components.passkey-registration','components.passkey-verify','components.placeholder-pattern','components.settings.layout','dashboard',
'desktop-user-menu','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.book-open-text','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.chevrons-up-down','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.folder-git-2','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.layout-grid',
'e60dd9d2c3a62d619c9acb38f20d5aa5::navlist.group','f4ac99e09542ff494432bc959d4fee61::app','f4ac99e09542ff494432bc959d4fee61::app.header','f4ac99e09542ff494432bc959d4fee61::app.sidebar','f4ac99e09542ff494432bc959d4fee61::auth',
'f4ac99e09542ff494432bc959d4fee61::auth.card','f4ac99e09542ff494432bc959d4fee61::auth.simple','f4ac99e09542ff494432bc959d4fee61::auth.split','f19b7451df9b9c3a280a49237410a69e::auth.confirm-password','f19b7451df9b9c3a280a49237410a69e::auth.forgot-password',
'f19b7451df9b9c3a280a49237410a69e::auth.login','f19b7451df9b9c3a280a49237410a69e::auth.register','f19b7451df9b9c3a280a49237410a69e::auth.reset-password','f19b7451df9b9c3a280a49237410a69e::auth.two-factor-challenge','f19b7451df9b9c3a280a49237410a69e::auth.verify-email',
'f19b7451df9b9c3a280a49237410a69e::settings.appearance','f19b7451df9b9c3a280a49237410a69e::settings.delete-user-form','f19b7451df9b9c3a280a49237410a69e::settings.profile','f19b7451df9b9c3a280a49237410a69e::settings.security','f19b7451df9b9c3a280a49237410a69e::settings.two-factor.recovery-codes',
'3abf392e8e57ee4745ddc08ed81f39bb::app-logo','3abf392e8e57ee4745ddc08ed81f39bb::app-logo-icon','3abf392e8e57ee4745ddc08ed81f39bb::auth-header','3abf392e8e57ee4745ddc08ed81f39bb::auth-session-status','3abf392e8e57ee4745ddc08ed81f39bb::desktop-user-menu',
'3abf392e8e57ee4745ddc08ed81f39bb::passkey-registration','3abf392e8e57ee4745ddc08ed81f39bb::passkey-verify','3abf392e8e57ee4745ddc08ed81f39bb::placeholder-pattern','3abf392e8e57ee4745ddc08ed81f39bb::settings.layout','11deb60597c835580c3807d6c4d160c7::auth.confirm-password',
'11deb60597c835580c3807d6c4d160c7::auth.forgot-password','11deb60597c835580c3807d6c4d160c7::auth.login','11deb60597c835580c3807d6c4d160c7::auth.register','11deb60597c835580c3807d6c4d160c7::auth.reset-password','11deb60597c835580c3807d6c4d160c7::auth.two-factor-challenge',
'11deb60597c835580c3807d6c4d160c7::auth.verify-email','11deb60597c835580c3807d6c4d160c7::settings.appearance','11deb60597c835580c3807d6c4d160c7::settings.delete-user-form','11deb60597c835580c3807d6c4d160c7::settings.profile','11deb60597c835580c3807d6c4d160c7::settings.security',
'11deb60597c835580c3807d6c4d160c7::settings.two-factor.recovery-codes','app-logo','app-logo-icon','auth-header','auth-session-status',
'auth.confirm-password','auth.forgot-password','auth.login','auth.register','auth.reset-password',
'auth.two-factor-challenge','auth.verify-email','components.app-logo','components.app-logo-icon','components.auth-header',
'components.auth-session-status','components.desktop-user-menu','components.passkey-registration','components.passkey-verify','components.placeholder-pattern',
'components.settings.layout','dashboard','desktop-user-menu','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.book-open-text','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.chevrons-up-down',
'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.folder-git-2','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.layout-grid','e60dd9d2c3a62d619c9acb38f20d5aa5::navlist.group','f4ac99e09542ff494432bc959d4fee61::app','f4ac99e09542ff494432bc959d4fee61::app.header',
'f4ac99e09542ff494432bc959d4fee61::app.sidebar','f4ac99e09542ff494432bc959d4fee61::auth','f4ac99e09542ff494432bc959d4fee61::auth.card','f4ac99e09542ff494432bc959d4fee61::auth.simple','f4ac99e09542ff494432bc959d4fee61::auth.split',
'flux.icon.book-open-text','flux.icon.chevrons-up-down','flux.icon.folder-git-2','flux.icon.layout-grid','flux.navlist.group',
'layouts.app','layouts.app.header','layouts.app.sidebar','layouts.auth','layouts.auth.card',
'layouts.auth.simple','layouts.auth.split','layouts::app','layouts::app.header','layouts::app.sidebar',
Expand Down Expand Up @@ -3384,13 +3384,13 @@
registerArgumentsSet('env',
'APP_NAME','APP_ENV','APP_KEY','APP_DEBUG','APP_URL',
'APP_LOCALE','APP_FALLBACK_LOCALE','APP_FAKER_LOCALE','APP_MAINTENANCE_DRIVER','BCRYPT_ROUNDS',
'LOG_CHANNEL','LOG_STACK','LOG_DEPRECATIONS_CHANNEL','LOG_LEVEL','DB_CONNECTION',
'SESSION_DRIVER','SESSION_LIFETIME','SESSION_ENCRYPT','SESSION_PATH','SESSION_DOMAIN',
'BROADCAST_CONNECTION','FILESYSTEM_DISK','QUEUE_CONNECTION','CACHE_STORE','MEMCACHED_HOST',
'REDIS_CLIENT','REDIS_HOST','REDIS_PASSWORD','REDIS_PORT','MAIL_MAILER',
'MAIL_SCHEME','MAIL_HOST','MAIL_PORT','MAIL_USERNAME','MAIL_PASSWORD',
'MAIL_FROM_ADDRESS','MAIL_FROM_NAME','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_DEFAULT_REGION',
'AWS_BUCKET','AWS_USE_PATH_STYLE_ENDPOINT','VITE_APP_NAME',);
'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED','LOG_CHANNEL','LOG_STACK','LOG_DEPRECATIONS_CHANNEL','LOG_LEVEL',
'DB_CONNECTION','SESSION_DRIVER','SESSION_LIFETIME','SESSION_ENCRYPT','SESSION_PATH',
'SESSION_DOMAIN','BROADCAST_CONNECTION','FILESYSTEM_DISK','QUEUE_CONNECTION','CACHE_STORE',
'MEMCACHED_HOST','REDIS_CLIENT','REDIS_HOST','REDIS_PASSWORD','REDIS_PORT',
'MAIL_MAILER','MAIL_SCHEME','MAIL_HOST','MAIL_PORT','MAIL_USERNAME',
'MAIL_PASSWORD','MAIL_FROM_ADDRESS','MAIL_FROM_NAME','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION','AWS_BUCKET','AWS_USE_PATH_STYLE_ENDPOINT','VITE_APP_NAME',);

expectedArguments(\Illuminate\Support\Facades\Gate::has(), 0, argumentsSet('auth'));
expectedArguments(\Illuminate\Support\Facades\Gate::allows(), 0, argumentsSet('auth'));
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Crate is single-tenant by construction. Each deployment lives in your own Larave

Crate is MIT licensed.

## Deploying

See [`docs/deploy.md`](docs/deploy.md) for the Laravel Cloud deployment guide, including resource provisioning, `crate:install`, first-run commands, and customer-app consumption.

## What Ships

- `artisan-build/crate-contracts`: framework-free DTOs and enums shared by the client and server packages.
Expand Down
2 changes: 2 additions & 0 deletions _ide_helper_models.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property string|null $two_factor_confirmed_at
* @property int $is_admin
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Passkeys\Passkey> $passkeys
Expand All @@ -36,6 +37,7 @@
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmailVerifiedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereIsAdmin($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRememberToken($value)
Expand Down
197 changes: 197 additions & 0 deletions app/Console/Commands/CrateInstallCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use ArtisanBuild\BuiltForCloud\Commands\Concerns\WritesInstallEnv;
use Illuminate\Console\Command;

final class CrateInstallCommand extends Command
{
use WritesInstallEnv;

protected $signature = 'crate:install
{--url= : Public Crate base URL}
{--archive-disk= : Crate archive filesystem disk name}
{--satis-path= : Path to the isolated Satis binary}
{--credential-api= : Enable the credential API (true or false)}
{--force : Overwrite existing values without prompting}
{--path= : Path to the env file to write}';

protected $description = 'Configure Crate application environment values.';

/**
* @var array<string, array{option: string, prompt: string, default: string}>
*/
private const array KEYS = [
'CRATE_URL' => [
'option' => 'url',
'prompt' => 'Crate public URL',
'default' => '',
],
'CRATE_ARCHIVE_DISK' => [
'option' => 'archive-disk',
'prompt' => 'Crate archive disk',
'default' => '',
],
'CRATE_SATIS_PATH' => [
'option' => 'satis-path',
'prompt' => 'Satis binary path',
'default' => 'vendor/bin/satis',
],
'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED' => [
'option' => 'credential-api',
'prompt' => 'Enable credential API',
'default' => 'true',
],
];

public function handle(): int
{
$path = $this->resolveEnvPath();
$current = $this->parseEnvValues(is_file($path) ? (string) file_get_contents($path) : '');
$changes = [];

foreach (self::KEYS as $key => $definition) {
$desired = $this->desiredValue($key, $definition, $current[$key] ?? null);

if ($desired === null || $desired === ($current[$key] ?? null)) {
continue;
}

if (! $this->mayOverwrite($key, $current[$key] ?? null)) {
continue;
}

$changes[$key] = $desired;
}

if ($changes === []) {
$this->info('Crate is already configured; no changes.');

return self::SUCCESS;
}

$this->writeEnvFile($path, $changes);
$this->summarize($changes);

return self::SUCCESS;
}

private function resolveEnvPath(): string
{
$path = $this->option('path');

if (is_string($path) && $path !== '') {
return $path;
}

return $this->laravel->environmentFilePath();
}

/**
* @return array<string, string>
*/
private function parseEnvValues(string $contents): array
{
$values = [];

foreach (preg_split('/\R/', $contents) ?: [] as $line) {
if (preg_match('/^([A-Z0-9_]+)=(.*)$/', $line, $matches) !== 1) {
continue;
}

$values[$matches[1]] = $this->unquoteEnvValue($matches[2]);
}

return $values;
}

private function unquoteEnvValue(string $value): string
{
if (strlen($value) < 2 || ! str_starts_with($value, '"') || ! str_ends_with($value, '"')) {
return $value;
}

// Reverse WritesInstallEnv::formatEnvironmentValue's escaping in a single left-to-right
// pass so an escaped backslash (\\) is never re-matched as part of a later sequence such
// as \n — otherwise a value like a Windows path round-trips incorrectly.
return (string) preg_replace_callback('/\\\\(.)/s', static fn (array $m): string => match ($m[1]) {
'n' => "\n",
'"' => '"',
'=' => '=',
'\\' => '\\',
default => $m[0],
}, substr($value, 1, -1));
}

/**
* @param array{option: string, prompt: string, default: string} $definition
*/
private function desiredValue(string $key, array $definition, ?string $current): ?string
{
$option = $this->option($definition['option']);

if (is_string($option)) {
return $this->normalizeValue($key, $option);
}

if (! $this->input->isInteractive()) {
return $current;
}

$default = $current ?? $this->defaultValue($definition);

if ($key === 'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED') {
return $this->confirm($definition['prompt'], $this->truthy($default)) ? 'true' : 'false';
}

return $this->normalizeValue($key, (string) $this->ask($definition['prompt'], $default));
}

/**
* @param array{option: string, prompt: string, default: string} $definition
*/
private function defaultValue(array $definition): string
{
if ($definition['option'] === 'satis-path') {
return base_path($definition['default']);
}

return $definition['default'];
}

private function normalizeValue(string $key, string $value): string
{
if ($key === 'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED') {
return $this->truthy($value) ? 'true' : 'false';
}

return $value;
}

private function truthy(string $value): bool
{
return filter_var($value, FILTER_VALIDATE_BOOL);
}

private function mayOverwrite(string $key, ?string $current): bool
{
if ($current === null || $current === '') {
return true;
}

if ($this->input->isInteractive()) {
return $this->confirm("Overwrite {$key} (current: {$current})?");
}

if ((bool) $this->option('force')) {
return true;
}

$this->warn("Kept existing {$key}; pass --force to overwrite.");

return false;
}
}
Loading