@darksol/terminal 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import { createServer } from 'http';
3
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { fileURLToPath } from 'url';
7
+ import { writePid, readPid, removePid, getDaemonStatus, DARKSOL_DIR } from './pid.js';
8
+ import { getAllServiceStatus, stopAllServices, listServices } from './manager.js';
9
+ import { theme } from '../ui/theme.js';
10
+ import { spinner, kvDisplay, success, error, warn, info } from '../ui/components.js';
11
+ import { showSection } from '../ui/banner.js';
12
+ import { createRequire } from 'module';
13
+ import fetch from 'node-fetch';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { version: PKG_VERSION } = require('../../package.json');
17
+
18
+ const LOGS_DIR = join(DARKSOL_DIR, 'logs');
19
+ const LOG_FILE = join(LOGS_DIR, 'daemon.log');
20
+ const DEFAULT_PORT = 18792;
21
+
22
+ function ensureLogsDir() {
23
+ if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
24
+ }
25
+
26
+ function daemonLog(msg) {
27
+ ensureLogsDir();
28
+ const ts = new Date().toISOString();
29
+ appendFileSync(LOG_FILE, `[${ts}] ${msg}\n`, 'utf8');
30
+ }
31
+
32
+ /**
33
+ * Start the daemon as a detached background process.
34
+ * @param {object} [opts]
35
+ * @param {number} [opts.port]
36
+ */
37
+ export async function daemonStart(opts = {}) {
38
+ const status = getDaemonStatus();
39
+ if (status.running) {
40
+ warn(`Daemon already running (PID ${status.pid})`);
41
+ return;
42
+ }
43
+
44
+ const port = parseInt(opts.port, 10) || DEFAULT_PORT;
45
+ const entryScript = fileURLToPath(new URL('./index.js', import.meta.url));
46
+
47
+ const child = spawn(process.execPath, [entryScript, '--daemon-run', String(port)], {
48
+ detached: true,
49
+ stdio: 'ignore',
50
+ env: { ...process.env, DARKSOL_DAEMON: '1' },
51
+ });
52
+
53
+ child.unref();
54
+
55
+ if (child.pid) {
56
+ writePid(child.pid);
57
+ success(`Daemon started (PID ${child.pid}, port ${port})`);
58
+ info(`Logs: ${LOG_FILE}`);
59
+ info(`Health: http://localhost:${port}/health`);
60
+ } else {
61
+ error('Failed to start daemon process');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Stop the running daemon.
67
+ */
68
+ export async function daemonStop() {
69
+ const status = getDaemonStatus();
70
+ if (!status.running) {
71
+ warn('Daemon is not running');
72
+ return;
73
+ }
74
+
75
+ const spin = spinner('Stopping daemon...').start();
76
+
77
+ try {
78
+ if (process.platform === 'win32') {
79
+ execSync(`taskkill /PID ${status.pid} /F /T`, { stdio: 'ignore' });
80
+ } else {
81
+ process.kill(status.pid, 'SIGTERM');
82
+ }
83
+ removePid();
84
+ spin.succeed(`Daemon stopped (PID ${status.pid})`);
85
+ } catch (err) {
86
+ removePid();
87
+ spin.fail('Daemon stop had issues');
88
+ warn(err.message);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Show daemon status — process check + health endpoint query.
94
+ * @param {object} [opts]
95
+ * @param {number} [opts.port]
96
+ */
97
+ export async function daemonStatus(opts = {}) {
98
+ const port = parseInt(opts.port, 10) || DEFAULT_PORT;
99
+ const status = getDaemonStatus();
100
+
101
+ showSection('DAEMON STATUS');
102
+
103
+ if (!status.running) {
104
+ kvDisplay([
105
+ ['Process', theme.dim('not running')],
106
+ ['PID File', theme.dim('none')],
107
+ ]);
108
+ console.log('');
109
+ info('Start with: darksol daemon start');
110
+ console.log('');
111
+ return;
112
+ }
113
+
114
+ // Process is alive — try health endpoint
115
+ let health = null;
116
+ try {
117
+ const res = await fetch(`http://localhost:${port}/health`, { timeout: 3000 });
118
+ if (res.ok) health = await res.json();
119
+ } catch {
120
+ // health endpoint unreachable
121
+ }
122
+
123
+ const pairs = [
124
+ ['Process', theme.success(`running (PID ${status.pid})`)],
125
+ ['Port', String(port)],
126
+ ];
127
+
128
+ if (health) {
129
+ pairs.push(['Uptime', `${Math.round(health.uptime)}s`]);
130
+ pairs.push(['Version', health.version || PKG_VERSION]);
131
+ pairs.push(['Services', health.services?.length ? health.services.join(', ') : theme.dim('none')]);
132
+ } else {
133
+ pairs.push(['Health', theme.warning('unreachable')]);
134
+ }
135
+
136
+ pairs.push(['Log', LOG_FILE]);
137
+ kvDisplay(pairs);
138
+ console.log('');
139
+ }
140
+
141
+ /**
142
+ * Restart the daemon (stop + start).
143
+ * @param {object} [opts]
144
+ */
145
+ export async function daemonRestart(opts = {}) {
146
+ const status = getDaemonStatus();
147
+ if (status.running) {
148
+ await daemonStop();
149
+ // Brief pause to let the OS release the port
150
+ await new Promise((r) => setTimeout(r, 500));
151
+ }
152
+ await daemonStart(opts);
153
+ }
154
+
155
+ // ─────────────────────────────────────
156
+ // DAEMON PROCESS ENTRY (run by child)
157
+ // ─────────────────────────────────────
158
+
159
+ /**
160
+ * Run the actual daemon process (HTTP health server + service management).
161
+ * Called when the script is executed directly with --daemon-run.
162
+ * @param {number} port
163
+ */
164
+ export async function runDaemonProcess(port) {
165
+ const startTime = Date.now();
166
+
167
+ daemonLog(`Daemon starting on port ${port} (PID ${process.pid})`);
168
+
169
+ const server = createServer((req, res) => {
170
+ if (req.url === '/health' && req.method === 'GET') {
171
+ const uptimeSec = (Date.now() - startTime) / 1000;
172
+ const services = getAllServiceStatus();
173
+ const body = JSON.stringify({
174
+ status: 'ok',
175
+ pid: process.pid,
176
+ uptime: uptimeSec,
177
+ version: PKG_VERSION,
178
+ services: services.map((s) => s.name),
179
+ serviceDetails: services,
180
+ });
181
+ res.writeHead(200, { 'Content-Type': 'application/json' });
182
+ res.end(body);
183
+ return;
184
+ }
185
+
186
+ res.writeHead(404, { 'Content-Type': 'application/json' });
187
+ res.end(JSON.stringify({ error: 'not found' }));
188
+ });
189
+
190
+ server.listen(port, '127.0.0.1', () => {
191
+ daemonLog(`Health server listening on http://127.0.0.1:${port}/health`);
192
+ });
193
+
194
+ // Graceful shutdown
195
+ const shutdown = async () => {
196
+ daemonLog('Shutting down daemon...');
197
+ await stopAllServices();
198
+ server.close();
199
+ removePid();
200
+ daemonLog('Daemon stopped');
201
+ process.exit(0);
202
+ };
203
+
204
+ process.on('SIGTERM', shutdown);
205
+ process.on('SIGINT', shutdown);
206
+ process.on('uncaughtException', (err) => {
207
+ daemonLog(`Uncaught exception: ${err.message}`);
208
+ });
209
+ process.on('unhandledRejection', (err) => {
210
+ daemonLog(`Unhandled rejection: ${err}`);
211
+ });
212
+ }
213
+
214
+ // ─────────────────────────────────────
215
+ // SELF-EXECUTION: when run directly as daemon child process
216
+ // ─────────────────────────────────────
217
+
218
+ const args = process.argv.slice(2);
219
+ if (args[0] === '--daemon-run') {
220
+ const port = parseInt(args[1], 10) || DEFAULT_PORT;
221
+ writePid(process.pid);
222
+ runDaemonProcess(port);
223
+ }
224
+
225
+ export { DEFAULT_PORT, LOG_FILE };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Service registry and lifecycle manager for the daemon.
3
+ * Services register with the manager and can be started/stopped/queried.
4
+ */
5
+
6
+ const services = new Map();
7
+
8
+ /**
9
+ * Register a service with the daemon manager.
10
+ * @param {string} name - Unique service name (e.g. 'telegram', 'web-shell')
11
+ * @param {{start: Function, stop: Function, status: Function}} handler
12
+ */
13
+ export function registerService(name, handler) {
14
+ if (services.has(name)) {
15
+ throw new Error(`Service "${name}" is already registered`);
16
+ }
17
+ services.set(name, {
18
+ name,
19
+ handler,
20
+ state: 'stopped',
21
+ startedAt: null,
22
+ error: null,
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Unregister a service.
28
+ * @param {string} name
29
+ */
30
+ export function unregisterService(name) {
31
+ services.delete(name);
32
+ }
33
+
34
+ /**
35
+ * Start a registered service.
36
+ * @param {string} name
37
+ * @param {object} [opts]
38
+ * @returns {Promise<void>}
39
+ */
40
+ export async function startService(name, opts = {}) {
41
+ const svc = services.get(name);
42
+ if (!svc) throw new Error(`Unknown service: ${name}`);
43
+ if (svc.state === 'running') return;
44
+
45
+ try {
46
+ svc.state = 'starting';
47
+ svc.error = null;
48
+ await svc.handler.start(opts);
49
+ svc.state = 'running';
50
+ svc.startedAt = new Date().toISOString();
51
+ } catch (err) {
52
+ svc.state = 'error';
53
+ svc.error = err.message;
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Stop a registered service.
60
+ * @param {string} name
61
+ * @returns {Promise<void>}
62
+ */
63
+ export async function stopService(name) {
64
+ const svc = services.get(name);
65
+ if (!svc) return;
66
+ if (svc.state !== 'running' && svc.state !== 'starting') return;
67
+
68
+ try {
69
+ await svc.handler.stop();
70
+ } catch {
71
+ // best-effort stop
72
+ }
73
+ svc.state = 'stopped';
74
+ svc.startedAt = null;
75
+ }
76
+
77
+ /**
78
+ * Stop all running services.
79
+ * @returns {Promise<void>}
80
+ */
81
+ export async function stopAllServices() {
82
+ for (const [name] of services) {
83
+ await stopService(name);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get status of a specific service.
89
+ * @param {string} name
90
+ * @returns {object|null}
91
+ */
92
+ export function getServiceStatus(name) {
93
+ const svc = services.get(name);
94
+ if (!svc) return null;
95
+
96
+ let extra = {};
97
+ if (svc.handler.status) {
98
+ try {
99
+ extra = svc.handler.status() || {};
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+
105
+ return {
106
+ name: svc.name,
107
+ state: svc.state,
108
+ startedAt: svc.startedAt,
109
+ error: svc.error,
110
+ ...extra,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Get health summary for all registered services.
116
+ * @returns {Array<object>}
117
+ */
118
+ export function getAllServiceStatus() {
119
+ const result = [];
120
+ for (const [name] of services) {
121
+ result.push(getServiceStatus(name));
122
+ }
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * List names of all registered services.
128
+ * @returns {string[]}
129
+ */
130
+ export function listServices() {
131
+ return [...services.keys()];
132
+ }
133
+
134
+ /**
135
+ * Check if a service is registered.
136
+ * @param {string} name
137
+ * @returns {boolean}
138
+ */
139
+ export function hasService(name) {
140
+ return services.has(name);
141
+ }
142
+
143
+ /**
144
+ * Reset the manager (for testing).
145
+ */
146
+ export function resetManager() {
147
+ services.clear();
148
+ }
@@ -0,0 +1,80 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const DARKSOL_DIR = join(homedir(), '.darksol');
6
+ const PID_FILE = join(DARKSOL_DIR, 'daemon.pid');
7
+
8
+ function ensureDir() {
9
+ if (!existsSync(DARKSOL_DIR)) mkdirSync(DARKSOL_DIR, { recursive: true });
10
+ }
11
+
12
+ /**
13
+ * Write the current process PID (or a custom one) to the PID file.
14
+ * @param {number} [pid]
15
+ */
16
+ export function writePid(pid) {
17
+ ensureDir();
18
+ writeFileSync(PID_FILE, String(pid || process.pid), 'utf8');
19
+ }
20
+
21
+ /**
22
+ * Read the stored PID. Returns null if no PID file exists.
23
+ * @returns {number|null}
24
+ */
25
+ export function readPid() {
26
+ if (!existsSync(PID_FILE)) return null;
27
+ try {
28
+ const raw = readFileSync(PID_FILE, 'utf8').trim();
29
+ const pid = parseInt(raw, 10);
30
+ return Number.isFinite(pid) ? pid : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Remove the PID file.
38
+ */
39
+ export function removePid() {
40
+ try {
41
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
42
+ } catch {
43
+ // ignore - file may already be gone
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Check if a process with the given PID is alive.
49
+ * @param {number} pid
50
+ * @returns {boolean}
51
+ */
52
+ export function isProcessAlive(pid) {
53
+ if (!pid) return false;
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Check if the daemon is currently running.
64
+ * Cleans up stale PID files.
65
+ * @returns {{running: boolean, pid: number|null}}
66
+ */
67
+ export function getDaemonStatus() {
68
+ const pid = readPid();
69
+ if (!pid) return { running: false, pid: null };
70
+
71
+ if (isProcessAlive(pid)) {
72
+ return { running: true, pid };
73
+ }
74
+
75
+ // Stale PID file — process is gone
76
+ removePid();
77
+ return { running: false, pid: null };
78
+ }
79
+
80
+ export { PID_FILE, DARKSOL_DIR };