jm2 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/GNU-AGPL-3.0 +665 -0
- package/README.md +603 -0
- package/bin/jm2.js +24 -0
- package/package.json +70 -0
- package/src/cli/commands/add.js +206 -0
- package/src/cli/commands/config.js +212 -0
- package/src/cli/commands/edit.js +198 -0
- package/src/cli/commands/export.js +61 -0
- package/src/cli/commands/flush.js +132 -0
- package/src/cli/commands/history.js +179 -0
- package/src/cli/commands/import.js +180 -0
- package/src/cli/commands/list.js +174 -0
- package/src/cli/commands/logs.js +415 -0
- package/src/cli/commands/pause.js +97 -0
- package/src/cli/commands/remove.js +107 -0
- package/src/cli/commands/restart.js +68 -0
- package/src/cli/commands/resume.js +96 -0
- package/src/cli/commands/run.js +115 -0
- package/src/cli/commands/show.js +159 -0
- package/src/cli/commands/start.js +46 -0
- package/src/cli/commands/status.js +47 -0
- package/src/cli/commands/stop.js +48 -0
- package/src/cli/index.js +274 -0
- package/src/cli/utils/output.js +267 -0
- package/src/cli/utils/prompts.js +56 -0
- package/src/core/config.js +227 -0
- package/src/core/history-db.js +439 -0
- package/src/core/job.js +329 -0
- package/src/core/logger.js +382 -0
- package/src/core/storage.js +315 -0
- package/src/daemon/executor.js +409 -0
- package/src/daemon/index.js +873 -0
- package/src/daemon/scheduler.js +465 -0
- package/src/ipc/client.js +112 -0
- package/src/ipc/protocol.js +183 -0
- package/src/ipc/server.js +92 -0
- package/src/utils/cron.js +205 -0
- package/src/utils/datetime.js +237 -0
- package/src/utils/duration.js +226 -0
- package/src/utils/paths.js +164 -0
package/src/core/job.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job model and validation for JM2
|
|
3
|
+
* Defines job structure and provides validation utilities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseDuration } from '../utils/duration.js';
|
|
7
|
+
import { validateCronExpression } from '../utils/cron.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Job status constants
|
|
11
|
+
*/
|
|
12
|
+
export const JobStatus = {
|
|
13
|
+
ACTIVE: 'active',
|
|
14
|
+
PAUSED: 'paused',
|
|
15
|
+
COMPLETED: 'completed',
|
|
16
|
+
FAILED: 'failed',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Job type constants
|
|
21
|
+
*/
|
|
22
|
+
export const JobType = {
|
|
23
|
+
CRON: 'cron', // Periodic job with cron expression
|
|
24
|
+
ONCE: 'once', // One-time job (--at or --in)
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default job values
|
|
29
|
+
*/
|
|
30
|
+
export const JOB_DEFAULTS = {
|
|
31
|
+
status: JobStatus.ACTIVE,
|
|
32
|
+
tags: [],
|
|
33
|
+
env: {},
|
|
34
|
+
cwd: null,
|
|
35
|
+
shell: null,
|
|
36
|
+
timeout: null,
|
|
37
|
+
retry: 0,
|
|
38
|
+
retryCount: 0,
|
|
39
|
+
lastRun: null,
|
|
40
|
+
lastResult: null,
|
|
41
|
+
nextRun: null,
|
|
42
|
+
runCount: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new job object with defaults
|
|
47
|
+
* @param {object} data - Job data
|
|
48
|
+
* @returns {object} Job object with defaults applied
|
|
49
|
+
*/
|
|
50
|
+
export function createJob(data) {
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...JOB_DEFAULTS,
|
|
55
|
+
...data,
|
|
56
|
+
createdAt: data.createdAt || now,
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a job object
|
|
63
|
+
* @param {object} job - Job object to validate
|
|
64
|
+
* @returns {object} Validation result { valid: boolean, errors: string[] }
|
|
65
|
+
*/
|
|
66
|
+
export function validateJob(job) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
|
|
69
|
+
// Required fields
|
|
70
|
+
if (!job.command || typeof job.command !== 'string' || job.command.trim() === '') {
|
|
71
|
+
errors.push('command is required and must be a non-empty string');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Must have either cron or runAt
|
|
75
|
+
if (!job.cron && !job.runAt) {
|
|
76
|
+
errors.push('Either cron expression or runAt datetime is required');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (job.cron && job.runAt) {
|
|
80
|
+
errors.push('Cannot specify both cron and runAt');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate job type
|
|
84
|
+
if (job.type && !Object.values(JobType).includes(job.type)) {
|
|
85
|
+
errors.push(`Invalid job type: ${job.type}. Must be one of: ${Object.values(JobType).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Validate status
|
|
89
|
+
if (job.status && !Object.values(JobStatus).includes(job.status)) {
|
|
90
|
+
errors.push(`Invalid job status: ${job.status}. Must be one of: ${Object.values(JobStatus).join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate name if provided
|
|
94
|
+
if (job.name !== undefined && job.name !== null) {
|
|
95
|
+
if (typeof job.name !== 'string') {
|
|
96
|
+
errors.push('name must be a string');
|
|
97
|
+
} else if (job.name.trim() === '') {
|
|
98
|
+
errors.push('name cannot be empty');
|
|
99
|
+
} else if (!/^[a-zA-Z0-9_-]+$/.test(job.name)) {
|
|
100
|
+
errors.push('name can only contain letters, numbers, underscores, and hyphens');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate tags
|
|
105
|
+
if (job.tags !== undefined) {
|
|
106
|
+
if (!Array.isArray(job.tags)) {
|
|
107
|
+
errors.push('tags must be an array');
|
|
108
|
+
} else {
|
|
109
|
+
for (const tag of job.tags) {
|
|
110
|
+
if (typeof tag !== 'string' || tag.trim() === '') {
|
|
111
|
+
errors.push('Each tag must be a non-empty string');
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate env
|
|
119
|
+
if (job.env !== undefined && job.env !== null) {
|
|
120
|
+
if (typeof job.env !== 'object' || Array.isArray(job.env)) {
|
|
121
|
+
errors.push('env must be an object');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate timeout
|
|
126
|
+
if (job.timeout !== undefined && job.timeout !== null) {
|
|
127
|
+
if (typeof job.timeout === 'string') {
|
|
128
|
+
try {
|
|
129
|
+
parseDuration(job.timeout);
|
|
130
|
+
} catch {
|
|
131
|
+
errors.push(`Invalid timeout format: ${job.timeout}`);
|
|
132
|
+
}
|
|
133
|
+
} else if (typeof job.timeout !== 'number' || job.timeout <= 0) {
|
|
134
|
+
errors.push('timeout must be a positive number (milliseconds) or duration string');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Validate retry
|
|
139
|
+
if (job.retry !== undefined && job.retry !== null) {
|
|
140
|
+
if (typeof job.retry !== 'number' || job.retry < 0 || !Number.isInteger(job.retry)) {
|
|
141
|
+
errors.push('retry must be a non-negative integer');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate cron expression using cron-parser
|
|
146
|
+
if (job.cron) {
|
|
147
|
+
if (typeof job.cron !== 'string') {
|
|
148
|
+
errors.push('cron must be a string');
|
|
149
|
+
} else {
|
|
150
|
+
const validation = validateCronExpression(job.cron);
|
|
151
|
+
if (!validation.valid) {
|
|
152
|
+
errors.push(`Invalid cron expression: ${validation.error}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Validate runAt
|
|
158
|
+
if (job.runAt) {
|
|
159
|
+
if (typeof job.runAt !== 'string') {
|
|
160
|
+
errors.push('runAt must be a string (ISO 8601 datetime)');
|
|
161
|
+
} else {
|
|
162
|
+
const date = new Date(job.runAt);
|
|
163
|
+
if (isNaN(date.getTime())) {
|
|
164
|
+
errors.push('runAt must be a valid datetime');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
valid: errors.length === 0,
|
|
171
|
+
errors,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalize a job object (apply defaults and transformations)
|
|
177
|
+
* @param {object} job - Job object to normalize
|
|
178
|
+
* @returns {object} Normalized job object
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeJob(job) {
|
|
181
|
+
const normalized = { ...job };
|
|
182
|
+
|
|
183
|
+
// Determine job type
|
|
184
|
+
if (job.cron) {
|
|
185
|
+
normalized.type = JobType.CRON;
|
|
186
|
+
} else if (job.runAt) {
|
|
187
|
+
normalized.type = JobType.ONCE;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Normalize tags
|
|
191
|
+
if (normalized.tags) {
|
|
192
|
+
normalized.tags = normalized.tags.map(tag => tag.trim().toLowerCase());
|
|
193
|
+
normalized.tags = [...new Set(normalized.tags)]; // Remove duplicates
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Normalize timeout to milliseconds
|
|
197
|
+
if (normalized.timeout && typeof normalized.timeout === 'string') {
|
|
198
|
+
normalized.timeout = parseDuration(normalized.timeout);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Trim command
|
|
202
|
+
if (normalized.command) {
|
|
203
|
+
normalized.command = normalized.command.trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Trim name
|
|
207
|
+
if (normalized.name) {
|
|
208
|
+
normalized.name = normalized.name.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return normalized;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check if a job is a one-time job
|
|
216
|
+
* @param {object} job - Job object
|
|
217
|
+
* @returns {boolean} True if one-time job
|
|
218
|
+
*/
|
|
219
|
+
export function isOneTimeJob(job) {
|
|
220
|
+
return job.type === JobType.ONCE || !!job.runAt;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a job is a periodic job
|
|
225
|
+
* @param {object} job - Job object
|
|
226
|
+
* @returns {boolean} True if periodic job
|
|
227
|
+
*/
|
|
228
|
+
export function isPeriodicJob(job) {
|
|
229
|
+
return job.type === JobType.CRON || !!job.cron;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check if a job is active
|
|
234
|
+
* @param {object} job - Job object
|
|
235
|
+
* @returns {boolean} True if active
|
|
236
|
+
*/
|
|
237
|
+
export function isJobActive(job) {
|
|
238
|
+
return job.status === JobStatus.ACTIVE;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check if a job is paused
|
|
243
|
+
* @param {object} job - Job object
|
|
244
|
+
* @returns {boolean} True if paused
|
|
245
|
+
*/
|
|
246
|
+
export function isJobPaused(job) {
|
|
247
|
+
return job.status === JobStatus.PAUSED;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check if a job is completed
|
|
252
|
+
* @param {object} job - Job object
|
|
253
|
+
* @returns {boolean} True if completed
|
|
254
|
+
*/
|
|
255
|
+
export function isJobCompleted(job) {
|
|
256
|
+
return job.status === JobStatus.COMPLETED;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if a one-time job has expired (runAt is in the past)
|
|
261
|
+
* @param {object} job - Job object
|
|
262
|
+
* @returns {boolean} True if expired
|
|
263
|
+
*/
|
|
264
|
+
export function isJobExpired(job) {
|
|
265
|
+
if (!isOneTimeJob(job) || !job.runAt) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return new Date(job.runAt) < new Date();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Format job for display
|
|
273
|
+
* @param {object} job - Job object
|
|
274
|
+
* @returns {object} Formatted job object for display
|
|
275
|
+
*/
|
|
276
|
+
export function formatJobForDisplay(job) {
|
|
277
|
+
return {
|
|
278
|
+
id: job.id,
|
|
279
|
+
name: job.name,
|
|
280
|
+
command: job.command,
|
|
281
|
+
type: job.type,
|
|
282
|
+
schedule: job.cron || `at ${job.runAt}`,
|
|
283
|
+
status: job.status,
|
|
284
|
+
tags: job.tags?.join(', ') || '',
|
|
285
|
+
nextRun: job.nextRun,
|
|
286
|
+
lastRun: job.lastRun,
|
|
287
|
+
runCount: job.runCount,
|
|
288
|
+
createdAt: job.createdAt,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create a job execution result object
|
|
294
|
+
* @param {object} data - Result data
|
|
295
|
+
* @returns {object} Execution result object
|
|
296
|
+
*/
|
|
297
|
+
export function createExecutionResult(data) {
|
|
298
|
+
return {
|
|
299
|
+
jobId: data.jobId,
|
|
300
|
+
jobName: data.jobName,
|
|
301
|
+
startTime: data.startTime || new Date().toISOString(),
|
|
302
|
+
endTime: data.endTime,
|
|
303
|
+
duration: data.duration,
|
|
304
|
+
exitCode: data.exitCode,
|
|
305
|
+
success: data.exitCode === 0,
|
|
306
|
+
stdout: data.stdout || '',
|
|
307
|
+
stderr: data.stderr || '',
|
|
308
|
+
error: data.error || null,
|
|
309
|
+
triggeredBy: data.triggeredBy || 'scheduled',
|
|
310
|
+
attempt: data.attempt || 1,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export default {
|
|
315
|
+
JobStatus,
|
|
316
|
+
JobType,
|
|
317
|
+
JOB_DEFAULTS,
|
|
318
|
+
createJob,
|
|
319
|
+
validateJob,
|
|
320
|
+
normalizeJob,
|
|
321
|
+
isOneTimeJob,
|
|
322
|
+
isPeriodicJob,
|
|
323
|
+
isJobActive,
|
|
324
|
+
isJobPaused,
|
|
325
|
+
isJobCompleted,
|
|
326
|
+
isJobExpired,
|
|
327
|
+
formatJobForDisplay,
|
|
328
|
+
createExecutionResult,
|
|
329
|
+
};
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utilities for JM2
|
|
3
|
+
* Provides consistent logging for daemon and job execution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { getDaemonLogFile, getJobLogFile, ensureLogsDir } from '../utils/paths.js';
|
|
9
|
+
import { getConfigValue } from './config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Log levels
|
|
13
|
+
*/
|
|
14
|
+
export const LogLevel = {
|
|
15
|
+
DEBUG: 'DEBUG',
|
|
16
|
+
INFO: 'INFO',
|
|
17
|
+
WARN: 'WARN',
|
|
18
|
+
ERROR: 'ERROR',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Log level priority (higher = more severe)
|
|
23
|
+
*/
|
|
24
|
+
const LOG_LEVEL_PRIORITY = {
|
|
25
|
+
[LogLevel.DEBUG]: 0,
|
|
26
|
+
[LogLevel.INFO]: 1,
|
|
27
|
+
[LogLevel.WARN]: 2,
|
|
28
|
+
[LogLevel.ERROR]: 3,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Current minimum log level (can be set via environment variable)
|
|
33
|
+
*/
|
|
34
|
+
let minLogLevel = process.env.JM2_LOG_LEVEL?.toUpperCase() || LogLevel.INFO;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set the minimum log level
|
|
38
|
+
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
|
|
39
|
+
*/
|
|
40
|
+
export function setLogLevel(level) {
|
|
41
|
+
const upperLevel = level.toUpperCase();
|
|
42
|
+
if (LOG_LEVEL_PRIORITY[upperLevel] !== undefined) {
|
|
43
|
+
minLogLevel = upperLevel;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the current minimum log level
|
|
49
|
+
* @returns {string} Current log level
|
|
50
|
+
*/
|
|
51
|
+
export function getLogLevel() {
|
|
52
|
+
return minLogLevel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a log level should be logged based on current minimum level
|
|
57
|
+
* @param {string} level - Log level to check
|
|
58
|
+
* @returns {boolean} True if should be logged
|
|
59
|
+
*/
|
|
60
|
+
function shouldLog(level) {
|
|
61
|
+
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[minLogLevel];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Format a timestamp for logging
|
|
66
|
+
* @param {Date} date - Date to format
|
|
67
|
+
* @returns {string} Formatted timestamp
|
|
68
|
+
*/
|
|
69
|
+
function formatTimestamp(date = new Date()) {
|
|
70
|
+
return date.toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Format a log message
|
|
75
|
+
* @param {string} level - Log level
|
|
76
|
+
* @param {string} message - Log message
|
|
77
|
+
* @param {object} meta - Additional metadata
|
|
78
|
+
* @returns {string} Formatted log line
|
|
79
|
+
*/
|
|
80
|
+
function formatLogLine(level, message, meta = {}) {
|
|
81
|
+
const timestamp = formatTimestamp();
|
|
82
|
+
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
|
|
83
|
+
return `[${timestamp}] [${level}] ${message}${metaStr}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ensure the directory for a file exists
|
|
88
|
+
* @param {string} filePath - File path
|
|
89
|
+
*/
|
|
90
|
+
function ensureFileDir(filePath) {
|
|
91
|
+
const dir = dirname(filePath);
|
|
92
|
+
if (!existsSync(dir)) {
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write a log line to a file with rotation support
|
|
99
|
+
* @param {string} filePath - File path
|
|
100
|
+
* @param {string} line - Log line
|
|
101
|
+
* @param {object} rotation - Rotation options
|
|
102
|
+
* @param {number} rotation.maxSize - Maximum file size in bytes
|
|
103
|
+
* @param {number} rotation.maxFiles - Maximum number of log files to keep
|
|
104
|
+
*/
|
|
105
|
+
function writeToFile(filePath, line, rotation = null) {
|
|
106
|
+
ensureFileDir(filePath);
|
|
107
|
+
|
|
108
|
+
// Check for rotation if options provided
|
|
109
|
+
if (rotation && rotation.maxSize > 0) {
|
|
110
|
+
checkAndRotate(filePath, rotation.maxSize, rotation.maxFiles || 3);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
appendFileSync(filePath, line + '\n', 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get file size in bytes
|
|
118
|
+
* @param {string} filePath - File path
|
|
119
|
+
* @returns {number} File size in bytes, or 0 if file doesn't exist
|
|
120
|
+
*/
|
|
121
|
+
function getFileSize(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
if (existsSync(filePath)) {
|
|
124
|
+
return statSync(filePath).size;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore errors
|
|
128
|
+
}
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Rotate log files
|
|
134
|
+
* Renames existing log files: file.log -> file.log.1 -> file.log.2 -> ...
|
|
135
|
+
* @param {string} filePath - Base log file path
|
|
136
|
+
* @param {number} maxFiles - Maximum number of log files to keep (including current)
|
|
137
|
+
*/
|
|
138
|
+
function rotateLogs(filePath, maxFiles) {
|
|
139
|
+
// Start from the oldest file and work backwards
|
|
140
|
+
for (let i = maxFiles - 1; i >= 0; i--) {
|
|
141
|
+
const srcPath = i === 0 ? filePath : `${filePath}.${i}`;
|
|
142
|
+
const destPath = `${filePath}.${i + 1}`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(srcPath)) {
|
|
146
|
+
if (i === maxFiles - 1) {
|
|
147
|
+
// Delete the oldest file
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync(srcPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore unlink errors
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
renameSync(srcPath, destPath);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore rotation errors
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if log rotation is needed and perform rotation
|
|
165
|
+
* @param {string} filePath - Log file path
|
|
166
|
+
* @param {number} maxSize - Maximum file size in bytes
|
|
167
|
+
* @param {number} maxFiles - Maximum number of log files to keep
|
|
168
|
+
*/
|
|
169
|
+
function checkAndRotate(filePath, maxSize, maxFiles) {
|
|
170
|
+
const fileSize = getFileSize(filePath);
|
|
171
|
+
if (fileSize >= maxSize) {
|
|
172
|
+
rotateLogs(filePath, maxFiles);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse size string to bytes
|
|
178
|
+
* Supports formats like: 100, 10kb, 5mb, 1gb
|
|
179
|
+
* @param {string|number} size - Size string or number (in bytes)
|
|
180
|
+
* @returns {number} Size in bytes
|
|
181
|
+
*/
|
|
182
|
+
export function parseSize(size) {
|
|
183
|
+
if (typeof size === 'number') {
|
|
184
|
+
return size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const match = String(size).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
|
|
188
|
+
if (!match) {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const value = parseFloat(match[1]);
|
|
193
|
+
const unit = match[2] || 'b';
|
|
194
|
+
|
|
195
|
+
const multipliers = {
|
|
196
|
+
'b': 1,
|
|
197
|
+
'kb': 1024,
|
|
198
|
+
'mb': 1024 * 1024,
|
|
199
|
+
'gb': 1024 * 1024 * 1024,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return Math.floor(value * multipliers[unit]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Format bytes to human readable string
|
|
207
|
+
* @param {number} bytes - Size in bytes
|
|
208
|
+
* @returns {string} Human readable size (e.g., "10MB")
|
|
209
|
+
*/
|
|
210
|
+
export function formatSize(bytes) {
|
|
211
|
+
if (bytes === 0) return '0B';
|
|
212
|
+
|
|
213
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
214
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
215
|
+
const value = bytes / Math.pow(1024, i);
|
|
216
|
+
|
|
217
|
+
return `${value.toFixed(i === 0 ? 0 : 1)}${units[i]}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create a logger instance
|
|
222
|
+
* @param {object} options - Logger options
|
|
223
|
+
* @param {string} options.name - Logger name (for prefixing)
|
|
224
|
+
* @param {string} options.file - Log file path (optional)
|
|
225
|
+
* @param {boolean} options.console - Log to console (default: false for daemon)
|
|
226
|
+
* @param {object} options.rotation - Rotation options
|
|
227
|
+
* @param {number} options.rotation.maxSize - Maximum file size in bytes
|
|
228
|
+
* @param {number} options.rotation.maxFiles - Maximum number of log files to keep
|
|
229
|
+
* @returns {object} Logger instance
|
|
230
|
+
*/
|
|
231
|
+
export function createLogger(options = {}) {
|
|
232
|
+
const { name = 'JM2', file = null, console: logToConsole = false, rotation = null } = options;
|
|
233
|
+
|
|
234
|
+
const log = (level, message, meta = {}) => {
|
|
235
|
+
if (!shouldLog(level)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const line = formatLogLine(level, `[${name}] ${message}`, meta);
|
|
240
|
+
|
|
241
|
+
if (file) {
|
|
242
|
+
writeToFile(file, line, rotation);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (logToConsole) {
|
|
246
|
+
const consoleFn = level === LogLevel.ERROR ? console.error :
|
|
247
|
+
level === LogLevel.WARN ? console.warn :
|
|
248
|
+
console.log;
|
|
249
|
+
consoleFn(line);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
debug: (message, meta) => log(LogLevel.DEBUG, message, meta),
|
|
255
|
+
info: (message, meta) => log(LogLevel.INFO, message, meta),
|
|
256
|
+
warn: (message, meta) => log(LogLevel.WARN, message, meta),
|
|
257
|
+
error: (message, meta) => log(LogLevel.ERROR, message, meta),
|
|
258
|
+
log,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create a daemon logger
|
|
264
|
+
* Logs to ~/.jm2/daemon.log
|
|
265
|
+
* @param {object} options - Additional options
|
|
266
|
+
* @returns {object} Logger instance
|
|
267
|
+
*/
|
|
268
|
+
export function createDaemonLogger(options = {}) {
|
|
269
|
+
// Get rotation settings from config
|
|
270
|
+
const maxFileSize = getConfigValue('logging.maxFileSize', 10 * 1024 * 1024);
|
|
271
|
+
const maxFiles = getConfigValue('logging.maxFiles', 5);
|
|
272
|
+
|
|
273
|
+
return createLogger({
|
|
274
|
+
name: 'daemon',
|
|
275
|
+
file: getDaemonLogFile(),
|
|
276
|
+
console: options.foreground || false,
|
|
277
|
+
rotation: {
|
|
278
|
+
maxSize: maxFileSize,
|
|
279
|
+
maxFiles: maxFiles,
|
|
280
|
+
},
|
|
281
|
+
...options,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Create a job logger
|
|
287
|
+
* Logs to ~/.jm2/logs/{jobName}.log
|
|
288
|
+
* @param {string} jobName - Job name
|
|
289
|
+
* @param {object} options - Additional options
|
|
290
|
+
* @returns {object} Logger instance
|
|
291
|
+
*/
|
|
292
|
+
export function createJobLogger(jobName, options = {}) {
|
|
293
|
+
ensureLogsDir();
|
|
294
|
+
|
|
295
|
+
// Get rotation settings from config
|
|
296
|
+
const maxFileSize = getConfigValue('logging.maxFileSize', 10 * 1024 * 1024);
|
|
297
|
+
const maxFiles = getConfigValue('logging.maxFiles', 5);
|
|
298
|
+
|
|
299
|
+
return createLogger({
|
|
300
|
+
name: jobName,
|
|
301
|
+
file: getJobLogFile(jobName),
|
|
302
|
+
console: false,
|
|
303
|
+
rotation: {
|
|
304
|
+
maxSize: maxFileSize,
|
|
305
|
+
maxFiles: maxFiles,
|
|
306
|
+
},
|
|
307
|
+
...options,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Log job execution start
|
|
313
|
+
* @param {object} logger - Logger instance
|
|
314
|
+
* @param {object} job - Job object
|
|
315
|
+
* @param {string} triggeredBy - What triggered the execution (scheduled, manual)
|
|
316
|
+
*/
|
|
317
|
+
export function logJobStart(logger, job, triggeredBy = 'scheduled') {
|
|
318
|
+
logger.info(`Job execution started`, {
|
|
319
|
+
jobId: job.id,
|
|
320
|
+
jobName: job.name,
|
|
321
|
+
command: job.command,
|
|
322
|
+
triggeredBy,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Log job execution completion
|
|
328
|
+
* @param {object} logger - Logger instance
|
|
329
|
+
* @param {object} job - Job object
|
|
330
|
+
* @param {object} result - Execution result
|
|
331
|
+
*/
|
|
332
|
+
export function logJobComplete(logger, job, result) {
|
|
333
|
+
const level = result.exitCode === 0 ? LogLevel.INFO : LogLevel.ERROR;
|
|
334
|
+
logger.log(level, `Job execution completed`, {
|
|
335
|
+
jobId: job.id,
|
|
336
|
+
jobName: job.name,
|
|
337
|
+
exitCode: result.exitCode,
|
|
338
|
+
duration: result.duration,
|
|
339
|
+
success: result.exitCode === 0,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Log job output (stdout/stderr)
|
|
345
|
+
* @param {object} logger - Logger instance
|
|
346
|
+
* @param {string} stream - 'stdout' or 'stderr'
|
|
347
|
+
* @param {string} data - Output data
|
|
348
|
+
*/
|
|
349
|
+
export function logJobOutput(logger, stream, data) {
|
|
350
|
+
const lines = data.toString().split('\n').filter(line => line.trim());
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
if (stream === 'stderr') {
|
|
353
|
+
logger.error(`[${stream}] ${line}`);
|
|
354
|
+
} else {
|
|
355
|
+
logger.info(`[${stream}] ${line}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Clear a log file
|
|
362
|
+
* @param {string} filePath - File path to clear
|
|
363
|
+
*/
|
|
364
|
+
export function clearLogFile(filePath) {
|
|
365
|
+
ensureFileDir(filePath);
|
|
366
|
+
writeFileSync(filePath, '', 'utf8');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export default {
|
|
370
|
+
LogLevel,
|
|
371
|
+
setLogLevel,
|
|
372
|
+
getLogLevel,
|
|
373
|
+
createLogger,
|
|
374
|
+
createDaemonLogger,
|
|
375
|
+
createJobLogger,
|
|
376
|
+
logJobStart,
|
|
377
|
+
logJobComplete,
|
|
378
|
+
logJobOutput,
|
|
379
|
+
clearLogFile,
|
|
380
|
+
parseSize,
|
|
381
|
+
formatSize,
|
|
382
|
+
};
|