jm2 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -296,8 +296,14 @@ jm2 edit nightly-backup --cron "0 3 * * *"
296
296
  # Change command
297
297
  jm2 edit nightly-backup --command "npm run full-backup"
298
298
 
299
- # Add/remove tags
300
- jm2 edit nightly-backup --tag important --untag old
299
+ # Replace all tags (removes existing, sets new)
300
+ jm2 edit nightly-backup --tag production --tag critical
301
+
302
+ # Append tags without removing existing ones
303
+ jm2 edit nightly-backup --tag-append new-tag
304
+
305
+ # Remove specific tags
306
+ jm2 edit nightly-backup --tag-remove old-tag
301
307
 
302
308
  # Change working directory
303
309
  jm2 edit nightly-backup --cwd /new/path
@@ -308,14 +314,63 @@ Options:
308
314
  - `--at, -a <datetime>` - Convert to one-time job at datetime
309
315
  - `--command <cmd>` - New command to execute
310
316
  - `--name, -n <name>` - Rename the job
311
- - `--tag, -t <tag>` - Add tag (can be used multiple times)
312
- - `--untag <tag>` - Remove tag (can be used multiple times)
317
+ - `--tag, -t <tag>` - Set tags (replaces all existing tags, can be used multiple times)
318
+ - `--tag-append <tag>` - Append tags to existing tags (can be used multiple times)
319
+ - `--tag-remove <tag>` - Remove specific tags (can be used multiple times)
313
320
  - `--cwd <path>` - New working directory
314
321
  - `--env, -e <KEY=value>` - Set/update environment variable
315
- - `--unenv <KEY>` - Remove environment variable
316
322
  - `--timeout <duration>` - New timeout value
317
323
  - `--retry <count>` - New retry count
318
324
 
325
+ #### `jm2 tags <subcommand>`
326
+ Manage job tags in bulk.
327
+
328
+ ```bash
329
+ # List all tags with job counts
330
+ jm2 tags list
331
+
332
+ # List tags with associated jobs (verbose)
333
+ jm2 tags list -v
334
+
335
+ # Add tag to multiple jobs
336
+ jm2 tags add production 1 2 3
337
+ jm2 tags add staging job-name job2-name
338
+
339
+ # Remove tag from specific jobs
340
+ jm2 tags rm staging 1 2
341
+
342
+ # Remove tag from all jobs
343
+ jm2 tags rm old-tag --all
344
+
345
+ # Clear all tags from specific jobs
346
+ jm2 tags clear 1 2
347
+
348
+ # Clear all tags from all jobs (requires confirmation)
349
+ jm2 tags clear --all --force
350
+
351
+ # Rename a tag across all jobs
352
+ jm2 tags rename staging production
353
+
354
+ # Show jobs grouped by tag (includes untagged group)
355
+ jm2 tags jobs
356
+
357
+ # Show jobs with specific tag
358
+ jm2 tags jobs production
359
+ ```
360
+
361
+ Subcommands:
362
+ - `list` - List all tags with job counts
363
+ - `add <tag> <job-id-or-name>...` - Add tag to specified jobs
364
+ - `rm <tag> [job-id-or-name]...` - Remove tag from jobs (use `--all` for all jobs)
365
+ - `clear [job-id-or-name]...` - Clear all tags from jobs (use `--all --force` for all jobs)
366
+ - `rename <old-tag> <new-tag>` - Rename a tag across all jobs
367
+ - `jobs [tag-name]` - List jobs grouped by tag
368
+
369
+ Options:
370
+ - `-v, --verbose` - Show verbose output (list associated jobs)
371
+ - `-a, --all` - Apply to all jobs (for rm and clear commands)
372
+ - `-f, --force` - Skip confirmation for destructive operations
373
+
319
374
  ### Logs and History
320
375
 
321
376
  #### `jm2 logs [id|name]`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jm2",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Job Manager 2 - A simple yet powerful job scheduler combining cron and at functionality",
5
5
  "type": "module",
6
6
  "main": "src/cli/index.js",
@@ -138,9 +138,8 @@ export async function addCommand(command, options = {}) {
138
138
  jobData.tags = tags;
139
139
  }
140
140
 
141
- if (options.cwd) {
142
- jobData.cwd = options.cwd;
143
- }
141
+ // Set working directory - use explicit cwd or default to current directory
142
+ jobData.cwd = options.cwd || process.cwd();
144
143
 
145
144
  if (options.env) {
146
145
  // Parse env options (format: KEY=value)
@@ -0,0 +1,130 @@
1
+ /**
2
+ * JM2 backup command
3
+ * Creates a backup of all JM2 data including jobs, history, and logs
4
+ */
5
+
6
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'node:fs';
7
+ import { createGzip } from 'node:zlib';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { join, basename } from 'node:path';
10
+ import { getDataDir, getJobsFile, getConfigFile, getHistoryDbFile, getLogsDir } from '../../utils/paths.js';
11
+ import { printSuccess, printError, printInfo, printWarning } from '../utils/output.js';
12
+ import dayjs from 'dayjs';
13
+
14
+ /**
15
+ * Execute the backup command
16
+ * @param {string} outputPath - Output file path (optional)
17
+ * @param {object} options - Command options
18
+ * @returns {Promise<number>} Exit code
19
+ */
20
+ export async function backupCommand(outputPath, options = {}) {
21
+ try {
22
+ const dataDir = getDataDir();
23
+
24
+ if (!existsSync(dataDir)) {
25
+ printError('JM2 data directory not found. Nothing to backup.');
26
+ return 1;
27
+ }
28
+
29
+ // Generate default filename if not provided
30
+ if (!outputPath) {
31
+ const timestamp = dayjs().format('YYYYMMDD_HHmmss');
32
+ outputPath = `jm2-backup-${timestamp}.json.gz`;
33
+ }
34
+
35
+ // Ensure .json.gz extension
36
+ if (!outputPath.endsWith('.json.gz') && !outputPath.endsWith('.json')) {
37
+ outputPath += '.json.gz';
38
+ }
39
+
40
+ // Resolve full path
41
+ const backupPath = outputPath.startsWith('/')
42
+ ? outputPath
43
+ : join(process.cwd(), outputPath);
44
+
45
+ printInfo(`Creating backup: ${basename(backupPath)}`);
46
+
47
+ // Gather backup data
48
+ const backupData = {
49
+ version: '1.0.0',
50
+ createdAt: new Date().toISOString(),
51
+ platform: process.platform,
52
+ jobs: null,
53
+ config: null,
54
+ history: null,
55
+ logs: {},
56
+ };
57
+
58
+ // Backup jobs
59
+ if (existsSync(getJobsFile())) {
60
+ const jobsContent = readFileSync(getJobsFile(), 'utf8');
61
+ backupData.jobs = JSON.parse(jobsContent);
62
+ printInfo(`Included: ${backupData.jobs.length || 1} job(s)`);
63
+ }
64
+
65
+ // Backup config
66
+ if (existsSync(getConfigFile())) {
67
+ const configContent = readFileSync(getConfigFile(), 'utf8');
68
+ backupData.config = JSON.parse(configContent);
69
+ printInfo('Included: config.json');
70
+ }
71
+
72
+ // Backup history database (as base64)
73
+ if (existsSync(getHistoryDbFile())) {
74
+ const historyBuffer = readFileSync(getHistoryDbFile());
75
+ backupData.history = historyBuffer.toString('base64');
76
+ printInfo('Included: history.db');
77
+ }
78
+
79
+ // Backup logs
80
+ const logsDir = getLogsDir();
81
+ if (existsSync(logsDir)) {
82
+ const logFiles = readdirSync(logsDir).filter(f => f.endsWith('.log'));
83
+ for (const logFile of logFiles) {
84
+ const logPath = join(logsDir, logFile);
85
+ const logContent = readFileSync(logPath, 'utf8');
86
+ backupData.logs[logFile] = logContent;
87
+ }
88
+ if (logFiles.length > 0) {
89
+ printInfo(`Included: ${logFiles.length} log file(s)`);
90
+ }
91
+ }
92
+
93
+ // Convert to JSON
94
+ const jsonData = JSON.stringify(backupData, null, 2);
95
+
96
+ // Write compressed file
97
+ if (backupPath.endsWith('.gz')) {
98
+ const source = new ReadableStream({
99
+ start(controller) {
100
+ controller.enqueue(Buffer.from(jsonData));
101
+ controller.close();
102
+ }
103
+ });
104
+
105
+ const gzip = createGzip();
106
+ const output = createWriteStream(backupPath);
107
+
108
+ await pipeline(source, gzip, output);
109
+ } else {
110
+ // Write uncompressed
111
+ const { writeFileSync } = await import('node:fs');
112
+ writeFileSync(backupPath, jsonData);
113
+ }
114
+
115
+ // Get file size
116
+ const stats = statSync(backupPath);
117
+ const sizeKB = (stats.size / 1024).toFixed(2);
118
+
119
+ printSuccess(`Backup created successfully: ${basename(backupPath)}`);
120
+ printInfo(`Size: ${sizeKB} KB`);
121
+ printInfo(`Location: ${backupPath}`);
122
+
123
+ return 0;
124
+ } catch (error) {
125
+ printError(`Failed to create backup: ${error.message}`);
126
+ return 1;
127
+ }
128
+ }
129
+
130
+ export default backupCommand;
@@ -47,9 +47,10 @@ export async function editCommand(jobRef, options = {}) {
47
47
  options.tagRemove !== undefined;
48
48
 
49
49
  // Check for mutually exclusive tag options
50
- const hasTagSet = options.tag !== undefined;
51
- const hasTagAppend = options.tagAppend !== undefined;
52
- const hasTagRemove = options.tagRemove !== undefined;
50
+ // options.tag has a default of [], so check if it's non-empty
51
+ const hasTagSet = options.tag !== undefined && options.tag.length > 0;
52
+ const hasTagAppend = options.tagAppend !== undefined && options.tagAppend.length > 0;
53
+ const hasTagRemove = options.tagRemove !== undefined && options.tagRemove.length > 0;
53
54
 
54
55
  if (hasTagSet && (hasTagAppend || hasTagRemove)) {
55
56
  printError('Cannot use --tag with --tag-append or --tag-remove. Use --tag to replace all tags, or --tag-append/--tag-remove to modify existing tags.');
@@ -0,0 +1,156 @@
1
+ /**
2
+ * JM2 restore command
3
+ * Restores JM2 data from a backup file
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
7
+ import { createReadStream } from 'node:fs';
8
+ import { createGunzip } from 'node:zlib';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { join, basename } from 'node:path';
11
+ import { getDataDir, getJobsFile, getConfigFile, getHistoryDbFile, getLogsDir, ensureDataDir, ensureLogsDir } from '../../utils/paths.js';
12
+ import { printSuccess, printError, printInfo, printWarning } from '../utils/output.js';
13
+ import { isDaemonRunning } from '../../daemon/index.js';
14
+
15
+ /**
16
+ * Execute the restore command
17
+ * @param {string} backupPath - Path to backup file
18
+ * @param {object} options - Command options
19
+ * @returns {Promise<number>} Exit code
20
+ */
21
+ export async function restoreCommand(backupPath, options = {}) {
22
+ if (!backupPath) {
23
+ printError('Backup file path is required');
24
+ printInfo('Usage: jm2 restore <backup-file>');
25
+ return 1;
26
+ }
27
+
28
+ // Resolve full path
29
+ const fullPath = backupPath.startsWith('/')
30
+ ? backupPath
31
+ : join(process.cwd(), backupPath);
32
+
33
+ if (!existsSync(fullPath)) {
34
+ printError(`Backup file not found: ${backupPath}`);
35
+ return 1;
36
+ }
37
+
38
+ try {
39
+ printInfo(`Reading backup: ${basename(fullPath)}`);
40
+
41
+ // Read backup data
42
+ let jsonData;
43
+
44
+ if (fullPath.endsWith('.gz')) {
45
+ // Decompress gzip file
46
+ const chunks = [];
47
+ const source = createReadStream(fullPath);
48
+ const gunzip = createGunzip();
49
+
50
+ for await (const chunk of source.pipe(gunzip)) {
51
+ chunks.push(chunk);
52
+ }
53
+
54
+ jsonData = Buffer.concat(chunks).toString('utf8');
55
+ } else {
56
+ // Read uncompressed
57
+ const { readFileSync } = await import('node:fs');
58
+ jsonData = readFileSync(fullPath, 'utf8');
59
+ }
60
+
61
+ // Parse backup data
62
+ const backupData = JSON.parse(jsonData);
63
+
64
+ // Validate backup format
65
+ if (!backupData.version) {
66
+ printError('Invalid backup file format');
67
+ return 1;
68
+ }
69
+
70
+ printInfo(`Backup created: ${backupData.createdAt || 'unknown date'}`);
71
+ printInfo(`Platform: ${backupData.platform || 'unknown'}`);
72
+
73
+ // Warn if daemon is running
74
+ if (isDaemonRunning() && !options.force) {
75
+ printWarning('Daemon is currently running. Stop it first or use --force');
76
+ printInfo('Tip: jm2 stop && jm2 restore <backup-file> && jm2 start');
77
+ return 1;
78
+ }
79
+
80
+ // Confirm restore unless --yes flag
81
+ if (!options.yes) {
82
+ printWarning('This will overwrite existing JM2 data');
83
+ printInfo('Use --yes to skip this confirmation');
84
+
85
+ // For now, require --yes flag (interactive prompts would need additional deps)
86
+ printError('Use --yes flag to confirm restore');
87
+ return 1;
88
+ }
89
+
90
+ // Ensure data directory exists
91
+ ensureDataDir();
92
+ ensureLogsDir();
93
+
94
+ let restoredCount = 0;
95
+
96
+ // Restore jobs
97
+ if (backupData.jobs) {
98
+ writeFileSync(getJobsFile(), JSON.stringify(backupData.jobs, null, 2));
99
+ const jobCount = Array.isArray(backupData.jobs) ? backupData.jobs.length : 1;
100
+ printInfo(`Restored: ${jobCount} job(s)`);
101
+ restoredCount++;
102
+ }
103
+
104
+ // Restore config
105
+ if (backupData.config) {
106
+ writeFileSync(getConfigFile(), JSON.stringify(backupData.config, null, 2));
107
+ printInfo('Restored: config.json');
108
+ restoredCount++;
109
+ }
110
+
111
+ // Restore history
112
+ if (backupData.history) {
113
+ const historyBuffer = Buffer.from(backupData.history, 'base64');
114
+ writeFileSync(getHistoryDbFile(), historyBuffer);
115
+ printInfo('Restored: history.db');
116
+ restoredCount++;
117
+ }
118
+
119
+ // Restore logs
120
+ if (backupData.logs && typeof backupData.logs === 'object') {
121
+ const logsDir = getLogsDir();
122
+ let logCount = 0;
123
+
124
+ for (const [filename, content] of Object.entries(backupData.logs)) {
125
+ const logPath = join(logsDir, filename);
126
+ writeFileSync(logPath, content);
127
+ logCount++;
128
+ }
129
+
130
+ if (logCount > 0) {
131
+ printInfo(`Restored: ${logCount} log file(s)`);
132
+ restoredCount++;
133
+ }
134
+ }
135
+
136
+ if (restoredCount === 0) {
137
+ printWarning('No data found in backup file');
138
+ return 0;
139
+ }
140
+
141
+ printSuccess(`Restore completed successfully`);
142
+ printInfo(`Restored ${restoredCount} data type(s)`);
143
+
144
+ if (isDaemonRunning()) {
145
+ printInfo('Note: You may need to restart the daemon to load restored jobs');
146
+ printInfo('Run: jm2 restart');
147
+ }
148
+
149
+ return 0;
150
+ } catch (error) {
151
+ printError(`Failed to restore backup: ${error.message}`);
152
+ return 1;
153
+ }
154
+ }
155
+
156
+ export default restoreCommand;
@@ -3,7 +3,7 @@
3
3
  * Manually execute a job immediately
4
4
  */
5
5
 
6
- import { send } from '../../ipc/client.js';
6
+ import { send, sendWithStream } from '../../ipc/client.js';
7
7
  import { MessageType } from '../../ipc/protocol.js';
8
8
  import { printSuccess, printError, printInfo } from '../utils/output.js';
9
9
  import { isDaemonRunning } from '../../daemon/index.js';
@@ -35,33 +35,35 @@ export async function runCommand(jobRef, options = {}) {
35
35
 
36
36
  printInfo(`Running job: ${jobRef}...`);
37
37
 
38
- const response = await send(message);
39
-
40
- if (response.type === MessageType.ERROR) {
41
- printError(response.message);
42
- return 1;
43
- }
44
-
45
- if (response.type === MessageType.JOB_RUN_RESULT) {
46
- const result = response.result;
38
+ if (options.wait) {
39
+ // Use streaming for real-time output
40
+ const response = await sendWithStream(message, {
41
+ timeoutMs: null,
42
+ onStream: (chunk) => {
43
+ if (chunk.stream === 'stdout') {
44
+ process.stdout.write(chunk.data);
45
+ } else if (chunk.stream === 'stderr') {
46
+ process.stderr.write(chunk.data);
47
+ }
48
+ },
49
+ });
47
50
 
48
- if (result.error) {
49
- printError(`Job execution failed: ${result.error}`);
51
+ if (response.type === MessageType.ERROR) {
52
+ printError(response.message);
50
53
  return 1;
51
54
  }
52
55
 
53
- if (options.wait) {
54
- // Display execution results
56
+ if (response.type === MessageType.JOB_RUN_RESULT) {
57
+ const result = response.result;
58
+
59
+ if (result.error) {
60
+ printError(`Job execution failed: ${result.error}`);
61
+ return 1;
62
+ }
63
+
64
+ // Display final results
55
65
  if (result.status === 'success') {
56
66
  printSuccess('Job completed successfully');
57
- if (result.stdout) {
58
- console.log('\n--- stdout ---');
59
- console.log(result.stdout);
60
- }
61
- if (result.stderr) {
62
- console.log('\n--- stderr ---');
63
- console.log(result.stderr);
64
- }
65
67
  console.log(`\nExit code: ${result.exitCode || 0}`);
66
68
  console.log(`Duration: ${formatDuration(result.duration || 0)}`);
67
69
  } else if (result.status === 'timeout') {
@@ -69,22 +71,26 @@ export async function runCommand(jobRef, options = {}) {
69
71
  return 1;
70
72
  } else {
71
73
  printError(`Job failed with status: ${result.status}`);
72
- if (result.stdout) {
73
- console.log('\n--- stdout ---');
74
- console.log(result.stdout);
75
- }
76
- if (result.stderr) {
77
- console.log('\n--- stderr ---');
78
- console.log(result.stderr);
79
- }
80
74
  return 1;
81
75
  }
82
- } else {
76
+
77
+ return 0;
78
+ }
79
+ } else {
80
+ // Non-waiting mode - just queue the job
81
+ const response = await send(message, { timeoutMs: 5000 });
82
+
83
+ if (response.type === MessageType.ERROR) {
84
+ printError(response.message);
85
+ return 1;
86
+ }
87
+
88
+ if (response.type === MessageType.JOB_RUN_RESULT) {
89
+ const result = response.result;
83
90
  printSuccess(`Job queued for execution (ID: ${result.jobId || jobRef})`);
84
91
  printInfo('Use --wait to wait for completion and see output');
92
+ return 0;
85
93
  }
86
-
87
- return 0;
88
94
  }
89
95
 
90
96
  printError('Unexpected response from daemon');
@@ -59,7 +59,11 @@ export async function showCommand(jobRef, options = {}) {
59
59
  return 1;
60
60
  }
61
61
 
62
- printJobDetails(job);
62
+ if (options.recreateCommandOnly) {
63
+ console.log(generateRecreateCommand(job));
64
+ } else {
65
+ printJobDetails(job);
66
+ }
63
67
  return 0;
64
68
  }
65
69
 
@@ -137,6 +141,11 @@ function printJobDetails(job) {
137
141
  console.log(`${chalk.bold('Created:')} ${formatDate(job.createdAt)}`);
138
142
  console.log(`${chalk.bold('Updated:')} ${formatDate(job.updatedAt)}`);
139
143
 
144
+ // Recreate command
145
+ console.log();
146
+ console.log(`${chalk.bold('Recreate Command:')}`);
147
+ console.log(` ${generateRecreateCommand(job)}`);
148
+
140
149
  // Log file path
141
150
  console.log();
142
151
  const logFile = getJobLogFile(job.name || `job-${job.id}`);
@@ -156,4 +165,63 @@ function printJobDetails(job) {
156
165
  console.log();
157
166
  }
158
167
 
168
+ /**
169
+ * Generate the jm2 add command to recreate this job
170
+ * @param {object} job - Job object
171
+ * @returns {string} Command string
172
+ */
173
+ function generateRecreateCommand(job) {
174
+ const parts = ['jm2 add'];
175
+
176
+ // Add the command itself (quoted if it contains spaces)
177
+ const command = job.command.includes(' ') ? `"${job.command}"` : job.command;
178
+ parts.push(command);
179
+
180
+ // Add name
181
+ if (job.name) {
182
+ parts.push(`--name ${job.name}`);
183
+ }
184
+
185
+ // Add scheduling option
186
+ if (job.cron) {
187
+ parts.push(`--cron "${job.cron}"`);
188
+ } else if (job.runAt) {
189
+ // For runAt, we need to format it nicely
190
+ const runDate = new Date(job.runAt);
191
+ const dateStr = runDate.toISOString().slice(0, 16).replace('T', ' ');
192
+ parts.push(`--at "${dateStr}"`);
193
+ }
194
+
195
+ // Add working directory
196
+ if (job.cwd) {
197
+ parts.push(`--cwd "${job.cwd}"`);
198
+ }
199
+
200
+ // Add tags
201
+ if (job.tags && job.tags.length > 0) {
202
+ for (const tag of job.tags) {
203
+ parts.push(`--tag ${tag}`);
204
+ }
205
+ }
206
+
207
+ // Add environment variables
208
+ if (job.env && Object.keys(job.env).length > 0) {
209
+ for (const [key, value] of Object.entries(job.env)) {
210
+ parts.push(`--env "${key}=${value}"`);
211
+ }
212
+ }
213
+
214
+ // Add timeout
215
+ if (job.timeout) {
216
+ parts.push(`--timeout ${job.timeout}`);
217
+ }
218
+
219
+ // Add retry
220
+ if (job.retry > 0) {
221
+ parts.push(`--retry ${job.retry}`);
222
+ }
223
+
224
+ return parts.join(' ');
225
+ }
226
+
159
227
  export default { showCommand };
package/src/cli/index.js CHANGED
@@ -29,6 +29,8 @@ import { importCommand } from './commands/import.js';
29
29
  import { installCommand } from './commands/install.js';
30
30
  import { uninstallCommand } from './commands/uninstall.js';
31
31
  import { tagsCommand } from './commands/tags.js';
32
+ import { backupCommand } from './commands/backup.js';
33
+ import { restoreCommand } from './commands/restore.js';
32
34
 
33
35
  const __filename = fileURLToPath(import.meta.url);
34
36
  const __dirname = dirname(__filename);
@@ -140,8 +142,9 @@ export async function runCli() {
140
142
  program
141
143
  .command('show <job>')
142
144
  .description('Show detailed information about a job')
143
- .action(async (job) => {
144
- const exitCode = await showCommand(job);
145
+ .option('--recreate-command-only', 'Show only the recreate command', false)
146
+ .action(async (job, options) => {
147
+ const exitCode = await showCommand(job, options);
145
148
  process.exit(exitCode);
146
149
  });
147
150
 
@@ -299,6 +302,28 @@ export async function runCli() {
299
302
  process.exit(exitCode);
300
303
  });
301
304
 
305
+ // Backup command
306
+ program
307
+ .command('backup [file]')
308
+ .description('Create a backup of all JM2 data (jobs, config, history, logs)')
309
+ .option('-o, --output <file>', 'Output file path (default: jm2-backup-<timestamp>.json.gz)')
310
+ .action(async (file, options) => {
311
+ const outputPath = file || options.output;
312
+ const exitCode = await backupCommand(outputPath, options);
313
+ process.exit(exitCode);
314
+ });
315
+
316
+ // Restore command
317
+ program
318
+ .command('restore <file>')
319
+ .description('Restore JM2 data from a backup file')
320
+ .option('-y, --yes', 'Skip confirmation prompt', false)
321
+ .option('-f, --force', 'Force restore even if daemon is running', false)
322
+ .action(async (file, options) => {
323
+ const exitCode = await restoreCommand(file, options);
324
+ process.exit(exitCode);
325
+ });
326
+
302
327
  // Tags command
303
328
  program
304
329
  .command('tags <subcommand> [args...]')
@@ -188,6 +188,8 @@ class DarwinService extends PlatformService {
188
188
  <dict>
189
189
  <key>JM2_DATA_DIR</key>
190
190
  <string>${dataDir}</string>
191
+ <key>PATH</key>
192
+ <string>${process.env.PATH}</string>
191
193
  </dict>
192
194
  </dict>
193
195
  </plist>`;
@@ -143,6 +143,9 @@ export function executeJob(job, options = {}) {
143
143
  const chunk = data.toString();
144
144
  stdout += chunk;
145
145
  jobLogger.info(`[stdout] ${chunk.trim()}`);
146
+ if (execOptions.onStream) {
147
+ execOptions.onStream('stdout', chunk);
148
+ }
146
149
  });
147
150
  }
148
151
 
@@ -152,6 +155,9 @@ export function executeJob(job, options = {}) {
152
155
  const chunk = data.toString();
153
156
  stderr += chunk;
154
157
  jobLogger.warn(`[stderr] ${chunk.trim()}`);
158
+ if (execOptions.onStream) {
159
+ execOptions.onStream('stderr', chunk);
160
+ }
155
161
  });
156
162
  }
157
163
 
@@ -25,6 +25,7 @@ import {
25
25
  createJobPausedResponse,
26
26
  createJobResumedResponse,
27
27
  createJobRunResponse,
28
+ createJobStreamOutput,
28
29
  createFlushResultResponse,
29
30
  createTagListResponse,
30
31
  createTagAddResponse,
@@ -285,9 +286,10 @@ async function runDaemon(options = {}) {
285
286
  /**
286
287
  * Handle IPC messages
287
288
  * @param {object} message - Incoming message
289
+ * @param {import('node:net').Socket} socket - Socket for streaming responses
288
290
  * @returns {Promise<object|null>} Response message
289
291
  */
290
- async function handleIpcMessage(message) {
292
+ async function handleIpcMessage(message, socket) {
291
293
  logger?.debug(`Received message: ${JSON.stringify(message)}`);
292
294
 
293
295
  switch (message.type) {
@@ -329,7 +331,7 @@ async function handleIpcMessage(message) {
329
331
  return handleJobResume(message);
330
332
 
331
333
  case MessageType.JOB_RUN:
332
- return handleJobRun(message);
334
+ return handleJobRun(message, socket);
333
335
 
334
336
  case MessageType.FLUSH:
335
337
  return handleFlush(message);
@@ -647,9 +649,10 @@ function handleJobResume(message) {
647
649
  /**
648
650
  * Handle job run message (manual execution)
649
651
  * @param {object} message - Message with jobId
652
+ * @param {import('node:net').Socket} socket - Socket for streaming output
650
653
  * @returns {object} Response
651
654
  */
652
- async function handleJobRun(message) {
655
+ async function handleJobRun(message, socket) {
653
656
  try {
654
657
  let jobId = message.jobId;
655
658
 
@@ -681,8 +684,19 @@ async function handleJobRun(message) {
681
684
 
682
685
  // Execute the job
683
686
  if (message.wait) {
684
- // Wait for execution and return results
685
- const result = await executeJobAndReturnResult(job);
687
+ // Stream function to send output chunks
688
+ const streamOutput = (stream, data) => {
689
+ if (socket && !socket.destroyed) {
690
+ try {
691
+ socket.write(JSON.stringify(createJobStreamOutput(stream, data)) + '\n');
692
+ } catch (err) {
693
+ // Ignore socket write errors
694
+ }
695
+ }
696
+ };
697
+
698
+ // Wait for execution with streaming and return results
699
+ const result = await executeJobAndReturnResult(job, streamOutput);
686
700
  return createJobRunResponse({
687
701
  jobId,
688
702
  status: result.status,
@@ -713,9 +727,10 @@ async function handleJobRun(message) {
713
727
  /**
714
728
  * Execute a job and return the result
715
729
  * @param {object} job - Job to execute
730
+ * @param {Function} onStream - Callback for streaming output (stream, data) => void
716
731
  * @returns {Promise<object>} Execution result
717
732
  */
718
- async function executeJobAndReturnResult(job) {
733
+ async function executeJobAndReturnResult(job, onStream) {
719
734
  if (!scheduler.executor) {
720
735
  return {
721
736
  status: 'failed',
@@ -724,7 +739,7 @@ async function executeJobAndReturnResult(job) {
724
739
  }
725
740
 
726
741
  try {
727
- const result = await scheduler.executor.executeJobWithRetry(job);
742
+ const result = await scheduler.executor.executeJobWithRetry(job, { onStream });
728
743
 
729
744
  // Update job stats
730
745
  const updatedJob = scheduler.getJob(job.id);
package/src/ipc/client.js CHANGED
@@ -44,20 +44,102 @@ export function send(message, options = {}) {
44
44
  let buffer = '';
45
45
  let finished = false;
46
46
 
47
- const timeout = setTimeout(() => {
47
+ const timeout = timeoutMs !== null && timeoutMs !== undefined
48
+ ? setTimeout(() => {
49
+ if (!finished) {
50
+ finished = true;
51
+ client.destroy();
52
+ reject(new DaemonError('IPC request timed out', 'ETIMEOUT'));
53
+ }
54
+ }, timeoutMs)
55
+ : null;
56
+
57
+ client.on('error', err => {
48
58
  if (!finished) {
49
59
  finished = true;
50
- client.destroy();
51
- reject(new DaemonError('IPC request timed out', 'ETIMEOUT'));
60
+ clearTimeout(timeout);
61
+
62
+ // Provide user-friendly error for daemon not running
63
+ if (isConnectionRefusedError(err)) {
64
+ reject(new DaemonError(
65
+ 'Daemon is not running. Start it with: jm2 start',
66
+ 'EDAEMON_NOT_RUNNING'
67
+ ));
68
+ } else {
69
+ reject(new DaemonError(
70
+ `IPC communication failed: ${err.message}`,
71
+ err.code || 'EIPC_ERROR'
72
+ ));
73
+ }
52
74
  }
53
- }, timeoutMs);
75
+ });
76
+
77
+ client.on('data', data => {
78
+ buffer += data.toString();
79
+ let index;
80
+ while ((index = buffer.indexOf('\n')) !== -1) {
81
+ const line = buffer.slice(0, index).trim();
82
+ buffer = buffer.slice(index + 1);
83
+ if (!line) {
84
+ continue;
85
+ }
86
+ try {
87
+ const response = JSON.parse(line);
88
+ if (!finished) {
89
+ finished = true;
90
+ clearTimeout(timeout);
91
+ client.end();
92
+ resolve(response);
93
+ }
94
+ } catch (error) {
95
+ if (!finished) {
96
+ finished = true;
97
+ clearTimeout(timeout);
98
+ client.end();
99
+ reject(new DaemonError('Invalid JSON response from daemon', 'EINVALID_JSON'));
100
+ }
101
+ }
102
+ }
103
+ });
104
+
105
+ client.on('connect', () => {
106
+ client.write(`${JSON.stringify(message)}\n`);
107
+ });
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Send a message to the daemon with streaming support
113
+ * @param {object} message - Message to send
114
+ * @param {object} options - Client options
115
+ * @param {number|null} options.timeoutMs - Timeout in milliseconds (null for no timeout)
116
+ * @param {Function} options.onStream - Callback for stream messages (chunk) => void
117
+ * @returns {Promise<object>} Final response message
118
+ */
119
+ export function sendWithStream(message, options = {}) {
120
+ const { timeoutMs = null, onStream } = options;
121
+ const socketPath = getSocketPath();
122
+
123
+ return new Promise((resolve, reject) => {
124
+ const client = createConnection(socketPath);
125
+ let buffer = '';
126
+ let finished = false;
127
+
128
+ const timeout = timeoutMs !== null && timeoutMs !== undefined
129
+ ? setTimeout(() => {
130
+ if (!finished) {
131
+ finished = true;
132
+ client.destroy();
133
+ reject(new DaemonError('IPC request timed out', 'ETIMEOUT'));
134
+ }
135
+ }, timeoutMs)
136
+ : null;
54
137
 
55
138
  client.on('error', err => {
56
139
  if (!finished) {
57
140
  finished = true;
58
141
  clearTimeout(timeout);
59
142
 
60
- // Provide user-friendly error for daemon not running
61
143
  if (isConnectionRefusedError(err)) {
62
144
  reject(new DaemonError(
63
145
  'Daemon is not running. Start it with: jm2 start',
@@ -83,6 +165,14 @@ export function send(message, options = {}) {
83
165
  }
84
166
  try {
85
167
  const response = JSON.parse(line);
168
+
169
+ // Handle streaming output
170
+ if (response.type === 'job:stream:output' && onStream) {
171
+ onStream(response);
172
+ continue;
173
+ }
174
+
175
+ // Final result
86
176
  if (!finished) {
87
177
  finished = true;
88
178
  clearTimeout(timeout);
@@ -108,5 +198,6 @@ export function send(message, options = {}) {
108
198
 
109
199
  export default {
110
200
  send,
201
+ sendWithStream,
111
202
  DaemonError,
112
203
  };
@@ -25,6 +25,7 @@ export const MessageType = {
25
25
  JOB_RESUMED: 'job:resumed',
26
26
  JOB_RUN: 'job:run',
27
27
  JOB_RUN_RESULT: 'job:run:result',
28
+ JOB_STREAM_OUTPUT: 'job:stream:output',
28
29
 
29
30
  // Tag management
30
31
  TAG_LIST: 'tag:list',
@@ -143,6 +144,20 @@ export function createJobRunResponse(result) {
143
144
  };
144
145
  }
145
146
 
147
+ /**
148
+ * Create a job stream output message
149
+ * @param {string} stream - 'stdout' or 'stderr'
150
+ * @param {string} data - Output data
151
+ * @returns {{ type: string, stream: string, data: string }}
152
+ */
153
+ export function createJobStreamOutput(stream, data) {
154
+ return {
155
+ type: MessageType.JOB_STREAM_OUTPUT,
156
+ stream,
157
+ data,
158
+ };
159
+ }
160
+
146
161
  /**
147
162
  * Create a flush result response
148
163
  * @param {object} result - Flush result with jobsRemoved, logsRemoved, historyRemoved
@@ -257,6 +272,7 @@ export default {
257
272
  createJobPausedResponse,
258
273
  createJobResumedResponse,
259
274
  createJobRunResponse,
275
+ createJobStreamOutput,
260
276
  createFlushResultResponse,
261
277
  createTagListResponse,
262
278
  createTagAddResponse,
package/src/ipc/server.js CHANGED
@@ -62,7 +62,7 @@ export function startIpcServer(options = {}) {
62
62
  }
63
63
 
64
64
  if (onMessage) {
65
- Promise.resolve(onMessage(message))
65
+ Promise.resolve(onMessage(message, socket))
66
66
  .then(response => {
67
67
  if (response) {
68
68
  socket.write(JSON.stringify(response) + '\n');