// Owns the s3d process: start/stop, a log ring buffer, and one-shot CLI runs // (users/keys/version) against the same config file the daemon uses. import { spawn, execFile } from 'node:child_process'; import { EventEmitter } from 'node:events'; import path from 'node:path'; const LOG_LINES = 2000; const ANSI = /\x1b\[[0-9;]*m/g; export class Daemon extends EventEmitter { constructor({ binary, configFile }) { super(); this.binary = binary; this.configFile = configFile; this.child = null; this.state = 'stopped'; // stopped | starting | running | stopping | crashed this.exitCode = null; this.startedAt = null; this.logs = []; this.seq = 0; } env() { // S3D_CONFIG_FILE pins every invocation to the config this GUI edits; the // cwd is the config's folder because s3d also searches ./s3d.yml first. return { ...process.env, S3D_CONFIG_FILE: this.configFile }; } cwd() { return path.dirname(this.configFile); } status() { return { state: this.state, pid: this.child?.pid ?? null, exitCode: this.exitCode, startedAt: this.startedAt, binary: this.binary, needsLogin: !!this.needsLogin, configFile: this.configFile, }; } setState(state) { this.state = state; this.emit('status', this.status()); } pushLog(stream, chunk) { for (const raw of chunk.toString('utf8').split(/\r?\n/)) { const line = raw.replace(ANSI, ''); if (!line) continue; const entry = { seq: ++this.seq, t: Date.now(), stream, line }; // s3d logs "server started" once every listener is up. if (this.state === 'starting' && /server started/.test(line)) this.setState('running'); if (/No app key found/.test(line)) this.needsLogin = true; this.logs.push(entry); if (this.logs.length > LOG_LINES) this.logs.shift(); this.emit('log', entry); } } logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); } start() { if (!this.binary) throw new Error('s3d binary not found'); if (this.child) return this.status(); this.exitCode = null; this.needsLogin = false; this.setState('starting'); const child = spawn(this.binary, [], { cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); this.child = child; this.startedAt = Date.now(); child.stdout.on('data', (d) => this.pushLog('out', d)); child.stderr.on('data', (d) => this.pushLog('err', d)); child.on('error', (err) => { this.pushLog('err', `failed to start s3d: ${err.message}`); // A spawn failure never emits 'exit'. if (child.pid === undefined) { this.child = null; this.setState('crashed'); } }); child.on('exit', (code, signal) => { this.child = null; this.exitCode = code; this.pushLog('sys', `s3d exited (${signal || `code ${code}`})`); this.setState(this.state === 'stopping' ? 'stopped' : 'crashed'); }); return this.status(); } async stop() { const child = this.child; if (!child) return this.status(); this.setState('stopping'); const exited = new Promise((r) => child.once('exit', r)); child.kill(); // SIGTERM on unix; TerminateProcess on Windows const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 10_000); await exited; clearTimeout(timer); return this.status(); } // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a // non-zero exit so callers can surface s3d's own error text. run(args, { timeout = 30_000 } = {}) { if (!this.binary) return Promise.reject(new Error('s3d binary not found')); return new Promise((resolve, reject) => { execFile(this.binary, args, { cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, }, (err, stdout, stderr) => { if (err && typeof err.code !== 'number') return reject(err); resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); }); }); } }