s3d is driven entirely by a CLI and a two-route admin API, which keeps Sia storage out of reach for anyone who does not live in a terminal. Pithos wraps it: install the pinned s3d release (checksum verified), start/stop and logs, indexer login, users and access keys, a bucket and object browser with share links and public-read policies, and the upload pipeline stats. One control server (core/) serves one UI (ui/), and three hosts run it: a web console, an Electron desktop window and a Theseus add-on, so a feature lands everywhere at once. The server binds loopback, rejects foreign Host headers, and signs views in with a one-time secret that becomes a SameSite=Strict cookie. s3d login reads the recovery phrase with term.ReadPassword, so it is driven through a pty, with a console window fallback where the pty module is not shipped.
117 lines
4 KiB
JavaScript
117 lines
4 KiB
JavaScript
// 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, '') });
|
|
});
|
|
});
|
|
}
|
|
}
|