feat(installer): add web-based installer server and UI
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user