@yeaft/webchat-agent 0.0.2

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/service.js ADDED
@@ -0,0 +1,587 @@
1
+ /**
2
+ * Cross-platform service management for yeaft-agent
3
+ * Supports: Linux (systemd), macOS (launchd), Windows (Task Scheduler)
4
+ */
5
+ import { execSync, spawn } from 'child_process';
6
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { platform, homedir } from 'os';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const SERVICE_NAME = 'yeaft-agent';
13
+
14
+ /**
15
+ * Load .env file from agent directory (or cwd) into process.env
16
+ * Only sets vars that are not already set (won't override existing env)
17
+ */
18
+ function loadDotenv() {
19
+ // Try agent source directory first, then cwd
20
+ const candidates = [join(__dirname, '.env'), join(process.cwd(), '.env')];
21
+ for (const envPath of candidates) {
22
+ if (!existsSync(envPath)) continue;
23
+ try {
24
+ const content = readFileSync(envPath, 'utf-8');
25
+ for (const line of content.split('\n')) {
26
+ const match = line.match(/^\s*([^#][^=]*)\s*=\s*(.*)$/);
27
+ if (match) {
28
+ const key = match[1].trim();
29
+ let value = match[2].trim();
30
+ value = value.replace(/^["']|["']$/g, '');
31
+ // Don't override existing env vars
32
+ if (!process.env[key]) {
33
+ process.env[key] = value;
34
+ }
35
+ }
36
+ }
37
+ return; // loaded successfully, stop
38
+ } catch {
39
+ // continue to next candidate
40
+ }
41
+ }
42
+ }
43
+
44
+ // Standard config/log directory per platform
45
+ export function getConfigDir() {
46
+ if (platform() === 'win32') {
47
+ return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), SERVICE_NAME);
48
+ }
49
+ return join(homedir(), '.config', SERVICE_NAME);
50
+ }
51
+
52
+ export function getLogDir() {
53
+ return join(getConfigDir(), 'logs');
54
+ }
55
+
56
+ export function getConfigPath() {
57
+ return join(getConfigDir(), 'config.json');
58
+ }
59
+
60
+ /** Save agent configuration to standard location */
61
+ export function saveServiceConfig(config) {
62
+ const dir = getConfigDir();
63
+ mkdirSync(dir, { recursive: true });
64
+ mkdirSync(getLogDir(), { recursive: true });
65
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
66
+ }
67
+
68
+ /** Load agent configuration from standard location */
69
+ export function loadServiceConfig() {
70
+ const configPath = getConfigPath();
71
+ if (!existsSync(configPath)) return null;
72
+ try {
73
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Resolve the full path to the node binary */
80
+ function getNodePath() {
81
+ return process.execPath;
82
+ }
83
+
84
+ /** Resolve the full path to cli.js */
85
+ function getCliPath() {
86
+ return join(__dirname, 'cli.js');
87
+ }
88
+
89
+ /**
90
+ * Parse --server/--name/--secret/--work-dir from args, merge with existing config
91
+ */
92
+ export function parseServiceArgs(args) {
93
+ // Load .env if available (for dev / source-based usage)
94
+ loadDotenv();
95
+
96
+ const existing = loadServiceConfig() || {};
97
+ const config = {
98
+ serverUrl: existing.serverUrl || '',
99
+ agentName: existing.agentName || '',
100
+ agentSecret: existing.agentSecret || '',
101
+ workDir: existing.workDir || '',
102
+ };
103
+
104
+ // Environment variables override saved config
105
+ if (process.env.SERVER_URL) config.serverUrl = process.env.SERVER_URL;
106
+ if (process.env.AGENT_NAME) config.agentName = process.env.AGENT_NAME;
107
+ if (process.env.AGENT_SECRET) config.agentSecret = process.env.AGENT_SECRET;
108
+ if (process.env.WORK_DIR) config.workDir = process.env.WORK_DIR;
109
+
110
+ // CLI args override everything
111
+ for (let i = 0; i < args.length; i++) {
112
+ const arg = args[i];
113
+ const next = args[i + 1];
114
+ switch (arg) {
115
+ case '--server': if (next) { config.serverUrl = next; i++; } break;
116
+ case '--name': if (next) { config.agentName = next; i++; } break;
117
+ case '--secret': if (next) { config.agentSecret = next; i++; } break;
118
+ case '--work-dir': if (next) { config.workDir = next; i++; } break;
119
+ }
120
+ }
121
+
122
+ return config;
123
+ }
124
+
125
+ function validateConfig(config) {
126
+ if (!config.serverUrl) {
127
+ console.error('Error: --server <url> is required');
128
+ process.exit(1);
129
+ }
130
+ if (!config.agentSecret) {
131
+ console.error('Error: --secret <secret> is required');
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ // ─── Linux (systemd) ─────────────────────────────────────────
137
+
138
+ function getSystemdServicePath() {
139
+ const dir = join(homedir(), '.config', 'systemd', 'user');
140
+ mkdirSync(dir, { recursive: true });
141
+ return join(dir, `${SERVICE_NAME}.service`);
142
+ }
143
+
144
+ function generateSystemdUnit(config) {
145
+ const nodePath = getNodePath();
146
+ const cliPath = getCliPath();
147
+ const envLines = [];
148
+ if (config.serverUrl) envLines.push(`Environment=SERVER_URL=${config.serverUrl}`);
149
+ if (config.agentName) envLines.push(`Environment=AGENT_NAME=${config.agentName}`);
150
+ if (config.agentSecret) envLines.push(`Environment=AGENT_SECRET=${config.agentSecret}`);
151
+ if (config.workDir) envLines.push(`Environment=WORK_DIR=${config.workDir}`);
152
+
153
+ // Include node's bin dir in PATH for claude CLI access
154
+ const nodeBinDir = dirname(nodePath);
155
+
156
+ return `[Unit]
157
+ Description=Yeaft WebChat Agent
158
+ After=network-online.target
159
+ Wants=network-online.target
160
+
161
+ [Service]
162
+ Type=simple
163
+ ExecStart=${nodePath} ${cliPath}
164
+ WorkingDirectory=${config.workDir || homedir()}
165
+ Restart=on-failure
166
+ RestartSec=10
167
+ ${envLines.join('\n')}
168
+ Environment=PATH=${nodeBinDir}:${homedir()}/.local/bin:${homedir()}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
169
+
170
+ StandardOutput=append:${getLogDir()}/out.log
171
+ StandardError=append:${getLogDir()}/error.log
172
+
173
+ [Install]
174
+ WantedBy=default.target
175
+ `;
176
+ }
177
+
178
+ function linuxInstall(config) {
179
+ const servicePath = getSystemdServicePath();
180
+ writeFileSync(servicePath, generateSystemdUnit(config));
181
+ execSync('systemctl --user daemon-reload');
182
+ execSync(`systemctl --user enable ${SERVICE_NAME}`);
183
+ execSync(`systemctl --user start ${SERVICE_NAME}`);
184
+ console.log(`Service installed and started.`);
185
+ console.log(`\nManage with:`);
186
+ console.log(` yeaft-agent status`);
187
+ console.log(` yeaft-agent logs`);
188
+ console.log(` yeaft-agent restart`);
189
+ console.log(` yeaft-agent uninstall`);
190
+ console.log(`\nTo run when not logged in:`);
191
+ console.log(` sudo loginctl enable-linger $(whoami)`);
192
+ }
193
+
194
+ function linuxUninstall() {
195
+ try { execSync(`systemctl --user stop ${SERVICE_NAME} 2>/dev/null`); } catch {}
196
+ try { execSync(`systemctl --user disable ${SERVICE_NAME} 2>/dev/null`); } catch {}
197
+ const servicePath = getSystemdServicePath();
198
+ if (existsSync(servicePath)) unlinkSync(servicePath);
199
+ try { execSync('systemctl --user daemon-reload'); } catch {}
200
+ console.log('Service uninstalled.');
201
+ }
202
+
203
+ function linuxStart() {
204
+ execSync(`systemctl --user start ${SERVICE_NAME}`, { stdio: 'inherit' });
205
+ console.log('Service started.');
206
+ }
207
+
208
+ function linuxStop() {
209
+ execSync(`systemctl --user stop ${SERVICE_NAME}`, { stdio: 'inherit' });
210
+ console.log('Service stopped.');
211
+ }
212
+
213
+ function linuxRestart() {
214
+ execSync(`systemctl --user restart ${SERVICE_NAME}`, { stdio: 'inherit' });
215
+ console.log('Service restarted.');
216
+ }
217
+
218
+ function linuxStatus() {
219
+ try {
220
+ execSync(`systemctl --user status ${SERVICE_NAME}`, { stdio: 'inherit' });
221
+ } catch {
222
+ // systemctl status returns non-zero when service is stopped
223
+ }
224
+ }
225
+
226
+ function linuxLogs() {
227
+ try {
228
+ execSync(`journalctl --user -u ${SERVICE_NAME} -f --no-pager -n 100`, { stdio: 'inherit' });
229
+ } catch {
230
+ // Fallback to log files
231
+ const logFile = join(getLogDir(), 'out.log');
232
+ if (existsSync(logFile)) {
233
+ execSync(`tail -f -n 100 ${logFile}`, { stdio: 'inherit' });
234
+ } else {
235
+ console.log('No logs found.');
236
+ }
237
+ }
238
+ }
239
+
240
+ // ─── macOS (launchd) ─────────────────────────────────────────
241
+
242
+ function getLaunchdPlistPath() {
243
+ const dir = join(homedir(), 'Library', 'LaunchAgents');
244
+ mkdirSync(dir, { recursive: true });
245
+ return join(dir, 'com.yeaft.agent.plist');
246
+ }
247
+
248
+ function generateLaunchdPlist(config) {
249
+ const nodePath = getNodePath();
250
+ const cliPath = getCliPath();
251
+ const logDir = getLogDir();
252
+
253
+ const envDict = [];
254
+ if (config.serverUrl) envDict.push(` <key>SERVER_URL</key>\n <string>${config.serverUrl}</string>`);
255
+ if (config.agentName) envDict.push(` <key>AGENT_NAME</key>\n <string>${config.agentName}</string>`);
256
+ if (config.agentSecret) envDict.push(` <key>AGENT_SECRET</key>\n <string>${config.agentSecret}</string>`);
257
+ if (config.workDir) envDict.push(` <key>WORK_DIR</key>\n <string>${config.workDir}</string>`);
258
+
259
+ return `<?xml version="1.0" encoding="UTF-8"?>
260
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
261
+ <plist version="1.0">
262
+ <dict>
263
+ <key>Label</key>
264
+ <string>com.yeaft.agent</string>
265
+ <key>ProgramArguments</key>
266
+ <array>
267
+ <string>${nodePath}</string>
268
+ <string>${cliPath}</string>
269
+ </array>
270
+ <key>WorkingDirectory</key>
271
+ <string>${config.workDir || homedir()}</string>
272
+ <key>EnvironmentVariables</key>
273
+ <dict>
274
+ ${envDict.join('\n')}
275
+ </dict>
276
+ <key>RunAtLoad</key>
277
+ <true/>
278
+ <key>KeepAlive</key>
279
+ <dict>
280
+ <key>SuccessfulExit</key>
281
+ <false/>
282
+ </dict>
283
+ <key>ThrottleInterval</key>
284
+ <integer>10</integer>
285
+ <key>StandardOutPath</key>
286
+ <string>${logDir}/out.log</string>
287
+ <key>StandardErrorPath</key>
288
+ <string>${logDir}/error.log</string>
289
+ </dict>
290
+ </plist>
291
+ `;
292
+ }
293
+
294
+ function macInstall(config) {
295
+ const plistPath = getLaunchdPlistPath();
296
+ // Unload first if exists
297
+ if (existsSync(plistPath)) {
298
+ try { execSync(`launchctl unload ${plistPath} 2>/dev/null`); } catch {}
299
+ }
300
+ writeFileSync(plistPath, generateLaunchdPlist(config));
301
+ execSync(`launchctl load ${plistPath}`);
302
+ console.log('Service installed and started.');
303
+ console.log(`\nManage with:`);
304
+ console.log(` yeaft-agent status`);
305
+ console.log(` yeaft-agent logs`);
306
+ console.log(` yeaft-agent restart`);
307
+ console.log(` yeaft-agent uninstall`);
308
+ }
309
+
310
+ function macUninstall() {
311
+ const plistPath = getLaunchdPlistPath();
312
+ if (existsSync(plistPath)) {
313
+ try { execSync(`launchctl unload ${plistPath}`); } catch {}
314
+ unlinkSync(plistPath);
315
+ }
316
+ console.log('Service uninstalled.');
317
+ }
318
+
319
+ function macStart() {
320
+ const plistPath = getLaunchdPlistPath();
321
+ if (!existsSync(plistPath)) {
322
+ console.error('Service not installed. Run "yeaft-agent install" first.');
323
+ process.exit(1);
324
+ }
325
+ execSync(`launchctl load ${plistPath}`);
326
+ console.log('Service started.');
327
+ }
328
+
329
+ function macStop() {
330
+ const plistPath = getLaunchdPlistPath();
331
+ if (existsSync(plistPath)) {
332
+ execSync(`launchctl unload ${plistPath}`);
333
+ }
334
+ console.log('Service stopped.');
335
+ }
336
+
337
+ function macRestart() {
338
+ macStop();
339
+ macStart();
340
+ }
341
+
342
+ function macStatus() {
343
+ try {
344
+ const output = execSync(`launchctl list | grep com.yeaft.agent`, { encoding: 'utf-8' });
345
+ if (output.trim()) {
346
+ const parts = output.trim().split(/\s+/);
347
+ const pid = parts[0];
348
+ const exitCode = parts[1];
349
+ if (pid !== '-') {
350
+ console.log(`Service is running (PID: ${pid})`);
351
+ } else {
352
+ console.log(`Service is stopped (last exit code: ${exitCode})`);
353
+ }
354
+ } else {
355
+ console.log('Service is not installed.');
356
+ }
357
+ } catch {
358
+ console.log('Service is not installed.');
359
+ }
360
+ }
361
+
362
+ function macLogs() {
363
+ const logFile = join(getLogDir(), 'out.log');
364
+ if (existsSync(logFile)) {
365
+ execSync(`tail -f -n 100 ${logFile}`, { stdio: 'inherit' });
366
+ } else {
367
+ console.log('No logs found.');
368
+ }
369
+ }
370
+
371
+ // ─── Windows (Task Scheduler) ────────────────────────────────
372
+
373
+ const WIN_TASK_NAME = 'YeaftAgent';
374
+
375
+ function getWinWrapperPath() {
376
+ return join(getConfigDir(), 'run.vbs');
377
+ }
378
+
379
+ function getWinBatPath() {
380
+ return join(getConfigDir(), 'run.bat');
381
+ }
382
+
383
+ function winInstall(config) {
384
+ const nodePath = getNodePath();
385
+ const cliPath = getCliPath();
386
+ const logDir = getLogDir();
387
+
388
+ // Build environment variable settings for the batch file
389
+ const envLines = [];
390
+ if (config.serverUrl) envLines.push(`set "SERVER_URL=${config.serverUrl}"`);
391
+ if (config.agentName) envLines.push(`set "AGENT_NAME=${config.agentName}"`);
392
+ if (config.agentSecret) envLines.push(`set "AGENT_SECRET=${config.agentSecret}"`);
393
+ if (config.workDir) envLines.push(`set "WORK_DIR=${config.workDir}"`);
394
+
395
+ // Create a batch file that sets env vars and starts node
396
+ const batContent = `@echo off\r\n${envLines.join('\r\n')}\r\n"${nodePath}" "${cliPath}"\r\n`;
397
+ const batPath = getWinBatPath();
398
+ writeFileSync(batPath, batContent);
399
+
400
+ // Create VBS wrapper to run hidden (no console window)
401
+ const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\r\nWshShell.Run """${batPath}""", 0, False\r\n`;
402
+ const vbsPath = getWinWrapperPath();
403
+ writeFileSync(vbsPath, vbsContent);
404
+
405
+ // Remove existing task if any
406
+ try { execSync(`schtasks /delete /tn "${WIN_TASK_NAME}" /f 2>nul`, { stdio: 'pipe' }); } catch {}
407
+
408
+ // Create scheduled task that runs at logon
409
+ execSync(
410
+ `schtasks /create /tn "${WIN_TASK_NAME}" /tr "wscript.exe \\"${vbsPath}\\"" /sc onlogon /rl highest /f`,
411
+ { stdio: 'pipe' }
412
+ );
413
+
414
+ // Also start it now
415
+ execSync(`schtasks /run /tn "${WIN_TASK_NAME}"`, { stdio: 'pipe' });
416
+
417
+ console.log('Service installed and started.');
418
+ console.log(`\nManage with:`);
419
+ console.log(` yeaft-agent status`);
420
+ console.log(` yeaft-agent logs`);
421
+ console.log(` yeaft-agent restart`);
422
+ console.log(` yeaft-agent uninstall`);
423
+ }
424
+
425
+ function winUninstall() {
426
+ try { winStop(); } catch {}
427
+ try { execSync(`schtasks /delete /tn "${WIN_TASK_NAME}" /f`, { stdio: 'pipe' }); } catch {}
428
+ // Clean up wrapper files
429
+ const vbsPath = getWinWrapperPath();
430
+ const batPath = getWinBatPath();
431
+ if (existsSync(vbsPath)) unlinkSync(vbsPath);
432
+ if (existsSync(batPath)) unlinkSync(batPath);
433
+ console.log('Service uninstalled.');
434
+ }
435
+
436
+ function winStart() {
437
+ try {
438
+ execSync(`schtasks /run /tn "${WIN_TASK_NAME}"`, { stdio: 'pipe' });
439
+ console.log('Service started.');
440
+ } catch {
441
+ console.error('Service not installed. Run "yeaft-agent install" first.');
442
+ process.exit(1);
443
+ }
444
+ }
445
+
446
+ function winStop() {
447
+ // Find and kill the node process running cli.js
448
+ try {
449
+ const output = execSync('wmic process where "name=\'node.exe\'" get processid,commandline /format:csv', {
450
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
451
+ });
452
+ for (const line of output.split('\n')) {
453
+ if (line.includes('cli.js') && line.includes(SERVICE_NAME)) {
454
+ const pid = line.trim().split(',').pop();
455
+ if (pid && /^\d+$/.test(pid)) {
456
+ execSync(`taskkill /pid ${pid} /f`, { stdio: 'pipe' });
457
+ }
458
+ }
459
+ }
460
+ } catch {}
461
+ // Also try to end the task
462
+ try { execSync(`schtasks /end /tn "${WIN_TASK_NAME}"`, { stdio: 'pipe' }); } catch {}
463
+ console.log('Service stopped.');
464
+ }
465
+
466
+ function winRestart() {
467
+ winStop();
468
+ // Brief pause to ensure cleanup
469
+ execSync('ping -n 2 127.0.0.1 >nul', { stdio: 'pipe' });
470
+ winStart();
471
+ }
472
+
473
+ function winStatus() {
474
+ try {
475
+ const output = execSync(`schtasks /query /tn "${WIN_TASK_NAME}" /fo csv /v`, {
476
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
477
+ });
478
+ const lines = output.trim().split('\n');
479
+ if (lines.length >= 2) {
480
+ // Parse CSV header + data
481
+ const headers = lines[0].split('","').map(h => h.replace(/"/g, ''));
482
+ const values = lines[1].split('","').map(v => v.replace(/"/g, ''));
483
+ const statusIdx = headers.indexOf('Status');
484
+ const status = statusIdx >= 0 ? values[statusIdx] : 'Unknown';
485
+ console.log(`Service status: ${status}`);
486
+ console.log(`Task name: ${WIN_TASK_NAME}`);
487
+ }
488
+ } catch {
489
+ console.log('Service is not installed.');
490
+ }
491
+ }
492
+
493
+ function winLogs() {
494
+ const logFile = join(getLogDir(), 'out.log');
495
+ if (existsSync(logFile)) {
496
+ // Windows: use PowerShell Get-Content -Wait (like tail -f)
497
+ const child = spawn('powershell', ['-Command', `Get-Content -Path "${logFile}" -Tail 100 -Wait`], {
498
+ stdio: 'inherit'
499
+ });
500
+ child.on('error', () => {
501
+ console.log(readFileSync(logFile, 'utf-8'));
502
+ });
503
+ } else {
504
+ console.log('No logs found.');
505
+ }
506
+ }
507
+
508
+ // ─── Platform dispatcher ─────────────────────────────────────
509
+
510
+ const os = platform();
511
+
512
+ function ensureInstalled() {
513
+ if (os === 'linux') {
514
+ if (!existsSync(getSystemdServicePath())) {
515
+ console.error('Service not installed. Run "yeaft-agent install" first.');
516
+ process.exit(1);
517
+ }
518
+ } else if (os === 'darwin') {
519
+ if (!existsSync(getLaunchdPlistPath())) {
520
+ console.error('Service not installed. Run "yeaft-agent install" first.');
521
+ process.exit(1);
522
+ }
523
+ }
524
+ // Windows check is done inside individual functions
525
+ }
526
+
527
+ export function install(args) {
528
+ const config = parseServiceArgs(args);
529
+ validateConfig(config);
530
+ saveServiceConfig(config);
531
+
532
+ console.log(`Installing ${SERVICE_NAME} service...`);
533
+ console.log(` Server: ${config.serverUrl}`);
534
+ console.log(` Name: ${config.agentName || '(auto)'}`);
535
+ console.log(` WorkDir: ${config.workDir || '(home)'}`);
536
+ console.log('');
537
+
538
+ if (os === 'linux') linuxInstall(config);
539
+ else if (os === 'darwin') macInstall(config);
540
+ else if (os === 'win32') winInstall(config);
541
+ else {
542
+ console.error(`Unsupported platform: ${os}`);
543
+ console.log('You can run the agent directly: yeaft-agent --server <url> --secret <secret>');
544
+ process.exit(1);
545
+ }
546
+ }
547
+
548
+ export function uninstall() {
549
+ console.log(`Uninstalling ${SERVICE_NAME} service...`);
550
+ if (os === 'linux') linuxUninstall();
551
+ else if (os === 'darwin') macUninstall();
552
+ else if (os === 'win32') winUninstall();
553
+ else { console.error(`Unsupported platform: ${os}`); process.exit(1); }
554
+ }
555
+
556
+ export function start() {
557
+ ensureInstalled();
558
+ if (os === 'linux') linuxStart();
559
+ else if (os === 'darwin') macStart();
560
+ else if (os === 'win32') winStart();
561
+ }
562
+
563
+ export function stop() {
564
+ ensureInstalled();
565
+ if (os === 'linux') linuxStop();
566
+ else if (os === 'darwin') macStop();
567
+ else if (os === 'win32') winStop();
568
+ }
569
+
570
+ export function restart() {
571
+ ensureInstalled();
572
+ if (os === 'linux') linuxRestart();
573
+ else if (os === 'darwin') macRestart();
574
+ else if (os === 'win32') winRestart();
575
+ }
576
+
577
+ export function status() {
578
+ if (os === 'linux') linuxStatus();
579
+ else if (os === 'darwin') macStatus();
580
+ else if (os === 'win32') winStatus();
581
+ }
582
+
583
+ export function logs() {
584
+ if (os === 'linux') linuxLogs();
585
+ else if (os === 'darwin') macLogs();
586
+ else if (os === 'win32') winLogs();
587
+ }