lsh-framework 0.5.4

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.
Files changed (90) hide show
  1. package/.env.example +51 -0
  2. package/README.md +399 -0
  3. package/dist/app.js +33 -0
  4. package/dist/cicd/analytics.js +261 -0
  5. package/dist/cicd/auth.js +269 -0
  6. package/dist/cicd/cache-manager.js +172 -0
  7. package/dist/cicd/data-retention.js +305 -0
  8. package/dist/cicd/performance-monitor.js +224 -0
  9. package/dist/cicd/webhook-receiver.js +634 -0
  10. package/dist/cli.js +500 -0
  11. package/dist/commands/api.js +343 -0
  12. package/dist/commands/self.js +318 -0
  13. package/dist/commands/theme.js +257 -0
  14. package/dist/commands/zsh-import.js +240 -0
  15. package/dist/components/App.js +1 -0
  16. package/dist/components/Divider.js +29 -0
  17. package/dist/components/REPL.js +43 -0
  18. package/dist/components/Terminal.js +232 -0
  19. package/dist/components/UserInput.js +30 -0
  20. package/dist/daemon/api-server.js +315 -0
  21. package/dist/daemon/job-registry.js +554 -0
  22. package/dist/daemon/lshd.js +822 -0
  23. package/dist/daemon/monitoring-api.js +220 -0
  24. package/dist/examples/supabase-integration.js +106 -0
  25. package/dist/lib/api-error-handler.js +183 -0
  26. package/dist/lib/associative-arrays.js +285 -0
  27. package/dist/lib/base-api-server.js +290 -0
  28. package/dist/lib/base-command-registrar.js +286 -0
  29. package/dist/lib/base-job-manager.js +293 -0
  30. package/dist/lib/brace-expansion.js +160 -0
  31. package/dist/lib/builtin-commands.js +439 -0
  32. package/dist/lib/cloud-config-manager.js +347 -0
  33. package/dist/lib/command-validator.js +190 -0
  34. package/dist/lib/completion-system.js +344 -0
  35. package/dist/lib/cron-job-manager.js +364 -0
  36. package/dist/lib/daemon-client-helper.js +141 -0
  37. package/dist/lib/daemon-client.js +501 -0
  38. package/dist/lib/database-persistence.js +638 -0
  39. package/dist/lib/database-schema.js +259 -0
  40. package/dist/lib/enhanced-history-system.js +246 -0
  41. package/dist/lib/env-validator.js +265 -0
  42. package/dist/lib/executors/builtin-executor.js +52 -0
  43. package/dist/lib/extended-globbing.js +411 -0
  44. package/dist/lib/extended-parameter-expansion.js +227 -0
  45. package/dist/lib/floating-point-arithmetic.js +256 -0
  46. package/dist/lib/history-system.js +245 -0
  47. package/dist/lib/interactive-shell.js +460 -0
  48. package/dist/lib/job-builtins.js +580 -0
  49. package/dist/lib/job-manager.js +386 -0
  50. package/dist/lib/job-storage-database.js +156 -0
  51. package/dist/lib/job-storage-memory.js +73 -0
  52. package/dist/lib/logger.js +274 -0
  53. package/dist/lib/lshrc-init.js +177 -0
  54. package/dist/lib/pathname-expansion.js +216 -0
  55. package/dist/lib/prompt-system.js +328 -0
  56. package/dist/lib/script-runner.js +226 -0
  57. package/dist/lib/secrets-manager.js +193 -0
  58. package/dist/lib/shell-executor.js +2504 -0
  59. package/dist/lib/shell-parser.js +958 -0
  60. package/dist/lib/shell-types.js +6 -0
  61. package/dist/lib/shell.lib.js +40 -0
  62. package/dist/lib/supabase-client.js +58 -0
  63. package/dist/lib/theme-manager.js +476 -0
  64. package/dist/lib/variable-expansion.js +385 -0
  65. package/dist/lib/zsh-compatibility.js +658 -0
  66. package/dist/lib/zsh-import-manager.js +699 -0
  67. package/dist/lib/zsh-options.js +328 -0
  68. package/dist/pipeline/job-tracker.js +491 -0
  69. package/dist/pipeline/mcli-bridge.js +302 -0
  70. package/dist/pipeline/pipeline-service.js +1116 -0
  71. package/dist/pipeline/workflow-engine.js +867 -0
  72. package/dist/services/api/api.js +58 -0
  73. package/dist/services/api/auth.js +35 -0
  74. package/dist/services/api/config.js +7 -0
  75. package/dist/services/api/file.js +22 -0
  76. package/dist/services/cron/cron-registrar.js +235 -0
  77. package/dist/services/cron/cron.js +9 -0
  78. package/dist/services/daemon/daemon-registrar.js +565 -0
  79. package/dist/services/daemon/daemon.js +9 -0
  80. package/dist/services/lib/lib.js +86 -0
  81. package/dist/services/log-file-extractor.js +170 -0
  82. package/dist/services/secrets/secrets.js +94 -0
  83. package/dist/services/shell/shell.js +28 -0
  84. package/dist/services/supabase/supabase-registrar.js +367 -0
  85. package/dist/services/supabase/supabase.js +9 -0
  86. package/dist/services/zapier.js +16 -0
  87. package/dist/simple-api-server.js +148 -0
  88. package/dist/store/store.js +31 -0
  89. package/dist/util/lib.util.js +11 -0
  90. package/package.json +144 -0
@@ -0,0 +1,822 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LSH Job Daemon - Persistent job execution service
4
+ * Runs independently of LSH shell processes to ensure reliable job execution
5
+ */
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as net from 'net';
11
+ import { EventEmitter } from 'events';
12
+ import JobManager from '../lib/job-manager.js';
13
+ import { LSHApiServer } from './api-server.js';
14
+ import { validateCommand } from '../lib/command-validator.js';
15
+ import { validateEnvironment, printValidationResults } from '../lib/env-validator.js';
16
+ import { createLogger } from '../lib/logger.js';
17
+ const execAsync = promisify(exec);
18
+ export class LSHJobDaemon extends EventEmitter {
19
+ config;
20
+ jobManager;
21
+ isRunning = false;
22
+ checkTimer;
23
+ logStream;
24
+ ipcServer; // Unix socket server for communication
25
+ lastRunTimes = new Map(); // Track last run time per job
26
+ apiServer; // API server instance
27
+ logger = createLogger('LSHJobDaemon');
28
+ constructor(config) {
29
+ super();
30
+ const userSuffix = process.env.USER ? `-${process.env.USER}` : '';
31
+ this.config = {
32
+ pidFile: `/tmp/lsh-job-daemon${userSuffix}.pid`,
33
+ logFile: `/tmp/lsh-job-daemon${userSuffix}.log`,
34
+ jobsFile: `/tmp/lsh-daemon-jobs${userSuffix}.json`,
35
+ socketPath: `/tmp/lsh-job-daemon${userSuffix}.sock`,
36
+ checkInterval: 2000, // 2 seconds for better cron accuracy
37
+ maxLogSize: 10 * 1024 * 1024, // 10MB
38
+ autoRestart: true,
39
+ apiEnabled: process.env.LSH_API_ENABLED === 'true' || false,
40
+ apiPort: parseInt(process.env.LSH_API_PORT || '3030'),
41
+ apiKey: process.env.LSH_API_KEY,
42
+ enableWebhooks: process.env.LSH_ENABLE_WEBHOOKS === 'true',
43
+ ...config
44
+ };
45
+ this.jobManager = new JobManager(this.config.jobsFile);
46
+ this.setupLogging();
47
+ this.setupIPC();
48
+ }
49
+ /**
50
+ * Start the daemon
51
+ */
52
+ async start() {
53
+ if (this.isRunning) {
54
+ throw new Error('Daemon is already running');
55
+ }
56
+ // Validate environment variables
57
+ this.log('INFO', 'Validating environment configuration');
58
+ const envValidation = validateEnvironment();
59
+ // Print validation results
60
+ if (envValidation.errors.length > 0 || envValidation.warnings.length > 0) {
61
+ printValidationResults(envValidation, false);
62
+ }
63
+ // Fail fast in production if validation fails
64
+ if (!envValidation.isValid && process.env.NODE_ENV === 'production') {
65
+ this.log('ERROR', 'Environment validation failed in production');
66
+ throw new Error('Invalid environment configuration. Check logs for details.');
67
+ }
68
+ // Log warnings even in development
69
+ if (envValidation.warnings.length > 0) {
70
+ envValidation.warnings.forEach(warn => this.log('WARN', warn));
71
+ }
72
+ // Check if daemon is already running
73
+ if (await this.isDaemonRunning()) {
74
+ throw new Error('Another daemon instance is already running');
75
+ }
76
+ this.log('INFO', 'Starting LSH Job Daemon');
77
+ // Write PID file
78
+ await fs.promises.writeFile(this.config.pidFile, process.pid.toString());
79
+ this.isRunning = true;
80
+ this.startJobScheduler();
81
+ this.startIPCServer();
82
+ // Start API server if enabled
83
+ if (this.config.apiEnabled) {
84
+ try {
85
+ this.apiServer = new LSHApiServer(this, {
86
+ port: this.config.apiPort,
87
+ apiKey: this.config.apiKey,
88
+ enableWebhooks: this.config.enableWebhooks,
89
+ webhookEndpoints: this.config.webhookEndpoints
90
+ });
91
+ await this.apiServer.start();
92
+ this.log('INFO', `API Server started on port ${this.config.apiPort}`);
93
+ }
94
+ catch (error) {
95
+ this.log('ERROR', `Failed to start API server: ${error.message}`);
96
+ }
97
+ }
98
+ // Setup cleanup handlers
99
+ this.setupSignalHandlers();
100
+ this.log('INFO', `Daemon started with PID ${process.pid}`);
101
+ this.emit('started');
102
+ }
103
+ /**
104
+ * Stop the daemon gracefully
105
+ */
106
+ async stop() {
107
+ if (!this.isRunning) {
108
+ return;
109
+ }
110
+ this.log('INFO', 'Stopping LSH Job Daemon');
111
+ this.isRunning = false;
112
+ // Stop API server if running
113
+ if (this.apiServer) {
114
+ await this.apiServer.stop();
115
+ this.log('INFO', 'API Server stopped');
116
+ }
117
+ if (this.checkTimer) {
118
+ clearInterval(this.checkTimer);
119
+ }
120
+ // Stop all running jobs gracefully
121
+ await this.stopAllJobs();
122
+ // Cleanup IPC
123
+ if (this.ipcServer) {
124
+ this.ipcServer.close();
125
+ }
126
+ // Remove PID file
127
+ try {
128
+ await fs.promises.unlink(this.config.pidFile);
129
+ }
130
+ catch (_error) {
131
+ // Ignore if file doesn\'t exist
132
+ }
133
+ // Close log stream
134
+ if (this.logStream) {
135
+ this.logStream.end();
136
+ }
137
+ this.log('INFO', 'Daemon stopped');
138
+ this.emit('stopped');
139
+ }
140
+ /**
141
+ * Restart the daemon
142
+ */
143
+ async restart() {
144
+ await this.stop();
145
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
146
+ await this.start();
147
+ }
148
+ /**
149
+ * Get daemon status
150
+ */
151
+ async getStatus() {
152
+ const stats = this.jobManager.getJobStats();
153
+ const uptime = process.uptime();
154
+ return {
155
+ isRunning: this.isRunning,
156
+ pid: process.pid,
157
+ uptime,
158
+ jobs: stats,
159
+ config: this.config,
160
+ memoryUsage: process.memoryUsage(),
161
+ cpuUsage: process.cpuUsage()
162
+ };
163
+ }
164
+ /**
165
+ * Add a job to the daemon
166
+ */
167
+ async addJob(jobSpec) {
168
+ this.log('INFO', `Adding job: ${jobSpec.name || 'unnamed'}`);
169
+ const job = await this.jobManager.createJob(jobSpec);
170
+ return job;
171
+ }
172
+ /**
173
+ * Start a job
174
+ */
175
+ async startJob(jobId) {
176
+ this.log('INFO', `Starting job: ${jobId}`);
177
+ const job = await this.jobManager.startJob(jobId);
178
+ return job;
179
+ }
180
+ /**
181
+ * Trigger a job to run immediately (returns sanitized result with output)
182
+ */
183
+ async triggerJob(jobId) {
184
+ this.log('INFO', `Triggering job: ${jobId}`);
185
+ try {
186
+ // Get the job details
187
+ const job = await this.jobManager.getJob(jobId);
188
+ if (!job) {
189
+ throw new Error(`Job ${jobId} not found`);
190
+ }
191
+ // Validate command for security issues
192
+ const validation = validateCommand(job.command, {
193
+ allowDangerousCommands: process.env.LSH_ALLOW_DANGEROUS_COMMANDS === 'true',
194
+ maxLength: 10000
195
+ });
196
+ if (!validation.isValid) {
197
+ const errorMsg = `Command validation failed: ${validation.errors.join(', ')}`;
198
+ this.log('ERROR', `${errorMsg} - Risk level: ${validation.riskLevel}`);
199
+ throw new Error(errorMsg);
200
+ }
201
+ // Log warnings if any
202
+ if (validation.warnings.length > 0) {
203
+ this.log('WARN', `Command warnings for job ${jobId}: ${validation.warnings.join(', ')}`);
204
+ }
205
+ // Execute the job command directly and capture output
206
+ const { stdout, stderr } = await execAsync(job.command, {
207
+ cwd: job.cwd || process.cwd(),
208
+ env: { ...process.env, ...job.env },
209
+ timeout: job.timeout || 30000 // 30 second timeout
210
+ });
211
+ this.log('INFO', `Job ${jobId} triggered successfully`);
212
+ return {
213
+ success: true,
214
+ output: stdout || stderr || 'Job completed with no output',
215
+ warnings: validation.warnings.length > 0 ? validation.warnings : undefined
216
+ };
217
+ }
218
+ catch (error) {
219
+ this.log('ERROR', `Failed to trigger job ${jobId}: ${error.message}`);
220
+ return {
221
+ success: false,
222
+ error: error.message,
223
+ output: error.stdout || error.stderr
224
+ };
225
+ }
226
+ }
227
+ /**
228
+ * Stop a job
229
+ */
230
+ async stopJob(jobId, signal = 'SIGTERM') {
231
+ this.log('INFO', `Stopping job: ${jobId} with signal ${signal}`);
232
+ const job = await this.jobManager.killJob(jobId, signal);
233
+ return job;
234
+ }
235
+ /**
236
+ * Get job information
237
+ */
238
+ async getJob(jobId) {
239
+ const job = await this.jobManager.getJob(jobId);
240
+ return job ? this.sanitizeJobForSerialization(job) : undefined;
241
+ }
242
+ /**
243
+ * Sanitize job objects for safe JSON serialization
244
+ */
245
+ sanitizeJobForSerialization(job) {
246
+ // Use a whitelist approach - only include safe properties
247
+ const sanitized = {
248
+ id: job.id,
249
+ name: job.name,
250
+ command: job.command,
251
+ args: job.args,
252
+ type: job.type,
253
+ status: job.status,
254
+ priority: job.priority,
255
+ pid: job.pid,
256
+ ppid: job.ppid,
257
+ createdAt: job.createdAt,
258
+ startedAt: job.startedAt,
259
+ completedAt: job.completedAt,
260
+ cpuUsage: job.cpuUsage,
261
+ memoryUsage: job.memoryUsage,
262
+ env: job.env,
263
+ cwd: job.cwd,
264
+ user: job.user,
265
+ maxMemory: job.maxMemory,
266
+ maxCpu: job.maxCpu,
267
+ timeout: typeof job.timeout === 'number' ? job.timeout : undefined,
268
+ stdout: job.stdout,
269
+ stderr: job.stderr,
270
+ exitCode: job.exitCode,
271
+ error: job.error,
272
+ tags: job.tags,
273
+ maxRetries: job.maxRetries,
274
+ retryCount: job.retryCount,
275
+ killSignal: job.killSignal,
276
+ killed: job.killed,
277
+ description: job.description,
278
+ workingDirectory: job.workingDirectory,
279
+ databaseSync: job.databaseSync
280
+ };
281
+ // Handle schedule object safely
282
+ if (job.schedule) {
283
+ sanitized.schedule = {
284
+ cron: job.schedule.cron,
285
+ interval: job.schedule.interval,
286
+ nextRun: job.schedule.nextRun
287
+ };
288
+ }
289
+ // Remove any undefined properties to keep the object clean
290
+ Object.keys(sanitized).forEach(key => {
291
+ if (sanitized[key] === undefined) {
292
+ delete sanitized[key];
293
+ }
294
+ });
295
+ return sanitized;
296
+ }
297
+ /**
298
+ * List all jobs
299
+ */
300
+ async listJobs(filter, limit) {
301
+ try {
302
+ const jobs = await this.jobManager.listJobs(filter);
303
+ // Sanitize jobs to remove circular references before serialization
304
+ const sanitizedJobs = jobs.map(job => this.sanitizeJobForSerialization(job));
305
+ // Apply limit if specified
306
+ if (limit && limit > 0) {
307
+ return sanitizedJobs.slice(0, limit);
308
+ }
309
+ // Default limit to prevent oversized responses
310
+ const defaultLimit = 100;
311
+ return sanitizedJobs.slice(0, defaultLimit);
312
+ }
313
+ catch (error) {
314
+ this.log('ERROR', `Failed to list jobs: ${error.message}`);
315
+ return [];
316
+ }
317
+ }
318
+ /**
319
+ * Remove a job
320
+ */
321
+ async removeJob(jobId, force = false) {
322
+ this.log('INFO', `Removing job: ${jobId}, force: ${force}`);
323
+ return await this.jobManager.removeJob(jobId, force);
324
+ }
325
+ async isDaemonRunning() {
326
+ try {
327
+ // First, kill any existing daemon processes for this socket path
328
+ await this.killExistingDaemons();
329
+ const pidData = await fs.promises.readFile(this.config.pidFile, 'utf8');
330
+ const pid = parseInt(pidData.trim());
331
+ // Check if process is running
332
+ try {
333
+ process.kill(pid, 0); // Signal 0 just checks if process exists
334
+ return true;
335
+ }
336
+ catch (_error) {
337
+ // Process doesn't exist, remove stale PID file
338
+ await fs.promises.unlink(this.config.pidFile);
339
+ return false;
340
+ }
341
+ }
342
+ catch (_error) {
343
+ return false; // PID file doesn't exist
344
+ }
345
+ }
346
+ async killExistingDaemons() {
347
+ try {
348
+ // Find all lshd processes with the same socket path
349
+ const { stdout } = await execAsync(`ps aux | grep "lshd.js" | grep "${this.config.socketPath}" | grep -v grep || true`);
350
+ if (stdout.trim()) {
351
+ const lines = stdout.trim().split('\n');
352
+ for (const line of lines) {
353
+ const parts = line.trim().split(/\s+/);
354
+ const pid = parseInt(parts[1]);
355
+ if (pid && pid !== process.pid) {
356
+ try {
357
+ this.log('INFO', `Killing existing daemon process ${pid}`);
358
+ process.kill(pid, 9); // Force kill
359
+ }
360
+ catch (_error) {
361
+ // Process might already be dead
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ catch (_error) {
368
+ // ps command failed, ignore
369
+ }
370
+ }
371
+ startJobScheduler() {
372
+ try {
373
+ this.log('INFO', `📅 Starting job scheduler with ${this.config.checkInterval}ms interval`);
374
+ this.checkTimer = setInterval(() => {
375
+ try {
376
+ this.checkScheduledJobs();
377
+ this.cleanupCompletedJobs();
378
+ this.rotateLogs();
379
+ }
380
+ catch (error) {
381
+ this.log('ERROR', `❌ Scheduler error: ${error.message}`);
382
+ }
383
+ }, this.config.checkInterval);
384
+ this.log('INFO', `✅ Job scheduler started successfully`);
385
+ }
386
+ catch (error) {
387
+ this.log('ERROR', `❌ Failed to start job scheduler: ${error.message}`);
388
+ throw error;
389
+ }
390
+ }
391
+ async checkScheduledJobs() {
392
+ // Debug: Log scheduler activity periodically
393
+ if (Date.now() % 60000 < 5000) { // Log once per minute approximately
394
+ this.log('DEBUG', `🔄 Scheduler check: Looking for jobs to run...`);
395
+ }
396
+ // Check both created and completed jobs (for recurring schedules)
397
+ const jobs = await this.jobManager.listJobs({ status: ['created', 'completed'] });
398
+ const now = new Date();
399
+ for (const job of jobs) {
400
+ if (job.schedule) {
401
+ let shouldRun = false;
402
+ // Check cron schedule
403
+ if (job.schedule.cron) {
404
+ // For completed jobs, check if cron schedule matches (allow re-run)
405
+ // For created jobs, check if we haven't run this job in the current minute
406
+ const currentMinute = Math.floor(now.getTime() / 60000);
407
+ const lastRun = this.lastRunTimes.get(job.id);
408
+ if (job.status === 'completed') {
409
+ // Always check cron for completed jobs to allow recurring execution
410
+ shouldRun = this.shouldRunByCron(job.schedule.cron, now);
411
+ }
412
+ else if (!lastRun || lastRun < currentMinute) {
413
+ shouldRun = this.shouldRunByCron(job.schedule.cron, now);
414
+ }
415
+ }
416
+ // Check interval schedule
417
+ if (job.schedule.interval && job.schedule.nextRun) {
418
+ shouldRun = now >= job.schedule.nextRun;
419
+ }
420
+ if (shouldRun) {
421
+ try {
422
+ // For completed cron jobs, reset to created status before starting
423
+ if (job.schedule.cron && job.status === 'completed') {
424
+ job.status = 'created';
425
+ job.completedAt = undefined;
426
+ job.stdout = '';
427
+ job.stderr = '';
428
+ this.jobManager.persistJobs();
429
+ this.log('INFO', `🔄 Reset completed job for recurring execution: ${job.id} (${job.name})`);
430
+ }
431
+ // Track that we're running this job now
432
+ if (job.schedule.cron) {
433
+ const currentMinute = Math.floor(now.getTime() / 60000);
434
+ this.lastRunTimes.set(job.id, currentMinute);
435
+ }
436
+ this.log('INFO', `Started scheduled job: ${job.id} (${job.name})`);
437
+ await this.jobManager.startJob(job.id);
438
+ // Schedule next run for interval jobs
439
+ if (job.schedule.interval) {
440
+ job.schedule.nextRun = new Date(now.getTime() + job.schedule.interval);
441
+ }
442
+ }
443
+ catch (error) {
444
+ this.log('ERROR', `❌ Failed to start scheduled job ${job.id}: ${error.message}`);
445
+ }
446
+ }
447
+ }
448
+ }
449
+ }
450
+ shouldRunByCron(cronExpr, now) {
451
+ try {
452
+ const [minute, hour, day, month, weekday] = cronExpr.split(' ');
453
+ // We check if we're at the exact minute/second to avoid duplicate runs
454
+ // Only run in the first 30 seconds of the target minute
455
+ // This gives us a wider window with 2-second check intervals
456
+ if (now.getSeconds() > 30) {
457
+ return false;
458
+ }
459
+ // Check minute field
460
+ if (!this.matchesCronField(minute, now.getMinutes(), 0, 59)) {
461
+ return false;
462
+ }
463
+ // Check hour field
464
+ if (!this.matchesCronField(hour, now.getHours(), 0, 23)) {
465
+ return false;
466
+ }
467
+ // Check day field
468
+ if (!this.matchesCronField(day, now.getDate(), 1, 31)) {
469
+ return false;
470
+ }
471
+ // Check month field
472
+ if (!this.matchesCronField(month, now.getMonth() + 1, 1, 12)) {
473
+ return false;
474
+ }
475
+ // Check weekday field (0 = Sunday, 6 = Saturday)
476
+ if (!this.matchesCronField(weekday, now.getDay(), 0, 6)) {
477
+ return false;
478
+ }
479
+ return true;
480
+ }
481
+ catch (error) {
482
+ this.log('ERROR', `Invalid cron expression: ${cronExpr} - ${error.message}`);
483
+ return false;
484
+ }
485
+ }
486
+ matchesCronField(field, currentValue, _min, _max) {
487
+ // Handle wildcard
488
+ if (field === '*') {
489
+ return true;
490
+ }
491
+ // Handle specific number (e.g., "0", "2", "15")
492
+ if (/^\d+$/.test(field)) {
493
+ return parseInt(field) === currentValue;
494
+ }
495
+ // Handle intervals (e.g., "*/5", "*/2", "*/30")
496
+ if (field.startsWith('*/')) {
497
+ const interval = parseInt(field.substring(2));
498
+ return currentValue % interval === 0;
499
+ }
500
+ // Handle ranges (e.g., "1-5", "10-15")
501
+ if (field.includes('-')) {
502
+ const [start, end] = field.split('-').map(x => parseInt(x));
503
+ return currentValue >= start && currentValue <= end;
504
+ }
505
+ // Handle lists (e.g., "1,3,5", "10,20,30")
506
+ if (field.includes(',')) {
507
+ const values = field.split(',').map(x => parseInt(x));
508
+ return values.includes(currentValue);
509
+ }
510
+ // Handle step values (e.g., "1-10/2" = every 2 from 1 to 10)
511
+ if (field.includes('/')) {
512
+ const [range, step] = field.split('/');
513
+ const stepNum = parseInt(step);
514
+ if (range.includes('-')) {
515
+ const [start, end] = range.split('-').map(x => parseInt(x));
516
+ if (currentValue < start || currentValue > end)
517
+ return false;
518
+ return (currentValue - start) % stepNum === 0;
519
+ }
520
+ }
521
+ return false;
522
+ }
523
+ /**
524
+ * Reset job status for recurring cron jobs after completion
525
+ */
526
+ async resetRecurringJobStatus(jobId) {
527
+ try {
528
+ const job = await this.jobManager.getJob(jobId);
529
+ if (job && job.schedule?.cron && job.status === 'completed') {
530
+ // Reset status for next scheduled run
531
+ job.status = 'created';
532
+ job.completedAt = undefined;
533
+ job.stdout = '';
534
+ job.stderr = '';
535
+ // Force persistence by calling internal method via reflection
536
+ // Note: This is a temporary workaround for private method access
537
+ this.jobManager.persistJobs();
538
+ this.log('INFO', `🔄 Reset recurring job status: ${jobId} (${job.name}) for next scheduled run`);
539
+ }
540
+ }
541
+ catch (error) {
542
+ this.log('ERROR', `Failed to reset recurring job status ${jobId}: ${error.message}`);
543
+ }
544
+ }
545
+ async cleanupCompletedJobs() {
546
+ const cleaned = await this.jobManager.cleanupJobs(24); // Clean jobs older than 24 hours
547
+ if (cleaned > 0) {
548
+ this.log('INFO', `Cleaned up ${cleaned} old jobs`);
549
+ }
550
+ }
551
+ async stopAllJobs() {
552
+ const runningJobs = await this.jobManager.listJobs({ status: ['running', 'stopped'] });
553
+ for (const job of runningJobs) {
554
+ try {
555
+ await this.jobManager.killJob(job.id, 'SIGTERM');
556
+ this.log('INFO', `Stopped job: ${job.id}`);
557
+ }
558
+ catch (error) {
559
+ this.log('ERROR', `Failed to stop job ${job.id}: ${error.message}`);
560
+ }
561
+ }
562
+ }
563
+ setupLogging() {
564
+ // Create log directory if it doesn't exist
565
+ const logDir = path.dirname(this.config.logFile);
566
+ if (!fs.existsSync(logDir)) {
567
+ fs.mkdirSync(logDir, { recursive: true });
568
+ }
569
+ this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
570
+ // Log uncaught exceptions
571
+ process.on('uncaughtException', (error) => {
572
+ this.log('FATAL', `Uncaught exception: ${error.message}`);
573
+ this.log('FATAL', error.stack || '');
574
+ if (this.config.autoRestart) {
575
+ this.restart();
576
+ }
577
+ else {
578
+ process.exit(1);
579
+ }
580
+ });
581
+ process.on('unhandledRejection', (reason) => {
582
+ this.log('ERROR', `Unhandled promise rejection: ${reason}`);
583
+ });
584
+ }
585
+ setupIPC() {
586
+ // Setup Unix domain socket for communication with LSH clients
587
+ // Remove existing socket file
588
+ try {
589
+ fs.unlinkSync(this.config.socketPath);
590
+ }
591
+ catch (_error) {
592
+ // Ignore if doesn't exist
593
+ }
594
+ this.ipcServer = net.createServer((socket) => {
595
+ socket.on('data', async (data) => {
596
+ let messageId;
597
+ try {
598
+ const message = JSON.parse(data.toString());
599
+ messageId = message.id;
600
+ const response = await this.handleIPCMessage(message);
601
+ socket.write(JSON.stringify({
602
+ success: true,
603
+ data: response,
604
+ id: messageId
605
+ }));
606
+ }
607
+ catch (error) {
608
+ socket.write(JSON.stringify({
609
+ success: false,
610
+ error: error.message,
611
+ id: messageId
612
+ }));
613
+ }
614
+ });
615
+ });
616
+ }
617
+ startIPCServer() {
618
+ if (this.ipcServer) {
619
+ // Clean up any existing socket file
620
+ try {
621
+ if (fs.existsSync(this.config.socketPath)) {
622
+ fs.unlinkSync(this.config.socketPath);
623
+ }
624
+ }
625
+ catch (_error) {
626
+ // Ignore cleanup errors
627
+ }
628
+ this.ipcServer.listen(this.config.socketPath, () => {
629
+ this.log('INFO', `IPC server listening on ${this.config.socketPath}`);
630
+ });
631
+ this.ipcServer.on('error', (error) => {
632
+ this.log('ERROR', `IPC server error: ${error.message}`);
633
+ if (error.message.includes('EADDRINUSE')) {
634
+ this.log('INFO', 'Socket already in use, attempting cleanup...');
635
+ try {
636
+ fs.unlinkSync(this.config.socketPath);
637
+ // Retry after cleanup
638
+ setTimeout(() => {
639
+ this.ipcServer.listen(this.config.socketPath);
640
+ }, 1000);
641
+ }
642
+ catch (cleanupError) {
643
+ this.log('ERROR', `Failed to cleanup socket: ${cleanupError.message}`);
644
+ }
645
+ }
646
+ });
647
+ }
648
+ }
649
+ async handleIPCMessage(message) {
650
+ const { command, args } = message;
651
+ switch (command) {
652
+ case 'status':
653
+ return await this.getStatus();
654
+ case 'addJob':
655
+ return await this.addJob(args.jobSpec);
656
+ case 'startJob':
657
+ return await this.startJob(args.jobId);
658
+ case 'triggerJob':
659
+ return await this.triggerJob(args.jobId);
660
+ case 'stopJob':
661
+ return await this.stopJob(args.jobId, args.signal);
662
+ case 'listJobs':
663
+ return this.listJobs(args.filter, args.limit);
664
+ case 'getJob':
665
+ return this.getJob(args.jobId);
666
+ case 'removeJob':
667
+ return await this.removeJob(args.jobId, args.force);
668
+ case 'restart':
669
+ await this.restart();
670
+ return { message: 'Daemon restarted' };
671
+ case 'stop':
672
+ await this.stop();
673
+ return { message: 'Daemon stopped' };
674
+ default:
675
+ throw new Error(`Unknown command: ${command}`);
676
+ }
677
+ }
678
+ log(level, message) {
679
+ const timestamp = new Date().toISOString();
680
+ const logEntry = `[${timestamp}] ${level}: ${message}\n`;
681
+ // Write to log file
682
+ if (this.logStream) {
683
+ this.logStream.write(logEntry);
684
+ }
685
+ // Also output using logger
686
+ switch (level.toUpperCase()) {
687
+ case 'DEBUG':
688
+ this.logger.debug(message);
689
+ break;
690
+ case 'INFO':
691
+ this.logger.info(message);
692
+ break;
693
+ case 'WARN':
694
+ case 'WARNING':
695
+ this.logger.warn(message);
696
+ break;
697
+ case 'ERROR':
698
+ case 'FATAL':
699
+ this.logger.error(message);
700
+ break;
701
+ default:
702
+ this.logger.info(message);
703
+ }
704
+ this.emit('log', level, message);
705
+ }
706
+ rotateLogs() {
707
+ try {
708
+ const stats = fs.statSync(this.config.logFile);
709
+ if (stats.size > this.config.maxLogSize) {
710
+ const backupFile = `${this.config.logFile}.${Date.now()}`;
711
+ fs.renameSync(this.config.logFile, backupFile);
712
+ // Close current stream and create new one
713
+ if (this.logStream) {
714
+ this.logStream.end();
715
+ this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
716
+ }
717
+ this.log('INFO', `Rotated log file to ${backupFile}`);
718
+ }
719
+ }
720
+ catch (_error) {
721
+ // Ignore rotation errors
722
+ }
723
+ }
724
+ setupSignalHandlers() {
725
+ process.on('SIGTERM', async () => {
726
+ this.log('INFO', 'Received SIGTERM, shutting down gracefully');
727
+ await this.stop();
728
+ process.exit(0);
729
+ });
730
+ process.on('SIGINT', async () => {
731
+ this.log('INFO', 'Received SIGINT, shutting down gracefully');
732
+ await this.stop();
733
+ process.exit(0);
734
+ });
735
+ process.on('SIGHUP', async () => {
736
+ this.log('INFO', 'Received SIGHUP, restarting');
737
+ await this.restart();
738
+ });
739
+ }
740
+ }
741
+ // Module-level logger for CLI operations
742
+ const cliLogger = createLogger('LSHDaemonCLI');
743
+ // CLI interface for the daemon
744
+ if (import.meta.url === `file://${process.argv[1]}`) {
745
+ const command = process.argv[2];
746
+ const subCommand = process.argv[3];
747
+ const _args = process.argv.slice(4);
748
+ // Handle job commands
749
+ if (command === 'job-add') {
750
+ (async () => {
751
+ try {
752
+ const jobCommand = subCommand;
753
+ if (!jobCommand) {
754
+ cliLogger.error('Usage: lshd job-add "command-to-run"');
755
+ process.exit(1);
756
+ }
757
+ const client = new (await import('../lib/daemon-client.js')).default();
758
+ if (!client.isDaemonRunning()) {
759
+ cliLogger.error('Daemon is not running. Start it with: lsh daemon start');
760
+ process.exit(1);
761
+ }
762
+ await client.connect();
763
+ const jobSpec = {
764
+ id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
765
+ name: `Manual Job - ${jobCommand}`,
766
+ command: jobCommand,
767
+ type: 'manual',
768
+ schedule: { interval: 0 }, // Run once
769
+ env: process.env,
770
+ cwd: process.cwd(),
771
+ user: process.env.USER,
772
+ priority: 5,
773
+ tags: ['manual'],
774
+ enabled: true,
775
+ maxRetries: 0,
776
+ timeout: 0,
777
+ };
778
+ const result = await client.addJob(jobSpec);
779
+ cliLogger.info('Job added successfully', { id: result.id, command: result.command, status: result.status });
780
+ // Start the job immediately
781
+ await client.startJob(result.id);
782
+ cliLogger.info(`Job ${result.id} started`);
783
+ client.disconnect();
784
+ process.exit(0);
785
+ }
786
+ catch (error) {
787
+ cliLogger.error('Failed to add job', error);
788
+ process.exit(1);
789
+ }
790
+ })();
791
+ }
792
+ else {
793
+ const socketPath = subCommand;
794
+ const daemon = new LSHJobDaemon(socketPath ? { socketPath } : undefined);
795
+ switch (command) {
796
+ case 'start':
797
+ daemon.start().catch((error) => cliLogger.error('Failed to start daemon', error));
798
+ // Keep the process alive
799
+ process.stdin.resume();
800
+ break;
801
+ case 'stop':
802
+ daemon.stop().catch((error) => cliLogger.error('Failed to stop daemon', error));
803
+ break;
804
+ case 'restart':
805
+ daemon.restart().catch((error) => cliLogger.error('Failed to restart daemon', error));
806
+ // Keep the process alive
807
+ process.stdin.resume();
808
+ break;
809
+ case 'status':
810
+ daemon.getStatus().then(status => {
811
+ cliLogger.info(JSON.stringify(status, null, 2));
812
+ process.exit(0);
813
+ }).catch((error) => cliLogger.error('Failed to get daemon status', error));
814
+ break;
815
+ default:
816
+ cliLogger.info('Usage: lshd {start|stop|restart|status|job-add}');
817
+ cliLogger.info(' lshd job-add "command" - Add and start a job');
818
+ process.exit(1);
819
+ }
820
+ }
821
+ }
822
+ export default LSHJobDaemon;