seedproject-web/api/app/Services/Installer.php
Carlos Arias 1fdfc3174b feat(api): Foundation — installer, migrations, two-tier auth, health round-trip
Builds the /api Foundation into the base (per api/.memory/foundation-plan.md):
- app/Services/Installer.php  DB test + dump.sql import + crypto keys + /api config + lock
- app/Services/Migrator.php   versioned db/migrations/*.sql runner (+ migrations table)
- commands/InstallCommand.php (app:install), commands/MigrateCommand.php (db:migrate)
- app/Controllers/{JsonController,PublicController,ApiController}  envelope + two-tier auth
- public/controllers/{health,admin}.php  GET /api/health (public), /api/admin/ping (bearer)
- db/migrations/001_*.sql  smoke migration
- install/controllers/index.php  web wizard now delegates to Installer (path bugs fixed, lock)
- console + composer.json  register commands / add commands to classmap
- app/src/pages/api-health-test.astro  browser round-trip proof page

Verified (no DB): composer install OK; php console lists app:install + db:migrate;
PSR-4 classes autoload; CLI fails gracefully (validation, bad DB, missing config) with
no artifacts left; app builds 14 pages. Live DB + HTTP round-trip pending a served instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 23:23:51 +00:00

132 lines
4.9 KiB
PHP

<?php
namespace App\Services;
use PDO;
use PDOException;
use RuntimeException;
/**
* Installer — provisions config.php + imports the skeleton schema.
* Shared by the CLI (app:install) and the (locked) web wizard.
* Uses filesystem-relative paths, never $_SERVER['DOCUMENT_ROOT'].
*/
class Installer
{
private string $baseDir; // == api/
public function __construct()
{
// app/Services/Installer.php -> up two levels -> api/
$this->baseDir = dirname(__DIR__, 2);
}
public function lockFile(): string
{
return $this->baseDir . '/system/.installed';
}
public function isInstalled(): bool
{
return is_file($this->lockFile());
}
public function testConnection(array $db): bool
{
try {
new PDO(
"mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4",
$db['user'], $db['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return true;
} catch (PDOException $e) {
return false;
}
}
public function importSchema(array $db): void
{
$sqlFile = $this->baseDir . '/install/dump.sql';
if (!is_file($sqlFile)) {
throw new RuntimeException("Schema dump not found: {$sqlFile}");
}
$mysqli = new \mysqli($db['host'], $db['user'], $db['pass'], $db['name']);
if ($mysqli->connect_errno) {
throw new RuntimeException('DB connect failed: ' . $mysqli->connect_error);
}
if (!$mysqli->multi_query((string) file_get_contents($sqlFile))) {
throw new RuntimeException('Schema import failed: ' . $mysqli->error);
}
// Drain all result sets so the connection finishes cleanly.
while ($mysqli->more_results() && $mysqli->next_result()) { /* noop */ }
if ($mysqli->errno) {
throw new RuntimeException('Schema import error: ' . $mysqli->error);
}
$mysqli->close();
}
public function generateKey(int $bytes = 32): string
{
return bin2hex(random_bytes($bytes)); // hex only: safe inside single-quoted PHP
}
public function writeConfig(array $c): void
{
$e = fn($v) => addslashes((string) $v); // escape operator-provided values
$tpl = "<?php\n"
. "// Generated by SeedProject installer. DO NOT COMMIT (gitignored).\n"
. "define('URL', '" . $e($c['url']) . "');\n"
. "define('SITE_BASE', '/api');\n"
. "define('ASSETS', '/api/public/assets/');\n"
. "define('LIBS', 'core/');\n"
. "define('PROJECT_NAME', '" . $e($c['name']) . "');\n"
. "define('PROJECT_LOGO', '/api/public/assets/imgs/SeedProject.png');\n"
. "define('DEBUG', false);\n"
. "define('SECUREAPI', false);\n\n"
. "define('ADMIN_TOKEN', '" . $c['admin_token'] . "');\n"
. "define('ALLOWED_ORIGINS', '" . $e($c['url']) . "');\n\n"
. "define('EMAILUSER', 'ADDEMAILUSER');\n"
. "define('EMAILPASSWORD', 'ADDEMAILPASSWORD');\n"
. "define('EMAILHOST', 'ADDEMAILHOST');\n\n"
. "define('DB_TYPE', 'mysql');\n"
. "define('DB_HOST', '" . $e($c['db']['host']) . "');\n"
. "define('DB_NAME', '" . $e($c['db']['name']) . "');\n"
. "define('DB_USER', '" . $e($c['db']['user']) . "');\n"
. "define('DB_PASS', '" . $e($c['db']['pass']) . "');\n\n"
. "define('HASH_PASSWORD_KEY', '" . $c['hash_password_key'] . "');\n"
. "define('HASH_API_KEY', '" . $c['hash_api_key'] . "');\n"
. "define('TIMESTAMP', date('Y-m-d H:i:s'));\n"
. "date_default_timezone_set('America/New_York');\n\n"
. "\\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS, DB_HOST);\n";
file_put_contents($this->baseDir . '/config.php', $tpl);
}
public function lock(): void
{
file_put_contents($this->lockFile(), date('c') . "\n");
}
/**
* Full install. $cfg = ['url','name','db'=>['host','name','user','pass']].
*/
public function run(array $cfg): void
{
if (!$this->testConnection($cfg['db'])) {
throw new RuntimeException('Database connection failed. Check credentials.');
}
$this->importSchema($cfg['db']);
$cfg['admin_token'] = $this->generateKey(24);
$cfg['hash_password_key'] = $this->generateKey(32);
$cfg['hash_api_key'] = $this->generateKey(32);
$this->writeConfig($cfg);
$this->lock();
// Apply migrations on top of the freshly imported baseline.
$pdo = new PDO(
"mysql:host={$cfg['db']['host']};dbname={$cfg['db']['name']};charset=utf8mb4",
$cfg['db']['user'], $cfg['db']['pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
(new Migrator($pdo, $this->baseDir . '/db/migrations'))->migrate();
}
}