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,132 @@
1
+ /**
2
+ * JM2 flush command
3
+ * Cleans up completed one-time jobs, old logs, and old history entries
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import { printSuccess, printError, printInfo, printWarning } from '../utils/output.js';
9
+ import { isDaemonRunning } from '../../daemon/index.js';
10
+ import { confirmDestructive } from '../utils/prompts.js';
11
+ import { parseDuration } from '../../utils/duration.js';
12
+
13
+ /**
14
+ * Execute the flush command
15
+ * @param {object} options - Command options
16
+ * @returns {Promise<number>} Exit code
17
+ */
18
+ export async function flushCommand(options = {}) {
19
+ // Check if daemon is running
20
+ if (!isDaemonRunning()) {
21
+ printError('Daemon is not running. Start it with: jm2 start');
22
+ return 1;
23
+ }
24
+
25
+ // Build a summary of what will be flushed
26
+ const actions = [];
27
+ if (options.jobs !== false) {
28
+ actions.push('completed one-time jobs');
29
+ }
30
+ if (options.logs) {
31
+ actions.push(`logs older than ${options.logs}`);
32
+ }
33
+ if (options.history) {
34
+ actions.push(`history entries older than ${options.history}`);
35
+ }
36
+ if (options.all) {
37
+ actions.length = 0;
38
+ actions.push('completed one-time jobs, all logs, and all history');
39
+ }
40
+
41
+ if (actions.length === 0) {
42
+ actions.push('completed one-time jobs (default)');
43
+ }
44
+
45
+ // Confirm destructive action unless --force is used
46
+ const action = `flush ${actions.join(', ')}`;
47
+ const confirmed = await confirmDestructive(action, options.force);
48
+ if (!confirmed) {
49
+ printInfo('Operation cancelled');
50
+ return 0;
51
+ }
52
+
53
+ try {
54
+ // Build flush request
55
+ const flushRequest = {
56
+ type: MessageType.FLUSH,
57
+ jobs: options.jobs !== false && !options.all,
58
+ logs: options.logs || options.all || false,
59
+ logsAge: options.logs || null,
60
+ history: options.history || options.all || false,
61
+ historyAge: options.history || null,
62
+ };
63
+
64
+ // Parse duration options if provided
65
+ if (flushRequest.logsAge && !options.all) {
66
+ const duration = parseDuration(flushRequest.logsAge);
67
+ if (duration === null) {
68
+ printError(`Invalid duration for --logs: ${options.logs}`);
69
+ return 1;
70
+ }
71
+ flushRequest.logsAgeMs = duration;
72
+ }
73
+
74
+ if (flushRequest.historyAge && !options.all) {
75
+ const duration = parseDuration(flushRequest.historyAge);
76
+ if (duration === null) {
77
+ printError(`Invalid duration for --history: ${options.history}`);
78
+ return 1;
79
+ }
80
+ flushRequest.historyAgeMs = duration;
81
+ }
82
+
83
+ const response = await send(flushRequest);
84
+
85
+ if (response.type === MessageType.ERROR) {
86
+ printError(response.message);
87
+ return 1;
88
+ }
89
+
90
+ if (response.type === MessageType.FLUSH_RESULT) {
91
+ // Report results
92
+ let hasResults = false;
93
+
94
+ if (response.jobsRemoved > 0) {
95
+ printSuccess(`Removed ${response.jobsRemoved} completed one-time job(s)`);
96
+ hasResults = true;
97
+ } else if (options.jobs !== false && !options.all) {
98
+ printInfo('No completed one-time jobs to remove');
99
+ }
100
+
101
+ if (options.logs || options.all) {
102
+ if (response.logsRemoved > 0) {
103
+ printSuccess(`Removed ${response.logsRemoved} log file(s)`);
104
+ hasResults = true;
105
+ } else {
106
+ printInfo('No log files to remove');
107
+ }
108
+ }
109
+
110
+ if (options.history || options.all) {
111
+ if (response.historyRemoved > 0) {
112
+ printSuccess(`Removed ${response.historyRemoved} history entries(s)`);
113
+ hasResults = true;
114
+ } else {
115
+ printInfo('No history entries to remove');
116
+ }
117
+ }
118
+
119
+ if (!hasResults && options.jobs === false) {
120
+ printInfo('Nothing to flush');
121
+ }
122
+
123
+ return 0;
124
+ }
125
+
126
+ printError('Unexpected response from daemon');
127
+ return 1;
128
+ } catch (error) {
129
+ printError(`Failed to flush: ${error.message}`);
130
+ return 1;
131
+ }
132
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * JM2 history command
3
+ * Show execution history for jobs
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import {
9
+ printSuccess,
10
+ printError,
11
+ printInfo,
12
+ printHeader,
13
+ formatDate,
14
+ formatDuration,
15
+ } from '../utils/output.js';
16
+ import { isDaemonRunning } from '../../daemon/index.js';
17
+ import { getHistory } from '../../core/storage.js';
18
+ import chalk from 'chalk';
19
+ import Table from 'cli-table3';
20
+
21
+ /**
22
+ * Execute the history command
23
+ * @param {string} jobRef - Job ID or name (optional)
24
+ * @param {object} options - Command options
25
+ * @returns {Promise<number>} Exit code
26
+ */
27
+ export async function historyCommand(jobRef, options = {}) {
28
+ // Check if daemon is running
29
+ if (!isDaemonRunning()) {
30
+ printError('Daemon is not running. Start it with: jm2 start');
31
+ return 1;
32
+ }
33
+
34
+ try {
35
+ let history;
36
+ let jobName = null;
37
+
38
+ if (jobRef) {
39
+ // Get specific job's history
40
+ const jobId = parseInt(jobRef, 10);
41
+ const message = isNaN(jobId)
42
+ ? { type: MessageType.JOB_GET, jobName: jobRef }
43
+ : { type: MessageType.JOB_GET, jobId };
44
+
45
+ const response = await send(message);
46
+
47
+ if (response.type === MessageType.ERROR) {
48
+ printError(response.message);
49
+ return 1;
50
+ }
51
+
52
+ if (response.type !== MessageType.JOB_GET_RESULT || !response.job) {
53
+ printError(`Job not found: ${jobRef}`);
54
+ return 1;
55
+ }
56
+
57
+ const job = response.job;
58
+ jobName = job.name || String(job.id);
59
+
60
+ // Get history and filter by job ID
61
+ const allHistory = getHistory();
62
+ history = allHistory.filter(entry => entry.jobId === job.id);
63
+ } else {
64
+ // Get all history
65
+ history = getHistory();
66
+ }
67
+
68
+ // Apply filters
69
+ if (options.failed) {
70
+ history = history.filter(entry => entry.exitCode !== 0 || entry.status === 'failed');
71
+ }
72
+
73
+ if (options.success) {
74
+ history = history.filter(entry => entry.exitCode === 0 || entry.status === 'success');
75
+ }
76
+
77
+ // Sort by timestamp (newest first)
78
+ history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
79
+
80
+ // Apply limit
81
+ const limit = options.limit || 20;
82
+ if (limit > 0) {
83
+ history = history.slice(0, limit);
84
+ }
85
+
86
+ // Display results
87
+ if (history.length === 0) {
88
+ if (jobName) {
89
+ printInfo(`No execution history found for job: ${jobName}`);
90
+ } else {
91
+ printInfo('No execution history found');
92
+ }
93
+ return 0;
94
+ }
95
+
96
+ if (jobName) {
97
+ printHeader(`Execution History for: ${jobName}`);
98
+ } else {
99
+ printHeader('Execution History');
100
+ }
101
+
102
+ printHistoryTable(history, !jobName);
103
+
104
+ // Print summary
105
+ console.log();
106
+ console.log(`Showing ${chalk.bold(history.length)} entries`);
107
+
108
+ return 0;
109
+ } catch (error) {
110
+ printError(`Failed to get history: ${error.message}`);
111
+ return 1;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Print history as a formatted table
117
+ * @param {Array} history - Array of history entries
118
+ * @param {boolean} showJobName - Whether to show job name column
119
+ */
120
+ function printHistoryTable(history, showJobName = false) {
121
+ const headers = [
122
+ chalk.bold('Time'),
123
+ chalk.bold('Status'),
124
+ chalk.bold('Duration'),
125
+ chalk.bold('Exit'),
126
+ ];
127
+
128
+ if (showJobName) {
129
+ headers.splice(1, 0, chalk.bold('Job'));
130
+ }
131
+
132
+ const colWidths = showJobName
133
+ ? [20, 15, 10, 12, 8]
134
+ : [20, 10, 12, 8];
135
+
136
+ const table = new Table({
137
+ head: headers,
138
+ colWidths,
139
+ wordWrap: true,
140
+ });
141
+
142
+ for (const entry of history) {
143
+ const row = [
144
+ formatDate(entry.timestamp),
145
+ formatStatus(entry.status, entry.exitCode),
146
+ entry.duration ? formatDuration(entry.duration) : '-',
147
+ entry.exitCode !== undefined ? entry.exitCode : '-',
148
+ ];
149
+
150
+ if (showJobName) {
151
+ row.splice(1, 0, entry.jobName || `Job ${entry.jobId}`);
152
+ }
153
+
154
+ table.push(row);
155
+ }
156
+
157
+ console.log(table.toString());
158
+ }
159
+
160
+ /**
161
+ * Format status with color
162
+ * @param {string} status - Status string
163
+ * @param {number} exitCode - Exit code
164
+ * @returns {string} Colorized status
165
+ */
166
+ function formatStatus(status, exitCode) {
167
+ if (status === 'timeout') {
168
+ return chalk.yellow('timeout');
169
+ }
170
+ if (exitCode === 0 || status === 'success') {
171
+ return chalk.green('success');
172
+ }
173
+ if (status === 'failed' || (exitCode !== undefined && exitCode !== 0)) {
174
+ return chalk.red('failed');
175
+ }
176
+ return chalk.gray(status || 'unknown');
177
+ }
178
+
179
+ export default { historyCommand };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * JM2 import command
3
+ * Imports job configurations from a JSON file
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { getJobs, saveJobs, jobNameExists, getNextJobId } from '../../core/storage.js';
9
+ import { validateJob, createJob } from '../../core/job.js';
10
+ import { printSuccess, printError, printInfo, printWarning } from '../utils/output.js';
11
+ import { confirmDestructive } from '../utils/prompts.js';
12
+ import { send } from '../../ipc/client.js';
13
+ import { MessageType } from '../../ipc/protocol.js';
14
+
15
+ /**
16
+ * Generate a unique name by appending a number suffix
17
+ * @param {string} baseName - Base name to start with
18
+ * @param {Set<string>} existingNames - Set of existing names
19
+ * @returns {string} Unique name
20
+ */
21
+ function makeUniqueName(baseName, existingNames) {
22
+ if (!existingNames.has(baseName)) {
23
+ return baseName;
24
+ }
25
+ let suffix = 2;
26
+ let newName;
27
+ do {
28
+ newName = `${baseName}-${suffix}`;
29
+ suffix++;
30
+ } while (existingNames.has(newName));
31
+ return newName;
32
+ }
33
+
34
+ /**
35
+ * Execute the import command
36
+ * @param {string} file - Path to the import file
37
+ * @param {object} options - Command options
38
+ * @returns {Promise<number>} Exit code
39
+ */
40
+ export async function importCommand(file, options = {}) {
41
+ try {
42
+ // Resolve file path
43
+ const filePath = resolve(file);
44
+
45
+ // Check if file exists
46
+ if (!existsSync(filePath)) {
47
+ printError(`Import file not found: ${filePath}`);
48
+ return 1;
49
+ }
50
+
51
+ // Read and parse the import file
52
+ let importData;
53
+ try {
54
+ const content = readFileSync(filePath, 'utf8');
55
+ importData = JSON.parse(content);
56
+ } catch (error) {
57
+ printError(`Failed to parse import file: ${error.message}`);
58
+ return 1;
59
+ }
60
+
61
+ // Validate import data structure
62
+ if (!importData.jobs || !Array.isArray(importData.jobs)) {
63
+ printError('Invalid import file format: missing or invalid "jobs" array');
64
+ return 1;
65
+ }
66
+
67
+ if (importData.jobs.length === 0) {
68
+ printInfo('No jobs found in import file');
69
+ return 0;
70
+ }
71
+
72
+ // Get existing jobs
73
+ const existingJobs = getJobs();
74
+ const existingNames = new Set(existingJobs.map(j => j.name).filter(Boolean));
75
+
76
+ // Get starting ID for new jobs (imported jobs get new IDs)
77
+ let nextId = getNextJobId();
78
+
79
+ // Prepare jobs for import - track original name to final name mapping
80
+ const jobsToImport = [];
81
+ const nameMapping = []; // { original, final, renamed }
82
+ const skippedJobs = [];
83
+ const invalidJobs = [];
84
+
85
+ for (const jobData of importData.jobs) {
86
+ // Validate job structure
87
+ const validation = validateJob(jobData);
88
+ if (!validation.valid) {
89
+ invalidJobs.push({ name: jobData.name || 'unnamed', errors: validation.errors });
90
+ continue;
91
+ }
92
+
93
+ // Determine the final name
94
+ const originalName = jobData.name || 'unnamed';
95
+ let finalName = jobData.name;
96
+
97
+ if (existingNames.has(finalName)) {
98
+ if (options.skip) {
99
+ skippedJobs.push(finalName);
100
+ continue;
101
+ }
102
+ // Generate a unique name
103
+ finalName = makeUniqueName(finalName, existingNames);
104
+ }
105
+
106
+ // Create the job object with a new ID
107
+ const job = createJob({
108
+ ...jobData,
109
+ id: nextId++, // Assign a new ID
110
+ name: finalName,
111
+ // Reset runtime state
112
+ status: jobData.status === 'paused' ? 'paused' : 'active',
113
+ runCount: 0,
114
+ lastRun: null,
115
+ lastResult: null,
116
+ nextRun: null,
117
+ retryCount: 0,
118
+ });
119
+
120
+ jobsToImport.push(job);
121
+ existingNames.add(finalName);
122
+ nameMapping.push({
123
+ original: originalName,
124
+ final: finalName,
125
+ renamed: originalName !== finalName
126
+ });
127
+ }
128
+
129
+ // Report issues
130
+ if (invalidJobs.length > 0) {
131
+ printWarning(`Skipping ${invalidJobs.length} invalid job(s):`);
132
+ for (const { name, errors } of invalidJobs) {
133
+ printWarning(` - ${name}: ${errors.join(', ')}`);
134
+ }
135
+ }
136
+
137
+ if (skippedJobs.length > 0) {
138
+ printWarning(`Skipping ${skippedJobs.length} existing job(s): ${skippedJobs.join(', ')}`);
139
+ }
140
+
141
+ if (jobsToImport.length === 0) {
142
+ printInfo('No jobs to import');
143
+ return 0;
144
+ }
145
+
146
+ // Confirm import unless --force is used
147
+ const action = `import ${jobsToImport.length} job(s)`;
148
+ const confirmed = await confirmDestructive(action, options.force);
149
+ if (!confirmed) {
150
+ printInfo('Import cancelled');
151
+ return 0;
152
+ }
153
+
154
+ // Import the jobs
155
+ const allJobs = [...existingJobs, ...jobsToImport];
156
+ saveJobs(allJobs);
157
+
158
+ // Notify daemon to reload jobs
159
+ try {
160
+ await send({ type: MessageType.RELOAD_JOBS });
161
+ } catch (error) {
162
+ printWarning(`Failed to notify daemon to reload jobs: ${error.message}`);
163
+ }
164
+
165
+ // Report results
166
+ printSuccess(`Successfully imported ${jobsToImport.length} job(s)`);
167
+ for (const mapping of nameMapping) {
168
+ if (mapping.renamed) {
169
+ printInfo(` - ${mapping.original} → ${mapping.final} (renamed)`);
170
+ } else {
171
+ printInfo(` - ${mapping.final}`);
172
+ }
173
+ }
174
+
175
+ return 0;
176
+ } catch (error) {
177
+ printError(`Failed to import jobs: ${error.message}`);
178
+ return 1;
179
+ }
180
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * JM2 list command
3
+ * Lists all jobs with optional filtering
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import {
9
+ printSuccess,
10
+ printError,
11
+ printInfo,
12
+ printHeader,
13
+ createJobTable,
14
+ colorizeStatus,
15
+ formatDate,
16
+ formatRelativeTime,
17
+ } from '../utils/output.js';
18
+ import { isDaemonRunning } from '../../daemon/index.js';
19
+ import chalk from 'chalk';
20
+
21
+ /**
22
+ * Execute the list command
23
+ * @param {object} options - Command options
24
+ * @returns {Promise<number>} Exit code
25
+ */
26
+ export async function listCommand(options = {}) {
27
+ // Check if daemon is running
28
+ if (!isDaemonRunning()) {
29
+ printError('Daemon is not running. Start it with: jm2 start');
30
+ return 1;
31
+ }
32
+
33
+ try {
34
+ // Build filters
35
+ const filters = {};
36
+
37
+ if (options.tag) {
38
+ filters.tag = options.tag;
39
+ }
40
+
41
+ if (options.status) {
42
+ filters.status = options.status;
43
+ }
44
+
45
+ if (options.type) {
46
+ filters.type = options.type;
47
+ }
48
+
49
+ // Request jobs from daemon
50
+ const response = await send({
51
+ type: MessageType.JOB_LIST,
52
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
53
+ });
54
+
55
+ if (response.type === MessageType.ERROR) {
56
+ printError(response.message);
57
+ return 1;
58
+ }
59
+
60
+ if (response.type === MessageType.JOB_LIST_RESULT) {
61
+ const jobs = response.jobs || [];
62
+
63
+ if (jobs.length === 0) {
64
+ printInfo('No jobs found');
65
+ return 0;
66
+ }
67
+
68
+ // Print header
69
+ const filterDesc = [];
70
+ if (options.tag) filterDesc.push(`tag: ${options.tag}`);
71
+ if (options.status) filterDesc.push(`status: ${options.status}`);
72
+ if (options.type) filterDesc.push(`type: ${options.type}`);
73
+
74
+ if (filterDesc.length > 0) {
75
+ printHeader(`Jobs (${filterDesc.join(', ')})`);
76
+ } else {
77
+ printHeader('Jobs');
78
+ }
79
+
80
+ if (options.verbose) {
81
+ // Verbose output - detailed list
82
+ for (const job of jobs) {
83
+ printJobDetails(job);
84
+ }
85
+ } else {
86
+ // Table output
87
+ const table = createJobTable();
88
+
89
+ for (const job of jobs) {
90
+ const schedule = job.cron
91
+ ? chalk.gray(job.cron)
92
+ : job.runAt
93
+ ? formatRelativeTime(job.runAt)
94
+ : chalk.gray('Manual');
95
+
96
+ table.push([
97
+ job.id,
98
+ job.name || chalk.gray('-'),
99
+ colorizeStatus(job.status),
100
+ schedule,
101
+ formatRelativeTime(job.nextRun),
102
+ formatRelativeTime(job.lastRun),
103
+ ]);
104
+ }
105
+
106
+ console.log(table.toString());
107
+ }
108
+
109
+ console.log();
110
+ printInfo(`${jobs.length} job${jobs.length === 1 ? '' : 's'} found`);
111
+
112
+ return 0;
113
+ }
114
+
115
+ printError('Unexpected response from daemon');
116
+ return 1;
117
+ } catch (error) {
118
+ printError(`Failed to list jobs: ${error.message}`);
119
+ return 1;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Print detailed job information
125
+ * @param {object} job - Job object
126
+ */
127
+ function printJobDetails(job) {
128
+ console.log(chalk.bold(`\nJob: ${job.name || job.id}`));
129
+ console.log(` ID: ${job.id}`);
130
+ console.log(` Status: ${colorizeStatus(job.status)}`);
131
+ console.log(` Command: ${chalk.gray(job.command)}`);
132
+
133
+ if (job.cron) {
134
+ console.log(` Schedule: ${chalk.gray(job.cron)} (cron)`);
135
+ } else if (job.runAt) {
136
+ console.log(` Schedule: ${formatDate(job.runAt)} (one-time)`);
137
+ }
138
+
139
+ if (job.nextRun) {
140
+ console.log(` Next Run: ${formatRelativeTime(job.nextRun)}`);
141
+ }
142
+
143
+ if (job.lastRun) {
144
+ console.log(` Last Run: ${formatRelativeTime(job.lastRun)}`);
145
+ if (job.lastResult) {
146
+ const resultColor = job.lastResult === 'success' ? chalk.green : chalk.red;
147
+ console.log(` Last Result: ${resultColor(job.lastResult)}`);
148
+ }
149
+ }
150
+
151
+ if (job.tags && job.tags.length > 0) {
152
+ console.log(` Tags: ${job.tags.map(t => chalk.cyan(t)).join(', ')}`);
153
+ }
154
+
155
+ if (job.cwd) {
156
+ console.log(` Working Dir: ${chalk.gray(job.cwd)}`);
157
+ }
158
+
159
+ if (job.timeout) {
160
+ console.log(` Timeout: ${chalk.gray(job.timeout)}ms`);
161
+ }
162
+
163
+ if (job.retry > 0) {
164
+ console.log(` Retry: ${chalk.gray(job.retry)} attempts`);
165
+ }
166
+
167
+ if (job.runCount > 0) {
168
+ console.log(` Run Count: ${chalk.gray(job.runCount)}`);
169
+ }
170
+
171
+ console.log(` Created: ${formatDate(job.createdAt)}`);
172
+ }
173
+
174
+ export default listCommand;