darkmesh-node 0.1.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/src/index.js ADDED
@@ -0,0 +1,400 @@
1
+ import http from 'http';
2
+ import { createEnvelope, EVENT_TYPES } from '../../../packages/protocol/src/index.js';
3
+ import { createId, createKeyPair, createProviderFingerprintHash, decryptFromCoordinator, nowIso, safeJsonParse, signPayload } from '../../../packages/shared/src/index.js';
4
+ import { createOperatorBindingAuth } from './operator.js';
5
+
6
+ export class MemoryProviderAdapter {
7
+ constructor(models = ['qwen3:latest'], options = {}) {
8
+ this.provider = 'memory-provider';
9
+ this.providerType = 'memory';
10
+ this.models = models;
11
+ this.delayMs = options.delayMs || 0;
12
+ }
13
+
14
+ async listModels() {
15
+ return this.models;
16
+ }
17
+
18
+ async getFingerprint() {
19
+ return {
20
+ providerType: this.providerType,
21
+ providerLabel: this.provider,
22
+ models: this.models,
23
+ transport: 'in-memory'
24
+ };
25
+ }
26
+
27
+ async generate({ prompt, model, signal }) {
28
+ if (this.delayMs > 0) {
29
+ await abortableDelay(this.delayMs, signal);
30
+ }
31
+ if (signal?.aborted) {
32
+ throw new Error('job_cancelled');
33
+ }
34
+ return {
35
+ model,
36
+ output: `[${model}] ${prompt}`
37
+ };
38
+ }
39
+
40
+ async runChallenge({ prompt, expectedModel, signal }) {
41
+ const result = await this.generate({ prompt, model: expectedModel || this.models[0], signal });
42
+ return {
43
+ model: result.model,
44
+ output: result.output
45
+ };
46
+ }
47
+ }
48
+
49
+ export class OpenAICompatibleAdapter {
50
+ constructor({ baseUrl, model = 'qwen3:latest', providerLabel = 'openai-compatible', headers = {} }) {
51
+ this.baseUrl = baseUrl.replace(/\/$/, '');
52
+ this.provider = providerLabel;
53
+ this.providerType = 'openai-compatible';
54
+ this.defaultModel = model;
55
+ this.headers = headers;
56
+ }
57
+
58
+ async listModels() {
59
+ const res = await fetch(`${this.baseUrl}/models`, { headers: this.headers });
60
+ if (!res.ok) throw new Error(`model_list_failed_${res.status}`);
61
+ const data = await res.json();
62
+ return (data.data || []).map(item => item.id);
63
+ }
64
+
65
+ async getFingerprint() {
66
+ const models = await this.listModels();
67
+ return {
68
+ providerType: this.providerType,
69
+ providerLabel: this.provider,
70
+ baseUrl: this.baseUrl,
71
+ defaultModel: this.defaultModel,
72
+ models,
73
+ authMode: Object.keys(this.headers || {}).length ? 'custom-headers' : 'none'
74
+ };
75
+ }
76
+
77
+ async generate({ prompt, model, signal }) {
78
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json', ...this.headers },
81
+ body: JSON.stringify({
82
+ model: model || this.defaultModel,
83
+ messages: [{ role: 'user', content: prompt }],
84
+ stream: false
85
+ }),
86
+ signal
87
+ });
88
+ if (!res.ok) throw new Error(`generation_failed_${res.status}`);
89
+ const data = await res.json();
90
+ return {
91
+ model: data.model || model || this.defaultModel,
92
+ output: data.choices?.[0]?.message?.content || ''
93
+ };
94
+ }
95
+
96
+ async runChallenge({ prompt, expectedModel, signal }) {
97
+ const result = await this.generate({ prompt, model: expectedModel || this.defaultModel, signal });
98
+ return {
99
+ model: result.model,
100
+ output: result.output
101
+ };
102
+ }
103
+ }
104
+
105
+ function abortableDelay(ms, signal) {
106
+ return new Promise((resolve, reject) => {
107
+ if (signal?.aborted) {
108
+ reject(new Error('job_cancelled'));
109
+ return;
110
+ }
111
+ const timer = setTimeout(() => {
112
+ cleanup();
113
+ resolve();
114
+ }, ms);
115
+ const onAbort = () => {
116
+ clearTimeout(timer);
117
+ cleanup();
118
+ reject(new Error('job_cancelled'));
119
+ };
120
+ const cleanup = () => signal?.removeEventListener('abort', onAbort);
121
+ signal?.addEventListener('abort', onAbort, { once: true });
122
+ });
123
+ }
124
+
125
+ export class DarkMeshNode {
126
+ constructor(options = {}) {
127
+ const operatorIdExplicit = Object.prototype.hasOwnProperty.call(options, 'operatorId');
128
+ this.options = {
129
+ coordinatorBaseUrl: 'http://127.0.0.1:8787',
130
+ operatorId: 'operator-local',
131
+ label: createId('node-label'),
132
+ adapter: new MemoryProviderAdapter(),
133
+ requestedModel: null,
134
+ privacyClass: 'class-a-public-safe',
135
+ privacySecret: null,
136
+ heartbeatIntervalMs: 500,
137
+ ...options
138
+ };
139
+ this.keyPair = this.options.keyPair || createKeyPair();
140
+ if (!operatorIdExplicit && this.options.operatorId === 'operator-local') {
141
+ this.options.operatorId = createId('operator');
142
+ }
143
+ this.operatorKeyPair = this.options.operatorKeyPair || this.keyPair;
144
+ this.nodeId = this.options.nodeId || null;
145
+ this.wsPath = this.options.wsPath || null;
146
+ this.request = null;
147
+ this.socket = null;
148
+ this.response = null;
149
+ this.buffer = '';
150
+ this.activeJobs = new Map();
151
+ this.heartbeatTimer = null;
152
+ }
153
+
154
+ async register() {
155
+ if (this.operatorKeyPair?.publicKey) {
156
+ const operatorRegisterRes = await fetch(`${this.options.coordinatorBaseUrl}/v1/operators/register`, {
157
+ method: 'POST',
158
+ headers: { 'content-type': 'application/json' },
159
+ body: JSON.stringify({
160
+ operatorId: this.options.operatorId,
161
+ label: this.options.label,
162
+ publicKey: this.operatorKeyPair.publicKey
163
+ })
164
+ });
165
+ if (!operatorRegisterRes.ok && operatorRegisterRes.status !== 200 && operatorRegisterRes.status !== 201) {
166
+ throw new Error(`operator_register_failed_${operatorRegisterRes.status}`);
167
+ }
168
+ }
169
+
170
+ const operatorAuth = this.operatorKeyPair
171
+ ? await createOperatorBindingAuth({
172
+ coordinatorUrl: this.options.coordinatorBaseUrl,
173
+ operator: {
174
+ operatorId: this.options.operatorId,
175
+ label: this.options.label,
176
+ publicKey: this.operatorKeyPair.publicKey,
177
+ privateKey: this.operatorKeyPair.privateKey
178
+ },
179
+ nodeId: this.nodeId,
180
+ nodePublicKey: this.keyPair.publicKey,
181
+ label: this.options.label
182
+ })
183
+ : null;
184
+ const res = await fetch(`${this.options.coordinatorBaseUrl}/v1/nodes/register`, {
185
+ method: 'POST',
186
+ headers: { 'content-type': 'application/json' },
187
+ body: JSON.stringify({
188
+ nodeId: this.nodeId,
189
+ operatorId: this.options.operatorId,
190
+ label: this.options.label,
191
+ publicKey: this.keyPair.publicKey,
192
+ privacyClass: this.options.privacyClass,
193
+ operatorAuth
194
+ })
195
+ });
196
+ if (!res.ok) throw new Error(`node_register_failed_${res.status}`);
197
+ const data = await res.json();
198
+ this.nodeId = data.nodeId;
199
+ this.wsPath = data.wsPath;
200
+ return data;
201
+ }
202
+
203
+ async connect() {
204
+ if (!this.nodeId || !this.wsPath) await this.register();
205
+ const url = new URL(this.wsPath, this.options.coordinatorBaseUrl);
206
+ await this.openSocket(url);
207
+ const models = await this.options.adapter.listModels();
208
+ const fingerprint = this.options.adapter.getFingerprint
209
+ ? await this.options.adapter.getFingerprint()
210
+ : {
211
+ providerType: this.options.adapter.providerType,
212
+ providerLabel: this.options.adapter.provider,
213
+ models
214
+ };
215
+ const providerFingerprint = {
216
+ ...fingerprint,
217
+ models
218
+ };
219
+ const claim = {
220
+ nodeId: this.nodeId,
221
+ operatorId: this.options.operatorId,
222
+ provider: this.options.adapter.provider,
223
+ providerType: this.options.adapter.providerType,
224
+ privacyClass: this.options.privacyClass,
225
+ models,
226
+ providerFingerprintHash: createProviderFingerprintHash(providerFingerprint),
227
+ timestamp: nowIso()
228
+ };
229
+ const signature = signPayload(this.keyPair.privateKey, JSON.stringify(claim));
230
+ this.send(createEnvelope(EVENT_TYPES.NODE_HELLO, {
231
+ claim,
232
+ provider: this.options.adapter.provider,
233
+ providerType: this.options.adapter.providerType,
234
+ privacyClass: this.options.privacyClass,
235
+ models,
236
+ providerFingerprint,
237
+ signature
238
+ }));
239
+ this.send(createEnvelope(EVENT_TYPES.NODE_MODELS, {
240
+ provider: this.options.adapter.provider,
241
+ providerType: this.options.adapter.providerType,
242
+ privacyClass: this.options.privacyClass,
243
+ models,
244
+ providerFingerprint
245
+ }));
246
+ this.send(createEnvelope(EVENT_TYPES.NODE_PRIVACY_CAPS, {
247
+ privacyClass: this.options.privacyClass,
248
+ protectedTransport: Boolean(this.options.privacySecret)
249
+ }));
250
+ this.startHeartbeatLoop();
251
+ return { nodeId: this.nodeId, models };
252
+ }
253
+
254
+ startHeartbeatLoop() {
255
+ this.stopHeartbeatLoop();
256
+ this.send(createEnvelope(EVENT_TYPES.NODE_HEARTBEAT, { nodeId: this.nodeId }));
257
+ this.heartbeatTimer = setInterval(() => {
258
+ if (!this.request || this.request.destroyed) return;
259
+ this.send(createEnvelope(EVENT_TYPES.NODE_HEARTBEAT, { nodeId: this.nodeId }));
260
+ }, this.options.heartbeatIntervalMs);
261
+ }
262
+
263
+ stopHeartbeatLoop() {
264
+ if (this.heartbeatTimer) {
265
+ clearInterval(this.heartbeatTimer);
266
+ this.heartbeatTimer = null;
267
+ }
268
+ }
269
+
270
+ async openSocket(url) {
271
+ return new Promise((resolve, reject) => {
272
+ const req = http.request({
273
+ hostname: url.hostname,
274
+ port: url.port,
275
+ path: url.pathname + url.search,
276
+ method: 'POST',
277
+ headers: {
278
+ 'content-type': 'application/x-ndjson'
279
+ }
280
+ });
281
+ this.request = req;
282
+ req.on('response', res => {
283
+ this.response = res;
284
+ res.setEncoding('utf8');
285
+ res.on('data', chunk => this.onData(chunk));
286
+ res.on('error', reject);
287
+ this.socket = req.socket;
288
+ resolve();
289
+ });
290
+ req.on('error', reject);
291
+ req.flushHeaders();
292
+ });
293
+ }
294
+
295
+ onData(chunk) {
296
+ this.buffer += chunk;
297
+ let index;
298
+ while ((index = this.buffer.indexOf('\n')) >= 0) {
299
+ const raw = this.buffer.slice(0, index).trim();
300
+ this.buffer = this.buffer.slice(index + 1);
301
+ if (!raw) continue;
302
+ const message = safeJsonParse(raw);
303
+ if (!message) continue;
304
+ this.handleMessage(message).catch(error => {
305
+ if (message?.payload?.jobId) {
306
+ this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: error.message }));
307
+ }
308
+ });
309
+ }
310
+ }
311
+
312
+ async handleMessage(message) {
313
+ if (message.type === EVENT_TYPES.SESSION_READY) return;
314
+
315
+ if (message.type === EVENT_TYPES.NODE_RUNTIME_CHALLENGE) {
316
+ const payload = message.payload || {};
317
+ const challengeId = payload.challengeId;
318
+ const controller = new AbortController();
319
+ try {
320
+ const proof = this.options.adapter.runChallenge
321
+ ? await this.options.adapter.runChallenge({
322
+ prompt: payload.prompt,
323
+ expectedModel: payload.expectedModel,
324
+ signal: controller.signal
325
+ })
326
+ : await this.options.adapter.generate({
327
+ prompt: payload.prompt,
328
+ model: payload.expectedModel,
329
+ signal: controller.signal
330
+ });
331
+
332
+ this.send(createEnvelope(EVENT_TYPES.NODE_RUNTIME_PROOF, {
333
+ challengeId,
334
+ model: proof.model,
335
+ output: proof.output
336
+ }));
337
+ } catch (error) {
338
+ this.send(createEnvelope(EVENT_TYPES.NODE_RUNTIME_PROOF, {
339
+ challengeId,
340
+ error: error.message || 'runtime_challenge_failed'
341
+ }));
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (message.type === EVENT_TYPES.JOB_CANCEL) {
347
+ const active = this.activeJobs.get(message.payload.jobId);
348
+ if (active) active.controller.abort();
349
+ return;
350
+ }
351
+
352
+ if (message.type === EVENT_TYPES.JOB_ASSIGN) {
353
+ this.send(createEnvelope(EVENT_TYPES.JOB_ACCEPT, { jobId: message.payload.jobId }));
354
+ const controller = new AbortController();
355
+ this.activeJobs.set(message.payload.jobId, { controller });
356
+ try {
357
+ let prompt = message.payload.prompt;
358
+ if (message.payload.protectedPayload) {
359
+ if (!this.options.privacySecret) {
360
+ throw new Error('protected_payload_not_supported');
361
+ }
362
+ prompt = decryptFromCoordinator(message.payload.protectedPayload, this.options.privacySecret);
363
+ }
364
+ const result = await this.options.adapter.generate({
365
+ prompt,
366
+ model: message.payload.requestedModel,
367
+ signal: controller.signal
368
+ });
369
+ if (controller.signal.aborted) {
370
+ this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: 'job_cancelled' }));
371
+ return;
372
+ }
373
+ this.send(createEnvelope(EVENT_TYPES.JOB_COMPLETE, { jobId: message.payload.jobId, result }));
374
+ } catch (error) {
375
+ this.send(createEnvelope(EVENT_TYPES.JOB_FAIL, { jobId: message.payload.jobId, error: error.message }));
376
+ } finally {
377
+ this.activeJobs.delete(message.payload.jobId);
378
+ }
379
+ }
380
+ }
381
+
382
+ send(message) {
383
+ if (!this.request || this.request.destroyed) throw new Error('node_connection_not_open');
384
+ this.request.write(JSON.stringify(message) + '\n');
385
+ }
386
+
387
+ async close() {
388
+ this.stopHeartbeatLoop();
389
+ for (const active of this.activeJobs.values()) active.controller.abort();
390
+ this.activeJobs.clear();
391
+ if (this.request && !this.request.destroyed) this.request.destroy();
392
+ if (this.response && !this.response.destroyed) this.response.destroy();
393
+ if (this.socket && !this.socket.destroyed) this.socket.destroy();
394
+ }
395
+ }
396
+
397
+ if (import.meta.url === `file://${process.argv[1]}`) {
398
+ const node = new DarkMeshNode();
399
+ node.connect().then(info => console.log(JSON.stringify({ ok: true, ...info }, null, 2)));
400
+ }
@@ -0,0 +1,113 @@
1
+ import { createKeyPair, nowIso, signPayload } from '../../../packages/shared/src/index.js';
2
+ import { JsonFileStore } from '../../../packages/shared/src/store.js';
3
+ import { ensureDarkMeshHome, getOperatorIdentityPath } from './config.js';
4
+
5
+ function createOperatorStore(customHome) {
6
+ return new JsonFileStore(getOperatorIdentityPath(customHome));
7
+ }
8
+
9
+ export async function loadOperatorIdentity(customHome) {
10
+ const store = createOperatorStore(customHome);
11
+ return store.load(null);
12
+ }
13
+
14
+ export async function ensureOperatorIdentity(customHome, overrides = {}) {
15
+ await ensureDarkMeshHome(customHome);
16
+ const store = createOperatorStore(customHome);
17
+ const existing = await store.load(null);
18
+ const operatorId = overrides.operatorId || existing?.operatorId || 'operator-local';
19
+ const label = overrides.label || existing?.label || operatorId;
20
+
21
+ if (existing?.publicKey && existing?.privateKey && existing.operatorId === operatorId) {
22
+ const next = {
23
+ ...existing,
24
+ label,
25
+ operatorId,
26
+ updatedAt: nowIso()
27
+ };
28
+ await store.save(next);
29
+ return next;
30
+ }
31
+
32
+ const keyPair = createKeyPair();
33
+ const createdAt = nowIso();
34
+ const identity = {
35
+ operatorId,
36
+ label,
37
+ publicKey: keyPair.publicKey,
38
+ privateKey: keyPair.privateKey,
39
+ createdAt,
40
+ updatedAt: createdAt
41
+ };
42
+ await store.save(identity);
43
+ return identity;
44
+ }
45
+
46
+ export function buildOperatorBindingPayload({ operatorId, nodeId, nodePublicKey, label, challengeId, timestamp, nonce }) {
47
+ return JSON.stringify({
48
+ action: 'bind_node',
49
+ operatorId,
50
+ nodeId,
51
+ nodePublicKey,
52
+ label: label || null,
53
+ challengeId,
54
+ timestamp,
55
+ nonce
56
+ });
57
+ }
58
+
59
+ export async function registerOperatorAccount({ coordinatorUrl, operator }) {
60
+ const res = await fetch(`${coordinatorUrl}/v1/operators/register`, {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/json' },
63
+ body: JSON.stringify({
64
+ operatorId: operator.operatorId,
65
+ label: operator.label,
66
+ publicKey: operator.publicKey
67
+ })
68
+ });
69
+ const data = await res.json();
70
+ if (!res.ok) throw new Error(data.error || `operator_register_failed_${res.status}`);
71
+ return data;
72
+ }
73
+
74
+ export async function fetchOperatorAccount({ coordinatorUrl, operatorId }) {
75
+ const res = await fetch(`${coordinatorUrl}/v1/operators/${encodeURIComponent(operatorId)}`);
76
+ if (res.status === 404) return null;
77
+ const data = await res.json();
78
+ if (!res.ok) throw new Error(data.error || `operator_fetch_failed_${res.status}`);
79
+ return data.operator || null;
80
+ }
81
+
82
+ export async function createOperatorBindingAuth({ coordinatorUrl, operator, nodeId, nodePublicKey, label }) {
83
+ const challengeRes = await fetch(`${coordinatorUrl}/v1/operators/bind-challenge`, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({
87
+ operatorId: operator.operatorId,
88
+ nodeId,
89
+ nodePublicKey,
90
+ label: label || null
91
+ })
92
+ });
93
+ const challengeData = await challengeRes.json();
94
+ if (!challengeRes.ok) throw new Error(challengeData.error || `operator_bind_challenge_failed_${challengeRes.status}`);
95
+
96
+ const payload = buildOperatorBindingPayload({
97
+ operatorId: operator.operatorId,
98
+ nodeId,
99
+ nodePublicKey,
100
+ label: label || null,
101
+ challengeId: challengeData.challengeId,
102
+ timestamp: challengeData.timestamp,
103
+ nonce: challengeData.nonce
104
+ });
105
+
106
+ return {
107
+ operatorId: operator.operatorId,
108
+ challengeId: challengeData.challengeId,
109
+ timestamp: challengeData.timestamp,
110
+ nonce: challengeData.nonce,
111
+ signature: signPayload(operator.privateKey, payload)
112
+ };
113
+ }
@@ -0,0 +1,19 @@
1
+ import { MemoryProviderAdapter, OpenAICompatibleAdapter } from './index.js';
2
+
3
+ export function createProviderAdapter(options = {}) {
4
+ const providerType = options.providerType || 'memory';
5
+
6
+ if (providerType === 'memory') {
7
+ return new MemoryProviderAdapter(options.models || ['qwen3:latest']);
8
+ }
9
+
10
+ if (providerType === 'openai-compatible' || providerType === 'ollama') {
11
+ return new OpenAICompatibleAdapter({
12
+ baseUrl: options.baseUrl || 'http://127.0.0.1:11434/v1',
13
+ model: options.model || 'qwen3:latest',
14
+ providerLabel: providerType
15
+ });
16
+ }
17
+
18
+ throw new Error(`unsupported_provider_${providerType}`);
19
+ }
package/src/service.js ADDED
@@ -0,0 +1,148 @@
1
+ import fs from 'fs';
2
+ import fsPromises from 'fs/promises';
3
+ import path from 'path';
4
+ import { ensureDarkMeshHome, getDarkMeshHome } from './config.js';
5
+
6
+ function nowIso() {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ export function getServiceRuntimeDir(customHome) {
11
+ return path.join(getDarkMeshHome(customHome), 'runtime');
12
+ }
13
+
14
+ export function getServiceStatePath(customHome) {
15
+ return path.join(getServiceRuntimeDir(customHome), 'service.json');
16
+ }
17
+
18
+ export function getServiceLogPath(customHome) {
19
+ return path.join(getServiceRuntimeDir(customHome), 'service.log');
20
+ }
21
+
22
+ export function getServiceStopPath(customHome) {
23
+ return path.join(getServiceRuntimeDir(customHome), 'service.stop');
24
+ }
25
+
26
+ export async function ensureServiceRuntime(customHome) {
27
+ await ensureDarkMeshHome(customHome);
28
+ await fsPromises.mkdir(getServiceRuntimeDir(customHome), { recursive: true });
29
+ }
30
+
31
+ export async function loadServiceState(customHome) {
32
+ try {
33
+ const raw = await fsPromises.readFile(getServiceStatePath(customHome), 'utf8');
34
+ return JSON.parse(raw);
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return null;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ export async function saveServiceState(customHome, state) {
42
+ await ensureServiceRuntime(customHome);
43
+ const {
44
+ installed: _installed,
45
+ running: _running,
46
+ pidRunning: _pidRunning,
47
+ stopRequested: _stopRequested,
48
+ ...persistable
49
+ } = state || {};
50
+ const nextState = {
51
+ ...persistable,
52
+ updatedAt: nowIso(),
53
+ logPath: getServiceLogPath(customHome)
54
+ };
55
+ await fsPromises.writeFile(getServiceStatePath(customHome), `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
56
+ return nextState;
57
+ }
58
+
59
+ export async function clearServiceStopRequest(customHome) {
60
+ try {
61
+ await fsPromises.unlink(getServiceStopPath(customHome));
62
+ } catch (error) {
63
+ if (error?.code !== 'ENOENT') throw error;
64
+ }
65
+ }
66
+
67
+ export async function writeServiceStopRequest(customHome) {
68
+ await ensureServiceRuntime(customHome);
69
+ await fsPromises.writeFile(getServiceStopPath(customHome), nowIso(), 'utf8');
70
+ }
71
+
72
+ export async function serviceStopRequested(customHome) {
73
+ try {
74
+ await fsPromises.access(getServiceStopPath(customHome));
75
+ return true;
76
+ } catch (error) {
77
+ if (error?.code === 'ENOENT') return false;
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export function isPidRunning(pid) {
83
+ if (!pid || !Number.isInteger(pid)) return false;
84
+ try {
85
+ process.kill(pid, 0);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ export async function refreshServiceState(customHome) {
93
+ const state = await loadServiceState(customHome);
94
+ if (!state) {
95
+ return {
96
+ installed: false,
97
+ running: false,
98
+ status: 'stopped',
99
+ pid: null,
100
+ logPath: getServiceLogPath(customHome),
101
+ stopRequested: await serviceStopRequested(customHome)
102
+ };
103
+ }
104
+
105
+ const running = ['starting', 'running', 'stopping'].includes(state.status);
106
+
107
+ return {
108
+ ...state,
109
+ installed: true,
110
+ running,
111
+ pidRunning: state.pid ? isPidRunning(state.pid) : false,
112
+ stopRequested: await serviceStopRequested(customHome),
113
+ logPath: state.logPath || getServiceLogPath(customHome)
114
+ };
115
+ }
116
+
117
+ export function openServiceLogFd(customHome) {
118
+ fs.mkdirSync(getServiceRuntimeDir(customHome), { recursive: true });
119
+ return fs.openSync(getServiceLogPath(customHome), 'a');
120
+ }
121
+
122
+ export async function readServiceLog(customHome, options = {}) {
123
+ const logPath = getServiceLogPath(customHome);
124
+ const tailLines = Math.max(1, Number(options.tailLines || 50));
125
+ try {
126
+ const text = await fsPromises.readFile(logPath, 'utf8');
127
+ const lines = text.split(/\r?\n/).filter(Boolean);
128
+ const tail = lines.slice(-tailLines);
129
+ return {
130
+ exists: true,
131
+ logPath,
132
+ lineCount: lines.length,
133
+ tailLines,
134
+ text: tail.join('\n')
135
+ };
136
+ } catch (error) {
137
+ if (error?.code === 'ENOENT') {
138
+ return {
139
+ exists: false,
140
+ logPath,
141
+ lineCount: 0,
142
+ tailLines,
143
+ text: ''
144
+ };
145
+ }
146
+ throw error;
147
+ }
148
+ }