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.
Files changed (40) hide show
  1. package/GNU-AGPL-3.0 +665 -0
  2. package/README.md +603 -0
  3. package/bin/jm2.js +24 -0
  4. package/package.json +70 -0
  5. package/src/cli/commands/add.js +206 -0
  6. package/src/cli/commands/config.js +212 -0
  7. package/src/cli/commands/edit.js +198 -0
  8. package/src/cli/commands/export.js +61 -0
  9. package/src/cli/commands/flush.js +132 -0
  10. package/src/cli/commands/history.js +179 -0
  11. package/src/cli/commands/import.js +180 -0
  12. package/src/cli/commands/list.js +174 -0
  13. package/src/cli/commands/logs.js +415 -0
  14. package/src/cli/commands/pause.js +97 -0
  15. package/src/cli/commands/remove.js +107 -0
  16. package/src/cli/commands/restart.js +68 -0
  17. package/src/cli/commands/resume.js +96 -0
  18. package/src/cli/commands/run.js +115 -0
  19. package/src/cli/commands/show.js +159 -0
  20. package/src/cli/commands/start.js +46 -0
  21. package/src/cli/commands/status.js +47 -0
  22. package/src/cli/commands/stop.js +48 -0
  23. package/src/cli/index.js +274 -0
  24. package/src/cli/utils/output.js +267 -0
  25. package/src/cli/utils/prompts.js +56 -0
  26. package/src/core/config.js +227 -0
  27. package/src/core/history-db.js +439 -0
  28. package/src/core/job.js +329 -0
  29. package/src/core/logger.js +382 -0
  30. package/src/core/storage.js +315 -0
  31. package/src/daemon/executor.js +409 -0
  32. package/src/daemon/index.js +873 -0
  33. package/src/daemon/scheduler.js +465 -0
  34. package/src/ipc/client.js +112 -0
  35. package/src/ipc/protocol.js +183 -0
  36. package/src/ipc/server.js +92 -0
  37. package/src/utils/cron.js +205 -0
  38. package/src/utils/datetime.js +237 -0
  39. package/src/utils/duration.js +226 -0
  40. 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
+ }