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
132 lines
4.9 KiB
PHP
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();
|
|
}
|
|
}
|