diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.agents/skills/laravel-best-practices/rules/security.md +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.claude/skills/laravel-best-practices/rules/security.md +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/.env.example b/.env.example index 40bb13b..e7d6e0a 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database + # PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 diff --git a/.github/skills/laravel-best-practices/rules/security.md b/.github/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.github/skills/laravel-best-practices/rules/security.md +++ b/.github/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/AGENTS.md b/AGENTS.md index 29201e6..74ddab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac ## Project Rules -- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. - Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. ## Artisan diff --git a/CLAUDE.md b/CLAUDE.md index 29201e6..74ddab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac ## Project Rules -- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. - Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. ## Artisan diff --git a/README.md b/README.md index 8b5462a..9d24ec8 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,48 @@ -# Get Started +# Summit -Getting started with Summit is super easy. Just start a new Laravel project, and use the Summit repo as your starter kit +Summit is an opinionated Laravel Vue starter kit. + +## Create a project + +Create a fresh application with the Laravel installer: ```bash laravel new my-project --using=zacksmash/summit ``` -Then, `cd` into `my-project` and run +The installer creates the environment file and SQLite database, generates the application key and Passport keys, runs the migrations, and launches Chisel. Chisel lets you choose authentication features, teams, Passport, application MCP scaffolding, Octane, browser testing, AI tooling, IDE Helper, Whisky, and Herd integration. It removes everything you deselect and rebuilds the database schema to match. The feature selection needs an interactive terminal; in a non-interactive session it is skipped, and you can run it later with `php artisan install:features`. + +Then start local development: + +```bash +cd my-project +composer dev +``` + +If you pass `--no-node` to `laravel new`, install and build the frontend separately with `npm install && npm run build`. + +## Set up a cloned repository + +For a direct Git clone instead of a Laravel installer project, run: ```bash composer setup ``` -Now, you're all setup with Laravel Octane over HTTPS! Just run `composer dev` for local development and happy coding! +This installs the dependencies, creates the environment file and keys, migrates and seeds the database, installs the Playwright browsers, and builds the frontend — without requiring Herd or resetting an existing database. It is safe to run again. The first dependency install also launches the Chisel feature selection when a terminal is attached. + +## Optional local tooling + +Install the FrankenPHP runtime for Octane on macOS, Linux, or Windows via WSL: + +```bash +composer setup:octane +``` + +Run the complete opinionated tooling setup, including Octane, Whisky, IDE Helper, Playwright, and Laravel Herd HTTPS proxying: + +```bash +composer setup:tools +``` + +The tooling setup requires Laravel Herd and a POSIX shell (macOS, Linux, or Windows via WSL). It initializes a Git repository when needed, installs the Git hooks, and creates the generated baseline with a one-time `--no-verify` commit after setup succeeds. Later commits run Whisky normally. Without an installed Octane runtime, `composer dev` automatically uses Laravel's built-in development server. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index fd8283d..c35826d 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,11 +2,15 @@ namespace App\Actions\Fortify; +/* @chisel-teams */ use App\Actions\Teams\CreateTeam; +/* @end-chisel-teams */ use App\Concerns\PasswordValidationRules; use App\Concerns\ProfileValidationRules; use App\Models\User; +/* @chisel-teams */ use Illuminate\Support\Facades\DB; +/* @end-chisel-teams */ use Illuminate\Support\Facades\Validator; use Laravel\Fortify\Contracts\CreatesNewUsers; @@ -14,10 +18,12 @@ class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules, ProfileValidationRules; + /* @chisel-teams */ public function __construct(private CreateTeam $createTeam) { // } + /* @end-chisel-teams */ /** * Validate and create a newly registered user. diff --git a/app/Console/Commands/InstallFeaturesCommand.php b/app/Console/Commands/InstallFeaturesCommand.php new file mode 100644 index 0000000..70f746d --- /dev/null +++ b/app/Console/Commands/InstallFeaturesCommand.php @@ -0,0 +1,117 @@ +shouldDeferInstallerHooks()) { + return self::SUCCESS; + } + + if (! file_exists(base_path('chisel.php'))) { + return self::SUCCESS; + } + + if ($this->option('answers') === null && ! $this->input->isInteractive()) { + $this->components->warn( + 'Skipping starter kit feature selection because the session is not interactive.' + .' Run [php artisan install:features] from a terminal, or pass [--answers] to select features without prompts.', + ); + + return self::SUCCESS; + } + + /** @var Script $script */ + $script = require base_path('chisel.php'); + + $providedAnswers = $this->option('answers') === null + ? [] + : json_decode((string) $this->option('answers'), true, 512, JSON_THROW_ON_ERROR); + + $answers = $script + ->collectAnswers() + ->onQuestion(fn (Question $question): array => multiselect( + label: $question->label, + options: $question->options, + default: $question->default ?? [], + required: $question->required, + hint: $question->hint, + )) + ->interactive($this->input->isInteractive()) + ->withAnswers($providedAnswers); + + $skipNode = $this->shouldSkipNode(); + + if (! $skipNode) { + $this->installNodeDependencies(); + } + + $script->chisel($answers); + + if (! $skipNode) { + $this->buildAssets(); + } + + return self::SUCCESS; + } + + protected function shouldDeferInstallerHooks(): bool + { + if ($this->option('answers') !== null) { + return false; + } + + return $this->installerFlag('LARAVEL_INSTALLER_DEFER_HOOKS'); + } + + protected function shouldSkipNode(): bool + { + return $this->installerFlag('LARAVEL_INSTALLER_NO_NODE'); + } + + protected function installerFlag(string $name): bool + { + return filter_var( + Env::get($name, Request::server($name) ?? getenv($name)), + FILTER_VALIDATE_BOOL, + ); + } + + protected function installNodeDependencies(): void + { + $npm = Chisel::in(base_path())->npm(); + $packageManager = $npm->packageManager(); + + spin( + fn () => $npm->install(), + "Installing dependencies with {$packageManager->value}...", + ); + } + + protected function buildAssets(): void + { + $npm = Chisel::in(base_path())->npm(); + + spin( + fn () => $npm->run('build'), + 'Building assets...', + ); + } +} diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 0d007d3..bcac28c 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -19,7 +19,10 @@ class SecurityController extends Controller public function edit(TwoFactorAuthenticationRequest $request): Response { $props = [ + /* @chisel-2fa */ 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), + /* @end-chisel-2fa */ + /* @chisel-passkeys */ 'canManagePasskeys' => Features::canManagePasskeys(), 'passkeys' => Features::canManagePasskeys() ? $request->user() @@ -37,15 +40,18 @@ public function edit(TwoFactorAuthenticationRequest $request): Response ->values() ->all() : [], + /* @end-chisel-passkeys */ 'passwordRules' => Password::defaults()->toPasswordRulesString(), ]; + /* @chisel-2fa */ if (Features::canManageTwoFactorAuthentication()) { $request->ensureStateIsValid(); $props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication(); $props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); } + /* @end-chisel-2fa */ return Inertia::render('settings/Security', $props); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 95406cc..5c41aa3 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -46,8 +46,10 @@ public function share(Request $request): array 'user' => $user, ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + /* @chisel-teams */ 'currentTeam' => fn () => $user?->currentTeam ? $user->toUserTeam($user->currentTeam) : null, 'teams' => fn () => $user?->toUserTeams(includeCurrent: true) ?? [], + /* @end-chisel-teams */ ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index e6e76cf..1af7d6b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,7 +9,9 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +/* @chisel-teams */ use Illuminate\Database\Eloquent\Collection; +/* @end-chisel-teams */ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -56,7 +58,9 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + /* @chisel-2fa */ 'two_factor_confirmed_at' => 'datetime', + /* @end-chisel-2fa */ ]; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0b1fd95..3658d8f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,14 +3,22 @@ namespace App\Providers; use Carbon\CarbonImmutable; +/* @chisel-octane */ use Illuminate\Foundation\DevCommands; +/* @end-chisel-octane */ use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +/* @chisel-oauth-api */ use Inertia\Inertia; use Laravel\Passport\Passport; use Symfony\Component\HttpFoundation\Response; +/* @end-chisel-oauth-api */ +/* @chisel-octane */ +use Symfony\Component\Process\ExecutableFinder; + +/* @end-chisel-octane */ class AppServiceProvider extends ServiceProvider { @@ -28,22 +36,45 @@ public function register(): void */ public function boot(): void { + /* @chisel-octane */ $this->registerDevCommands(); + /* @end-chisel-octane */ $this->configureDefaults(); - $this->configureMcpAuthorizationView(); + /* @chisel-oauth-api */ + $this->configurePassportAuthorizationView(); + /* @end-chisel-oauth-api */ } + /* @chisel-octane */ /** * Register development commands for the "dev" Artisan command. */ protected function registerDevCommands(): void { - DevCommands::except('server'); + if (! $this->app->runningInConsole()) { + return; + } + + if (! $this->octaneServerIsAvailable()) { + DevCommands::artisan('serve', 'server'); + } + } - DevCommands::artisan('octane:start --watch', 'octane')->orange(); + /** + * Determine whether the configured Octane server can run locally. + */ + protected function octaneServerIsAvailable(): bool + { + return match ((string) config('octane.server')) { + 'frankenphp' => (new ExecutableFinder)->find('frankenphp', null, [base_path()]) !== null, + 'roadrunner' => (new ExecutableFinder)->find('rr', null, [base_path()]) !== null, + 'swoole' => extension_loaded('swoole') || extension_loaded('openswoole'), + default => false, + }; } + /* @end-chisel-octane */ /** * Configure default behaviors for production-ready applications. @@ -67,10 +98,11 @@ protected function configureDefaults(): void ); } + /* @chisel-oauth-api */ /** - * Configure the Passport authorization view for the MCP server + * Configure the Passport authorization view. */ - public function configureMcpAuthorizationView(): void + public function configurePassportAuthorizationView(): void { Passport::authorizationView( fn (array $parameters): Response => Inertia::render('auth/OAuthConsent', [ @@ -87,4 +119,5 @@ public function configureMcpAuthorizationView(): void ])->toResponse(request()) ); } + /* @end-chisel-oauth-api */ } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 7cfa22d..6518e41 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -2,29 +2,75 @@ namespace App\Providers; +/* @chisel-registration */ use App\Actions\Fortify\CreateNewUser; +/* @end-chisel-registration */ +/* @chisel-password-reset */ use App\Actions\Fortify\ResetUserPassword; +/* @end-chisel-password-reset */ +/* @chisel-teams */ use App\Http\Responses\LoginResponse; +/* @end-chisel-teams */ +/* @chisel-passkeys */ +/* @chisel-teams */ use App\Http\Responses\PasskeyLoginResponse; +/* @end-chisel-teams */ +/* @end-chisel-passkeys */ +/* @chisel-registration */ +/* @chisel-teams */ use App\Http\Responses\RegisterResponse; +/* @end-chisel-teams */ +/* @end-chisel-registration */ +/* @chisel-2fa */ +/* @chisel-teams */ use App\Http\Responses\TwoFactorLoginResponse; +/* @end-chisel-teams */ +/* @end-chisel-2fa */ +/* @chisel-email-verification */ +/* @chisel-teams */ use App\Http\Responses\VerifyEmailResponse; +/* @end-chisel-teams */ +/* @end-chisel-email-verification */ +/* @chisel-teams */ use App\Models\TeamInvitation; +/* @end-chisel-teams */ use Illuminate\Cache\RateLimiting\Limit; +/* @chisel-teams */ use Illuminate\Contracts\Database\Query\Builder; +/* @end-chisel-teams */ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Inertia\Inertia; +/* @chisel-teams */ use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; +/* @end-chisel-teams */ +/* @chisel-registration */ +/* @chisel-teams */ use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +/* @end-chisel-teams */ +/* @end-chisel-registration */ +/* @chisel-2fa */ +/* @chisel-teams */ use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; +/* @end-chisel-teams */ +/* @end-chisel-2fa */ +/* @chisel-email-verification */ +/* @chisel-teams */ use Laravel\Fortify\Contracts\VerifyEmailResponse as VerifyEmailResponseContract; +/* @end-chisel-teams */ +/* @end-chisel-email-verification */ use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; +/* @chisel-passkeys */ +/* @chisel-teams */ use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract; +/* @end-chisel-teams */ + +/* @end-chisel-passkeys */ + class FortifyServiceProvider extends ServiceProvider { /** @@ -33,11 +79,29 @@ class FortifyServiceProvider extends ServiceProvider #[\Override] public function register(): void { + /* @chisel-teams */ $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + /* @end-chisel-teams */ + /* @chisel-passkeys */ + /* @chisel-teams */ $this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class); + /* @end-chisel-teams */ + /* @end-chisel-passkeys */ + /* @chisel-registration */ + /* @chisel-teams */ $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); + /* @end-chisel-teams */ + /* @end-chisel-registration */ + /* @chisel-2fa */ + /* @chisel-teams */ $this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class); + /* @end-chisel-teams */ + /* @end-chisel-2fa */ + /* @chisel-email-verification */ + /* @chisel-teams */ $this->app->singleton(VerifyEmailResponseContract::class, VerifyEmailResponse::class); + /* @end-chisel-teams */ + /* @end-chisel-email-verification */ } /** @@ -55,8 +119,12 @@ public function boot(): void */ private function configureActions(): void { + /* @chisel-password-reset */ Fortify::resetUserPasswordsUsing(ResetUserPassword::class); + /* @end-chisel-password-reset */ + /* @chisel-registration */ Fortify::createUsersUsing(CreateNewUser::class); + /* @end-chisel-registration */ } /** @@ -65,11 +133,16 @@ private function configureActions(): void private function configureViews(): void { Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [ + /* @chisel-password-reset */ 'canResetPassword' => Features::enabled(Features::resetPasswords()), + /* @end-chisel-password-reset */ 'status' => $request->session()->get('status'), + /* @chisel-teams */ 'teamInvitation' => $this->teamInvitation($request), + /* @end-chisel-teams */ ])); + /* @chisel-password-reset */ Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [ 'email' => $request->email, 'token' => $request->route('token'), @@ -78,18 +151,29 @@ private function configureViews(): void Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/ForgotPassword', [ 'status' => $request->session()->get('status'), ])); + /* @end-chisel-password-reset */ + /* @chisel-email-verification */ Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [ 'status' => $request->session()->get('status'), ])); + /* @end-chisel-email-verification */ + /* @chisel-registration */ Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [ + /* @chisel-teams */ 'teamInvitation' => $this->teamInvitation($request), + /* @end-chisel-teams */ ])); + /* @end-chisel-registration */ + /* @chisel-2fa */ Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge')); + /* @end-chisel-2fa */ + /* @chisel-password-confirmation */ Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword')); + /* @end-chisel-password-confirmation */ } /** @@ -97,7 +181,9 @@ private function configureViews(): void */ private function configureRateLimiting(): void { + /* @chisel-2fa */ RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id'))); + /* @end-chisel-2fa */ RateLimiter::for('login', function (Request $request) { $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); @@ -105,6 +191,7 @@ private function configureRateLimiting(): void return Limit::perMinute(5)->by($throttleKey); }); + /* @chisel-passkeys */ RateLimiter::for('passkeys', function (Request $request) { $credentialId = $request->input('credential.id'); @@ -112,8 +199,10 @@ private function configureRateLimiting(): void ($credentialId ?: $request->session()->getId()).'|'.$request->ip(), ); }); + /* @end-chisel-passkeys */ } + /* @chisel-teams */ /** * Get the pending team invitation context for auth pages. * @@ -145,4 +234,5 @@ private function teamInvitation(Request $request): ?array 'teamName' => $invitation->team->name, ]; } + /* @end-chisel-teams */ } diff --git a/bootstrap/app.php b/bootstrap/app.php index 789adc6..f8fd0e5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,7 +4,9 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +/* @chisel-teams */ use App\Http\Middleware\SetTeamUrlDefaults; +/* @end-chisel-teams */ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -14,7 +16,9 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + /* @chisel-oauth-api */ api: __DIR__.'/../routes/api.php', + /* @end-chisel-oauth-api */ commands: __DIR__.'/../routes/console.php', health: '/up', ) @@ -25,7 +29,9 @@ HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + /* @chisel-teams */ SetTeamUrlDefaults::class, + /* @end-chisel-teams */ ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/chisel-paths.php b/chisel-paths.php new file mode 100644 index 0000000..dc06934 --- /dev/null +++ b/chisel-paths.php @@ -0,0 +1,82 @@ + 'resources/js/pages/auth/Login.vue', + 'register' => 'resources/js/pages/auth/Register.vue', + 'welcome' => 'resources/js/pages/Welcome.vue', + 'profile' => 'resources/js/pages/settings/Profile.vue', + 'security' => 'resources/js/pages/settings/Security.vue', + 'verify_email' => 'resources/js/pages/auth/VerifyEmail.vue', + 'two_factor_challenge' => 'resources/js/pages/auth/TwoFactorChallenge.vue', + 'confirm_password' => 'resources/js/pages/auth/ConfirmPassword.vue', + 'auth_types' => 'resources/js/types/auth.ts', + + 'two_factor_files' => [ + 'resources/js/components/ManageTwoFactor.vue', + 'resources/js/components/TwoFactorSetupModal.vue', + 'resources/js/components/TwoFactorRecoveryCodes.vue', + 'resources/js/components/ui/input-otp/index.ts', + 'resources/js/components/ui/input-otp/InputOTP.vue', + 'resources/js/components/ui/input-otp/InputOTPGroup.vue', + 'resources/js/components/ui/input-otp/InputOTPSeparator.vue', + 'resources/js/components/ui/input-otp/InputOTPSlot.vue', + 'resources/js/composables/useTwoFactorAuth.ts', + ], + + 'two_factor_otp_package' => 'vue-input-otp', + + 'passkey_files' => [ + 'resources/js/components/PasskeyItem.vue', + 'resources/js/components/ManagePasskeys.vue', + 'resources/js/components/PasskeyRegister.vue', + 'resources/js/components/PasskeyVerify.vue', + ], + + 'team_files' => [ + 'app/Actions/Teams/CreateTeam.php', + 'app/Concerns/GeneratesUniqueTeamSlugs.php', + 'app/Concerns/HasTeams.php', + 'app/Data/TeamPermissions.php', + 'app/Data/UserTeam.php', + 'app/Enums/TeamPermission.php', + 'app/Enums/TeamRole.php', + 'app/Http/Controllers/Teams/TeamController.php', + 'app/Http/Controllers/Teams/TeamInvitationController.php', + 'app/Http/Controllers/Teams/TeamMemberController.php', + 'app/Http/Middleware/EnsureTeamMembership.php', + 'app/Http/Middleware/SetTeamUrlDefaults.php', + 'app/Http/Requests/Teams/CreateTeamInvitationRequest.php', + 'app/Http/Requests/Teams/DeleteTeamRequest.php', + 'app/Http/Requests/Teams/RespondToTeamInvitationRequest.php', + 'app/Http/Requests/Teams/SaveTeamRequest.php', + 'app/Http/Requests/Teams/UpdateTeamMemberRequest.php', + 'app/Models/Membership.php', + 'app/Models/Team.php', + 'app/Models/TeamInvitation.php', + 'app/Notifications/Teams/TeamInvitation.php', + 'app/Policies/TeamPolicy.php', + 'app/Rules/TeamName.php', + 'app/Rules/UniqueTeamInvitation.php', + 'app/Rules/ValidTeamInvitation.php', + 'database/factories/TeamFactory.php', + 'database/factories/TeamInvitationFactory.php', + 'database/migrations/2026_01_27_000001_create_teams_table.php', + 'database/migrations/2026_01_27_000002_add_current_team_id_to_users_table.php', + 'resources/js/components/CancelInvitationModal.vue', + 'resources/js/components/CreateTeamModal.vue', + 'resources/js/components/DeleteTeamModal.vue', + 'resources/js/components/InviteMemberModal.vue', + 'resources/js/components/LeaveTeamModal.vue', + 'resources/js/components/PendingInvitationsModal.vue', + 'resources/js/components/RemoveMemberModal.vue', + 'resources/js/components/TeamInvitationAlert.vue', + 'resources/js/components/TeamSwitcher.vue', + 'resources/js/pages/teams/Edit.vue', + 'resources/js/pages/teams/Index.vue', + 'resources/js/types/teams.ts', + 'tests/Feature/Teams/PruneExpiredTeamInvitationsTest.php', + 'tests/Feature/Teams/TeamInvitationTest.php', + 'tests/Feature/Teams/TeamMemberTest.php', + 'tests/Feature/Teams/TeamTest.php', + ], +]; diff --git a/chisel.php b/chisel.php new file mode 100644 index 0000000..2361c9e --- /dev/null +++ b/chisel.php @@ -0,0 +1,790 @@ +run(function ($type, $line) use ($logger) { + $logger->line($line); + }); + + if ($process->isSuccessful()) { + $logger->success(implode(' ', $command)); + + return $process; + } + + $logger->error(implode(' ', $command)); + $logger->error('Error output: '.trim($process->getErrorOutput())); + $logger->error('Chisel: Your project may be in a partially-modified state — review the output above before continuing.'); + + return $process; + }, + ); + + if (! $process->isSuccessful()) { + exit($process->getExitCode()); + } +} + +function chiselSkipsNode(): bool +{ + return filter_var( + $_ENV['LARAVEL_INSTALLER_NO_NODE'] + ?? $_SERVER['LARAVEL_INSTALLER_NO_NODE'] + ?? getenv('LARAVEL_INSTALLER_NO_NODE'), + FILTER_VALIDATE_BOOL, + ); +} + +function chiselRemoveNpmPackages(Chisel $c, string ...$packages): void +{ + if (! chiselSkipsNode()) { + $c->npm()->remove(...$packages); + + return; + } + + foreach ($packages as $package) { + $c->file('package.json')->removeLinesContaining('"'.$package.'":'); + } +} + +/** + * @param list $packages + */ +function chiselRemoveComposerPackages(array $packages, bool $dev = false): void +{ + if ($packages === []) { + return; + } + + chiselRun([ + 'composer', + 'remove', + ...($dev ? ['--dev'] : []), + '--no-interaction', + '--no-scripts', + '--no-audit', + '--minimal-changes', + ...$packages, + ], $dev ? 'Remove Composer Development Packages' : 'Remove Composer Packages'); +} + +function chiselDeleteDirectory(string $directory): void +{ + $path = __DIR__.'/'.$directory; + + if (! is_dir($path)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + ($file->isDir() && ! $file->isLink()) + ? rmdir($file->getPathname()) + : unlink($file->getPathname()); + } + + rmdir($path); +} + +/** + * @param array $answers + */ +function chiselSelected(array $answers, string $question, string $option): bool +{ + return in_array($option, (array) ($answers[$question] ?? []), true); +} + +/** + * Framework-specific filenames are supplied by the sibling chisel-paths.php + * that ships with each Inertia kit (React/Svelte/Vue). After build both files + * land in the project root. + * + * @var array{ + * login: string, + * register: string, + * welcome: string, + * profile: string, + * security: string, + * verify_email: string, + * two_factor_challenge: string, + * confirm_password: string, + * auth_types: string, + * two_factor_files: list, + * two_factor_otp_package: ?string, + * passkey_files: list, + * team_files: list, + * } $paths + */ +$paths = require __DIR__.'/chisel-paths.php'; + +return Chisel::script(__DIR__) + ->questions([ + Question::multiselect( + name: 'auth_features', + label: 'Which authentication features would you like to enable?', + options: [ + 'email-verification' => 'Email verification', + 'registration' => 'Registration', + 'password-reset' => 'Password reset', + '2fa' => 'Two-factor authentication', + 'passkeys' => 'Passkeys', + 'password-confirmation' => 'Password confirmation', + ], + default: ['email-verification', 'registration', 'password-reset', '2fa', 'passkeys', 'password-confirmation'], + hint: 'Use space to select, enter to confirm.', + ), + Question::multiselect( + name: 'application_features', + label: 'Which application features would you like to include?', + options: [ + 'teams' => 'Teams and invitations', + 'oauth-api' => 'Passport OAuth2 API', + 'mcp' => 'Application MCP server scaffolding', + ], + default: ['teams', 'oauth-api', 'mcp'], + hint: 'Use space to select, enter to confirm.', + ), + Question::multiselect( + name: 'development_features', + label: 'Which development tools would you like to include?', + options: [ + 'octane' => 'Octane with FrankenPHP', + 'browser-testing' => 'Pest browser testing', + 'ai-tooling' => 'Boost and AI agent tooling', + 'ide-helper' => 'Laravel IDE Helper', + 'git-hooks' => 'Whisky Git hooks', + 'herd' => 'Herd HTTPS and proxy setup', + ], + default: ['octane', 'browser-testing', 'ai-tooling', 'ide-helper', 'git-hooks', 'herd'], + hint: 'Use space to select, enter to confirm.', + ), + ]) + ->selected( + 'auth_features', + 'registration', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSectionMarkers('registration'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php')->removeSection('registration'); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSection('registration'); + + $c->files( + 'app/Actions/Fortify/CreateNewUser.php', + 'app/Http/Responses/RegisterResponse.php', + $paths['register'], + 'tests/Feature/Auth/RegistrationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + 'password-reset', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'database/migrations/0001_01_01_000000_create_users_table.php', + $paths['login'], + )->removeSectionMarkers('password-reset'); + }, + else: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'database/migrations/0001_01_01_000000_create_users_table.php', + $paths['login'], + )->removeSection('password-reset'); + + $c->files( + 'app/Actions/Fortify/ResetUserPassword.php', + 'resources/js/pages/auth/ForgotPassword.vue', + 'resources/js/pages/auth/ResetPassword.vue', + 'tests/Feature/Auth/PasswordResetTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + 'email-verification', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + $paths['profile'], + 'app/Providers/FortifyServiceProvider.php', + )->removeSectionMarkers('email-verification'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Illuminate\Contracts\Auth\MustVerifyEmail') + ->removeInterface('MustVerifyEmail'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['profile'], + )->removeSection('email-verification'); + + $c->files( + 'app/Http/Responses/VerifyEmailResponse.php', + $paths['verify_email'], + 'tests/Feature/Auth/EmailVerificationTest.php', + 'tests/Feature/Auth/VerificationNotificationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + '2fa', + then: function (Chisel $c) use ($paths) { + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + $paths['security'], + $paths['auth_types'], + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSectionMarkers('2fa'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\TwoFactorAuthenticatable') + ->removeTrait('TwoFactorAuthenticatable'); + + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['security'], + $paths['auth_types'], + )->removeSection('2fa'); + + $c->file('app/Models/User.php') + ->removeLinesContaining('@property string|null $two_factor_secret') + ->removeLinesContaining('@property string|null $two_factor_recovery_codes') + ->removeLinesContaining('@property Carbon|null $two_factor_confirmed_at') + ->replace(", 'two_factor_secret', 'two_factor_recovery_codes'", ''); + + $c->file('app/Http/Controllers/Settings/SecurityController.php') + ->replace( + 'use App\\Http\\Requests\\Settings\\TwoFactorAuthenticationRequest;', + 'use Illuminate\\Http\\Request;', + ) + ->replace( + 'edit(TwoFactorAuthenticationRequest $request)', + 'edit(Request $request)', + ); + + $c->files(...[ + $paths['two_factor_challenge'], + ...$paths['two_factor_files'], + 'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php', + 'app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php', + 'app/Http/Responses/TwoFactorLoginResponse.php', + 'tests/Feature/Auth/TwoFactorChallengeTest.php', + ])->delete(); + }, + ) + ->selected( + 'auth_features', + 'passkeys', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSectionMarkers('passkeys'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\PasskeyAuthenticatable') + ->removeImport('Laravel\Fortify\Contracts\PasskeyUser') + ->removeTrait('PasskeyAuthenticatable') + ->removeInterface('PasskeyUser'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSection('passkeys'); + + $c->files(...[ + ...$paths['passkey_files'], + 'app/Http/Responses/PasskeyLoginResponse.php', + 'database/migrations/2024_01_01_000000_create_passkeys_table.php', + ])->delete(); + }, + ) + ->selected( + 'auth_features', + 'password-confirmation', + then: function (Chisel $c) { + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSectionMarkers('password-confirmation'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php') + ->replace("'confirmPassword' => true,", "'confirmPassword' => false,"); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSection('password-confirmation'); + + $c->files( + $paths['confirm_password'], + 'tests/Feature/Auth/PasswordConfirmationTest.php', + )->delete(); + }, + ) + ->selected( + 'application_features', + 'teams', + then: function (Chisel $c) { + $c->files( + 'app/Actions/Fortify/CreateNewUser.php', + 'app/Models/User.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Middleware/HandleInertiaRequests.php', + 'bootstrap/app.php', + 'database/factories/UserFactory.php', + 'routes/web.php', + 'routes/settings.php', + 'routes/console.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Auth/EmailVerificationTest.php', + 'tests/Feature/Auth/RegistrationTest.php', + 'tests/Feature/DashboardTest.php', + 'resources/js/app.ts', + 'resources/js/components/AppHeader.vue', + 'resources/js/components/AppSidebar.vue', + 'resources/js/components/NavUser.vue', + 'resources/js/components/UserInfo.vue', + 'resources/js/layouts/settings/Layout.vue', + 'resources/js/pages/Dashboard.vue', + 'resources/js/pages/Welcome.vue', + 'resources/js/pages/auth/Login.vue', + 'resources/js/pages/auth/Register.vue', + 'resources/js/types/global.d.ts', + 'resources/js/types/index.ts', + )->removeSectionMarkers('teams'); + + $c->file('routes/web.php')->removeLinesContaining('@chisel-no-teams-dashboard-route'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('App\Concerns\HasTeams') + ->removeTrait('HasTeams'); + + $c->files( + 'app/Actions/Fortify/CreateNewUser.php', + 'app/Models/User.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Middleware/HandleInertiaRequests.php', + 'bootstrap/app.php', + 'database/factories/UserFactory.php', + 'routes/web.php', + 'routes/settings.php', + 'routes/console.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Auth/EmailVerificationTest.php', + 'tests/Feature/Auth/RegistrationTest.php', + 'tests/Feature/DashboardTest.php', + 'resources/js/app.ts', + 'resources/js/components/AppHeader.vue', + 'resources/js/components/AppSidebar.vue', + 'resources/js/components/NavUser.vue', + 'resources/js/components/UserInfo.vue', + 'resources/js/layouts/settings/Layout.vue', + 'resources/js/pages/Dashboard.vue', + 'resources/js/pages/Welcome.vue', + 'resources/js/pages/auth/Login.vue', + 'resources/js/pages/auth/Register.vue', + 'resources/js/types/global.d.ts', + 'resources/js/types/index.ts', + )->removeSection('teams'); + + $c->file('app/Actions/Fortify/CreateNewUser.php')->replace( + " return DB::transaction(function () use (\$input) {\n \$user = User::query()->create([\n 'name' => \$input['name'],\n 'email' => \$input['email'],\n 'password' => \$input['password'],\n ]);\n\n \$this->createTeam->handle(\$user, \$user->name.\"'s Team\", isPersonal: true);\n\n return \$user;\n });", + " return User::query()->create([\n 'name' => \$input['name'],\n 'email' => \$input['email'],\n 'password' => \$input['password'],\n ]);", + ); + + $c->file('routes/web.php')->replace( + '// @chisel-no-teams-dashboard-route', + "Route::inertia('dashboard', 'Dashboard')\n ->middleware(['auth', 'verified'])\n ->name('dashboard');", + ); + + foreach (['resources/js/components/AppHeader.vue', 'resources/js/components/AppSidebar.vue', 'resources/js/pages/Welcome.vue'] as $file) { + $c->file($file)->replace( + "const dashboardUrl = computed(() =>\n page.props.currentTeam ? dashboard(page.props.currentTeam.slug).url : '/',\n);", + 'const dashboardUrl = computed(() => dashboard().url);', + ); + } + + $c->file('resources/js/components/AppSidebar.vue') + ->replace("import { Link, usePage } from '@inertiajs/vue3';", "import { Link } from '@inertiajs/vue3';"); + + $c->file('resources/js/pages/Welcome.vue') + ->replace("import { Head, Link, usePage } from '@inertiajs/vue3';", "import { Head, Link } from '@inertiajs/vue3';"); + + $c->file('resources/js/components/NavUser.vue') + ->replace("import { computed } from 'vue';\n", '') + ->replace('', ''); + + $c->file('resources/js/pages/Dashboard.vue')->replace( + "layout: (props: { currentTeam?: Team | null }) => ({\n breadcrumbs: [\n {\n title: 'Dashboard',\n href: props.currentTeam\n ? dashboard(props.currentTeam.slug)\n : '/',\n },\n ],\n }),", + "layout: {\n breadcrumbs: [\n {\n title: 'Dashboard',\n href: dashboard(),\n },\n ],\n },", + ); + + $c->file('resources/js/pages/auth/Login.vue')->replace( + "register({\n query: {\n invitation: teamInvitation?.code,\n },\n })", + 'register()', + ); + + $c->file('resources/js/pages/auth/Register.vue')->replace( + "teamInvitation\n ? login.url({\n query: {\n invitation: teamInvitation.code,\n },\n })\n : login()", + 'login()', + ); + + $c->file('resources/js/components/UserInfo.vue') + ->replace('v-else-if="showEmail"', 'v-if="showEmail"'); + + $c->file('tests/Feature/Auth/EmailVerificationTest.php') + ->replace('->assertRedirect("/{$team->slug}/dashboard?verified=1")', "->assertRedirect('/dashboard?verified=1')"); + + $c->file('app/Models/User.php') + ->removeLinesContaining('@property int|null $current_team_id') + ->removeLinesContaining('@property-read Team|null $currentTeam') + ->removeLinesContaining('@property-read Collection $ownedTeams') + ->removeLinesContaining('@property-read Collection $teamMemberships') + ->removeLinesContaining('@property-read Collection $teams') + ->replace(", 'current_team_id'", ''); + + $c->files( + 'app/Http/Controllers/DashboardController.php', + 'app/Http/Responses/Concerns/RedirectsToCurrentTeam.php', + 'app/Http/Responses/LoginResponse.php', + 'app/Http/Responses/RegisterResponse.php', + 'app/Http/Responses/VerifyEmailResponse.php', + 'app/Http/Responses/TwoFactorLoginResponse.php', + 'app/Http/Responses/PasskeyLoginResponse.php', + ...$paths['team_files'], + )->delete(); + }, + ) + ->selected( + 'application_features', + 'oauth-api', + then: function (Chisel $c) { + $c->files( + 'app/Models/User.php', + 'app/Providers/AppServiceProvider.php', + 'bootstrap/app.php', + 'config/auth.php', + 'resources/js/app.ts', + )->removeSectionMarkers('oauth-api'); + }, + else: function (Chisel $c) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Passport\Contracts\OAuthenticatable') + ->removeImport('Laravel\Passport\HasApiTokens') + ->removeTrait('HasApiTokens') + ->removeInterface('OAuthenticatable'); + + $c->files( + 'app/Models/User.php', + 'app/Providers/AppServiceProvider.php', + 'bootstrap/app.php', + 'config/auth.php', + 'resources/js/app.ts', + )->removeSection('oauth-api'); + + $c->file('composer.json') + ->removeLinesContaining('artisan passport:keys'); + + $c->files( + 'config/passport.php', + 'routes/api.php', + 'resources/js/pages/auth/OAuthConsent.vue', + 'storage/oauth-private.key', + 'storage/oauth-public.key', + 'database/migrations/2026_06_13_213702_create_oauth_auth_codes_table.php', + 'database/migrations/2026_06_13_213703_create_oauth_access_tokens_table.php', + 'database/migrations/2026_06_13_213704_create_oauth_refresh_tokens_table.php', + 'database/migrations/2026_06_13_213705_create_oauth_clients_table.php', + 'database/migrations/2026_06_13_213706_create_oauth_device_codes_table.php', + )->delete(); + }, + ) + ->selected( + 'application_features', + 'mcp', + else: function (Chisel $c) { + $c->file('composer.json')->replace( + " \"mcp:inspect\": [\n \"Composer\\\\Config::disableProcessTimeout\",\n \"NODE_OPTIONS=--use-system-ca npx @mcpjam/inspector@latest\"\n ],\n", + '', + ); + + $c->files( + 'routes/ai.php', + 'resources/views/vendor/mcp/components/app.blade.php', + )->delete(); + }, + ) + ->selected( + 'development_features', + 'octane', + then: function (Chisel $c) { + $c->files( + 'app/Providers/AppServiceProvider.php', + 'config/cache.php', + 'tests/Feature/StarterKitConfigurationTest.php', + )->removeSectionMarkers('octane'); + }, + else: function (Chisel $c) { + $c->files( + 'app/Providers/AppServiceProvider.php', + 'config/cache.php', + 'tests/Feature/StarterKitConfigurationTest.php', + )->removeSection('octane'); + + $c->file('composer.json') + ->replace( + " \"setup:octane\": [\n \"@php artisan octane:install --server=frankenphp --no-interaction\"\n ],\n", + '', + ) + ->removeLinesContaining('"@setup:octane"') + ->replace('php artisan octane:start --watch', 'php artisan serve'); + + $c->file('.gitignore') + ->removeLinesContaining('frankenphp'); + + $c->files('config/octane.php', 'public/frankenphp-worker.php')->delete(); + }, + ) + ->selected( + 'development_features', + 'browser-testing', + then: function (Chisel $c) { + $c->file('phpunit.xml')->removeSectionMarkers('browser-testing'); + }, + else: function (Chisel $c) { + $c->file('composer.json')->removeLinesContaining('npx playwright install'); + $c->file('tests/Pest.php')->replace("->in('Feature', 'Browser');", "->in('Feature');"); + $c->file('phpunit.xml')->removeSection('browser-testing'); + $c->file('.gitignore')->removeLinesContaining('/tests/Browser/Screenshots'); + $c->file('.github/workflows/tests.yml')->replace( + " - name: Install Playwright Browsers\n run: npx playwright install --with-deps\n\n", + '', + ); + $c->files('tests/Browser/ExampleTest.php')->delete(); + }, + ) + ->selected( + 'development_features', + 'ai-tooling', + else: function (Chisel $c) { + $c->file('composer.json')->replace( + " \"@php artisan vendor:publish --tag=laravel-assets --ansi --force\",\n \"@php artisan boost:update --env=local --ansi\"", + ' "@php artisan vendor:publish --tag=laravel-assets --ansi --force"', + ); + $c->files('AGENTS.md', 'CLAUDE.md', 'boost.json', '.mcp.json', '.vscode/mcp.json')->delete(); + + foreach (['.agents', '.ai', '.claude', '.codex', '.github/skills'] as $directory) { + chiselDeleteDirectory($directory); + } + + }, + ) + ->selected( + 'development_features', + 'ide-helper', + else: function (Chisel $c) { + $c->file('composer.json')->removeLinesContaining('ide-helper:generate'); + $c->files('_ide_helper.php')->delete(); + }, + ) + ->selected( + 'development_features', + 'git-hooks', + else: function (Chisel $c) { + $c->file('composer.json')->removeLinesContaining('vendor/bin/whisky'); + $c->files('whisky.json')->delete(); + }, + ) + ->selected( + 'development_features', + 'herd', + else: function (Chisel $c) { + $c->file('composer.json') + ->removeLinesContaining('herd secure') + ->removeLinesContaining('herd proxy'); + }, + ) + ->apply(function (Chisel $c, array $answers) use ($paths): void { + $composerPackages = []; + $composerDevPackages = []; + $npmPackages = []; + + if (! chiselSelected($answers, 'auth_features', '2fa') && $paths['two_factor_otp_package'] !== null) { + $npmPackages[] = $paths['two_factor_otp_package']; + } + + if (! chiselSelected($answers, 'auth_features', 'passkeys')) { + $npmPackages[] = '@laravel/passkeys'; + } + + if (! chiselSelected($answers, 'application_features', 'oauth-api')) { + $composerPackages[] = 'laravel/passport'; + } + + if (! chiselSelected($answers, 'application_features', 'mcp')) { + // Boost declares its own laravel/mcp dependency, so the app-level + // requirement can go even when the AI tooling stays. + $composerPackages[] = 'laravel/mcp'; + } + + if (! chiselSelected($answers, 'development_features', 'octane')) { + $composerPackages[] = 'laravel/octane'; + $npmPackages[] = 'chokidar'; + } + + if (! chiselSelected($answers, 'development_features', 'browser-testing')) { + $composerDevPackages[] = 'pestphp/pest-plugin-browser'; + $npmPackages[] = 'playwright'; + } + + if (! chiselSelected($answers, 'development_features', 'ai-tooling')) { + $composerDevPackages[] = 'laravel/boost'; + $composerDevPackages[] = 'laravel/pao'; + } + + if (! chiselSelected($answers, 'development_features', 'ide-helper')) { + $composerDevPackages[] = 'barryvdh/laravel-ide-helper'; + } + + if (! chiselSelected($answers, 'development_features', 'git-hooks')) { + $composerDevPackages[] = 'projektgopher/whisky'; + } + + $c->file('composer.json') + ->replace( + " \"@php artisan boost:update --env=local --ansi\",\n \"@php artisan install:features --ansi\"", + ' "@php artisan boost:update --env=local --ansi"', + ) + ->replace( + " \"@php artisan vendor:publish --tag=laravel-assets --ansi --force\",\n \"@php artisan install:features --ansi\"", + ' "@php artisan vendor:publish --tag=laravel-assets --ansi --force"', + ) + ->replace( + " \"installer\": {\n \"post-create-project\": [\n \"@php artisan install:features --ansi\"\n ]\n }", + " \"installer\": {\n \"post-create-project\": []\n }", + ); + + if ($npmPackages !== []) { + chiselRemoveNpmPackages($c, ...$npmPackages); + } + + chiselRemoveComposerPackages($composerDevPackages, dev: true); + chiselRemoveComposerPackages($composerPackages); + chiselRun(['composer', 'dump-autoload', '--no-interaction'], 'Refresh Composer Autoload'); + + // The installer migrates before Chisel deletes deselected migrations, so + // rebuild the schema from the surviving ones. The clone flow has no + // database yet at this point; its later migrate only sees survivors. + if (file_exists(__DIR__.'/database/database.sqlite')) { + chiselRun(['php', 'artisan', 'migrate:fresh', '--force', '--no-interaction'], 'Rebuild Database Schema'); + } + + chiselRun(['composer', 'lint'], 'Composer Lint'); + chiselRun(['php', 'artisan', 'wayfinder:generate', '--with-form', '--no-interaction'], 'Generate Wayfinder Resources'); + + if (! chiselSkipsNode()) { + if (chiselSelected($answers, 'development_features', 'browser-testing')) { + chiselRun(['npx', 'playwright', 'install'], 'Install Playwright Browsers'); + } + + $c->npm()->run('lint'); + $c->npm()->run('format'); + } + + if (file_exists(__DIR__.'/composer.lock')) { + chiselRun( + ['composer', 'update', '--lock', '--no-install', '--no-scripts', '--no-interaction'], + 'Refresh Composer Lock', + ); + } + + $c->files( + 'app/Console/Commands/InstallFeaturesCommand.php', + 'chisel.php', + 'chisel-paths.php', + 'tests/Feature/StarterKitConfigurationTest.php', + )->delete(); + + // Chisel's classes are loaded in memory while this script runs, so the + // toolkit must be removed last, after every consumer above is gone. + chiselRun([ + 'composer', + 'remove', + 'laravel/chisel', + '--no-interaction', + '--no-scripts', + '--no-audit', + '--minimal-changes', + ], 'Remove Laravel Chisel'); + }); diff --git a/composer.json b/composer.json index e188f99..97021c1 100644 --- a/composer.json +++ b/composer.json @@ -2,21 +2,22 @@ "$schema": "https://getcomposer.org/schema.json", "name": "zacksmash/summit", "type": "project", - "description": "The skeleton application for the Laravel framework.", + "description": "An opinionated Laravel Vue starter kit.", "keywords": [ "laravel", - "framework" + "starter-kit", + "vue" ], "license": "MIT", "require": { "php": "^8.4", "inertiajs/inertia-laravel": "^3.3.1", "laravel/chisel": "^0.1.1", - "laravel/fortify": "^1.37.3", - "laravel/framework": "^13.24.0", - "laravel/mcp": "^0.9.1", - "laravel/octane": "^2.18.0", - "laravel/passport": "^13.7.5", + "laravel/fortify": "^1.38.0", + "laravel/framework": "^13.25.0", + "laravel/mcp": "^0.9.3", + "laravel/octane": "^2.19.0", + "laravel/passport": "^13.7.6", "laravel/tinker": "^3.0.2", "laravel/wayfinder": "^0.1.21" }, @@ -25,17 +26,17 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "larastan/larastan": "^3.10.0", - "laravel/boost": "^2.5.0", + "laravel/boost": "^2.5.3", "laravel/pail": "^1.2.7", - "laravel/pao": "^1.1.3", - "laravel/pint": "^1.30.3", + "laravel/pao": "^1.1.4", + "laravel/pint": "^1.30.5", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", - "pestphp/pest": "^5.0.3", - "pestphp/pest-plugin-browser": "^5.0.0", + "pestphp/pest": "^5.1.1", + "pestphp/pest-plugin-browser": "^5.0.1", "pestphp/pest-plugin-laravel": "^5.0.1", "projektgopher/whisky": "^0.7.4", - "rector/rector": "^2.6.1" + "rector/rector": "^2.6.2" }, "autoload": { "psr-4": { @@ -51,26 +52,37 @@ }, "scripts": { "setup": [ - "git init -q", "composer install", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php artisan key:generate", - "@php artisan migrate --force", - "vendor/bin/whisky install --no-interaction", - "@php artisan ide-helper:generate", + "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan passport:keys --no-interaction --force", + "@php artisan migrate --seed --force", "npm install", "npx playwright install", - "npm run build", + "npm run build" + ], + "setup:octane": [ + "@php artisan octane:install --server=frankenphp --no-interaction" + ], + "setup:tools": [ + "git init -q", + "@setup:octane", + "vendor/bin/whisky install --no-interaction", + "@php artisan ide-helper:generate", + "npx playwright install", "herd secure --no-interaction", "herd proxy \"$(basename \"$PWD\")\" http://127.0.0.1:8000 --secure --no-interaction", - "@php artisan octane:install --server=frankenphp", - "@php artisan migrate:fresh --seed --force", - "echo \"Setup complete! Run 'composer dev' to start the development environment.\"" + "npm run format", + "git rev-parse -q --verify HEAD >/dev/null 2>&1 || (git add --all && git commit --no-verify -m \"Initial commit\")" ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan octane:start --watch\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "@php artisan dev" + ], + "serve": [ + "Composer\\Config::disableProcessTimeout", + "npx @laravel/multiplex 'server@yellow,php artisan octane:start --watch' 'queue@blue,php artisan queue:listen --tries=1 --timeout=0' 'logs@magenta,php artisan pail --timeout=0' 'vite@green,vite build'" ], "lint": [ "pint --parallel", @@ -107,7 +119,8 @@ ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", - "@php artisan boost:update --env=local --ansi" + "@php artisan boost:update --env=local --ansi", + "@php artisan install:features --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" @@ -115,6 +128,7 @@ "post-create-project-cmd": [ "@php artisan key:generate --ansi", "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "@php artisan passport:keys --no-interaction --ansi", "@php artisan migrate --graceful --ansi" ], "pre-package-uninstall": [ @@ -125,7 +139,9 @@ "laravel": { "dont-discover": [], "installer": { - "post-create-project": [] + "post-create-project": [ + "@php artisan install:features --ansi" + ] } } }, diff --git a/config/auth.php b/config/auth.php index 7e0116b..4bf28a8 100644 --- a/config/auth.php +++ b/config/auth.php @@ -45,10 +45,12 @@ 'provider' => 'users', ], + /* @chisel-oauth-api */ 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], + /* @end-chisel-oauth-api */ ], /* diff --git a/config/cache.php b/config/cache.php index 5fe0d20..844d230 100644 --- a/config/cache.php +++ b/config/cache.php @@ -95,9 +95,11 @@ 'endpoint' => env('DYNAMODB_ENDPOINT'), ], + /* @chisel-octane */ 'octane' => [ 'driver' => 'octane', ], + /* @end-chisel-octane */ 'failover' => [ 'driver' => 'failover', diff --git a/config/fortify.php b/config/fortify.php index 7bdde27..a09c2bd 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -118,8 +118,12 @@ 'limiters' => [ 'login' => 'login', + /* @chisel-2fa */ 'two-factor' => 'two-factor', + /* @end-chisel-2fa */ + /* @chisel-passkeys */ 'passkeys' => 'passkeys', + /* @end-chisel-passkeys */ ], /* @@ -135,6 +139,7 @@ 'views' => true, + /* @chisel-passkeys */ /* |-------------------------------------------------------------------------- | Passkeys @@ -150,6 +155,7 @@ 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), 'timeout' => 60000, ], + /* @end-chisel-passkeys */ /* |-------------------------------------------------------------------------- @@ -163,17 +169,27 @@ */ 'features' => [ + /* @chisel-registration */ Features::registration(), + /* @end-chisel-registration */ + /* @chisel-password-reset */ Features::resetPasswords(), + /* @end-chisel-password-reset */ + /* @chisel-email-verification */ Features::emailVerification(), + /* @end-chisel-email-verification */ + /* @chisel-2fa */ Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, // 'window' => 0 ]), + /* @end-chisel-2fa */ + /* @chisel-passkeys */ Features::passkeys([ 'confirmPassword' => true, ]), + /* @end-chisel-passkeys */ ], ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 7af5a10..f78d767 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,8 +2,10 @@ namespace Database\Factories; +/* @chisel-teams */ use App\Enums\TeamRole; use App\Models\Team; +/* @end-chisel-teams */ use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -32,12 +34,15 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + /* @chisel-2fa */ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, + /* @end-chisel-2fa */ ]; } + /* @chisel-teams */ /** * Configure the model factory. */ @@ -56,6 +61,7 @@ public function configure(): static $user->switchTeam($team); }); } + /* @end-chisel-teams */ /** * Indicate that the model's email address should be unverified. @@ -67,6 +73,7 @@ public function unverified(): static ]); } + /* @chisel-2fa */ /** * Indicate that the model has two-factor authentication configured. */ @@ -78,4 +85,5 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + /* @end-chisel-2fa */ } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 6a7de5d..a4f5069 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -21,11 +21,13 @@ public function up(): void $table->timestamps(); }); + /* @chisel-password-reset */ Schema::create('password_reset_tokens', function (Blueprint $table): void { $table->string('email')->primary(); $table->string('token'); $table->timestamp('created_at')->nullable(); }); + /* @end-chisel-password-reset */ Schema::create('sessions', function (Blueprint $table): void { $table->string('id')->primary(); @@ -43,7 +45,9 @@ public function up(): void public function down(): void { Schema::dropIfExists('users'); + /* @chisel-password-reset */ Schema::dropIfExists('password_reset_tokens'); + /* @end-chisel-password-reset */ Schema::dropIfExists('sessions'); } }; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index fda4692..35a04f4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,9 +19,11 @@ public function run(): void { // User::factory(10)->create(); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + if (User::query()->where('email', 'test@example.com')->doesntExist()) { + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } } } diff --git a/package.json b/package.json index 0828a40..64016eb 100644 --- a/package.json +++ b/package.json @@ -13,34 +13,36 @@ "types:check": "vue-tsc --noEmit" }, "devDependencies": { + "@laravel/multiplex": "^0.4.1", "@laravel/vite-plugin-wayfinder": "^0.1.7", "@tailwindcss/vite": "^4.3.3", - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "@vitejs/plugin-vue": "^6.0.8", + "chokidar": "^5.0.0", "concurrently": "^10.0.4", - "oxfmt": "^0.62.0", - "oxlint": "^1.77.0", + "oxfmt": "^0.63.0", + "oxlint": "^1.78.0", "oxlint-tsgolint": "^7.0.2001", "playwright": "^1.62.1", - "shadcn-vue": "^2.8.1", + "shadcn-vue": "^2.8.2", "typescript": "^6.0.3", - "vite": "^8.2.0", + "vite": "^8.2.1", "vue-tsc": "^3.3.9" }, "dependencies": { "@inertiajs/vite": "^3.6.1", "@inertiajs/vue3": "^3.6.1", "@laravel/passkeys": "^0.2.0", - "@lucide/vue": "^1.28.0", + "@lucide/vue": "^1.31.0", "@vueuse/core": "^14.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "laravel-vite-plugin": "^3.1.3", - "reka-ui": "^2.10.1", + "laravel-vite-plugin": "^3.2.0", + "reka-ui": "^2.10.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "vue": "^3.5.40", + "vue": "^3.5.41", "vue-input-otp": "^0.3.2", "vue-sonner": "^2.0.9" }, diff --git a/phpunit.xml b/phpunit.xml index 36e7b0d..aa86e0a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,9 +11,11 @@ tests/Feature + tests/Browser + diff --git a/resources/js/app.ts b/resources/js/app.ts index 37828d6..01fd3f9 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -12,12 +12,16 @@ createInertiaApp({ layout: (name) => { switch (true) { case name === 'Welcome': + /* @chisel-oauth-api */ case name === 'auth/OAuthConsent': + /* @end-chisel-oauth-api */ return null; case name.startsWith('auth/'): return AuthLayout; case name.startsWith('settings/'): + /* @chisel-teams */ case name.startsWith('teams/'): + /* @end-chisel-teams */ return [AppLayout, SettingsLayout]; default: return AppLayout; diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 41ebeed..78fde18 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -5,7 +5,9 @@ import { computed } from 'vue'; import AppLogo from '@/components/AppLogo.vue'; import AppLogoIcon from '@/components/AppLogoIcon.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue'; +/* @chisel-teams */ import TeamSwitcher from '@/components/TeamSwitcher.vue'; +/* @end-chisel-teams */ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -271,7 +273,9 @@ const rightNavItems: NavItem[] = [ + + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 39287bd..db5810b 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -6,7 +6,9 @@ import AppLogo from '@/components/AppLogo.vue'; import NavFooter from '@/components/NavFooter.vue'; import NavMain from '@/components/NavMain.vue'; import NavUser from '@/components/NavUser.vue'; +/* @chisel-teams */ import TeamSwitcher from '@/components/TeamSwitcher.vue'; +/* @end-chisel-teams */ import { Sidebar, SidebarContent, @@ -19,7 +21,9 @@ import { import { dashboard } from '@/routes'; import type { NavItem } from '@/types'; +/* @chisel-teams */ const page = usePage(); +/* @end-chisel-teams */ const dashboardUrl = computed(() => page.props.currentTeam ? dashboard(page.props.currentTeam.slug).url : '/', @@ -59,11 +63,13 @@ const footerNavItems: NavItem[] = [ + + diff --git a/resources/js/components/NavUser.vue b/resources/js/components/NavUser.vue index 8693dc3..6abc8ba 100644 --- a/resources/js/components/NavUser.vue +++ b/resources/js/components/NavUser.vue @@ -15,13 +15,17 @@ import { } from '@/components/ui/sidebar'; import UserInfo from '@/components/UserInfo.vue'; import UserMenuContent from '@/components/UserMenuContent.vue'; +/* @chisel-teams */ import type { Team } from '@/types'; +/* @end-chisel-teams */ const page = usePage(); const user = page.props.auth.user; const { isMobile, state } = useSidebar(); +/* @chisel-teams */ const currentTeam = computed(() => page.props.currentTeam as Team | null); +/* @end-chisel-teams */ diff --git a/resources/js/pages/auth/OAuthConsent.vue b/resources/js/pages/auth/OAuthConsent.vue index 06b4779..3a86992 100644 --- a/resources/js/pages/auth/OAuthConsent.vue +++ b/resources/js/pages/auth/OAuthConsent.vue @@ -80,8 +80,7 @@ function onDenySubmit(): void { - This application will be able to:
Use available MCP - functionality. + Review the permissions requested by this application.
diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue index 3a13412..8b2f378 100644 --- a/resources/js/pages/auth/Register.vue +++ b/resources/js/pages/auth/Register.vue @@ -2,7 +2,9 @@ import { Form, Head } from '@inertiajs/vue3'; import InputError from '@/components/InputError.vue'; import PasswordInput from '@/components/PasswordInput.vue'; +/* @chisel-teams */ import TeamInvitationAlert from '@/components/TeamInvitationAlert.vue'; +/* @end-chisel-teams */ import TextLink from '@/components/TextLink.vue'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,11 +12,15 @@ import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { login } from '@/routes'; import { store } from '@/routes/register'; +/* @chisel-teams */ import type { TeamInvitationContext } from '@/types'; +/* @end-chisel-teams */ defineProps<{ passwordRules: string; + /* @chisel-teams */ teamInvitation?: TeamInvitationContext | null; + /* @end-chisel-teams */ }>(); defineOptions({ @@ -28,11 +34,13 @@ defineOptions({ diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 24ec534..c72c332 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -4,7 +4,9 @@ export type User = { email: string; avatar?: string; email_verified_at: string | null; + /* @chisel-2fa */ two_factor_enabled?: boolean; + /* @end-chisel-2fa */ created_at: string; updated_at: string; [key: string]: unknown; @@ -24,8 +26,10 @@ export type Passkey = { }; /* @end-chisel-passkeys */ +/* @chisel-2fa */ export type TwoFactorConfigContent = { title: string; description: string; buttonText: string; }; +/* @end-chisel-2fa */ diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index ae443ff..0f244a6 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,5 +1,7 @@ import type { Auth } from '@/types/auth'; +/* @chisel-teams */ import type { Team } from '@/types/teams'; +/* @end-chisel-teams */ // Extend ImportMeta interface for Vite... declare module 'vite/client' { @@ -20,8 +22,10 @@ declare module '@inertiajs/core' { name: string; auth: Auth; sidebarOpen: boolean; + /* @chisel-teams */ currentTeam: Team | null; teams: Team[]; + /* @end-chisel-teams */ [key: string]: unknown; }; } diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 676159d..3ebd153 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -1,4 +1,6 @@ export * from './auth'; export * from './navigation'; +/* @chisel-teams */ export * from './teams'; +/* @end-chisel-teams */ export * from './ui'; diff --git a/routes/console.php b/routes/console.php index dc74b96..6569ebf 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ declare(strict_types=1); +/* @chisel-teams */ use App\Models\TeamInvitation; use Illuminate\Support\Facades\Schedule; @@ -11,3 +12,4 @@ ->where('expires_at', '<', now()) ->delete(); })->daily()->description('Delete expired team invitations'); +/* @end-chisel-teams */ diff --git a/routes/settings.php b/routes/settings.php index 85f0d19..d9fd8d1 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -2,11 +2,15 @@ use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\SecurityController; +/* @chisel-teams */ use App\Http\Controllers\Teams\TeamController; use App\Http\Controllers\Teams\TeamInvitationController; use App\Http\Controllers\Teams\TeamMemberController; use App\Http\Middleware\EnsureTeamMembership; +/* @end-chisel-teams */ +/* @chisel-password-confirmation */ use Illuminate\Auth\Middleware\RequirePassword; +/* @end-chisel-password-confirmation */ use Illuminate\Support\Facades\Route; Route::middleware(['auth'])->group(function (): void { @@ -20,7 +24,9 @@ Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::get('settings/security', [SecurityController::class, 'edit']) + /* @chisel-password-confirmation */ ->middleware(RequirePassword::class) + /* @end-chisel-password-confirmation */ ->name('security.edit'); Route::put('settings/password', [SecurityController::class, 'update']) @@ -29,6 +35,7 @@ Route::inertia('settings/appearance', 'settings/Appearance')->name('appearance.edit'); + /* @chisel-teams */ Route::get('settings/teams', [TeamController::class, 'index'])->name('teams.index'); Route::post('settings/teams', [TeamController::class, 'store'])->name('teams.store'); @@ -45,9 +52,12 @@ Route::post('settings/teams/{team}/invitations', [TeamInvitationController::class, 'store'])->name('teams.invitations.store'); Route::delete('settings/teams/{team}/invitations/{invitation}', [TeamInvitationController::class, 'destroy'])->name('teams.invitations.destroy'); }); + /* @end-chisel-teams */ }); +/* @chisel-passkeys */ Route::get('.well-known/passkey-endpoints', fn () => response()->json([ 'enroll' => route('security.edit'), 'manage' => route('security.edit'), ]))->name('well-known.passkeys'); +/* @end-chisel-passkeys */ diff --git a/routes/web.php b/routes/web.php index ccfeba6..11bb713 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,16 @@ name('home'); +// @chisel-no-teams-dashboard-route +/* @chisel-teams */ Route::prefix('{current_team}') ->middleware(['auth', 'verified', EnsureTeamMembership::class]) ->group(function (): void { @@ -17,5 +21,6 @@ Route::post('invitations/{invitation}/accept', [TeamInvitationController::class, 'accept'])->name('invitations.accept'); Route::delete('invitations/{invitation}', [TeamInvitationController::class, 'decline'])->name('invitations.decline'); }); +/* @end-chisel-teams */ require __DIR__.'/settings.php'; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 2ba3e0b..9936158 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -1,21 +1,29 @@ get(route('login')); $response->assertOk(); }); +/* @chisel-teams */ test('login screen includes team invitation context', function () { $owner = User::factory()->create(); $team = Team::factory()->create(['name' => 'Laravel Team']); @@ -36,6 +44,7 @@ ->where('teamInvitation.teamName', 'Laravel Team'), ); }); +/* @end-chisel-teams */ test('users can authenticate using the login screen', function () { $user = User::factory()->create(); @@ -49,6 +58,8 @@ $response->assertRedirect(route('dashboard')); }); +/* @chisel-passkeys */ +/* @chisel-teams */ test('passkey login response redirects to the current team dashboard', function () { $user = User::factory()->create(); @@ -62,7 +73,10 @@ expect($jsonResponse->getData()->redirect)->toBe(route('dashboard', ['current_team' => $user->personalTeam()->slug])); }); +/* @end-chisel-teams */ +/* @end-chisel-passkeys */ +/* @chisel-2fa */ test('users with two factor enabled are redirected to two factor challenge', function () { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -84,6 +98,7 @@ $response->assertSessionHas('login.id', $user->id); $this->assertGuest(); }); +/* @end-chisel-2fa */ test('users can not authenticate with invalid password', function () { $user = User::factory()->create(); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index f5ac53d..f28ce05 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -15,7 +15,9 @@ test('email can be verified', function () { $user = User::factory()->unverified()->create(); + /* @chisel-teams */ $team = $user->personalTeam(); + /* @end-chisel-teams */ Event::fake(); @@ -79,7 +81,9 @@ test('already verified user visiting verification link is redirected without firing event again', function () { $user = User::factory()->create(); + /* @chisel-teams */ $team = $user->personalTeam(); + /* @end-chisel-teams */ Event::fake(); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 80dad02..46490cb 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,8 +1,10 @@ assertOk(); }); +/* @chisel-teams */ test('registration screen includes team invitation context', function () { $owner = User::factory()->create(); $team = Team::factory()->create(['name' => 'Laravel Team']); @@ -32,6 +35,7 @@ ->where('teamInvitation.teamName', 'Laravel Team'), ); }); +/* @end-chisel-teams */ test('new users can register', function () { $response = $this->post(route('register.store'), [ diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 550ac19..7b886ab 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,14 +1,18 @@ create(); + /* @chisel-teams */ $team = $user->currentTeam; + /* @end-chisel-teams */ $response = $this->get(route('dashboard')); $response->assertRedirect(route('login')); @@ -16,7 +20,9 @@ test('authenticated users can visit the dashboard', function () { $user = User::factory()->create(); + /* @chisel-teams */ $team = $user->currentTeam; + /* @end-chisel-teams */ $response = $this ->actingAs($user) @@ -25,6 +31,7 @@ $response->assertOk(); }); +/* @chisel-teams */ test('dashboard includes pending invitations for the authenticated user', function () { $owner = User::factory()->create(['name' => 'Taylor Otwell']); $invitedUser = User::factory()->create(['email' => 'invited@example.com']); @@ -133,3 +140,4 @@ 'id' => $invitation->id, ]); }); +/* @end-chisel-teams */ diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index fef3096..f6c51e2 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -6,46 +6,59 @@ use Laravel\Fortify\Features; test('security page is displayed', function () { + /* @chisel-2fa */ $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, ]); + /* @end-chisel-2fa */ + /* @chisel-passkeys */ Features::passkeys([ 'confirmPassword' => true, ]); + /* @end-chisel-passkeys */ $user = User::factory()->create(); - $this->actingAs($user) + $response = $this->actingAs($user) + /* @chisel-password-confirmation */ ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('security.edit')) - ->assertInertia(fn (Assert $page) => $page - ->component('settings/Security') - ->where('canManagePasskeys', true) - ->where('passkeys', []) - ->where('canManageTwoFactor', true) - ->where('twoFactorEnabled', false), - ); + /* @end-chisel-password-confirmation */ + ->get(route('security.edit')); + + $response->assertInertia(fn (Assert $page) => $page + ->component('settings/Security'), + ); + + /* @chisel-passkeys */ + $response->assertInertia(fn (Assert $page) => $page + ->where('canManagePasskeys', true) + ->where('passkeys', []), + ); + /* @end-chisel-passkeys */ + + /* @chisel-2fa */ + $response->assertInertia(fn (Assert $page) => $page + ->where('canManageTwoFactor', true) + ->where('twoFactorEnabled', false), + ); + /* @end-chisel-2fa */ }); +/* @chisel-password-confirmation */ test('security page requires password confirmation when enabled', function () { - $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); - $user = User::factory()->create(); - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - $response = $this->actingAs($user) ->get(route('security.edit')); $response->assertRedirect(route('password.confirm')); }); +/* @end-chisel-password-confirmation */ +/* @chisel-2fa */ test('security page renders without two factor when feature is disabled', function () { $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); @@ -53,19 +66,21 @@ $user = User::factory()->create(); - $this->actingAs($user) + $response = $this->actingAs($user) + /* @chisel-password-confirmation */ ->withSession(['auth.password_confirmed_at' => time()]) + /* @end-chisel-password-confirmation */ ->get(route('security.edit')) - ->assertOk() - ->assertInertia(fn (Assert $page) => $page - ->component('settings/Security') - ->where('canManagePasskeys', false) - ->where('passkeys', []) - ->where('canManageTwoFactor', false) - ->missing('twoFactorEnabled') - ->missing('requiresConfirmation'), - ); + ->assertOk(); + + $response->assertInertia(fn (Assert $page) => $page + ->component('settings/Security') + ->where('canManageTwoFactor', false) + ->missing('twoFactorEnabled') + ->missing('requiresConfirmation'), + ); }); +/* @end-chisel-2fa */ test('password can be updated', function () { $user = User::factory()->create(); diff --git a/tests/Feature/StarterKitConfigurationTest.php b/tests/Feature/StarterKitConfigurationTest.php new file mode 100644 index 0000000..db5cc0b --- /dev/null +++ b/tests/Feature/StarterKitConfigurationTest.php @@ -0,0 +1,172 @@ +toBe('zacksmash/summit') + ->and($composer['type'])->toBe('project') + ->and($composer['extra']['laravel']['installer']['post-create-project']) + ->toBe(['@php artisan install:features --ansi']) + ->and($composer['scripts']['post-create-project-cmd']) + ->toContain('@php artisan passport:keys --no-interaction --ansi') + ->and(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("'tests/Feature/StarterKitConfigurationTest.php'"); +}); + +test('offers all bundled features as default Chisel selections', function () { + /** @var Script $script */ + $script = require dirname(__DIR__, 2).'/chisel.php'; + + $questions = collect($script->questions())->keyBy('name'); + + expect($questions->keys()->all())->toBe([ + 'auth_features', + 'application_features', + 'development_features', + ])->and($questions['auth_features']->default) + ->toBe(array_keys($questions['auth_features']->options)) + ->and($questions['application_features']->default) + ->toBe(array_keys($questions['application_features']->options)) + ->and($questions['development_features']->default) + ->toBe(array_keys($questions['development_features']->options)); +}); + +test('keeps the portable and machine-specific setup workflows separate', function () { + $composer = json_decode( + file_get_contents(dirname(__DIR__, 2).'/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + $setup = implode("\n", $composer['scripts']['setup']); + $octaneSetup = implode("\n", $composer['scripts']['setup:octane']); + $toolsSetup = implode("\n", $composer['scripts']['setup:tools']); + + expect($setup) + ->toContain('composer install') + ->toContain('artisan passport:keys --no-interaction --force') + ->toContain('npm install') + ->toContain('npx playwright install') + ->toContain('npm run build') + ->not->toContain('git init') + ->not->toContain('migrate:fresh') + ->not->toContain('herd ') + ->not->toContain('whisky') + ->not->toContain('ide-helper') + ->and($octaneSetup) + ->toContain('octane:install --server=frankenphp') + ->and($toolsSetup) + ->toStartWith('git init -q') + ->toContain('@setup:octane') + ->toContain('herd secure') + ->toContain('herd proxy') + ->toContain('playwright install') + ->toContain('whisky install') + ->toContain('ide-helper:generate') + ->toContain('npm run format') + ->toEndWith('git rev-parse -q --verify HEAD >/dev/null 2>&1 || (git add --all && git commit --no-verify -m "Initial commit")'); +}); + +test('seeds the database idempotently', function () { + $this->seed(); + $this->seed(); + + expect(User::query()->where('email', 'test@example.com')->count())->toBe(1); +}); + +test('keeps personal tunneling tooling out of the template', function () { + $root = dirname(__DIR__, 2); + + $composer = json_decode(file_get_contents($root.'/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $package = json_decode(file_get_contents($root.'/package.json'), true, 512, JSON_THROW_ON_ERROR); + + expect(implode("\n", $composer['scripts']['serve'])) + ->not->toContain('ngrok') + ->not->toContain('dotenv-cli') + ->and(file_get_contents($root.'/.env.example'))->not->toContain('NGROK_URL') + ->and($package['devDependencies'])->toHaveKey('@laravel/multiplex') + ->and($package['optionalDependencies'])->not->toHaveKey('@laravel/multiplex'); +}); + +test('installs Playwright browsers in the installer flow when browser testing is kept', function () { + expect(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("chiselRun(['npx', 'playwright', 'install'], 'Install Playwright Browsers')"); +}); + +test('runs feature selection after the other post-update scripts', function () { + $composer = json_decode( + file_get_contents(dirname(__DIR__, 2).'/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + expect($composer['scripts']['post-update-cmd'])->toBe([ + '@php artisan vendor:publish --tag=laravel-assets --ansi --force', + '@php artisan boost:update --env=local --ansi', + '@php artisan install:features --ansi', + ]); +}); + +test('keeps the views setting when the passkeys section is removed', function () { + $config = preg_replace( + '/\/\* @chisel-passkeys \*\/.*?\/\* @end-chisel-passkeys \*\//s', + '', + file_get_contents(dirname(__DIR__, 2).'/config/fortify.php'), + ); + + expect($config)->toContain("'views' => true,"); +}); + +test('drops laravel/mcp whenever the MCP scaffolding is deselected', function () { + expect(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("if (! chiselSelected(\$answers, 'application_features', 'mcp')) {"); +}); + +test('removes the Chisel toolkit from generated projects', function () { + expect(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("'laravel/chisel',"); +}); + +test('rewrites the serve script when Octane is deselected', function () { + expect(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("->replace('php artisan octane:start --watch', 'php artisan serve')"); +}); + +test('rebuilds the database schema after removing deselected migrations', function () { + expect(file_get_contents(dirname(__DIR__, 2).'/chisel.php')) + ->toContain("'migrate:fresh'"); +}); + +test('skips feature selection when the session is not interactive', function () { + $this->artisan('install:features', ['--no-interaction' => true]) + ->assertSuccessful(); + + expect(file_exists(base_path('chisel.php')))->toBeTrue() + ->and(file_exists(base_path('chisel-paths.php')))->toBeTrue(); +}); + +/* @chisel-octane */ +test('falls back to the Laravel development server without an Octane runtime', function () { + config()->set('octane.server', 'unavailable'); + + $provider = new AppServiceProvider(app()); + + Closure::bind(fn () => $this->registerDevCommands(), $provider, AppServiceProvider::class)(); + + $server = collect(DevCommands::commands())->firstWhere('name', 'server'); + + expect($server['command'])->toBe('php artisan serve'); +}); +/* @end-chisel-octane */