Skip to content

Commit fbd8b3f

Browse files
edgrosvenorEd Grosvenorclaude
authored
Step 8 — crate:install provisioning command + Laravel Cloud deploy guide (#8)
* Step 8 — add crate install command and deploy guide Add the first-run Crate installer and deployment guide so operators can safely configure only Crate-owned app env before first build. Cover idempotency, clobber protection, and Cloud-managed env exclusions in command tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden crate:install env round-trip + document non-interactive use Reverse the env-value escaping in a single left-to-right pass so a backslash- bearing value (e.g. a Windows Satis path) round-trips correctly and stays idempotent instead of tripping the clobber guard on re-run. Add tests proving unrelated .env keys are preserved and a backslash value round-trips, and note in deploy.md that a bare non-interactive crate:install makes no changes (pass the flags for automated deploys). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ed Grosvenor <ed@artisan.build> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 965afe1 commit fbd8b3f

6 files changed

Lines changed: 503 additions & 22 deletions

File tree

.phpstorm.meta.php

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,7 @@
14781478
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
14791479
'filesystems.disks.s3.throw' => 'boolean',
14801480
'filesystems.disks.s3.report' => 'boolean',
1481-
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
1481+
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
14821482
'fortify.guard' => 'string',
14831483
'fortify.middleware' => 'array',
14841484
'fortify.auth_middleware' => 'string',
@@ -2096,7 +2096,7 @@
20962096
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
20972097
'filesystems.disks.s3.throw' => 'boolean',
20982098
'filesystems.disks.s3.report' => 'boolean',
2099-
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
2099+
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
21002100
'fortify.guard' => 'string',
21012101
'fortify.middleware' => 'array',
21022102
'fortify.auth_middleware' => 'string',
@@ -2714,7 +2714,7 @@
27142714
'filesystems.disks.s3.use_path_style_endpoint' => 'boolean',
27152715
'filesystems.disks.s3.throw' => 'boolean',
27162716
'filesystems.disks.s3.report' => 'boolean',
2717-
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage' => 'string',
2717+
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage' => 'string',
27182718
'fortify.guard' => 'string',
27192719
'fortify.middleware' => 'array',
27202720
'fortify.auth_middleware' => 'string',
@@ -3145,7 +3145,7 @@
31453145
'filesystems.disks.public.root','filesystems.disks.public.url','filesystems.disks.public.visibility','filesystems.disks.public.throw','filesystems.disks.public.report',
31463146
'filesystems.disks.s3.driver','filesystems.disks.s3.key','filesystems.disks.s3.secret','filesystems.disks.s3.region','filesystems.disks.s3.bucket',
31473147
'filesystems.disks.s3.url','filesystems.disks.s3.endpoint','filesystems.disks.s3.use_path_style_endpoint','filesystems.disks.s3.throw','filesystems.disks.s3.report',
3148-
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/prb/public/storage','fortify.guard','fortify.middleware','fortify.auth_middleware','fortify.passwords',
3148+
'filesystems.links./Users/edgrosvenor/Herd/crate-wt/step8/public/storage','fortify.guard','fortify.middleware','fortify.auth_middleware','fortify.passwords',
31493149
'fortify.username','fortify.email','fortify.views','fortify.home','fortify.prefix',
31503150
'fortify.domain','fortify.lowercase_usernames','fortify.limiters.login','fortify.limiters.two-factor','fortify.limiters.passkeys',
31513151
'fortify.paths.login','fortify.paths.logout','fortify.paths.password.request','fortify.paths.password.reset','fortify.paths.password.email',
@@ -3226,17 +3226,17 @@
32263226
'passkey.store','passkey.destroy','default-livewire.update','livewire.upload-file','livewire.preview-file','home','dashboard',
32273227
'profile.edit','appearance.edit','security.edit','storage.local','storage.local.upload',);
32283228
registerArgumentsSet('views',
3229-
'4a433aca7e9281f2536d3caad73ce93e::app-logo','4a433aca7e9281f2536d3caad73ce93e::app-logo-icon','4a433aca7e9281f2536d3caad73ce93e::auth-header','4a433aca7e9281f2536d3caad73ce93e::auth-session-status','4a433aca7e9281f2536d3caad73ce93e::desktop-user-menu',
3230-
'4a433aca7e9281f2536d3caad73ce93e::passkey-registration','4a433aca7e9281f2536d3caad73ce93e::passkey-verify','4a433aca7e9281f2536d3caad73ce93e::placeholder-pattern','4a433aca7e9281f2536d3caad73ce93e::settings.layout','app-logo',
3231-
'app-logo-icon','auth-header','auth-session-status','auth.confirm-password','auth.forgot-password',
3232-
'auth.login','auth.register','auth.reset-password','auth.two-factor-challenge','auth.verify-email',
3233-
'components.app-logo','components.app-logo-icon','components.auth-header','components.auth-session-status','components.desktop-user-menu',
3234-
'components.passkey-registration','components.passkey-verify','components.placeholder-pattern','components.settings.layout','dashboard',
3235-
'desktop-user-menu','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.book-open-text','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.chevrons-up-down','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.folder-git-2','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.layout-grid',
3236-
'e60dd9d2c3a62d619c9acb38f20d5aa5::navlist.group','f4ac99e09542ff494432bc959d4fee61::app','f4ac99e09542ff494432bc959d4fee61::app.header','f4ac99e09542ff494432bc959d4fee61::app.sidebar','f4ac99e09542ff494432bc959d4fee61::auth',
3237-
'f4ac99e09542ff494432bc959d4fee61::auth.card','f4ac99e09542ff494432bc959d4fee61::auth.simple','f4ac99e09542ff494432bc959d4fee61::auth.split','f19b7451df9b9c3a280a49237410a69e::auth.confirm-password','f19b7451df9b9c3a280a49237410a69e::auth.forgot-password',
3238-
'f19b7451df9b9c3a280a49237410a69e::auth.login','f19b7451df9b9c3a280a49237410a69e::auth.register','f19b7451df9b9c3a280a49237410a69e::auth.reset-password','f19b7451df9b9c3a280a49237410a69e::auth.two-factor-challenge','f19b7451df9b9c3a280a49237410a69e::auth.verify-email',
3239-
'f19b7451df9b9c3a280a49237410a69e::settings.appearance','f19b7451df9b9c3a280a49237410a69e::settings.delete-user-form','f19b7451df9b9c3a280a49237410a69e::settings.profile','f19b7451df9b9c3a280a49237410a69e::settings.security','f19b7451df9b9c3a280a49237410a69e::settings.two-factor.recovery-codes',
3229+
'3abf392e8e57ee4745ddc08ed81f39bb::app-logo','3abf392e8e57ee4745ddc08ed81f39bb::app-logo-icon','3abf392e8e57ee4745ddc08ed81f39bb::auth-header','3abf392e8e57ee4745ddc08ed81f39bb::auth-session-status','3abf392e8e57ee4745ddc08ed81f39bb::desktop-user-menu',
3230+
'3abf392e8e57ee4745ddc08ed81f39bb::passkey-registration','3abf392e8e57ee4745ddc08ed81f39bb::passkey-verify','3abf392e8e57ee4745ddc08ed81f39bb::placeholder-pattern','3abf392e8e57ee4745ddc08ed81f39bb::settings.layout','11deb60597c835580c3807d6c4d160c7::auth.confirm-password',
3231+
'11deb60597c835580c3807d6c4d160c7::auth.forgot-password','11deb60597c835580c3807d6c4d160c7::auth.login','11deb60597c835580c3807d6c4d160c7::auth.register','11deb60597c835580c3807d6c4d160c7::auth.reset-password','11deb60597c835580c3807d6c4d160c7::auth.two-factor-challenge',
3232+
'11deb60597c835580c3807d6c4d160c7::auth.verify-email','11deb60597c835580c3807d6c4d160c7::settings.appearance','11deb60597c835580c3807d6c4d160c7::settings.delete-user-form','11deb60597c835580c3807d6c4d160c7::settings.profile','11deb60597c835580c3807d6c4d160c7::settings.security',
3233+
'11deb60597c835580c3807d6c4d160c7::settings.two-factor.recovery-codes','app-logo','app-logo-icon','auth-header','auth-session-status',
3234+
'auth.confirm-password','auth.forgot-password','auth.login','auth.register','auth.reset-password',
3235+
'auth.two-factor-challenge','auth.verify-email','components.app-logo','components.app-logo-icon','components.auth-header',
3236+
'components.auth-session-status','components.desktop-user-menu','components.passkey-registration','components.passkey-verify','components.placeholder-pattern',
3237+
'components.settings.layout','dashboard','desktop-user-menu','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.book-open-text','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.chevrons-up-down',
3238+
'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.folder-git-2','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.layout-grid','e60dd9d2c3a62d619c9acb38f20d5aa5::navlist.group','f4ac99e09542ff494432bc959d4fee61::app','f4ac99e09542ff494432bc959d4fee61::app.header',
3239+
'f4ac99e09542ff494432bc959d4fee61::app.sidebar','f4ac99e09542ff494432bc959d4fee61::auth','f4ac99e09542ff494432bc959d4fee61::auth.card','f4ac99e09542ff494432bc959d4fee61::auth.simple','f4ac99e09542ff494432bc959d4fee61::auth.split',
32403240
'flux.icon.book-open-text','flux.icon.chevrons-up-down','flux.icon.folder-git-2','flux.icon.layout-grid','flux.navlist.group',
32413241
'layouts.app','layouts.app.header','layouts.app.sidebar','layouts.auth','layouts.auth.card',
32423242
'layouts.auth.simple','layouts.auth.split','layouts::app','layouts::app.header','layouts::app.sidebar',
@@ -3384,13 +3384,13 @@
33843384
registerArgumentsSet('env',
33853385
'APP_NAME','APP_ENV','APP_KEY','APP_DEBUG','APP_URL',
33863386
'APP_LOCALE','APP_FALLBACK_LOCALE','APP_FAKER_LOCALE','APP_MAINTENANCE_DRIVER','BCRYPT_ROUNDS',
3387-
'LOG_CHANNEL','LOG_STACK','LOG_DEPRECATIONS_CHANNEL','LOG_LEVEL','DB_CONNECTION',
3388-
'SESSION_DRIVER','SESSION_LIFETIME','SESSION_ENCRYPT','SESSION_PATH','SESSION_DOMAIN',
3389-
'BROADCAST_CONNECTION','FILESYSTEM_DISK','QUEUE_CONNECTION','CACHE_STORE','MEMCACHED_HOST',
3390-
'REDIS_CLIENT','REDIS_HOST','REDIS_PASSWORD','REDIS_PORT','MAIL_MAILER',
3391-
'MAIL_SCHEME','MAIL_HOST','MAIL_PORT','MAIL_USERNAME','MAIL_PASSWORD',
3392-
'MAIL_FROM_ADDRESS','MAIL_FROM_NAME','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_DEFAULT_REGION',
3393-
'AWS_BUCKET','AWS_USE_PATH_STYLE_ENDPOINT','VITE_APP_NAME',);
3387+
'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED','LOG_CHANNEL','LOG_STACK','LOG_DEPRECATIONS_CHANNEL','LOG_LEVEL',
3388+
'DB_CONNECTION','SESSION_DRIVER','SESSION_LIFETIME','SESSION_ENCRYPT','SESSION_PATH',
3389+
'SESSION_DOMAIN','BROADCAST_CONNECTION','FILESYSTEM_DISK','QUEUE_CONNECTION','CACHE_STORE',
3390+
'MEMCACHED_HOST','REDIS_CLIENT','REDIS_HOST','REDIS_PASSWORD','REDIS_PORT',
3391+
'MAIL_MAILER','MAIL_SCHEME','MAIL_HOST','MAIL_PORT','MAIL_USERNAME',
3392+
'MAIL_PASSWORD','MAIL_FROM_ADDRESS','MAIL_FROM_NAME','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY',
3393+
'AWS_DEFAULT_REGION','AWS_BUCKET','AWS_USE_PATH_STYLE_ENDPOINT','VITE_APP_NAME',);
33943394

33953395
expectedArguments(\Illuminate\Support\Facades\Gate::has(), 0, argumentsSet('auth'));
33963396
expectedArguments(\Illuminate\Support\Facades\Gate::allows(), 0, argumentsSet('auth'));

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Crate is single-tenant by construction. Each deployment lives in your own Larave
88

99
Crate is MIT licensed.
1010

11+
## Deploying
12+
13+
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.
14+
1115
## What Ships
1216

1317
- `artisan-build/crate-contracts`: framework-free DTOs and enums shared by the client and server packages.

_ide_helper_models.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
* @property string|null $two_factor_secret
2525
* @property string|null $two_factor_recovery_codes
2626
* @property string|null $two_factor_confirmed_at
27+
* @property int $is_admin
2728
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
2829
* @property-read int|null $notifications_count
2930
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Passkeys\Passkey> $passkeys
@@ -36,6 +37,7 @@
3637
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmail($value)
3738
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmailVerifiedAt($value)
3839
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereId($value)
40+
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereIsAdmin($value)
3941
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereName($value)
4042
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePassword($value)
4143
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRememberToken($value)
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use ArtisanBuild\BuiltForCloud\Commands\Concerns\WritesInstallEnv;
8+
use Illuminate\Console\Command;
9+
10+
final class CrateInstallCommand extends Command
11+
{
12+
use WritesInstallEnv;
13+
14+
protected $signature = 'crate:install
15+
{--url= : Public Crate base URL}
16+
{--archive-disk= : Crate archive filesystem disk name}
17+
{--satis-path= : Path to the isolated Satis binary}
18+
{--credential-api= : Enable the credential API (true or false)}
19+
{--force : Overwrite existing values without prompting}
20+
{--path= : Path to the env file to write}';
21+
22+
protected $description = 'Configure Crate application environment values.';
23+
24+
/**
25+
* @var array<string, array{option: string, prompt: string, default: string}>
26+
*/
27+
private const array KEYS = [
28+
'CRATE_URL' => [
29+
'option' => 'url',
30+
'prompt' => 'Crate public URL',
31+
'default' => '',
32+
],
33+
'CRATE_ARCHIVE_DISK' => [
34+
'option' => 'archive-disk',
35+
'prompt' => 'Crate archive disk',
36+
'default' => '',
37+
],
38+
'CRATE_SATIS_PATH' => [
39+
'option' => 'satis-path',
40+
'prompt' => 'Satis binary path',
41+
'default' => 'vendor/bin/satis',
42+
],
43+
'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED' => [
44+
'option' => 'credential-api',
45+
'prompt' => 'Enable credential API',
46+
'default' => 'true',
47+
],
48+
];
49+
50+
public function handle(): int
51+
{
52+
$path = $this->resolveEnvPath();
53+
$current = $this->parseEnvValues(is_file($path) ? (string) file_get_contents($path) : '');
54+
$changes = [];
55+
56+
foreach (self::KEYS as $key => $definition) {
57+
$desired = $this->desiredValue($key, $definition, $current[$key] ?? null);
58+
59+
if ($desired === null || $desired === ($current[$key] ?? null)) {
60+
continue;
61+
}
62+
63+
if (! $this->mayOverwrite($key, $current[$key] ?? null)) {
64+
continue;
65+
}
66+
67+
$changes[$key] = $desired;
68+
}
69+
70+
if ($changes === []) {
71+
$this->info('Crate is already configured; no changes.');
72+
73+
return self::SUCCESS;
74+
}
75+
76+
$this->writeEnvFile($path, $changes);
77+
$this->summarize($changes);
78+
79+
return self::SUCCESS;
80+
}
81+
82+
private function resolveEnvPath(): string
83+
{
84+
$path = $this->option('path');
85+
86+
if (is_string($path) && $path !== '') {
87+
return $path;
88+
}
89+
90+
return $this->laravel->environmentFilePath();
91+
}
92+
93+
/**
94+
* @return array<string, string>
95+
*/
96+
private function parseEnvValues(string $contents): array
97+
{
98+
$values = [];
99+
100+
foreach (preg_split('/\R/', $contents) ?: [] as $line) {
101+
if (preg_match('/^([A-Z0-9_]+)=(.*)$/', $line, $matches) !== 1) {
102+
continue;
103+
}
104+
105+
$values[$matches[1]] = $this->unquoteEnvValue($matches[2]);
106+
}
107+
108+
return $values;
109+
}
110+
111+
private function unquoteEnvValue(string $value): string
112+
{
113+
if (strlen($value) < 2 || ! str_starts_with($value, '"') || ! str_ends_with($value, '"')) {
114+
return $value;
115+
}
116+
117+
// Reverse WritesInstallEnv::formatEnvironmentValue's escaping in a single left-to-right
118+
// pass so an escaped backslash (\\) is never re-matched as part of a later sequence such
119+
// as \n — otherwise a value like a Windows path round-trips incorrectly.
120+
return (string) preg_replace_callback('/\\\\(.)/s', static fn (array $m): string => match ($m[1]) {
121+
'n' => "\n",
122+
'"' => '"',
123+
'=' => '=',
124+
'\\' => '\\',
125+
default => $m[0],
126+
}, substr($value, 1, -1));
127+
}
128+
129+
/**
130+
* @param array{option: string, prompt: string, default: string} $definition
131+
*/
132+
private function desiredValue(string $key, array $definition, ?string $current): ?string
133+
{
134+
$option = $this->option($definition['option']);
135+
136+
if (is_string($option)) {
137+
return $this->normalizeValue($key, $option);
138+
}
139+
140+
if (! $this->input->isInteractive()) {
141+
return $current;
142+
}
143+
144+
$default = $current ?? $this->defaultValue($definition);
145+
146+
if ($key === 'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED') {
147+
return $this->confirm($definition['prompt'], $this->truthy($default)) ? 'true' : 'false';
148+
}
149+
150+
return $this->normalizeValue($key, (string) $this->ask($definition['prompt'], $default));
151+
}
152+
153+
/**
154+
* @param array{option: string, prompt: string, default: string} $definition
155+
*/
156+
private function defaultValue(array $definition): string
157+
{
158+
if ($definition['option'] === 'satis-path') {
159+
return base_path($definition['default']);
160+
}
161+
162+
return $definition['default'];
163+
}
164+
165+
private function normalizeValue(string $key, string $value): string
166+
{
167+
if ($key === 'BUILT_FOR_CLOUD_CREDENTIAL_API_ENABLED') {
168+
return $this->truthy($value) ? 'true' : 'false';
169+
}
170+
171+
return $value;
172+
}
173+
174+
private function truthy(string $value): bool
175+
{
176+
return filter_var($value, FILTER_VALIDATE_BOOL);
177+
}
178+
179+
private function mayOverwrite(string $key, ?string $current): bool
180+
{
181+
if ($current === null || $current === '') {
182+
return true;
183+
}
184+
185+
if ($this->input->isInteractive()) {
186+
return $this->confirm("Overwrite {$key} (current: {$current})?");
187+
}
188+
189+
if ((bool) $this->option('force')) {
190+
return true;
191+
}
192+
193+
$this->warn("Kept existing {$key}; pass --force to overwrite.");
194+
195+
return false;
196+
}
197+
}

0 commit comments

Comments
 (0)