@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.
- package/README.md +111 -1
- package/package.json +4 -1
- package/src/browser/actions.js +58 -0
- package/src/cli.js +327 -24
- package/src/config/keys.js +12 -2
- package/src/daemon/index.js +225 -0
- package/src/daemon/manager.js +148 -0
- package/src/daemon/pid.js +80 -0
- package/src/services/browser.js +659 -0
- package/src/services/poker.js +937 -0
- package/src/services/telegram.js +570 -0
- package/src/web/commands.js +387 -1
- package/src/web/server.js +27 -6
- package/src/web/terminal.js +1 -1
|
@@ -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 };
|