feat(operations): fresh platform-operations implementation

Full rewrite of the installer (incl. the finish step), self-update pipeline,
settings store, cron webhook, dashboard/bootstrap surface, environment
manager, wizard login, config endpoint and shared helpers. Byte-compatible
public interfaces and observable behavior, including documented legacy
quirks; verified by the pilot behavioral suite and the full test suite.
This commit is contained in:
Darko Gjorgjijoski
2026-08-20 18:30:12 +02:00
parent a1828fac52
commit 7db2a8d094
31 changed files with 2869 additions and 0 deletions
@@ -0,0 +1,35 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\EnvironmentManager;
use App\Platform\Operations\Installation\Http\Requests\DomainEnvironmentRequest;
use Illuminate\Support\Facades\Artisan;
/**
* The wizard's domain step: records the host this instance will be served
* from, so sessions and stateful API calls are scoped to it.
*/
class AppDomainController extends Controller
{
/**
* Compiled configuration is dropped first, otherwise the manager would
* compare the submitted domain against a cached application URL.
*
* The step answers success unconditionally — a failed write has never been
* reported to the client here, and the wizard's later steps rewrite the
* same two variables anyway. Surfacing an error at this point would change
* the contract the wizard front end is built against.
*/
public function __invoke(DomainEnvironmentRequest $request)
{
Artisan::call('optimize:clear');
(new EnvironmentManager)->saveDomainVariables($request);
return response()->json([
'success' => true,
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\EnvironmentManager;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Installation\Http\Requests\DatabaseEnvironmentRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
/**
* The wizard's database step, in both directions: what the form should be
* prefilled with, and what happens once it comes back.
*/
class DatabaseConfigurationController extends Controller
{
/**
* @var EnvironmentManager
*/
protected $EnvironmentManager;
public function __construct(EnvironmentManager $environmentManager)
{
$this->EnvironmentManager = $environmentManager;
}
/**
* Caches go first so the manager works against the environment as it sits
* on disk. A clean write is followed by building the installation out in
* place: public storage symlink, the whole migration chain with seeders,
* and the version stamp.
*
* The application key is deliberately not regenerated here. Rotating it
* mid-wizard invalidates the session the browser is holding, which surfaces
* as a token mismatch on the very next step; an instance that needs a fresh
* key generates it before the wizard runs.
*/
public function saveDatabaseEnvironment(DatabaseEnvironmentRequest $request)
{
Artisan::call('config:clear');
Artisan::call('cache:clear');
$results = $this->EnvironmentManager->saveDatabaseVariables($request);
if (array_key_exists('success', $results)) {
Artisan::call('optimize:clear');
Artisan::call('config:clear');
Artisan::call('cache:clear');
Artisan::call('storage:link');
Artisan::call('migrate --seed --force');
InstallationState::setCurrentVersion();
}
return response()->json($results);
}
/**
* Defaults for the database form. The driver comes from the query string,
* falling back to whatever the runtime is configured with.
*
* A driver with no arm of its own is echoed back with the server defaults
* rather than an empty config: the wizard chooses which form to render from
* database_connection, so answering with nothing left the step blank and
* the install stuck (as a DB_CONNECTION=mariadb compose file once did).
*
* The prefill key is database_host while the form posts back
* database_hostname. The mismatch is what the front end expects.
*/
public function getDatabaseEnvironment(Request $request)
{
$connection = $request->connection ?? config('database.default');
$databaseData = match ($connection) {
'sqlite' => [
'database_connection' => 'sqlite',
'database_name' => config('database.connections.sqlite.database') ?: 'storage/app/database.sqlite',
],
'pgsql' => [
'database_connection' => 'pgsql',
'database_host' => '127.0.0.1',
'database_port' => 5432,
],
default => [
'database_connection' => $connection,
'database_host' => '127.0.0.1',
'database_port' => 3306,
],
};
return response()->json([
'config' => $databaseData,
'success' => true,
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\FilePermissionChecker;
use Illuminate\Http\JsonResponse;
class FilePermissionsController extends Controller
{
protected FilePermissionChecker $permissions;
public function __construct(FilePermissionChecker $checker)
{
$this->permissions = $checker;
}
/**
* Second wizard gate: the writability of the folders listed in
* config/installer.php.
*/
public function permissions(): JsonResponse
{
return response()->json([
'permissions' => $this->permissions->check(
config('installer.permissions')
),
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* The wizard's closing step. Nothing is persisted here — completion state is
* tracked through the wizard-step endpoint — so acknowledging is all there is
* to do.
*/
class FinishController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Authentication\InstallWizardAuth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Signs the wizard in as the administrator the seeders just created, so the
* remaining steps can call the authenticated API.
*/
class LoginController extends Controller
{
/**
* There is exactly one candidate at this point in the install: the first
* platform administrator, and the first company attached to them. Any
* browser session that got this far is thrown away — the wizard carries on
* with a bearer token limited to the wizard ability, and only one such
* token is ever live at a time.
*/
public function __invoke(Request $request): JsonResponse
{
$user = User::where('role', 'super admin')->first();
if ($user === null) {
return response()->json([
'message' => 'Super admin user not found.',
], 404);
}
$company = $user->companies()->first();
if ($company === null) {
return response()->json([
'message' => 'Super admin company not found.',
], 422);
}
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$user->tokens()->where('name', InstallWizardAuth::TOKEN_NAME)->delete();
return response()->json([
'success' => true,
'type' => 'Bearer',
'token' => $user->createToken(
InstallWizardAuth::TOKEN_NAME,
[InstallWizardAuth::TOKEN_ABILITY],
)->plainTextToken,
'user' => $user,
'company' => $company,
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tracks how far through the setup wizard the operator has walked, so a
* reloaded browser resumes on the step it left off.
*/
class OnboardingWizardController extends Controller
{
/**
* Terminal value of the profile_complete setting.
*/
private const FINISHED = 'COMPLETED';
/**
* Until the schema exists there is nowhere to read progress from, so the
* very first step and the default language are answered from thin air.
*/
public function getStep(Request $request): JsonResponse
{
if (! InstallationState::isDbCreated()) {
return response()->json([
'profile_complete' => 0,
'profile_language' => 'en',
]);
}
return response()->json([
'profile_complete' => Setting::getSetting('profile_complete'),
'profile_language' => Setting::getSetting('profile_language'),
]);
}
/**
* Record progress, unless the wizard has already run to completion — a
* finished install must not be walked backwards into the setup flow.
*/
public function updateStep(Request $request): JsonResponse
{
$step = Setting::getSetting('profile_complete');
if ($step !== self::FINISHED) {
Setting::setSetting('profile_complete', $request->input('profile_complete'));
$step = Setting::getSetting('profile_complete');
}
return response()->json([
'profile_complete' => $step,
]);
}
public function saveLanguage(Request $request): JsonResponse
{
Setting::setSetting('profile_language', $request->input('profile_language'));
return response()->json([
'profile_language' => Setting::getSetting('profile_language'),
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\RequirementsChecker;
use Illuminate\Http\JsonResponse;
class RequirementsController extends Controller
{
protected RequirementsChecker $requirements;
public function __construct(RequirementsChecker $checker)
{
$this->requirements = $checker;
}
/**
* First wizard gate: the interpreter version block plus the per-extension
* verdicts drawn from config/installer.php.
*/
public function requirements(): JsonResponse
{
return response()->json([
'phpSupportInfo' => $this->requirements->checkPHPVersion(
config('installer.core.minPhpVersion')
),
'requirements' => $this->requirements->check(
config('installer.requirements')
),
]);
}
}