jm2 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/cli/commands/add.js +6 -3
- package/src/cli/commands/backup.js +130 -0
- package/src/cli/commands/restore.js +156 -0
- package/src/cli/commands/run.js +39 -33
- package/src/cli/commands/show.js +69 -1
- package/src/cli/index.js +27 -2
- package/src/core/logger.js +15 -2
- package/src/core/service.js +2 -0
- package/src/daemon/executor.js +6 -0
- package/src/daemon/index.js +22 -7
- package/src/daemon/scheduler.js +86 -8
- package/src/ipc/client.js +96 -5
- package/src/ipc/protocol.js +16 -0
- package/src/ipc/server.js +1 -1
package/package.json
CHANGED
package/src/cli/commands/add.js
CHANGED
|
@@ -31,6 +31,10 @@ Common examples of JM2 add:
|
|
|
31
31
|
jm2 add "weekly-backup.sh" --cron "0 0 * * 0"
|
|
32
32
|
jm2 add "monthly-task.sh" --cron "0 0 1 * *"
|
|
33
33
|
|
|
34
|
+
# Advanced cron patterns (multiple ranges, steps)
|
|
35
|
+
jm2 add "check.sh" --cron "0 */5 9-18 * * *" # Every 5 min, 9AM-6PM
|
|
36
|
+
jm2 add "offpeak.sh" --cron "*/30 0-8,18-23 * * *" # Every 30 min, off-peak hours
|
|
37
|
+
|
|
34
38
|
# Add a job with a name
|
|
35
39
|
jm2 add "backup.sh" --name "daily-backup" --cron "0 2 * * *"
|
|
36
40
|
|
|
@@ -138,9 +142,8 @@ export async function addCommand(command, options = {}) {
|
|
|
138
142
|
jobData.tags = tags;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
145
|
+
// Set working directory - use explicit cwd or default to current directory
|
|
146
|
+
jobData.cwd = options.cwd || process.cwd();
|
|
144
147
|
|
|
145
148
|
if (options.env) {
|
|
146
149
|
// 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;
|
|
@@ -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;
|
package/src/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
49
|
-
printError(
|
|
51
|
+
if (response.type === MessageType.ERROR) {
|
|
52
|
+
printError(response.message);
|
|
50
53
|
return 1;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
if (
|
|
54
|
-
|
|
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
|
-
|
|
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');
|
package/src/cli/commands/show.js
CHANGED
|
@@ -59,7 +59,11 @@ export async function showCommand(jobRef, options = {}) {
|
|
|
59
59
|
return 1;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
.
|
|
144
|
-
|
|
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...]')
|
package/src/core/logger.js
CHANGED
|
@@ -62,12 +62,25 @@ function shouldLog(level) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Format a timestamp for logging
|
|
65
|
+
* Format a timestamp for logging in local timezone
|
|
66
66
|
* @param {Date} date - Date to format
|
|
67
67
|
* @returns {string} Formatted timestamp
|
|
68
68
|
*/
|
|
69
69
|
function formatTimestamp(date = new Date()) {
|
|
70
|
-
|
|
70
|
+
const year = date.getFullYear();
|
|
71
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
72
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
73
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
74
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
75
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
76
|
+
const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
|
|
77
|
+
|
|
78
|
+
const tzOffset = -date.getTimezoneOffset();
|
|
79
|
+
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
|
|
80
|
+
const tzMinutes = String(Math.abs(tzOffset) % 60).padStart(2, '0');
|
|
81
|
+
const tzSign = tzOffset >= 0 ? '+' : '-';
|
|
82
|
+
|
|
83
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${tzSign}${tzHours}:${tzMinutes}`;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
/**
|
package/src/core/service.js
CHANGED
package/src/daemon/executor.js
CHANGED
|
@@ -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
|
|
package/src/daemon/index.js
CHANGED
|
@@ -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
|
-
//
|
|
685
|
-
const
|
|
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/daemon/scheduler.js
CHANGED
|
@@ -31,6 +31,7 @@ export class Scheduler {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
this.running = true;
|
|
34
|
+
this.lastTickTime = Date.now();
|
|
34
35
|
this.logger.info('Scheduler starting...');
|
|
35
36
|
|
|
36
37
|
// Load jobs from storage
|
|
@@ -132,7 +133,7 @@ export class Scheduler {
|
|
|
132
133
|
// Calculate next run time for active jobs
|
|
133
134
|
let nextRun = null;
|
|
134
135
|
if (job.status === JobStatus.ACTIVE) {
|
|
135
|
-
nextRun = this.calculateNextRun(job);
|
|
136
|
+
nextRun = this.calculateNextRun(job, new Date());
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
this.jobs.set(job.id, {
|
|
@@ -147,15 +148,16 @@ export class Scheduler {
|
|
|
147
148
|
/**
|
|
148
149
|
* Calculate the next run time for a job
|
|
149
150
|
* @param {object} job - Job object
|
|
151
|
+
* @param {Date} fromDate - Date to calculate from
|
|
150
152
|
* @returns {Date|null} Next run time or null
|
|
151
153
|
*/
|
|
152
|
-
calculateNextRun(job) {
|
|
154
|
+
calculateNextRun(job, fromDate) {
|
|
153
155
|
if (job.status !== JobStatus.ACTIVE) {
|
|
154
156
|
return null;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
if (job.type === JobType.CRON && job.cron) {
|
|
158
|
-
return getNextRunTime(job.cron);
|
|
160
|
+
return getNextRunTime(job.cron, fromDate);
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
if (job.type === JobType.ONCE && job.runAt) {
|
|
@@ -167,6 +169,57 @@ export class Scheduler {
|
|
|
167
169
|
return null;
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Calculate the next run time for a cron job, accounting for missed runs
|
|
174
|
+
* This ensures that after sleep/wake, we find the very next occurrence
|
|
175
|
+
* @param {object} job - Job object
|
|
176
|
+
* @param {Date} originalRunTime - The time the job was originally scheduled to run
|
|
177
|
+
* @returns {Date|null} Next run time or null
|
|
178
|
+
*/
|
|
179
|
+
calculateNextRunAfterExecution(job, originalRunTime) {
|
|
180
|
+
if (job.status !== JobStatus.ACTIVE || job.type !== JobType.CRON || !job.cron) {
|
|
181
|
+
return this.calculateNextRun(job, new Date());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const now = new Date();
|
|
185
|
+
let nextRun = this.calculateNextRun(job, originalRunTime);
|
|
186
|
+
|
|
187
|
+
// Keep calculating until we find a time in the future
|
|
188
|
+
// This handles the case where the system woke from sleep and we missed multiple runs
|
|
189
|
+
while (nextRun && nextRun <= now) {
|
|
190
|
+
nextRun = this.calculateNextRun(job, nextRun);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return nextRun;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Recalculate next run times for periodic jobs that have drifted into the past
|
|
198
|
+
* This handles system sleep/wake scenarios where nextRun becomes stale
|
|
199
|
+
* @param {Date} now - Current time
|
|
200
|
+
*/
|
|
201
|
+
recalculateStalePeriodicJobs(now) {
|
|
202
|
+
for (const [id, job] of this.jobs) {
|
|
203
|
+
if (
|
|
204
|
+
job.status === JobStatus.ACTIVE &&
|
|
205
|
+
job.type === JobType.CRON &&
|
|
206
|
+
job.cron &&
|
|
207
|
+
job.nextRun &&
|
|
208
|
+
job.nextRun < now
|
|
209
|
+
) {
|
|
210
|
+
// Job's next run is in the past - recalculate from now to find next future occurrence
|
|
211
|
+
const newNextRun = this.calculateNextRun(job, now);
|
|
212
|
+
if (newNextRun && newNextRun !== job.nextRun) {
|
|
213
|
+
this.logger.debug(
|
|
214
|
+
`Recalculating next run for job ${id} (${job.name || 'unnamed'}): ` +
|
|
215
|
+
`${job.nextRun.toISOString()} → ${newNextRun.toISOString()}`
|
|
216
|
+
);
|
|
217
|
+
this.updateJobNextRun(id, newNextRun);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
170
223
|
/**
|
|
171
224
|
* Get jobs that are due to run
|
|
172
225
|
* @returns {Array} Array of jobs that should run now
|
|
@@ -249,11 +302,32 @@ export class Scheduler {
|
|
|
249
302
|
return [];
|
|
250
303
|
}
|
|
251
304
|
|
|
305
|
+
// Detect sleep/wake events by checking if too much time has passed since last tick
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
const timeSinceLastTick = this.lastTickTime ? now - this.lastTickTime : 0;
|
|
308
|
+
const sleepThreshold = this.checkIntervalMs * 5; // If more than 5 intervals passed, likely woke from sleep
|
|
309
|
+
|
|
310
|
+
if (timeSinceLastTick > sleepThreshold) {
|
|
311
|
+
const secondsAsleep = Math.round(timeSinceLastTick / 1000);
|
|
312
|
+
this.logger.info(`System wake detected - was asleep for ${secondsAsleep}s, catching up on due jobs`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.lastTickTime = now;
|
|
316
|
+
|
|
317
|
+
const nowDate = new Date();
|
|
318
|
+
|
|
319
|
+
// Recalculate next run for periodic jobs that have drifted into the past
|
|
320
|
+
// This handles system sleep/wake scenarios
|
|
321
|
+
this.recalculateStalePeriodicJobs(nowDate);
|
|
322
|
+
|
|
252
323
|
const dueJobs = this.getDueJobs();
|
|
253
324
|
|
|
254
325
|
for (const job of dueJobs) {
|
|
255
326
|
this.logger.debug(`Job ${job.id} (${job.name || 'unnamed'}) is due`);
|
|
256
327
|
|
|
328
|
+
// Store the original scheduled time before execution
|
|
329
|
+
const originalNextRun = job.nextRun ? new Date(job.nextRun) : new Date();
|
|
330
|
+
|
|
257
331
|
// Execute the job
|
|
258
332
|
this.executeJob(job);
|
|
259
333
|
|
|
@@ -261,8 +335,12 @@ export class Scheduler {
|
|
|
261
335
|
if (job.type === JobType.ONCE) {
|
|
262
336
|
this.updateJobStatus(job.id, JobStatus.COMPLETED);
|
|
263
337
|
} else {
|
|
264
|
-
// For cron jobs, recalculate next run time
|
|
265
|
-
|
|
338
|
+
// For cron jobs, recalculate next run time from the original scheduled time
|
|
339
|
+
// This ensures we don't miss runs after system wake from sleep
|
|
340
|
+
const nextRun = this.calculateNextRunAfterExecution(
|
|
341
|
+
{ ...job, status: JobStatus.ACTIVE },
|
|
342
|
+
originalNextRun
|
|
343
|
+
);
|
|
266
344
|
this.updateJobNextRun(job.id, nextRun);
|
|
267
345
|
}
|
|
268
346
|
}
|
|
@@ -291,7 +369,7 @@ export class Scheduler {
|
|
|
291
369
|
}
|
|
292
370
|
|
|
293
371
|
// Calculate initial next run
|
|
294
|
-
const nextRun = this.calculateNextRun(jobData);
|
|
372
|
+
const nextRun = this.calculateNextRun(jobData, new Date());
|
|
295
373
|
const job = { ...jobData, nextRun };
|
|
296
374
|
|
|
297
375
|
// Add to memory
|
|
@@ -368,7 +446,7 @@ export class Scheduler {
|
|
|
368
446
|
};
|
|
369
447
|
|
|
370
448
|
// Recalculate next run if needed
|
|
371
|
-
updatedJob.nextRun = this.calculateNextRun(updatedJob);
|
|
449
|
+
updatedJob.nextRun = this.calculateNextRun(updatedJob, new Date());
|
|
372
450
|
|
|
373
451
|
this.jobs.set(jobId, updatedJob);
|
|
374
452
|
this.persistJobs();
|
|
@@ -390,7 +468,7 @@ export class Scheduler {
|
|
|
390
468
|
// Recalculate next run when activating
|
|
391
469
|
const job = this.jobs.get(jobId);
|
|
392
470
|
if (job) {
|
|
393
|
-
updates.nextRun = this.calculateNextRun({ ...job, status });
|
|
471
|
+
updates.nextRun = this.calculateNextRun({ ...job, status }, new Date());
|
|
394
472
|
}
|
|
395
473
|
} else {
|
|
396
474
|
updates.nextRun = null;
|
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 =
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
}
|
|
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
|
};
|
package/src/ipc/protocol.js
CHANGED
|
@@ -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