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,206 @@
1
+ /**
2
+ * JM2 add command
3
+ * Adds a new job to the scheduler
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import { printSuccess, printError, printInfo } from '../utils/output.js';
9
+ import { isDaemonRunning } from '../../daemon/index.js';
10
+ import { parseDateTime, parseRunIn } from '../../utils/datetime.js';
11
+
12
+ /**
13
+ * Print common examples of JM2 add command
14
+ */
15
+ function printExamples() {
16
+ console.log(`
17
+ Common examples of JM2 add:
18
+
19
+ # Run a command once at a specific time
20
+ jm2 add "backup.sh" --at "today 14:30"
21
+ jm2 add "backup.sh" --at "tomorrow 09:00"
22
+ jm2 add "backup.sh" --at "2025-12-25 08:00"
23
+
24
+ # Run a command after a delay
25
+ jm2 add "cleanup.sh" --delay "30m"
26
+ jm2 add "cleanup.sh" --delay "2h"
27
+ jm2 add "cleanup.sh" --delay "1d"
28
+
29
+ # Run a command on a schedule (cron)
30
+ jm2 add "daily-report.sh" --cron "0 9 * * *"
31
+ jm2 add "weekly-backup.sh" --cron "0 0 * * 0"
32
+ jm2 add "monthly-task.sh" --cron "0 0 1 * *"
33
+
34
+ # Add a job with a name
35
+ jm2 add "backup.sh" --name "daily-backup" --cron "0 2 * * *"
36
+
37
+ # Add a job with tags
38
+ jm2 add "deploy.sh" --tag "production" --tag "deployment" --delay "5m"
39
+
40
+ # Add a job with environment variables
41
+ jm2 add "script.sh" --env "NODE_ENV=production" --env "DEBUG=true" --cron "0 */6 * * *"
42
+
43
+ # Add a job with a working directory
44
+ jm2 add "npm run build" --cwd /path/to/project --at "today 15:00"
45
+
46
+ # Add a job with timeout and retry
47
+ jm2 add "long-running.sh" --timeout "2h" --retry 3 --cron "0 3 * * *"
48
+ `);
49
+ }
50
+
51
+ /**
52
+ * Execute the add command
53
+ * @param {string} command - Command to execute
54
+ * @param {object} options - Command options
55
+ * @returns {Promise<number>} Exit code
56
+ */
57
+ export async function addCommand(command, options = {}) {
58
+ // Handle --examples flag
59
+ if (options.examples) {
60
+ printExamples();
61
+ return 0;
62
+ }
63
+
64
+ // Check if daemon is running
65
+ if (!isDaemonRunning()) {
66
+ printError('Daemon is not running. Start it with: jm2 start');
67
+ return 1;
68
+ }
69
+
70
+ if (!command || command.trim() === '') {
71
+ printError('Command is required');
72
+ return 1;
73
+ }
74
+
75
+ try {
76
+ // Build job data
77
+ const jobData = {
78
+ command: command.trim(),
79
+ };
80
+
81
+ // Handle scheduling options
82
+ const { cron, at, delay } = options;
83
+
84
+ if (cron && at) {
85
+ printError('Cannot specify both --cron and --at');
86
+ return 1;
87
+ }
88
+
89
+ if (cron && delay) {
90
+ printError('Cannot specify both --cron and --delay');
91
+ return 1;
92
+ }
93
+
94
+ if (at && delay) {
95
+ printError('Cannot specify both --at and --delay');
96
+ return 1;
97
+ }
98
+
99
+ // Parse scheduling
100
+ if (cron) {
101
+ jobData.cron = cron;
102
+ } else if (at) {
103
+ try {
104
+ const date = parseDateTime(at);
105
+ jobData.runAt = date.toISOString();
106
+ } catch (error) {
107
+ printError(`Invalid datetime: ${error.message}`);
108
+ return 1;
109
+ }
110
+ } else if (delay) {
111
+ try {
112
+ const date = parseRunIn(delay);
113
+ jobData.runAt = date.toISOString();
114
+ } catch (error) {
115
+ printError(`Invalid duration: ${error.message}`);
116
+ return 1;
117
+ }
118
+ } else {
119
+ printError('Scheduling option required: --cron, --at, or --delay');
120
+ return 1;
121
+ }
122
+
123
+ // Optional fields
124
+ if (options.name) {
125
+ // Check if name is a pure number (would conflict with job ID access)
126
+ if (/^\d+$/.test(options.name)) {
127
+ printError('Job name cannot be a pure number (conflicts with job ID)');
128
+ return 1;
129
+ }
130
+ jobData.name = options.name;
131
+ }
132
+
133
+ if (options.tag) {
134
+ // Handle multiple tags
135
+ const tags = Array.isArray(options.tag)
136
+ ? options.tag
137
+ : [options.tag];
138
+ jobData.tags = tags;
139
+ }
140
+
141
+ if (options.cwd) {
142
+ jobData.cwd = options.cwd;
143
+ }
144
+
145
+ if (options.env) {
146
+ // Parse env options (format: KEY=value)
147
+ const envVars = Array.isArray(options.env)
148
+ ? options.env
149
+ : [options.env];
150
+ jobData.env = {};
151
+ for (const envVar of envVars) {
152
+ const [key, ...valueParts] = envVar.split('=');
153
+ if (key && valueParts.length > 0) {
154
+ jobData.env[key] = valueParts.join('=');
155
+ }
156
+ }
157
+ }
158
+
159
+ if (options.timeout) {
160
+ jobData.timeout = options.timeout;
161
+ }
162
+
163
+ if (options.retry !== undefined) {
164
+ jobData.retry = parseInt(options.retry, 10);
165
+ if (isNaN(jobData.retry) || jobData.retry < 0) {
166
+ printError('Retry must be a non-negative integer');
167
+ return 1;
168
+ }
169
+ }
170
+
171
+ // Send to daemon
172
+ printInfo('Adding job...');
173
+
174
+ const response = await send({
175
+ type: MessageType.JOB_ADD,
176
+ jobData,
177
+ });
178
+
179
+ if (response.type === MessageType.ERROR) {
180
+ printError(response.message);
181
+ return 1;
182
+ }
183
+
184
+ if (response.type === MessageType.JOB_ADDED && response.job) {
185
+ const job = response.job;
186
+ printSuccess(`Job added: ${job.name || job.id}`);
187
+
188
+ if (job.cron) {
189
+ printInfo(`Schedule: ${job.cron}`);
190
+ } else if (job.runAt) {
191
+ const runDate = new Date(job.runAt);
192
+ printInfo(`Scheduled for: ${runDate.toLocaleString()}`);
193
+ }
194
+
195
+ return 0;
196
+ }
197
+
198
+ printError('Unexpected response from daemon');
199
+ return 1;
200
+ } catch (error) {
201
+ printError(`Failed to add job: ${error.message}`);
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ export default addCommand;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Config command for JM2
3
+ * View and modify configuration settings
4
+ */
5
+
6
+ import { getConfig, saveConfig, setConfigValue, getConfigValue, validateConfig, DEFAULT_CONFIG } from '../../core/config.js';
7
+ import output from '../utils/output.js';
8
+
9
+ /**
10
+ * Parse a value to the appropriate type based on the config key
11
+ * @param {string} key - Config key
12
+ * @param {string} value - Value as string
13
+ * @returns {*} Parsed value
14
+ */
15
+ function parseConfigValue(key, value) {
16
+ // Handle numeric values
17
+ if (key.includes('max') || key.includes('Count') || key.includes('Days') || key.includes('Size')) {
18
+ const num = parseInt(value, 10);
19
+ if (isNaN(num)) {
20
+ throw new Error(`Invalid numeric value: ${value}`);
21
+ }
22
+ return num;
23
+ }
24
+
25
+ // Handle boolean values
26
+ if (value.toLowerCase() === 'true') return true;
27
+ if (value.toLowerCase() === 'false') return false;
28
+
29
+ // Return as string
30
+ return value;
31
+ }
32
+
33
+ /**
34
+ * Format a config value for display
35
+ * @param {*} value - Config value
36
+ * @returns {string} Formatted value
37
+ */
38
+ function formatValue(value) {
39
+ if (value === null) return 'null';
40
+ if (value === undefined) return 'undefined';
41
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
42
+ if (typeof value === 'string') return value;
43
+ if (typeof value === 'number') {
44
+ // Format large numbers (bytes) as human readable
45
+ if (value >= 1024 * 1024) {
46
+ return `${value} (${(value / 1024 / 1024).toFixed(1)}MB)`;
47
+ }
48
+ if (value >= 1024) {
49
+ return `${value} (${(value / 1024).toFixed(1)}KB)`;
50
+ }
51
+ return String(value);
52
+ }
53
+ if (Array.isArray(value)) return value.join(', ');
54
+ if (typeof value === 'object') return JSON.stringify(value);
55
+ return String(value);
56
+ }
57
+
58
+ /**
59
+ * Show all configuration settings
60
+ * @param {object} config - Config object
61
+ */
62
+ function showAllConfig(config) {
63
+ output.section('Daemon Settings');
64
+ output.keyValue('Max Concurrent', formatValue(config.daemon?.maxConcurrent));
65
+ output.keyValue('Shell', formatValue(config.daemon?.shell));
66
+ output.keyValue('Shell Args', formatValue(config.daemon?.shellArgs));
67
+
68
+ output.section('Job Defaults');
69
+ output.keyValue('Default Timeout', formatValue(config.jobs?.defaultTimeout));
70
+ output.keyValue('Default Retry', formatValue(config.jobs?.defaultRetry));
71
+ output.keyValue('Default CWD', formatValue(config.jobs?.defaultCwd));
72
+
73
+ output.section('Logging Settings');
74
+ output.keyValue('Log Level', formatValue(config.logging?.level));
75
+ output.keyValue('Max File Size', formatValue(config.logging?.maxFileSize));
76
+ output.keyValue('Max Files', formatValue(config.logging?.maxFiles));
77
+
78
+ output.section('History Settings');
79
+ output.keyValue('Max Entries Per Job', formatValue(config.history?.maxEntriesPerJob));
80
+ output.keyValue('Retention Days', formatValue(config.history?.retentionDays));
81
+
82
+ output.section('Cleanup Settings');
83
+ output.keyValue('Completed Job Retention (days)', formatValue(config.cleanup?.completedJobRetentionDays));
84
+ output.keyValue('Log Retention (days)', formatValue(config.cleanup?.logRetentionDays));
85
+ }
86
+
87
+ /**
88
+ * Config command implementation
89
+ * @param {object} options - Command options
90
+ * @returns {number} Exit code
91
+ */
92
+ export async function configCommand(options) {
93
+ try {
94
+ const config = getConfig();
95
+
96
+ // Show all config if no specific option provided
97
+ if (options.show || Object.keys(options).length === 0 ||
98
+ (!options.logMaxSize && !options.logMaxFiles && !options.level &&
99
+ !options.maxConcurrent && !options.reset)) {
100
+ showAllConfig(config);
101
+ return 0;
102
+ }
103
+
104
+ // Handle reset
105
+ if (options.reset) {
106
+ saveConfig({});
107
+ output.success('Configuration reset to defaults');
108
+ return 0;
109
+ }
110
+
111
+ let changes = [];
112
+
113
+ // Handle log-max-size
114
+ if (options.logMaxSize !== undefined) {
115
+ const size = parseSizeOption(options.logMaxSize);
116
+ if (size === null) {
117
+ output.error(`Invalid size format: ${options.logMaxSize}`);
118
+ output.info('Use formats like: 10mb, 50MB, 100kb, 1gb');
119
+ return 1;
120
+ }
121
+ setConfigValue('logging.maxFileSize', size);
122
+ changes.push(`Log max file size: ${formatValue(size)}`);
123
+ }
124
+
125
+ // Handle log-max-files
126
+ if (options.logMaxFiles !== undefined) {
127
+ const count = parseInt(options.logMaxFiles, 10);
128
+ if (isNaN(count) || count < 1) {
129
+ output.error(`Invalid file count: ${options.logMaxFiles}`);
130
+ output.info('Must be a positive number');
131
+ return 1;
132
+ }
133
+ setConfigValue('logging.maxFiles', count);
134
+ changes.push(`Log max files: ${count}`);
135
+ }
136
+
137
+ // Handle log level
138
+ if (options.level !== undefined) {
139
+ const validLevels = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
140
+ const level = options.level.toUpperCase();
141
+ if (!validLevels.includes(level)) {
142
+ output.error(`Invalid log level: ${options.level}`);
143
+ output.info(`Valid levels: ${validLevels.join(', ')}`);
144
+ return 1;
145
+ }
146
+ setConfigValue('logging.level', level);
147
+ changes.push(`Log level: ${level}`);
148
+ }
149
+
150
+ // Handle max concurrent
151
+ if (options.maxConcurrent !== undefined) {
152
+ const count = parseInt(options.maxConcurrent, 10);
153
+ if (isNaN(count) || count < 1) {
154
+ output.error(`Invalid concurrent count: ${options.maxConcurrent}`);
155
+ output.info('Must be a positive number');
156
+ return 1;
157
+ }
158
+ setConfigValue('daemon.maxConcurrent', count);
159
+ changes.push(`Max concurrent jobs: ${count}`);
160
+ }
161
+
162
+ // Validate the new config
163
+ const newConfig = getConfig();
164
+ const validation = validateConfig(newConfig);
165
+ if (!validation.valid) {
166
+ output.error('Configuration validation failed:');
167
+ for (const error of validation.errors) {
168
+ output.error(` - ${error}`);
169
+ }
170
+ return 1;
171
+ }
172
+
173
+ // Show changes
174
+ if (changes.length > 0) {
175
+ output.success('Configuration updated:');
176
+ for (const change of changes) {
177
+ output.info(` • ${change}`);
178
+ }
179
+ }
180
+
181
+ return 0;
182
+ } catch (error) {
183
+ output.error(`Config command failed: ${error.message}`);
184
+ return 1;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Parse a size option (e.g., "10mb", "50MB")
190
+ * @param {string} value - Size string
191
+ * @returns {number|null} Size in bytes or null if invalid
192
+ */
193
+ function parseSizeOption(value) {
194
+ const match = String(value).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
195
+ if (!match) {
196
+ return null;
197
+ }
198
+
199
+ const num = parseFloat(match[1]);
200
+ const unit = match[2] || 'b';
201
+
202
+ const multipliers = {
203
+ 'b': 1,
204
+ 'kb': 1024,
205
+ 'mb': 1024 * 1024,
206
+ 'gb': 1024 * 1024 * 1024,
207
+ };
208
+
209
+ return Math.floor(num * multipliers[unit]);
210
+ }
211
+
212
+ export default { configCommand };
@@ -0,0 +1,198 @@
1
+ /**
2
+ * JM2 edit command
3
+ * Edit an existing job's properties
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import { printSuccess, printError, printInfo } from '../utils/output.js';
9
+ import { isDaemonRunning } from '../../daemon/index.js';
10
+ import { parseDateTime, parseRunIn } from '../../utils/datetime.js';
11
+
12
+ /**
13
+ * Execute the edit command
14
+ * @param {string} jobRef - Job ID or name
15
+ * @param {object} options - Command options
16
+ * @returns {Promise<number>} Exit code
17
+ */
18
+ export async function editCommand(jobRef, 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
+ if (!jobRef || jobRef.trim() === '') {
26
+ printError('Job ID or name is required');
27
+ return 1;
28
+ }
29
+
30
+ // Build updates object from options
31
+ const updates = {};
32
+
33
+ // Validate that at least one option is provided
34
+ const hasUpdates =
35
+ options.command !== undefined ||
36
+ options.name !== undefined ||
37
+ options.cron !== undefined ||
38
+ options.at !== undefined ||
39
+ options.delay !== undefined ||
40
+ options.cwd !== undefined ||
41
+ options.env !== undefined ||
42
+ options.env !== undefined ||
43
+ options.timeout !== undefined ||
44
+ options.retry !== undefined ||
45
+ options.tag !== undefined;
46
+
47
+ if (!hasUpdates) {
48
+ printError('No changes specified. Use options like --command, --cron, --name, etc.');
49
+ return 1;
50
+ }
51
+
52
+ // Handle mutually exclusive scheduling options
53
+ const hasCron = options.cron !== undefined;
54
+ const hasAt = options.at !== undefined;
55
+ const hasDelay = options.delay !== undefined;
56
+
57
+ if (hasCron && hasAt) {
58
+ printError('Cannot specify both --cron and --at');
59
+ return 1;
60
+ }
61
+ if (hasCron && hasDelay) {
62
+ printError('Cannot specify both --cron and --delay');
63
+ return 1;
64
+ }
65
+ if (hasAt && hasDelay) {
66
+ printError('Cannot specify both --at and --delay');
67
+ return 1;
68
+ }
69
+
70
+ // Apply updates
71
+ if (options.command !== undefined) {
72
+ if (!options.command.trim()) {
73
+ printError('Command cannot be empty');
74
+ return 1;
75
+ }
76
+ updates.command = options.command.trim();
77
+ }
78
+
79
+ if (options.name !== undefined) {
80
+ if (!options.name.trim()) {
81
+ printError('Name cannot be empty');
82
+ return 1;
83
+ }
84
+ updates.name = options.name.trim();
85
+ }
86
+
87
+ // Handle scheduling updates
88
+ if (hasCron) {
89
+ updates.cron = options.cron;
90
+ updates.runAt = null; // Clear runAt when switching to cron
91
+ } else if (hasAt) {
92
+ try {
93
+ const date = parseDateTime(options.at);
94
+ updates.runAt = date.toISOString();
95
+ updates.cron = null; // Clear cron when switching to runAt
96
+ } catch (error) {
97
+ printError(`Invalid datetime: ${error.message}`);
98
+ return 1;
99
+ }
100
+ } else if (hasDelay) {
101
+ try {
102
+ const date = parseRunIn(options.delay);
103
+ updates.runAt = date.toISOString();
104
+ updates.cron = null; // Clear cron when switching to runAt
105
+ } catch (error) {
106
+ printError(`Invalid duration: ${error.message}`);
107
+ return 1;
108
+ }
109
+ }
110
+
111
+ if (options.cwd !== undefined) {
112
+ updates.cwd = options.cwd || null;
113
+ }
114
+
115
+ if (options.env !== undefined) {
116
+ // Parse env options (format: KEY=value)
117
+ const envVars = Array.isArray(options.env)
118
+ ? options.env
119
+ : options.env ? [options.env] : [];
120
+
121
+ if (envVars.length > 0) {
122
+ updates.env = {};
123
+ for (const envVar of envVars) {
124
+ const [key, ...valueParts] = envVar.split('=');
125
+ if (key && valueParts.length > 0) {
126
+ updates.env[key] = valueParts.join('=');
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ if (options.timeout !== undefined) {
133
+ updates.timeout = options.timeout || null;
134
+ }
135
+
136
+ if (options.retry !== undefined) {
137
+ const retry = parseInt(options.retry, 10);
138
+ if (isNaN(retry) || retry < 0) {
139
+ printError('Retry must be a non-negative integer');
140
+ return 1;
141
+ }
142
+ updates.retry = retry;
143
+ }
144
+
145
+ if (options.tag !== undefined) {
146
+ // Handle multiple tags
147
+ const tags = Array.isArray(options.tag)
148
+ ? options.tag
149
+ : options.tag ? [options.tag] : [];
150
+ if (tags.length > 0) {
151
+ updates.tags = tags;
152
+ }
153
+ }
154
+
155
+ try {
156
+ // Determine if jobRef is an ID (numeric) or name
157
+ const jobId = parseInt(jobRef, 10);
158
+ const message = isNaN(jobId)
159
+ ? { type: MessageType.JOB_UPDATE, jobName: jobRef, updates }
160
+ : { type: MessageType.JOB_UPDATE, jobId, updates };
161
+
162
+ printInfo(`Updating job: ${jobRef}...`);
163
+
164
+ const response = await send(message);
165
+
166
+ if (response.type === MessageType.ERROR) {
167
+ printError(response.message);
168
+ return 1;
169
+ }
170
+
171
+ if (response.type === MessageType.JOB_UPDATED) {
172
+ const job = response.job;
173
+
174
+ if (!job) {
175
+ printError(`Job not found: ${jobRef}`);
176
+ return 1;
177
+ }
178
+
179
+ printSuccess(`Job updated: ${job.name || job.id}`);
180
+
181
+ // Show what was updated
182
+ const updatedFields = Object.keys(updates);
183
+ if (updatedFields.length > 0) {
184
+ printInfo(`Updated fields: ${updatedFields.join(', ')}`);
185
+ }
186
+
187
+ return 0;
188
+ }
189
+
190
+ printError('Unexpected response from daemon');
191
+ return 1;
192
+ } catch (error) {
193
+ printError(`Failed to update job: ${error.message}`);
194
+ return 1;
195
+ }
196
+ }
197
+
198
+ export default { editCommand };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * JM2 export command
3
+ * Exports job configurations to a JSON file
4
+ */
5
+
6
+ import { writeFileSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { getJobs } from '../../core/storage.js';
9
+ import { printSuccess, printError, printInfo } from '../utils/output.js';
10
+
11
+ /**
12
+ * Execute the export command
13
+ * @param {object} options - Command options
14
+ * @returns {Promise<number>} Exit code
15
+ */
16
+ export async function exportCommand(options = {}) {
17
+ try {
18
+ // Get all jobs from storage
19
+ const jobs = getJobs();
20
+
21
+ if (jobs.length === 0) {
22
+ printInfo('No jobs to export');
23
+ return 0;
24
+ }
25
+
26
+ // Prepare export data
27
+ const exportData = {
28
+ version: '1.0',
29
+ exportedAt: new Date().toISOString(),
30
+ jobs: jobs.map(job => ({
31
+ // Export all job fields except runtime state
32
+ // Generate a name for unnamed jobs using job ID
33
+ name: job.name || `job-${job.id}`,
34
+ command: job.command,
35
+ type: job.type,
36
+ cron: job.cron,
37
+ runAt: job.runAt,
38
+ status: job.status,
39
+ tags: job.tags,
40
+ env: job.env,
41
+ cwd: job.cwd,
42
+ timeout: job.timeout,
43
+ retry: job.retry,
44
+ createdAt: job.createdAt,
45
+ updatedAt: job.updatedAt,
46
+ })),
47
+ };
48
+
49
+ // Determine output path
50
+ const outputPath = resolve(options.output || 'jm2-export.json');
51
+
52
+ // Write to file
53
+ writeFileSync(outputPath, JSON.stringify(exportData, null, 2), 'utf8');
54
+
55
+ printSuccess(`Exported ${jobs.length} job(s) to ${outputPath}`);
56
+ return 0;
57
+ } catch (error) {
58
+ printError(`Failed to export jobs: ${error.message}`);
59
+ return 1;
60
+ }
61
+ }