jm2 0.1.4 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jm2",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
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",
@@ -42,7 +42,19 @@ export async function editCommand(jobRef, options = {}) {
42
42
  options.env !== undefined ||
43
43
  options.timeout !== undefined ||
44
44
  options.retry !== undefined ||
45
- options.tag !== undefined;
45
+ options.tag !== undefined ||
46
+ options.tagAppend !== undefined ||
47
+ options.tagRemove !== undefined;
48
+
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;
53
+
54
+ if (hasTagSet && (hasTagAppend || hasTagRemove)) {
55
+ 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.');
56
+ return 1;
57
+ }
46
58
 
47
59
  if (!hasUpdates) {
48
60
  printError('No changes specified. Use options like --command, --cron, --name, etc.');
@@ -143,12 +155,30 @@ export async function editCommand(jobRef, options = {}) {
143
155
  }
144
156
 
145
157
  if (options.tag !== undefined) {
146
- // Handle multiple tags
158
+ // Handle multiple tags (replace mode)
147
159
  const tags = Array.isArray(options.tag)
148
160
  ? options.tag
149
161
  : options.tag ? [options.tag] : [];
150
- if (tags.length > 0) {
151
- updates.tags = tags;
162
+ updates.tags = tags;
163
+ }
164
+
165
+ if (options.tagAppend !== undefined) {
166
+ // Handle tags to append
167
+ const tagsToAppend = Array.isArray(options.tagAppend)
168
+ ? options.tagAppend
169
+ : options.tagAppend ? [options.tagAppend] : [];
170
+ if (tagsToAppend.length > 0) {
171
+ updates.tagsAppend = tagsToAppend;
172
+ }
173
+ }
174
+
175
+ if (options.tagRemove !== undefined) {
176
+ // Handle tags to remove
177
+ const tagsToRemove = Array.isArray(options.tagRemove)
178
+ ? options.tagRemove
179
+ : options.tagRemove ? [options.tagRemove] : [];
180
+ if (tagsToRemove.length > 0) {
181
+ updates.tagsRemove = tagsToRemove;
152
182
  }
153
183
  }
154
184
 
@@ -0,0 +1,399 @@
1
+ /**
2
+ * JM2 tags command
3
+ * Manage job tags - list, add, remove, rename, and more
4
+ */
5
+
6
+ import { send } from '../../ipc/client.js';
7
+ import { MessageType } from '../../ipc/protocol.js';
8
+ import {
9
+ printSuccess,
10
+ printError,
11
+ printInfo,
12
+ printHeader,
13
+ createJobTable,
14
+ colorizeStatus,
15
+ formatRelativeTime,
16
+ } from '../utils/output.js';
17
+ import { isDaemonRunning } from '../../daemon/index.js';
18
+ import chalk from 'chalk';
19
+
20
+ /**
21
+ * Execute the tags command
22
+ * @param {string} subcommand - Subcommand to run (list, add, rm, clear, rename, jobs)
23
+ * @param {string[]} args - Arguments for the subcommand
24
+ * @param {object} options - Command options
25
+ * @returns {Promise<number>} Exit code
26
+ */
27
+ export async function tagsCommand(subcommand, args, options = {}) {
28
+ // Check if daemon is running
29
+ if (!isDaemonRunning()) {
30
+ printError('Daemon is not running. Start it with: jm2 start');
31
+ return 1;
32
+ }
33
+
34
+ switch (subcommand) {
35
+ case 'list':
36
+ return await listTags(options);
37
+ case 'add':
38
+ return await addTags(args, options);
39
+ case 'rm':
40
+ case 'remove':
41
+ return await removeTags(args, options);
42
+ case 'clear':
43
+ return await clearTags(args, options);
44
+ case 'rename':
45
+ return await renameTag(args, options);
46
+ case 'jobs':
47
+ return await listJobsByTag(args, options);
48
+ default:
49
+ printError(`Unknown subcommand: ${subcommand}`);
50
+ printInfo('Available subcommands: list, add, rm, clear, rename, jobs');
51
+ return 1;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * List all tags with job counts
57
+ * @param {object} options - Command options
58
+ * @returns {Promise<number>} Exit code
59
+ */
60
+ async function listTags(options) {
61
+ try {
62
+ const response = await send({
63
+ type: MessageType.TAG_LIST,
64
+ });
65
+
66
+ if (response.type === MessageType.ERROR) {
67
+ printError(response.message);
68
+ return 1;
69
+ }
70
+
71
+ if (response.type === MessageType.TAG_LIST_RESULT) {
72
+ const tags = response.tags || {};
73
+ const tagNames = Object.keys(tags).sort();
74
+
75
+ if (tagNames.length === 0) {
76
+ printInfo('No tags found');
77
+ return 0;
78
+ }
79
+
80
+ printHeader('Tags');
81
+
82
+ // Group by tag
83
+ for (const tagName of tagNames) {
84
+ const tagData = tags[tagName];
85
+ const jobCount = tagData.jobs?.length || 0;
86
+ console.log(` ${chalk.cyan(tagName)} ${chalk.gray(`(${jobCount} job${jobCount === 1 ? '' : 's'})`)}`);
87
+
88
+ if (options.verbose && tagData.jobs) {
89
+ for (const job of tagData.jobs) {
90
+ console.log(` - ${job.name || job.id} ${chalk.gray(`[${job.id}]`)}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ console.log();
96
+ printInfo(`${tagNames.length} tag${tagNames.length === 1 ? '' : 's'} found`);
97
+
98
+ return 0;
99
+ }
100
+
101
+ printError('Unexpected response from daemon');
102
+ return 1;
103
+ } catch (error) {
104
+ printError(`Failed to list tags: ${error.message}`);
105
+ return 1;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Add tags to jobs
111
+ * @param {string[]} args - [tagName, ...jobIds]
112
+ * @param {object} options - Command options
113
+ * @returns {Promise<number>} Exit code
114
+ */
115
+ async function addTags(args, options) {
116
+ if (args.length < 2) {
117
+ printError('Usage: jm2 tags add <tag> <job-id-or-name> [...job-ids-or-names]');
118
+ return 1;
119
+ }
120
+
121
+ const tagName = args[0];
122
+ const jobRefs = args.slice(1);
123
+
124
+ try {
125
+ const response = await send({
126
+ type: MessageType.TAG_ADD,
127
+ tag: tagName,
128
+ jobRefs,
129
+ });
130
+
131
+ if (response.type === MessageType.ERROR) {
132
+ printError(response.message);
133
+ return 1;
134
+ }
135
+
136
+ if (response.type === MessageType.TAG_ADD_RESULT) {
137
+ printSuccess(`Tag "${tagName}" added to ${response.count} job${response.count === 1 ? '' : 's'}`);
138
+
139
+ if (response.count > 0 && options.verbose) {
140
+ printInfo(`Updated job IDs: ${response.jobIds.join(', ')}`);
141
+ }
142
+
143
+ return 0;
144
+ }
145
+
146
+ printError('Unexpected response from daemon');
147
+ return 1;
148
+ } catch (error) {
149
+ printError(`Failed to add tag: ${error.message}`);
150
+ return 1;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Remove tags from jobs
156
+ * @param {string[]} args - [tagName, ...jobIds] or just [tagName] to remove from all
157
+ * @param {object} options - Command options
158
+ * @returns {Promise<number>} Exit code
159
+ */
160
+ async function removeTags(args, options) {
161
+ if (args.length < 1) {
162
+ printError('Usage: jm2 tags rm <tag> [job-id-or-name ...]');
163
+ printInfo('Or: jm2 tags rm <tag> --all (to remove tag from all jobs)');
164
+ return 1;
165
+ }
166
+
167
+ const tagName = args[0];
168
+ const jobRefs = args.length > 1 ? args.slice(1) : [];
169
+
170
+ // If --all flag is set or no specific jobs, remove from all
171
+ const removeFromAll = options.all || jobRefs.length === 0;
172
+
173
+ try {
174
+ const response = await send({
175
+ type: MessageType.TAG_REMOVE,
176
+ tag: tagName,
177
+ jobRefs: removeFromAll ? null : jobRefs,
178
+ all: removeFromAll,
179
+ });
180
+
181
+ if (response.type === MessageType.ERROR) {
182
+ printError(response.message);
183
+ return 1;
184
+ }
185
+
186
+ if (response.type === MessageType.TAG_REMOVE_RESULT) {
187
+ printSuccess(`Tag "${tagName}" removed from ${response.count} job${response.count === 1 ? '' : 's'}`);
188
+
189
+ if (response.count > 0 && options.verbose) {
190
+ printInfo(`Updated job IDs: ${response.jobIds.join(', ')}`);
191
+ }
192
+
193
+ return 0;
194
+ }
195
+
196
+ printError('Unexpected response from daemon');
197
+ return 1;
198
+ } catch (error) {
199
+ printError(`Failed to remove tag: ${error.message}`);
200
+ return 1;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Clear all tags from jobs
206
+ * @param {string[]} args - [...jobIds] or empty for all jobs
207
+ * @param {object} options - Command options
208
+ * @returns {Promise<number>} Exit code
209
+ */
210
+ async function clearTags(args, options) {
211
+ const jobRefs = args.length > 0 ? args : null;
212
+ const clearAll = options.all || !jobRefs;
213
+
214
+ if (clearAll && !options.force) {
215
+ printError('This will clear all tags from all jobs. Use --force to confirm.');
216
+ return 1;
217
+ }
218
+
219
+ try {
220
+ const response = await send({
221
+ type: MessageType.TAG_CLEAR,
222
+ jobRefs,
223
+ all: clearAll,
224
+ });
225
+
226
+ if (response.type === MessageType.ERROR) {
227
+ printError(response.message);
228
+ return 1;
229
+ }
230
+
231
+ if (response.type === MessageType.TAG_CLEAR_RESULT) {
232
+ printSuccess(`Tags cleared from ${response.count} job${response.count === 1 ? '' : 's'}`);
233
+
234
+ if (response.count > 0 && options.verbose) {
235
+ printInfo(`Updated job IDs: ${response.jobIds.join(', ')}`);
236
+ }
237
+
238
+ return 0;
239
+ }
240
+
241
+ printError('Unexpected response from daemon');
242
+ return 1;
243
+ } catch (error) {
244
+ printError(`Failed to clear tags: ${error.message}`);
245
+ return 1;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Rename a tag across all jobs
251
+ * @param {string[]} args - [oldTagName, newTagName]
252
+ * @param {object} options - Command options
253
+ * @returns {Promise<number>} Exit code
254
+ */
255
+ async function renameTag(args, options) {
256
+ if (args.length !== 2) {
257
+ printError('Usage: jm2 tags rename <old-tag> <new-tag>');
258
+ return 1;
259
+ }
260
+
261
+ const [oldTag, newTag] = args;
262
+
263
+ try {
264
+ const response = await send({
265
+ type: MessageType.TAG_RENAME,
266
+ oldTag,
267
+ newTag,
268
+ });
269
+
270
+ if (response.type === MessageType.ERROR) {
271
+ printError(response.message);
272
+ return 1;
273
+ }
274
+
275
+ if (response.type === MessageType.TAG_RENAME_RESULT) {
276
+ printSuccess(`Tag "${oldTag}" renamed to "${newTag}" in ${response.count} job${response.count === 1 ? '' : 's'}`);
277
+ return 0;
278
+ }
279
+
280
+ printError('Unexpected response from daemon');
281
+ return 1;
282
+ } catch (error) {
283
+ printError(`Failed to rename tag: ${error.message}`);
284
+ return 1;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * List jobs grouped by tag (including jobs with no tags)
290
+ * @param {string[]} args - Optional tag name to filter
291
+ * @param {object} options - Command options
292
+ * @returns {Promise<number>} Exit code
293
+ */
294
+ async function listJobsByTag(args, options) {
295
+ const filterTag = args[0];
296
+
297
+ try {
298
+ const response = await send({
299
+ type: MessageType.TAG_LIST,
300
+ });
301
+
302
+ if (response.type === MessageType.ERROR) {
303
+ printError(response.message);
304
+ return 1;
305
+ }
306
+
307
+ if (response.type === MessageType.TAG_LIST_RESULT) {
308
+ const tags = response.tags || {};
309
+
310
+ // If filtering by specific tag
311
+ if (filterTag) {
312
+ const tagData = tags[filterTag];
313
+ if (!tagData || !tagData.jobs || tagData.jobs.length === 0) {
314
+ printInfo(`No jobs found with tag "${filterTag}"`);
315
+ return 0;
316
+ }
317
+
318
+ printHeader(`Jobs with tag "${filterTag}"`);
319
+ printJobsTable(tagData.jobs);
320
+ console.log();
321
+ printInfo(`${tagData.jobs.length} job${tagData.jobs.length === 1 ? '' : 's'} found`);
322
+ return 0;
323
+ }
324
+
325
+ // Show all jobs grouped by tag
326
+ printHeader('Jobs by Tag');
327
+
328
+ const tagNames = Object.keys(tags).sort();
329
+ const noTagJobs = tags['(no tag)']?.jobs || [];
330
+
331
+ // First show tagged jobs
332
+ for (const tagName of tagNames) {
333
+ if (tagName === '(no tag)') continue;
334
+
335
+ const tagData = tags[tagName];
336
+ if (!tagData.jobs || tagData.jobs.length === 0) continue;
337
+
338
+ console.log(`\n${chalk.bold.cyan(tagName)} ${chalk.gray(`(${tagData.jobs.length} job${tagData.jobs.length === 1 ? '' : 's'})`)}`);
339
+ printJobsTable(tagData.jobs, true);
340
+ }
341
+
342
+ // Then show jobs with no tags
343
+ if (noTagJobs.length > 0) {
344
+ console.log(`\n${chalk.bold.gray('(no tag)')} ${chalk.gray(`(${noTagJobs.length} job${noTagJobs.length === 1 ? '' : 's'})`)}`);
345
+ printJobsTable(noTagJobs, true);
346
+ }
347
+
348
+ console.log();
349
+ const totalJobs = Object.values(tags).reduce((sum, t) => sum + (t.jobs?.length || 0), 0);
350
+ const uniqueJobs = new Set();
351
+ Object.values(tags).forEach(t => t.jobs?.forEach(j => uniqueJobs.add(j.id)));
352
+ printInfo(`${uniqueJobs.size} job${uniqueJobs.size === 1 ? '' : 's'} found (${tagNames.length - (noTagJobs.length > 0 ? 1 : 0)} tag${tagNames.length === 1 ? '' : 's'})`);
353
+
354
+ return 0;
355
+ }
356
+
357
+ printError('Unexpected response from daemon');
358
+ return 1;
359
+ } catch (error) {
360
+ printError(`Failed to list jobs by tag: ${error.message}`);
361
+ return 1;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Print jobs in a compact table format
367
+ * @param {Array} jobs - Array of job objects
368
+ * @param {boolean} compact - Whether to use compact format
369
+ */
370
+ function printJobsTable(jobs, compact = false) {
371
+ const table = createJobTable();
372
+
373
+ for (const job of jobs) {
374
+ const schedule = job.cron
375
+ ? chalk.gray(job.cron)
376
+ : job.runAt
377
+ ? formatRelativeTime(job.runAt)
378
+ : chalk.gray('Manual');
379
+
380
+ if (compact) {
381
+ console.log(` ${job.id} ${job.name || chalk.gray('-')} ${colorizeStatus(job.status)} ${schedule}`);
382
+ } else {
383
+ table.push([
384
+ job.id,
385
+ job.name || chalk.gray('-'),
386
+ colorizeStatus(job.status),
387
+ schedule,
388
+ formatRelativeTime(job.nextRun),
389
+ formatRelativeTime(job.lastRun),
390
+ ]);
391
+ }
392
+ }
393
+
394
+ if (!compact && jobs.length > 0) {
395
+ console.log(table.toString());
396
+ }
397
+ }
398
+
399
+ export default { tagsCommand };
package/src/cli/index.js CHANGED
@@ -28,6 +28,7 @@ import { exportCommand } from './commands/export.js';
28
28
  import { importCommand } from './commands/import.js';
29
29
  import { installCommand } from './commands/install.js';
30
30
  import { uninstallCommand } from './commands/uninstall.js';
31
+ import { tagsCommand } from './commands/tags.js';
31
32
 
32
33
  const __filename = fileURLToPath(import.meta.url);
33
34
  const __dirname = dirname(__filename);
@@ -191,6 +192,8 @@ export async function runCli() {
191
192
  .option('--timeout <duration>', 'New timeout for job execution')
192
193
  .option('--retry <count>', 'New retry count on failure')
193
194
  .option('-t, --tag <tag>', 'Set tags (replaces all existing tags, can be used multiple times)', collect, [])
195
+ .option('--tag-append <tag>', 'Append tags to existing tags (can be used multiple times)', collect, [])
196
+ .option('--tag-remove <tag>', 'Remove tags from existing tags (can be used multiple times)', collect, [])
194
197
  .action(async (job, options) => {
195
198
  const exitCode = await editCommand(job, options);
196
199
  process.exit(exitCode);
@@ -296,6 +299,31 @@ export async function runCli() {
296
299
  process.exit(exitCode);
297
300
  });
298
301
 
302
+ // Tags command
303
+ program
304
+ .command('tags <subcommand> [args...]')
305
+ .description('Manage job tags')
306
+ .option('-v, --verbose', 'Show verbose output', false)
307
+ .option('-a, --all', 'Apply to all jobs', false)
308
+ .option('-f, --force', 'Skip confirmation for destructive operations', false)
309
+ .addHelpText('after', `
310
+ Examples:
311
+ jm2 tags list List all tags with job counts
312
+ jm2 tags list -v List tags with associated jobs
313
+ jm2 tags add production 1 2 3 Add "production" tag to jobs 1, 2, 3
314
+ jm2 tags rm staging 1 2 Remove "staging" tag from jobs 1, 2
315
+ jm2 tags rm old-tag --all Remove "old-tag" from all jobs
316
+ jm2 tags clear 1 2 Clear all tags from jobs 1, 2
317
+ jm2 tags clear --all --force Clear all tags from all jobs
318
+ jm2 tags rename old new Rename tag "old" to "new"
319
+ jm2 tags jobs Show all jobs grouped by tag
320
+ jm2 tags jobs production Show jobs with "production" tag
321
+ `)
322
+ .action(async (subcommand, args, options) => {
323
+ const exitCode = await tagsCommand(subcommand, args || [], options);
324
+ process.exit(exitCode);
325
+ });
326
+
299
327
  // Parse command line arguments
300
328
  await program.parseAsync();
301
329
  }
@@ -26,6 +26,11 @@ import {
26
26
  createJobResumedResponse,
27
27
  createJobRunResponse,
28
28
  createFlushResultResponse,
29
+ createTagListResponse,
30
+ createTagAddResponse,
31
+ createTagRemoveResponse,
32
+ createTagClearResponse,
33
+ createTagRenameResponse,
29
34
  } from '../ipc/protocol.js';
30
35
 
31
36
  const __filename = fileURLToPath(import.meta.url);
@@ -332,6 +337,21 @@ async function handleIpcMessage(message) {
332
337
  case MessageType.RELOAD_JOBS:
333
338
  return handleReloadJobs(message);
334
339
 
340
+ case MessageType.TAG_LIST:
341
+ return handleTagList(message);
342
+
343
+ case MessageType.TAG_ADD:
344
+ return handleTagAdd(message);
345
+
346
+ case MessageType.TAG_REMOVE:
347
+ return handleTagRemove(message);
348
+
349
+ case MessageType.TAG_CLEAR:
350
+ return handleTagClear(message);
351
+
352
+ case MessageType.TAG_RENAME:
353
+ return handleTagRename(message);
354
+
335
355
  default:
336
356
  return {
337
357
  type: 'error',
@@ -763,6 +783,8 @@ function handleFlush(message) {
763
783
  result.jobsRemoved = initialCount - filteredJobs.length;
764
784
  if (result.jobsRemoved > 0) {
765
785
  saveJobs(filteredJobs);
786
+ // Reload jobs into scheduler to sync in-memory state with storage
787
+ scheduler.loadJobs();
766
788
  logger?.info(`Flushed ${result.jobsRemoved} completed one-time jobs`);
767
789
  }
768
790
  }
@@ -828,6 +850,231 @@ function handleFlush(message) {
828
850
  }
829
851
  }
830
852
 
853
+ /**
854
+ * Handle tag list message
855
+ * Returns all tags grouped with their jobs
856
+ * @returns {object} Response
857
+ */
858
+ function handleTagList() {
859
+ try {
860
+ const jobs = scheduler.getAllJobs();
861
+ const tags = {};
862
+
863
+ // Group jobs by tag
864
+ for (const job of jobs) {
865
+ const jobTags = job.tags || [];
866
+
867
+ if (jobTags.length === 0) {
868
+ // Jobs with no tags
869
+ if (!tags['(no tag)']) {
870
+ tags['(no tag)'] = { jobs: [] };
871
+ }
872
+ tags['(no tag)'].jobs.push(job);
873
+ } else {
874
+ // Jobs with tags (can be in multiple groups)
875
+ for (const tag of jobTags) {
876
+ if (!tags[tag]) {
877
+ tags[tag] = { jobs: [] };
878
+ }
879
+ tags[tag].jobs.push(job);
880
+ }
881
+ }
882
+ }
883
+
884
+ return createTagListResponse(tags);
885
+ } catch (error) {
886
+ logger?.error(`Failed to list tags: ${error.message}`);
887
+ return {
888
+ type: MessageType.ERROR,
889
+ message: `Failed to list tags: ${error.message}`,
890
+ };
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Handle tag add message
896
+ * Adds a tag to specified jobs
897
+ * @param {object} message - Message with tag and jobRefs
898
+ * @returns {object} Response
899
+ */
900
+ function handleTagAdd(message) {
901
+ try {
902
+ const { tag, jobRefs } = message;
903
+ const normalizedTag = tag.trim().toLowerCase();
904
+
905
+ const jobs = scheduler.getAllJobs();
906
+ const updatedJobIds = [];
907
+
908
+ for (const jobRef of jobRefs) {
909
+ // Find job by ID or name
910
+ let job = null;
911
+ const jobId = parseInt(jobRef, 10);
912
+
913
+ if (!isNaN(jobId)) {
914
+ job = jobs.find(j => j.id === jobId);
915
+ }
916
+ if (!job) {
917
+ job = jobs.find(j => j.name === jobRef);
918
+ }
919
+
920
+ if (job) {
921
+ const currentTags = job.tags || [];
922
+ if (!currentTags.includes(normalizedTag)) {
923
+ const newTags = [...currentTags, normalizedTag];
924
+ scheduler.updateJob(job.id, { tags: newTags });
925
+ updatedJobIds.push(job.id);
926
+ }
927
+ }
928
+ }
929
+
930
+ if (updatedJobIds.length > 0) {
931
+ logger?.info(`Added tag "${normalizedTag}" to ${updatedJobIds.length} jobs`);
932
+ }
933
+
934
+ return createTagAddResponse(updatedJobIds.length, updatedJobIds);
935
+ } catch (error) {
936
+ logger?.error(`Failed to add tag: ${error.message}`);
937
+ return {
938
+ type: MessageType.ERROR,
939
+ message: `Failed to add tag: ${error.message}`,
940
+ };
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Handle tag remove message
946
+ * Removes a tag from specified jobs or all jobs
947
+ * @param {object} message - Message with tag, jobRefs, and all flag
948
+ * @returns {object} Response
949
+ */
950
+ function handleTagRemove(message) {
951
+ try {
952
+ const { tag, jobRefs, all } = message;
953
+ const normalizedTag = tag.trim().toLowerCase();
954
+
955
+ const jobs = scheduler.getAllJobs();
956
+ const updatedJobIds = [];
957
+
958
+ // Determine which jobs to process
959
+ const jobsToProcess = all
960
+ ? jobs
961
+ : jobRefs.map(ref => {
962
+ const jobId = parseInt(ref, 10);
963
+ if (!isNaN(jobId)) {
964
+ return jobs.find(j => j.id === jobId) || jobs.find(j => j.name === ref);
965
+ }
966
+ return jobs.find(j => j.name === ref);
967
+ }).filter(Boolean);
968
+
969
+ for (const job of jobsToProcess) {
970
+ const currentTags = job.tags || [];
971
+ if (currentTags.includes(normalizedTag)) {
972
+ const newTags = currentTags.filter(t => t !== normalizedTag);
973
+ scheduler.updateJob(job.id, { tags: newTags });
974
+ updatedJobIds.push(job.id);
975
+ }
976
+ }
977
+
978
+ if (updatedJobIds.length > 0) {
979
+ logger?.info(`Removed tag "${normalizedTag}" from ${updatedJobIds.length} jobs`);
980
+ }
981
+
982
+ return createTagRemoveResponse(updatedJobIds.length, updatedJobIds);
983
+ } catch (error) {
984
+ logger?.error(`Failed to remove tag: ${error.message}`);
985
+ return {
986
+ type: MessageType.ERROR,
987
+ message: `Failed to remove tag: ${error.message}`,
988
+ };
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Handle tag clear message
994
+ * Clears all tags from specified jobs or all jobs
995
+ * @param {object} message - Message with jobRefs and all flag
996
+ * @returns {object} Response
997
+ */
998
+ function handleTagClear(message) {
999
+ try {
1000
+ const { jobRefs, all } = message;
1001
+
1002
+ const jobs = scheduler.getAllJobs();
1003
+ const updatedJobIds = [];
1004
+
1005
+ // Determine which jobs to process
1006
+ const jobsToProcess = all
1007
+ ? jobs
1008
+ : (jobRefs || []).map(ref => {
1009
+ const jobId = parseInt(ref, 10);
1010
+ if (!isNaN(jobId)) {
1011
+ return jobs.find(j => j.id === jobId) || jobs.find(j => j.name === ref);
1012
+ }
1013
+ return jobs.find(j => j.name === ref);
1014
+ }).filter(Boolean);
1015
+
1016
+ for (const job of jobsToProcess) {
1017
+ const currentTags = job.tags || [];
1018
+ if (currentTags.length > 0) {
1019
+ scheduler.updateJob(job.id, { tags: [] });
1020
+ updatedJobIds.push(job.id);
1021
+ }
1022
+ }
1023
+
1024
+ if (updatedJobIds.length > 0) {
1025
+ logger?.info(`Cleared all tags from ${updatedJobIds.length} jobs`);
1026
+ }
1027
+
1028
+ return createTagClearResponse(updatedJobIds.length, updatedJobIds);
1029
+ } catch (error) {
1030
+ logger?.error(`Failed to clear tags: ${error.message}`);
1031
+ return {
1032
+ type: MessageType.ERROR,
1033
+ message: `Failed to clear tags: ${error.message}`,
1034
+ };
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Handle tag rename message
1040
+ * Renames a tag across all jobs
1041
+ * @param {object} message - Message with oldTag and newTag
1042
+ * @returns {object} Response
1043
+ */
1044
+ function handleTagRename(message) {
1045
+ try {
1046
+ const { oldTag, newTag } = message;
1047
+ const normalizedOldTag = oldTag.trim().toLowerCase();
1048
+ const normalizedNewTag = newTag.trim().toLowerCase();
1049
+
1050
+ const jobs = scheduler.getAllJobs();
1051
+ let updatedCount = 0;
1052
+
1053
+ for (const job of jobs) {
1054
+ const currentTags = job.tags || [];
1055
+ if (currentTags.includes(normalizedOldTag)) {
1056
+ const newTags = currentTags.map(t => t === normalizedOldTag ? normalizedNewTag : t);
1057
+ // Remove duplicates that might result from rename
1058
+ const uniqueTags = [...new Set(newTags)];
1059
+ scheduler.updateJob(job.id, { tags: uniqueTags });
1060
+ updatedCount++;
1061
+ }
1062
+ }
1063
+
1064
+ if (updatedCount > 0) {
1065
+ logger?.info(`Renamed tag "${normalizedOldTag}" to "${normalizedNewTag}" in ${updatedCount} jobs`);
1066
+ }
1067
+
1068
+ return createTagRenameResponse(updatedCount);
1069
+ } catch (error) {
1070
+ logger?.error(`Failed to rename tag: ${error.message}`);
1071
+ return {
1072
+ type: MessageType.ERROR,
1073
+ message: `Failed to rename tag: ${error.message}`,
1074
+ };
1075
+ }
1076
+ }
1077
+
831
1078
  /**
832
1079
  * Handle reload jobs message
833
1080
  * Reloads jobs from storage into scheduler
@@ -335,9 +335,34 @@ export class Scheduler {
335
335
  return null;
336
336
  }
337
337
 
338
+ // Handle tag append/remove operations
339
+ let finalTags = job.tags || [];
340
+
341
+ if (updates.tagsAppend && updates.tagsAppend.length > 0) {
342
+ // Normalize and append new tags
343
+ const tagsToAppend = updates.tagsAppend.map(t => t.trim().toLowerCase());
344
+ finalTags = [...new Set([...finalTags, ...tagsToAppend])];
345
+ }
346
+
347
+ if (updates.tagsRemove && updates.tagsRemove.length > 0) {
348
+ // Normalize and remove tags
349
+ const tagsToRemove = updates.tagsRemove.map(t => t.trim().toLowerCase());
350
+ finalTags = finalTags.filter(t => !tagsToRemove.includes(t));
351
+ }
352
+
353
+ // Create a clean updates object without the append/remove markers
354
+ const cleanUpdates = { ...updates };
355
+ delete cleanUpdates.tagsAppend;
356
+ delete cleanUpdates.tagsRemove;
357
+
358
+ // If we modified tags via append/remove, update the tags field
359
+ if (updates.tagsAppend || updates.tagsRemove) {
360
+ cleanUpdates.tags = finalTags;
361
+ }
362
+
338
363
  const updatedJob = {
339
364
  ...job,
340
- ...updates,
365
+ ...cleanUpdates,
341
366
  id: jobId, // Don't allow changing ID
342
367
  updatedAt: new Date().toISOString(),
343
368
  };
@@ -26,6 +26,18 @@ export const MessageType = {
26
26
  JOB_RUN: 'job:run',
27
27
  JOB_RUN_RESULT: 'job:run:result',
28
28
 
29
+ // Tag management
30
+ TAG_LIST: 'tag:list',
31
+ TAG_LIST_RESULT: 'tag:list:result',
32
+ TAG_ADD: 'tag:add',
33
+ TAG_ADD_RESULT: 'tag:add:result',
34
+ TAG_REMOVE: 'tag:remove',
35
+ TAG_REMOVE_RESULT: 'tag:remove:result',
36
+ TAG_CLEAR: 'tag:clear',
37
+ TAG_CLEAR_RESULT: 'tag:clear:result',
38
+ TAG_RENAME: 'tag:rename',
39
+ TAG_RENAME_RESULT: 'tag:rename:result',
40
+
29
41
  // Flush/cleanup
30
42
  FLUSH: 'flush',
31
43
  FLUSH_RESULT: 'flush:result',
@@ -145,6 +157,72 @@ export function createFlushResultResponse(result) {
145
157
  };
146
158
  }
147
159
 
160
+ /**
161
+ * Create a tag list response
162
+ * @param {object} tags - Tags grouped by name with job counts
163
+ * @returns {{ type: string, tags: object }}
164
+ */
165
+ export function createTagListResponse(tags) {
166
+ return {
167
+ type: MessageType.TAG_LIST_RESULT,
168
+ tags,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Create a tag add response
174
+ * @param {number} count - Number of jobs updated
175
+ * @param {string[]} jobIds - IDs of jobs that were updated
176
+ * @returns {{ type: string, count: number, jobIds: string[] }}
177
+ */
178
+ export function createTagAddResponse(count, jobIds) {
179
+ return {
180
+ type: MessageType.TAG_ADD_RESULT,
181
+ count,
182
+ jobIds,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Create a tag remove response
188
+ * @param {number} count - Number of jobs updated
189
+ * @param {string[]} jobIds - IDs of jobs that were updated
190
+ * @returns {{ type: string, count: number, jobIds: string[] }}
191
+ */
192
+ export function createTagRemoveResponse(count, jobIds) {
193
+ return {
194
+ type: MessageType.TAG_REMOVE_RESULT,
195
+ count,
196
+ jobIds,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Create a tag clear response
202
+ * @param {number} count - Number of jobs updated
203
+ * @param {string[]} jobIds - IDs of jobs that were updated
204
+ * @returns {{ type: string, count: number, jobIds: string[] }}
205
+ */
206
+ export function createTagClearResponse(count, jobIds) {
207
+ return {
208
+ type: MessageType.TAG_CLEAR_RESULT,
209
+ count,
210
+ jobIds,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Create a tag rename response
216
+ * @param {number} count - Number of jobs updated
217
+ * @returns {{ type: string, count: number }}
218
+ */
219
+ export function createTagRenameResponse(count) {
220
+ return {
221
+ type: MessageType.TAG_RENAME_RESULT,
222
+ count,
223
+ };
224
+ }
225
+
148
226
  /**
149
227
  * Create a standard error response
150
228
  * @param {string} message - Error message
@@ -180,4 +258,9 @@ export default {
180
258
  createJobResumedResponse,
181
259
  createJobRunResponse,
182
260
  createFlushResultResponse,
261
+ createTagListResponse,
262
+ createTagAddResponse,
263
+ createTagRemoveResponse,
264
+ createTagClearResponse,
265
+ createTagRenameResponse,
183
266
  };
package/src/ipc/server.js CHANGED
@@ -17,13 +17,19 @@ export function startIpcServer(options = {}) {
17
17
  const { onMessage } = options;
18
18
  const socketPath = getSocketPath();
19
19
 
20
- if (process.platform !== 'win32' && existsSync(socketPath)) {
21
- unlinkSync(socketPath);
22
- }
23
-
20
+ // Ensure directories exist first
24
21
  ensureDataDir();
25
22
  ensureRuntimeDir();
26
23
 
24
+ // Clean up stale socket file
25
+ if (process.platform !== 'win32' && existsSync(socketPath)) {
26
+ try {
27
+ unlinkSync(socketPath);
28
+ } catch (error) {
29
+ throw new Error(`Failed to remove stale socket file: ${error.message}`);
30
+ }
31
+ }
32
+
27
33
  const server = createServer(socket => {
28
34
  let buffer = '';
29
35
 
@@ -72,7 +78,21 @@ export function startIpcServer(options = {}) {
72
78
  });
73
79
  });
74
80
 
81
+ // Add error handler for the server
82
+ server.on('error', (error) => {
83
+ throw new Error(`IPC server error: ${error.message}`);
84
+ });
85
+
75
86
  server.listen(socketPath);
87
+
88
+ // Verify socket was created
89
+ server.on('listening', () => {
90
+ if (process.platform !== 'win32' && !existsSync(socketPath)) {
91
+ server.close();
92
+ throw new Error(`Socket file was not created at ${socketPath}`);
93
+ }
94
+ });
95
+
76
96
  return server;
77
97
  }
78
98
 
@@ -85,6 +105,18 @@ export function stopIpcServer(server) {
85
105
  return;
86
106
  }
87
107
  server.close();
108
+
109
+ // Clean up socket file on Unix systems
110
+ if (process.platform !== 'win32') {
111
+ const socketPath = getSocketPath();
112
+ try {
113
+ if (existsSync(socketPath)) {
114
+ unlinkSync(socketPath);
115
+ }
116
+ } catch (error) {
117
+ // Ignore errors during cleanup
118
+ }
119
+ }
88
120
  }
89
121
 
90
122
  export default {
@@ -16,7 +16,7 @@ const DATA_DIR_NAME = '.jm2';
16
16
  * Get the runtime directory for sockets
17
17
  * Uses platform-specific standard locations:
18
18
  * - Linux: /run/user/<uid>/jm2/ (XDG standard)
19
- * - macOS: ~/Library/Caches/jm2/
19
+ * - macOS: ~/.jm2/ (Caches directory gets cleaned up by system)
20
20
  * - Others: ~/.jm2/
21
21
  * @returns {string} The runtime directory path
22
22
  */
@@ -33,12 +33,8 @@ function getRuntimeDir() {
33
33
  return join(xdgRuntimeDir, 'jm2');
34
34
  }
35
35
 
36
- if (process.platform === 'darwin') {
37
- // macOS standard - use Library/Caches for runtime files
38
- return join(homedir(), 'Library', 'Caches', 'jm2');
39
- }
40
-
41
- // Fallback to data directory for other platforms
36
+ // For macOS and other platforms, use data directory
37
+ // Note: Library/Caches gets cleaned up by macOS, causing socket files to disappear
42
38
  return getDataDir();
43
39
  }
44
40