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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC protocol definitions for JM2
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const MessageType = {
|
|
6
|
+
// Basic
|
|
7
|
+
PING: 'ping',
|
|
8
|
+
PONG: 'pong',
|
|
9
|
+
ERROR: 'error',
|
|
10
|
+
|
|
11
|
+
// Job management
|
|
12
|
+
JOB_ADD: 'job:add',
|
|
13
|
+
JOB_ADDED: 'job:added',
|
|
14
|
+
JOB_LIST: 'job:list',
|
|
15
|
+
JOB_LIST_RESULT: 'job:list:result',
|
|
16
|
+
JOB_GET: 'job:get',
|
|
17
|
+
JOB_GET_RESULT: 'job:get:result',
|
|
18
|
+
JOB_REMOVE: 'job:remove',
|
|
19
|
+
JOB_REMOVED: 'job:removed',
|
|
20
|
+
JOB_UPDATE: 'job:update',
|
|
21
|
+
JOB_UPDATED: 'job:updated',
|
|
22
|
+
JOB_PAUSE: 'job:pause',
|
|
23
|
+
JOB_PAUSED: 'job:paused',
|
|
24
|
+
JOB_RESUME: 'job:resume',
|
|
25
|
+
JOB_RESUMED: 'job:resumed',
|
|
26
|
+
JOB_RUN: 'job:run',
|
|
27
|
+
JOB_RUN_RESULT: 'job:run:result',
|
|
28
|
+
|
|
29
|
+
// Flush/cleanup
|
|
30
|
+
FLUSH: 'flush',
|
|
31
|
+
FLUSH_RESULT: 'flush:result',
|
|
32
|
+
|
|
33
|
+
// Import/reload
|
|
34
|
+
RELOAD_JOBS: 'jobs:reload',
|
|
35
|
+
RELOAD_JOBS_RESULT: 'jobs:reload:result',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a job add response
|
|
40
|
+
* @param {object} job - Added job
|
|
41
|
+
* @returns {{ type: string, job: object }}
|
|
42
|
+
*/
|
|
43
|
+
export function createJobAddedResponse(job) {
|
|
44
|
+
return {
|
|
45
|
+
type: MessageType.JOB_ADDED,
|
|
46
|
+
job,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a job list response
|
|
52
|
+
* @param {Array} jobs - List of jobs
|
|
53
|
+
* @returns {{ type: string, jobs: Array }}
|
|
54
|
+
*/
|
|
55
|
+
export function createJobListResponse(jobs) {
|
|
56
|
+
return {
|
|
57
|
+
type: MessageType.JOB_LIST_RESULT,
|
|
58
|
+
jobs,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a job get response
|
|
64
|
+
* @param {object|null} job - Job or null if not found
|
|
65
|
+
* @returns {{ type: string, job: object|null }}
|
|
66
|
+
*/
|
|
67
|
+
export function createJobGetResponse(job) {
|
|
68
|
+
return {
|
|
69
|
+
type: MessageType.JOB_GET_RESULT,
|
|
70
|
+
job,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a job removed response
|
|
76
|
+
* @param {boolean} success - Whether removal was successful
|
|
77
|
+
* @returns {{ type: string, success: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
export function createJobRemovedResponse(success) {
|
|
80
|
+
return {
|
|
81
|
+
type: MessageType.JOB_REMOVED,
|
|
82
|
+
success,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a job updated response
|
|
88
|
+
* @param {object|null} job - Updated job or null if not found
|
|
89
|
+
* @returns {{ type: string, job: object|null }}
|
|
90
|
+
*/
|
|
91
|
+
export function createJobUpdatedResponse(job) {
|
|
92
|
+
return {
|
|
93
|
+
type: MessageType.JOB_UPDATED,
|
|
94
|
+
job,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a job paused response
|
|
100
|
+
* @param {object|null} job - Paused job or null if not found
|
|
101
|
+
* @returns {{ type: string, job: object|null }}
|
|
102
|
+
*/
|
|
103
|
+
export function createJobPausedResponse(job) {
|
|
104
|
+
return {
|
|
105
|
+
type: MessageType.JOB_PAUSED,
|
|
106
|
+
job,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a job resumed response
|
|
112
|
+
* @param {object|null} job - Resumed job or null if not found
|
|
113
|
+
* @returns {{ type: string, job: object|null }}
|
|
114
|
+
*/
|
|
115
|
+
export function createJobResumedResponse(job) {
|
|
116
|
+
return {
|
|
117
|
+
type: MessageType.JOB_RESUMED,
|
|
118
|
+
job,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a job run response
|
|
124
|
+
* @param {object} result - Run result
|
|
125
|
+
* @returns {{ type: string, result: object }}
|
|
126
|
+
*/
|
|
127
|
+
export function createJobRunResponse(result) {
|
|
128
|
+
return {
|
|
129
|
+
type: MessageType.JOB_RUN_RESULT,
|
|
130
|
+
result,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a flush result response
|
|
136
|
+
* @param {object} result - Flush result with jobsRemoved, logsRemoved, historyRemoved
|
|
137
|
+
* @returns {{ type: string, jobsRemoved: number, logsRemoved: number, historyRemoved: number }}
|
|
138
|
+
*/
|
|
139
|
+
export function createFlushResultResponse(result) {
|
|
140
|
+
return {
|
|
141
|
+
type: MessageType.FLUSH_RESULT,
|
|
142
|
+
jobsRemoved: result.jobsRemoved || 0,
|
|
143
|
+
logsRemoved: result.logsRemoved || 0,
|
|
144
|
+
historyRemoved: result.historyRemoved || 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a standard error response
|
|
150
|
+
* @param {string} message - Error message
|
|
151
|
+
* @returns {{ type: string, message: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function createErrorResponse(message) {
|
|
154
|
+
return {
|
|
155
|
+
type: MessageType.ERROR,
|
|
156
|
+
message,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a pong response
|
|
162
|
+
* @returns {{ type: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function createPongResponse() {
|
|
165
|
+
return {
|
|
166
|
+
type: MessageType.PONG,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export default {
|
|
171
|
+
MessageType,
|
|
172
|
+
createErrorResponse,
|
|
173
|
+
createPongResponse,
|
|
174
|
+
createJobAddedResponse,
|
|
175
|
+
createJobListResponse,
|
|
176
|
+
createJobGetResponse,
|
|
177
|
+
createJobRemovedResponse,
|
|
178
|
+
createJobUpdatedResponse,
|
|
179
|
+
createJobPausedResponse,
|
|
180
|
+
createJobResumedResponse,
|
|
181
|
+
createJobRunResponse,
|
|
182
|
+
createFlushResultResponse,
|
|
183
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC server for JM2 daemon
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createServer } from 'node:net';
|
|
6
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
7
|
+
import { getSocketPath, ensureDataDir } from '../utils/paths.js';
|
|
8
|
+
import { MessageType, createErrorResponse, createPongResponse } from './protocol.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start IPC server
|
|
12
|
+
* @param {object} options - Server options
|
|
13
|
+
* @param {function} options.onMessage - Handler for incoming messages
|
|
14
|
+
* @returns {import('node:net').Server}
|
|
15
|
+
*/
|
|
16
|
+
export function startIpcServer(options = {}) {
|
|
17
|
+
const { onMessage } = options;
|
|
18
|
+
const socketPath = getSocketPath();
|
|
19
|
+
|
|
20
|
+
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
|
21
|
+
unlinkSync(socketPath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ensureDataDir();
|
|
25
|
+
|
|
26
|
+
const server = createServer(socket => {
|
|
27
|
+
let buffer = '';
|
|
28
|
+
|
|
29
|
+
socket.on('data', data => {
|
|
30
|
+
buffer += data.toString();
|
|
31
|
+
let index;
|
|
32
|
+
while ((index = buffer.indexOf('\n')) !== -1) {
|
|
33
|
+
const line = buffer.slice(0, index).trim();
|
|
34
|
+
buffer = buffer.slice(index + 1);
|
|
35
|
+
if (!line) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let message;
|
|
40
|
+
try {
|
|
41
|
+
message = JSON.parse(line);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
socket.write(JSON.stringify(createErrorResponse('Invalid JSON')) + '\n');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!message?.type) {
|
|
48
|
+
socket.write(JSON.stringify(createErrorResponse('Missing message type')) + '\n');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (message.type === MessageType.PING) {
|
|
53
|
+
socket.write(JSON.stringify(createPongResponse()) + '\n');
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (onMessage) {
|
|
58
|
+
Promise.resolve(onMessage(message))
|
|
59
|
+
.then(response => {
|
|
60
|
+
if (response) {
|
|
61
|
+
socket.write(JSON.stringify(response) + '\n');
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
.catch(err => {
|
|
65
|
+
socket.write(JSON.stringify(createErrorResponse(err.message)) + '\n');
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
socket.write(JSON.stringify(createErrorResponse('No handler configured')) + '\n');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
server.listen(socketPath);
|
|
75
|
+
return server;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stop IPC server
|
|
80
|
+
* @param {import('node:net').Server} server - IPC server instance
|
|
81
|
+
*/
|
|
82
|
+
export function stopIpcServer(server) {
|
|
83
|
+
if (!server) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
server.close();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default {
|
|
90
|
+
startIpcServer,
|
|
91
|
+
stopIpcServer,
|
|
92
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron expression utilities for JM2
|
|
3
|
+
* Uses cron-parser library for parsing and calculating next run times
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default options for cron parsing with UTC
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_OPTIONS = {
|
|
12
|
+
utc: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate a cron expression
|
|
17
|
+
* @param {string} expression - Cron expression to validate
|
|
18
|
+
* @returns {{ valid: boolean, error?: string }} Validation result
|
|
19
|
+
*/
|
|
20
|
+
export function validateCronExpression(expression) {
|
|
21
|
+
if (!expression || typeof expression !== 'string') {
|
|
22
|
+
return { valid: false, error: 'Cron expression must be a non-empty string' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = expression.trim();
|
|
26
|
+
if (trimmed === '') {
|
|
27
|
+
return { valid: false, error: 'Cron expression cannot be empty' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
CronExpressionParser.parse(trimmed, DEFAULT_OPTIONS);
|
|
32
|
+
return { valid: true };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { valid: false, error: error.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the next run time for a cron expression
|
|
40
|
+
* @param {string} expression - Cron expression
|
|
41
|
+
* @param {Date} [fromDate] - Date to calculate from (default: now)
|
|
42
|
+
* @returns {Date|null} Next run time or null if invalid
|
|
43
|
+
*/
|
|
44
|
+
export function getNextRunTime(expression, fromDate = new Date()) {
|
|
45
|
+
try {
|
|
46
|
+
const interval = CronExpressionParser.parse(expression.trim(), {
|
|
47
|
+
...DEFAULT_OPTIONS,
|
|
48
|
+
currentDate: fromDate,
|
|
49
|
+
});
|
|
50
|
+
return interval.next().toDate();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get multiple upcoming run times for a cron expression
|
|
58
|
+
* @param {string} expression - Cron expression
|
|
59
|
+
* @param {number} count - Number of run times to get (default: 5)
|
|
60
|
+
* @param {Date} [fromDate] - Date to calculate from (default: now)
|
|
61
|
+
* @returns {Date[]} Array of upcoming run times
|
|
62
|
+
*/
|
|
63
|
+
export function getUpcomingRunTimes(expression, count = 5, fromDate = new Date()) {
|
|
64
|
+
try {
|
|
65
|
+
const interval = CronExpressionParser.parse(expression.trim(), {
|
|
66
|
+
...DEFAULT_OPTIONS,
|
|
67
|
+
currentDate: fromDate,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const times = [];
|
|
71
|
+
for (let i = 0; i < count; i++) {
|
|
72
|
+
times.push(interval.next().toDate());
|
|
73
|
+
}
|
|
74
|
+
return times;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a cron expression has a valid format
|
|
82
|
+
* @param {string} expression - Cron expression to check
|
|
83
|
+
* @returns {boolean} True if valid
|
|
84
|
+
*/
|
|
85
|
+
export function isValidCronExpression(expression) {
|
|
86
|
+
return validateCronExpression(expression).valid;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get a human-readable description of a cron expression
|
|
91
|
+
* @param {string} expression - Cron expression
|
|
92
|
+
* @returns {string} Human-readable description or error message
|
|
93
|
+
*/
|
|
94
|
+
export function describeCronExpression(expression) {
|
|
95
|
+
const validation = validateCronExpression(expression);
|
|
96
|
+
if (!validation.valid) {
|
|
97
|
+
return `Invalid cron: ${validation.error}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const interval = CronExpressionParser.parse(expression.trim(), DEFAULT_OPTIONS);
|
|
102
|
+
const fields = interval.fields;
|
|
103
|
+
|
|
104
|
+
// Build a simple description using the cron string itself
|
|
105
|
+
// and getting a sample next run time
|
|
106
|
+
const nextRun = interval.next().toDate();
|
|
107
|
+
const timeStr = nextRun.toISOString().slice(11, 16); // HH:MM
|
|
108
|
+
const dateStr = nextRun.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
109
|
+
|
|
110
|
+
// Simple description based on the pattern
|
|
111
|
+
const parts = [];
|
|
112
|
+
|
|
113
|
+
// Check for common patterns
|
|
114
|
+
if (expression === '* * * * *') {
|
|
115
|
+
return 'Every minute';
|
|
116
|
+
}
|
|
117
|
+
if (expression === '0 * * * *') {
|
|
118
|
+
return 'Every hour at minute 0';
|
|
119
|
+
}
|
|
120
|
+
if (expression === '0 0 * * *') {
|
|
121
|
+
return 'Daily at midnight (00:00)';
|
|
122
|
+
}
|
|
123
|
+
if (expression === '0 0 * * 0') {
|
|
124
|
+
return 'Weekly on Sunday at midnight';
|
|
125
|
+
}
|
|
126
|
+
if (expression === '0 0 1 * *') {
|
|
127
|
+
return 'Monthly on the 1st at midnight';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generic description with next occurrence
|
|
131
|
+
return `Cron: ${expression.trim()} (next: ${dateStr} ${timeStr} UTC)`;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return `Invalid cron expression`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse a cron expression and return the interval object
|
|
139
|
+
* @param {string} expression - Cron expression
|
|
140
|
+
* @param {Date} [fromDate] - Date to calculate from
|
|
141
|
+
* @returns {CronExpression|null} CronExpression object or null if invalid
|
|
142
|
+
*/
|
|
143
|
+
export function parseCronExpression(expression, fromDate = new Date()) {
|
|
144
|
+
try {
|
|
145
|
+
return CronExpressionParser.parse(expression.trim(), {
|
|
146
|
+
...DEFAULT_OPTIONS,
|
|
147
|
+
currentDate: fromDate,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a job should run at a specific time
|
|
156
|
+
* @param {string} expression - Cron expression
|
|
157
|
+
* @param {Date} checkTime - Time to check
|
|
158
|
+
* @returns {boolean} True if job should run at checkTime
|
|
159
|
+
*/
|
|
160
|
+
export function shouldRunAt(expression, checkTime = new Date()) {
|
|
161
|
+
try {
|
|
162
|
+
const interval = CronExpressionParser.parse(expression.trim(), {
|
|
163
|
+
...DEFAULT_OPTIONS,
|
|
164
|
+
currentDate: new Date(checkTime.getTime() - 60000), // 1 minute before
|
|
165
|
+
});
|
|
166
|
+
const nextRun = interval.next().toDate();
|
|
167
|
+
|
|
168
|
+
// Check if the next run is within the same minute as checkTime
|
|
169
|
+
const nextMinute = Math.floor(nextRun.getTime() / 60000);
|
|
170
|
+
const checkMinute = Math.floor(checkTime.getTime() / 60000);
|
|
171
|
+
|
|
172
|
+
return nextMinute === checkMinute;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Common cron expression presets
|
|
180
|
+
*/
|
|
181
|
+
export const CronPresets = {
|
|
182
|
+
EVERY_MINUTE: '* * * * *',
|
|
183
|
+
EVERY_5_MINUTES: '*/5 * * * *',
|
|
184
|
+
EVERY_15_MINUTES: '*/15 * * * *',
|
|
185
|
+
EVERY_30_MINUTES: '*/30 * * * *',
|
|
186
|
+
HOURLY: '0 * * * *',
|
|
187
|
+
EVERY_2_HOURS: '0 */2 * * *',
|
|
188
|
+
DAILY: '0 0 * * *',
|
|
189
|
+
WEEKLY: '0 0 * * 0',
|
|
190
|
+
MONTHLY: '0 0 1 * *',
|
|
191
|
+
YEARLY: '0 0 1 1 *',
|
|
192
|
+
WEEKDAYS: '0 0 * * 1-5',
|
|
193
|
+
WEEKENDS: '0 0 * * 0,6',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export default {
|
|
197
|
+
validateCronExpression,
|
|
198
|
+
getNextRunTime,
|
|
199
|
+
getUpcomingRunTimes,
|
|
200
|
+
isValidCronExpression,
|
|
201
|
+
describeCronExpression,
|
|
202
|
+
parseCronExpression,
|
|
203
|
+
shouldRunAt,
|
|
204
|
+
CronPresets,
|
|
205
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datetime parsing utilities for JM2
|
|
3
|
+
* Handles parsing --at datetime strings and --in duration to datetime conversion
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseDuration } from './duration.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a datetime string into a Date object
|
|
10
|
+
* Supports various formats including ISO 8601, and human-readable formats
|
|
11
|
+
*
|
|
12
|
+
* @param {string} datetimeStr - Datetime string to parse
|
|
13
|
+
* @returns {Date} Parsed Date object
|
|
14
|
+
* @throws {Error} If the datetime string is invalid
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* parseDateTime('2026-01-31T10:00:00Z') // ISO 8601
|
|
18
|
+
* parseDateTime('2026-01-31 10:00:00') // Date and time
|
|
19
|
+
* parseDateTime('2026-01-31') // Date only (time set to 00:00:00)
|
|
20
|
+
* parseDateTime('today 10:00') // Today at specific time
|
|
21
|
+
* parseDateTime('tomorrow 14:30') // Tomorrow at specific time
|
|
22
|
+
*/
|
|
23
|
+
export function parseDateTime(datetimeStr) {
|
|
24
|
+
if (typeof datetimeStr !== 'string') {
|
|
25
|
+
throw new Error('Datetime must be a string');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trimmed = datetimeStr.trim();
|
|
29
|
+
|
|
30
|
+
if (trimmed === '') {
|
|
31
|
+
throw new Error('Datetime cannot be empty');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lower = trimmed.toLowerCase();
|
|
35
|
+
const now = new Date();
|
|
36
|
+
|
|
37
|
+
// Handle special keywords
|
|
38
|
+
if (lower === 'now') {
|
|
39
|
+
return new Date();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Handle "today" and "tomorrow" with time
|
|
43
|
+
const todayTomorrowMatch = lower.match(/^(today|tomorrow)(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
44
|
+
if (todayTomorrowMatch) {
|
|
45
|
+
const day = todayTomorrowMatch[1];
|
|
46
|
+
const hours = parseInt(todayTomorrowMatch[2] || '0', 10);
|
|
47
|
+
const minutes = parseInt(todayTomorrowMatch[3] || '0', 10);
|
|
48
|
+
const seconds = parseInt(todayTomorrowMatch[4] || '0', 10);
|
|
49
|
+
|
|
50
|
+
if (hours > 23 || minutes > 59 || seconds > 59) {
|
|
51
|
+
throw new Error(`Invalid time in datetime: "${datetimeStr}"`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const date = new Date(now);
|
|
55
|
+
if (day === 'tomorrow') {
|
|
56
|
+
date.setDate(date.getDate() + 1);
|
|
57
|
+
}
|
|
58
|
+
date.setHours(hours, minutes, seconds, 0);
|
|
59
|
+
return date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Try parsing as ISO 8601 or standard Date format
|
|
63
|
+
const date = new Date(trimmed);
|
|
64
|
+
|
|
65
|
+
if (!isNaN(date.getTime())) {
|
|
66
|
+
// Valid date parsed
|
|
67
|
+
return date;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Invalid datetime format: "${datetimeStr}". ` +
|
|
72
|
+
`Supported formats: ISO 8601 (2026-01-31T10:00:00Z), ` +
|
|
73
|
+
`date and time (2026-01-31 10:00:00), date only (2026-01-31), ` +
|
|
74
|
+
`today/tomorrow with time (today 10:00, tomorrow 14:30), or "now"`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Calculate a future datetime by adding a duration to now
|
|
80
|
+
* Used for --in option (e.g., --in 1h30m)
|
|
81
|
+
*
|
|
82
|
+
* @param {string} durationStr - Duration string (e.g., "30s", "5m", "2h", "1d", "1h30m")
|
|
83
|
+
* @param {Date} [fromDate] - Starting date (default: now)
|
|
84
|
+
* @returns {Date} Future datetime
|
|
85
|
+
* @throws {Error} If the duration string is invalid
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* parseRunIn('1h30m') // Returns Date for 1 hour 30 minutes from now
|
|
89
|
+
* parseRunIn('1d') // Returns Date for 1 day from now
|
|
90
|
+
* parseRunIn('30m') // Returns Date for 30 minutes from now
|
|
91
|
+
*/
|
|
92
|
+
export function parseRunIn(durationStr, fromDate = new Date()) {
|
|
93
|
+
if (typeof durationStr !== 'string') {
|
|
94
|
+
throw new Error('Duration must be a string');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const trimmed = durationStr.trim();
|
|
98
|
+
|
|
99
|
+
if (trimmed === '') {
|
|
100
|
+
throw new Error('Duration cannot be empty');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parse the duration to get milliseconds
|
|
104
|
+
const durationMs = parseDuration(trimmed);
|
|
105
|
+
|
|
106
|
+
// Calculate future datetime
|
|
107
|
+
return new Date(fromDate.getTime() + durationMs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert a datetime option (either --at or --in) to an ISO datetime string
|
|
112
|
+
* This is the main entry point for job creation
|
|
113
|
+
*
|
|
114
|
+
* @param {object} options - Options object with either 'at' or 'in' property
|
|
115
|
+
* @param {string} [options.at] - Datetime string for --at option
|
|
116
|
+
* @param {string} [options.in] - Duration string for --in option
|
|
117
|
+
* @returns {string} ISO 8601 datetime string
|
|
118
|
+
* @throws {Error} If neither or both options are provided, or if parsing fails
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* parseRunAtOption({ at: '2026-01-31T10:00:00Z' }) // Returns ISO string
|
|
122
|
+
* parseRunAtOption({ in: '1h30m' }) // Returns ISO string for 1h30m from now
|
|
123
|
+
*/
|
|
124
|
+
export function parseRunAtOption(options) {
|
|
125
|
+
if (!options || typeof options !== 'object') {
|
|
126
|
+
throw new Error('Options must be an object');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const hasAt = options.at !== undefined && options.at !== null && options.at !== '';
|
|
130
|
+
const hasIn = options.in !== undefined && options.in !== null && options.in !== '';
|
|
131
|
+
|
|
132
|
+
if (!hasAt && !hasIn) {
|
|
133
|
+
throw new Error('Either "at" or "in" option must be provided');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (hasAt && hasIn) {
|
|
137
|
+
throw new Error('Cannot specify both "at" and "in" options');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (hasIn) {
|
|
141
|
+
const date = parseRunIn(options.in);
|
|
142
|
+
return date.toISOString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hasAt) {
|
|
146
|
+
const date = parseDateTime(options.at);
|
|
147
|
+
return date.toISOString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error('Unexpected error parsing datetime option');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if a datetime has passed (is in the past)
|
|
155
|
+
*
|
|
156
|
+
* @param {Date|string} date - Date to check
|
|
157
|
+
* @param {Date} [referenceDate] - Reference date (default: now)
|
|
158
|
+
* @returns {boolean} True if the date is in the past
|
|
159
|
+
*/
|
|
160
|
+
export function isDateTimePast(date, referenceDate = new Date()) {
|
|
161
|
+
const checkDate = date instanceof Date ? date : new Date(date);
|
|
162
|
+
|
|
163
|
+
if (isNaN(checkDate.getTime())) {
|
|
164
|
+
throw new Error('Invalid date provided');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return checkDate.getTime() < referenceDate.getTime();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Format a date for display in CLI output
|
|
172
|
+
*
|
|
173
|
+
* @param {Date|string} date - Date to format
|
|
174
|
+
* @returns {string} Formatted date string
|
|
175
|
+
*/
|
|
176
|
+
export function formatDateTime(date) {
|
|
177
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
178
|
+
|
|
179
|
+
if (isNaN(d.getTime())) {
|
|
180
|
+
return 'Invalid date';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return d.toLocaleString('en-US', {
|
|
184
|
+
year: 'numeric',
|
|
185
|
+
month: 'short',
|
|
186
|
+
day: '2-digit',
|
|
187
|
+
hour: '2-digit',
|
|
188
|
+
minute: '2-digit',
|
|
189
|
+
second: '2-digit',
|
|
190
|
+
hour12: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get relative time description (e.g., "in 5 minutes", "2 hours ago")
|
|
196
|
+
*
|
|
197
|
+
* @param {Date|string} date - Date to describe
|
|
198
|
+
* @param {Date} [referenceDate] - Reference date (default: now)
|
|
199
|
+
* @returns {string} Relative time description
|
|
200
|
+
*/
|
|
201
|
+
export function getRelativeTimeDescription(date, referenceDate = new Date()) {
|
|
202
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
203
|
+
|
|
204
|
+
if (isNaN(d.getTime())) {
|
|
205
|
+
return 'Invalid date';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const diffMs = d.getTime() - referenceDate.getTime();
|
|
209
|
+
const diffSec = Math.round(diffMs / 1000);
|
|
210
|
+
const diffMin = Math.round(diffSec / 60);
|
|
211
|
+
const diffHour = Math.round(diffMin / 60);
|
|
212
|
+
const diffDay = Math.round(diffHour / 24);
|
|
213
|
+
|
|
214
|
+
if (Math.abs(diffSec) < 60) {
|
|
215
|
+
return diffSec >= 0 ? 'in a few seconds' : 'just now';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (Math.abs(diffMin) < 60) {
|
|
219
|
+
return diffMin >= 0
|
|
220
|
+
? `in ${diffMin} minute${diffMin !== 1 ? 's' : ''}`
|
|
221
|
+
: `${Math.abs(diffMin)} minute${Math.abs(diffMin) !== 1 ? 's' : ''} ago`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (Math.abs(diffHour) < 24) {
|
|
225
|
+
return diffHour >= 0
|
|
226
|
+
? `in ${diffHour} hour${diffHour !== 1 ? 's' : ''}`
|
|
227
|
+
: `${Math.abs(diffHour)} hour${Math.abs(diffHour) !== 1 ? 's' : ''} ago`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (Math.abs(diffDay) < 30) {
|
|
231
|
+
return diffDay >= 0
|
|
232
|
+
? `in ${diffDay} day${diffDay !== 1 ? 's' : ''}`
|
|
233
|
+
: `${Math.abs(diffDay)} day${Math.abs(diffDay) !== 1 ? 's' : ''} ago`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return formatDateTime(d);
|
|
237
|
+
}
|