#!/usr/bin/env node /** * Oikos Web Installer — temporary setup server. * Zero npm dependencies. Node.js built-ins only. * Run: node tools/installer/install-server.js */ import http from 'node:http'; import { readFileSync, writeFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..', '..'); const PORT = 8090; const IDLE_TIMEOUT_MS = 30 * 60 * 1000; let idleTimer = null; function resetIdle(server) { clearTimeout(idleTimer); idleTimer = setTimeout(() => { console.log('\nIdle timeout — shutting down installer server.'); server.close(() => process.exit(0)); }, IDLE_TIMEOUT_MS); } function readBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', chunk => { data += chunk; }); req.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch { reject(new Error('Invalid JSON')); } }); req.on('error', reject); }); } function json(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'Cache-Control': 'no-store', }); res.end(payload); } const ENV_CATALOG = [ { key: 'SESSION_SECRET', type: 'auto', label: 'Session Secret', required: true }, { key: 'DB_ENCRYPTION_KEY', type: 'auto', label: 'Database Encryption Key', required: true }, { key: 'OPENWEATHER_API_KEY', type: 'user', label: 'OpenWeather API Key', required: false }, { key: 'OPENWEATHER_CITY', type: 'default', label: 'City', default: 'Berlin' }, { key: 'OPENWEATHER_UNITS', type: 'default', label: 'Units', default: 'metric' }, { key: 'OPENWEATHER_LANG', type: 'default', label: 'Language', default: 'de' }, { key: 'GOOGLE_CLIENT_ID', type: 'user', label: 'Google Client ID', required: false }, { key: 'GOOGLE_CLIENT_SECRET', type: 'user', label: 'Google Client Secret', required: false }, { key: 'GOOGLE_REDIRECT_URI', type: 'user', label: 'Google Redirect URI', required: false }, { key: 'APPLE_USERNAME', type: 'user', label: 'Apple ID (email)', required: false }, { key: 'APPLE_APP_SPECIFIC_PASSWORD', type: 'user', label: 'App-Specific Password', required: false }, { key: 'SYNC_INTERVAL_MINUTES', type: 'default', label: 'Sync Interval (minutes)', default: '15' }, ]; async function route(req, res, server) { resetIdle(server); const url = new URL(req.url, `http://localhost:${PORT}`); if (req.method === 'GET' && url.pathname === '/') { const html = readFileSync(resolve(__dirname, 'install.html')); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': html.length, 'Cache-Control': 'no-store' }); res.end(html); return; } if (req.method === 'GET' && url.pathname === '/api/defaults') { return json(res, 200, { catalog: ENV_CATALOG }); } if (req.method === 'POST' && url.pathname === '/api/generate-secret') { return json(res, 200, { secret: randomBytes(32).toString('hex') }); } if (req.method === 'POST' && url.pathname === '/api/save-env') { try { const body = await readBody(req); const lines = ['# Generated by Oikos Installer']; for (const [key, value] of Object.entries(body.env || {})) { if (value != null && value !== '') lines.push(`${key}=${value}`); } writeFileSync(resolve(PROJECT_ROOT, '.env'), lines.join('\n') + '\n', 'utf8'); return json(res, 200, { ok: true }); } catch (err) { return json(res, 500, { error: err.message }); } } if (req.method === 'POST' && url.pathname === '/api/start') { const child = spawn('docker', ['compose', 'up', '-d'], { cwd: PROJECT_ROOT, stdio: 'pipe' }); child.on('error', err => console.error('docker compose error:', err.message)); return json(res, 200, { ok: true }); } if (req.method === 'GET' && url.pathname === '/api/status') { return new Promise(resolvePromise => { const inspect = spawn('docker', ['inspect', '--format', '{{.State.Health.Status}}', 'oikos'], { stdio: 'pipe' }); let out = ''; inspect.stdout.on('data', d => { out += d.toString().trim(); }); inspect.on('close', code => { if (code === 0 && out === 'healthy') return resolvePromise(json(res, 200, { status: 'running' })); if (code === 0 && (out === 'starting' || out === 'unhealthy')) return resolvePromise(json(res, 200, { status: 'starting' })); const state = spawn('docker', ['inspect', '--format', '{{.State.Status}}', 'oikos'], { stdio: 'pipe' }); let stateOut = ''; state.stdout.on('data', d => { stateOut += d.toString().trim(); }); state.on('close', () => { if (stateOut === 'running') return resolvePromise(json(res, 200, { status: 'starting' })); const logs = spawn('docker', ['compose', 'logs', '--tail', '30'], { cwd: PROJECT_ROOT, stdio: 'pipe' }); let logsOut = ''; logs.stdout.on('data', d => { logsOut += d.toString(); }); logs.stderr.on('data', d => { logsOut += d.toString(); }); logs.on('close', () => resolvePromise(json(res, 200, { status: 'error', logs: logsOut }))); }); }); }); } if (req.method === 'POST' && url.pathname === '/api/create-admin') { try { const body = await readBody(req); const oikosPort = body.port || 3000; const payload = JSON.stringify({ username: body.username, display_name: body.display_name, password: body.password, }); const result = await new Promise((resolve, reject) => { const opts = { hostname: 'localhost', port: oikosPort, path: '/api/v1/auth/setup', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, }; const proxyReq = http.request(opts, proxyRes => { let data = ''; proxyRes.on('data', d => { data += d; }); proxyRes.on('end', () => { try { resolve({ status: proxyRes.statusCode, body: JSON.parse(data) }); } catch { resolve({ status: proxyRes.statusCode, body: { error: 'Invalid response from Oikos' } }); } }); }); proxyReq.on('error', reject); proxyReq.write(payload); proxyReq.end(); }); json(res, result.status, result.body); if (result.status === 201 || result.status === 403) { setTimeout(() => { console.log('\nSetup complete — shutting down installer server.'); server.close(() => process.exit(0)); }, 2000); } } catch (err) { json(res, 500, { error: err.message }); } return; } json(res, 404, { error: 'Not found' }); } const server = http.createServer((req, res) => { route(req, res, server).catch(err => json(res, 500, { error: err.message })); }); server.listen(PORT, '127.0.0.1', () => { console.log(`\n Oikos Web Installer`); console.log(` Open: http://localhost:${PORT}\n`); resetIdle(server); });