7795a737c5
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
183 lines
7.3 KiB
JavaScript
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);
|
|
});
|