Files
2026-04-21 13:23:06 +02:00

183 lines
7.3 KiB
JavaScript

#!/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);
});