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 +60 -5
- package/package.json +1 -1
- package/src/cli/commands/add.js +2 -3
- package/src/cli/commands/backup.js +130 -0
- package/src/cli/commands/edit.js +4 -3
- 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/service.js +2 -0
- package/src/daemon/executor.js +6 -0
- package/src/daemon/index.js +22 -7
- package/src/ipc/client.js +96 -5
- package/src/ipc/protocol.js +16 -0
- package/src/ipc/server.js +1 -1
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
|
-
#
|
|
300
|
-
jm2 edit nightly-backup --tag
|
|
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>` -
|
|
312
|
-
- `--
|
|
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
package/src/cli/commands/add.js
CHANGED
|
@@ -138,9 +138,8 @@ export async function addCommand(command, options = {}) {
|
|
|
138
138
|
jobData.tags = tags;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
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;
|
package/src/cli/commands/edit.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
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;
|
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/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/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