diff --git a/tools/installer/README.md b/tools/installer/README.md new file mode 100644 index 0000000..667c653 --- /dev/null +++ b/tools/installer/README.md @@ -0,0 +1,30 @@ +# Oikos Web Installer + +A browser-based setup wizard for Oikos. Run it once to configure your `.env`, +start Docker, and create your admin account. + +## Usage + +From the repository root: + +```bash +node tools/installer/install-server.js +``` + +Then open **http://localhost:8090** in your browser. + +The server shuts down automatically after setup completes (or after 30 minutes of inactivity). + +## Requirements + +- Node.js 18+ +- Docker with Compose v2 +- The repository cloned locally + +## What it does + +1. Guides you through all configuration options +2. Writes `.env` to the project root +3. Starts the Docker container (`docker compose up -d`) +4. Polls the health endpoint until the container is ready +5. Creates your first admin account via `POST /api/v1/auth/setup` diff --git a/tools/installer/install-server.js b/tools/installer/install-server.js new file mode 100644 index 0000000..72387fb --- /dev/null +++ b/tools/installer/install-server.js @@ -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); +}); diff --git a/tools/installer/install.html b/tools/installer/install.html new file mode 100644 index 0000000..6b4903a --- /dev/null +++ b/tools/installer/install.html @@ -0,0 +1,616 @@ + + + + + + Oikos Setup + + + +
+
+ + +
+
+
Step 1 of 7
+

Basic Configuration

+

How will you access Oikos?

+
+
+
+ + +
The address you'll use to open Oikos in your browser.
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+
Step 2 of 7
+

Security Keys

+

These protect your session and encrypt the database. Store your .env file safely — losing these keys means losing access to your data.

+
+
+
+ +
+ + + +
+
Signs browser session cookies.
+
+
+ +
+ + + +
+
Encrypts the SQLite database at rest.
+
+
+
+
+ + +
+
+ + +
+
+
Step 3 of 7
+

Weather Widget

+

Show live weather on your dashboard. Requires a free OpenWeatherMap API key.

+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
Step 4 of 7
+

Calendar Sync

+

Connect Oikos to Google Calendar or Apple iCloud. Both optional — expand to configure.

+
+
+
+
+ Google Calendar + Optional +
+
+

+ Create OAuth credentials at console.cloud.google.com. + Redirect URI: +

+
+ + +
+
+ + +
+
+
+
+
+ Apple iCloud CalDAV + Optional +
+
+

+ Create an app-specific password at appleid.apple.com. +

+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
Step 5 of 7
+

Review

+

Check your settings before writing .env and starting the container.

+
+
+
+
Host
+
Port
+
Timezone
+
SESSION_SECRET
••••••••
+
DB_ENCRYPT_KEY
••••••••
+
Weather
+
Google Calendar
+
Apple CalDAV
+
+
+
+
+ + +
+
+ + +
+
+
Step 6 of 7
+

Starting Oikos

+

Pulling the image and starting the Docker container.

+
+
+
+
+
+
+
Starting container…
+
+
+
+
+
+
+ +
+ + +
+
+
Step 7 of 7
+

Create Admin Account

+

Set up your first login. This account has full access to Oikos.

+
+
+
+ + +
3–64 characters: letters, numbers, . - _
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
🎉
+

Oikos is ready!

+

Your family planner is running and your admin account is set up.

+ Open Oikos → +
+
+
+ +
+ + + +