unitup 0.0.12 → 0.1.1

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/src/unit.js CHANGED
@@ -4,12 +4,17 @@ import os from 'node:os';
4
4
  import {
5
5
  sanitizeServiceName,
6
6
  getUnitFilename,
7
+ getTimerFilename,
7
8
  getServiceNameFromUnit,
8
9
  formatSystemdEnv,
9
10
  escapeExecArg,
10
11
  resolveAbsolutePath,
11
12
  saveAppMetadata,
12
- deleteAppMetadata
13
+ deleteAppMetadata,
14
+ saveScheduleMetadata,
15
+ deleteScheduleMetadata,
16
+ validateDuration,
17
+ resolveEffectiveMemoryLimits
13
18
  } from './utils.js';
14
19
 
15
20
  /**
@@ -78,8 +83,11 @@ export function generateUnitContent(opts) {
78
83
  throw new Error('Either "command" or "script" must be provided to generate systemd unit file.');
79
84
  }
80
85
 
81
- const scriptOrCmdPath = opts.script ? resolveAbsolutePath(opts.script) : (execArgs[0] && (execArgs[0].startsWith('/') || execArgs[0].startsWith('./')) ? resolveAbsolutePath(execArgs[0]) : commandExec);
82
- const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptOrCmdPath || commandExec);
86
+ const cwd = opts.cwd
87
+ ? resolveAbsolutePath(opts.cwd)
88
+ : opts.script
89
+ ? path.dirname(resolveAbsolutePath(opts.script))
90
+ : process.cwd();
83
91
  const restartPolicy = opts.restart || 'on-failure';
84
92
 
85
93
  const execStartTokens = [commandExec, ...execArgs];
@@ -106,9 +114,10 @@ export function generateUnitContent(opts) {
106
114
  'StandardError=journal'
107
115
  ];
108
116
 
109
- const memHigh = opts.memoryHigh || opts.resources?.memoryHigh;
110
- const memMax = opts.memoryMax || opts.resources?.memoryMax;
111
- const memSwapMax = opts.memorySwapMax || opts.resources?.memorySwapMax;
117
+ const effectiveLimits = resolveEffectiveMemoryLimits(opts);
118
+ const memHigh = effectiveLimits.memoryHigh;
119
+ const memMax = effectiveLimits.memoryMax;
120
+ const memSwapMax = effectiveLimits.memorySwapMax;
112
121
 
113
122
  if (memHigh || memMax || memSwapMax) {
114
123
  serviceLines.push('MemoryAccounting=yes');
@@ -258,3 +267,226 @@ export function parseUnitContent(content) {
258
267
 
259
268
  return result;
260
269
  }
270
+
271
+ /**
272
+ * Returns the full file path for a given timer unit.
273
+ * @param {string} name
274
+ * @returns {string}
275
+ */
276
+ export function getTimerPath(name) {
277
+ const filename = getTimerFilename(name);
278
+ return path.join(getUserUnitDir(), filename);
279
+ }
280
+
281
+ /**
282
+ * Checks if a timer unit file exists for the given schedule name.
283
+ * @param {string} name
284
+ * @returns {boolean}
285
+ */
286
+ export function timerFileExists(name) {
287
+ const timerPath = getTimerPath(name);
288
+ return fs.existsSync(timerPath);
289
+ }
290
+
291
+ /**
292
+ * Generates systemd service unit content for a scheduled oneshot task.
293
+ * @param {Object} opts
294
+ * @returns {string}
295
+ */
296
+ export function generateScheduleServiceContent(opts) {
297
+ const safeName = sanitizeServiceName(opts.name);
298
+ let commandExec = '';
299
+ let execArgs = [];
300
+
301
+ if (opts.command) {
302
+ commandExec = resolveAbsolutePath(opts.command);
303
+ execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
304
+ } else if (opts.script) {
305
+ const scriptPath = resolveAbsolutePath(opts.script);
306
+ commandExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
307
+ execArgs = [scriptPath, ...(Array.isArray(opts.args) ? opts.args : [])];
308
+ } else {
309
+ throw new Error('Either "command" or "script" must be provided to generate scheduled unit file.');
310
+ }
311
+
312
+ const cwd = opts.cwd
313
+ ? resolveAbsolutePath(opts.cwd)
314
+ : opts.script
315
+ ? path.dirname(resolveAbsolutePath(opts.script))
316
+ : process.cwd();
317
+
318
+ const execStartTokens = [commandExec, ...execArgs];
319
+ const execStartLine = execStartTokens.map(escapeExecArg).join(' ');
320
+
321
+ const binDir = path.dirname(commandExec);
322
+ const defaultPath = [binDir, '/usr/local/bin', '/usr/bin', '/bin']
323
+ .filter((p, i, self) => p && self.indexOf(p) === i)
324
+ .join(':');
325
+
326
+ const serviceLines = [
327
+ '[Unit]',
328
+ `Description=unitup scheduled task: ${safeName}`,
329
+ 'After=network.target',
330
+ '',
331
+ '[Service]',
332
+ 'Type=oneshot',
333
+ `SyslogIdentifier=unitup-${safeName}`,
334
+ `WorkingDirectory=${cwd}`,
335
+ `ExecStart=${execStartLine}`,
336
+ 'StandardOutput=journal',
337
+ 'StandardError=journal'
338
+ ];
339
+
340
+ const effectiveLimits = resolveEffectiveMemoryLimits(opts);
341
+ const memHigh = effectiveLimits.memoryHigh;
342
+ const memMax = effectiveLimits.memoryMax;
343
+ const memSwapMax = effectiveLimits.memorySwapMax;
344
+
345
+ if (memHigh || memMax || memSwapMax) {
346
+ serviceLines.push('MemoryAccounting=yes');
347
+ if (memHigh) serviceLines.push(`MemoryHigh=${memHigh}`);
348
+ if (memMax) serviceLines.push(`MemoryMax=${memMax}`);
349
+ if (memSwapMax) serviceLines.push(`MemorySwapMax=${memSwapMax}`);
350
+ }
351
+
352
+ const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
353
+ if (!envObj.PATH) {
354
+ envObj.PATH = defaultPath;
355
+ }
356
+
357
+ if (opts.envFile) {
358
+ const absEnvFile = resolveAbsolutePath(opts.envFile);
359
+ serviceLines.push(`EnvironmentFile=${absEnvFile}`);
360
+ }
361
+
362
+ for (const [key, val] of Object.entries(envObj)) {
363
+ serviceLines.push(`Environment=${formatSystemdEnv(key, val)}`);
364
+ }
365
+
366
+ serviceLines.push('');
367
+ return serviceLines.join('\n');
368
+ }
369
+
370
+ /**
371
+ * Generates systemd timer unit content.
372
+ * @param {Object} opts
373
+ * @returns {string}
374
+ */
375
+ export function generateTimerContent(opts) {
376
+ const safeName = sanitizeServiceName(opts.name);
377
+
378
+ const timerLines = [
379
+ '[Unit]',
380
+ `Description=unitup timer: ${safeName}`,
381
+ '',
382
+ '[Timer]'
383
+ ];
384
+
385
+ if (opts.every) {
386
+ const validEvery = validateDuration(opts.every, '--every');
387
+ const onActive = opts.onActive ? validateDuration(opts.onActive, '--on-active') : validEvery;
388
+ timerLines.push(`OnActiveSec=${onActive}`);
389
+ timerLines.push(`OnUnitActiveSec=${validEvery}`);
390
+ } else if (opts.onActive) {
391
+ const validActive = validateDuration(opts.onActive, '--on-active');
392
+ timerLines.push(`OnActiveSec=${validActive}`);
393
+ }
394
+
395
+ if (opts.calendar) {
396
+ if (typeof opts.calendar !== 'string' || !opts.calendar.trim()) {
397
+ throw new Error('Calendar expression cannot be empty.');
398
+ }
399
+ timerLines.push(`OnCalendar=${opts.calendar.trim()}`);
400
+ }
401
+
402
+ if (opts.onBoot) {
403
+ const validBoot = validateDuration(opts.onBoot, '--on-boot');
404
+ timerLines.push(`OnBootSec=${validBoot}`);
405
+ }
406
+
407
+ if (opts.randomDelay) {
408
+ const validDelay = validateDuration(opts.randomDelay, '--random-delay');
409
+ timerLines.push(`RandomizedDelaySec=${validDelay}`);
410
+ }
411
+
412
+ if (opts.persistent) {
413
+ timerLines.push('Persistent=true');
414
+ }
415
+
416
+ const serviceFilename = getUnitFilename(safeName);
417
+ timerLines.push(`Unit=${serviceFilename}`);
418
+
419
+ timerLines.push('', '[Install]', 'WantedBy=timers.target', '');
420
+
421
+ return timerLines.join('\n');
422
+ }
423
+
424
+ /**
425
+ * Creates or updates systemd service and timer files for a schedule on disk.
426
+ * @param {Object} opts
427
+ * @returns {{ servicePath: string, timerPath: string, serviceContent: string, timerContent: string }}
428
+ */
429
+ export function writeScheduleUnitFiles(opts) {
430
+ const safeName = sanitizeServiceName(opts.name);
431
+ const unitDir = getUserUnitDir();
432
+
433
+ if (!fs.existsSync(unitDir)) {
434
+ fs.mkdirSync(unitDir, { recursive: true });
435
+ }
436
+
437
+ const servicePath = getUnitPath(safeName);
438
+ const timerPath = getTimerPath(safeName);
439
+
440
+ const serviceContent = generateScheduleServiceContent({ ...opts, name: safeName });
441
+ const timerContent = generateTimerContent({ ...opts, name: safeName });
442
+
443
+ fs.writeFileSync(servicePath, serviceContent, 'utf8');
444
+ fs.writeFileSync(timerPath, timerContent, 'utf8');
445
+
446
+ saveScheduleMetadata({
447
+ name: safeName,
448
+ group: opts.group || 'default',
449
+ runtime: opts.runtime || (opts.command ? 'custom' : 'node'),
450
+ command: opts.command,
451
+ args: opts.args,
452
+ cwd: opts.cwd,
453
+ memoryHigh: opts.memoryHigh || opts.resources?.memoryHigh,
454
+ memoryMax: opts.memoryMax || opts.resources?.memoryMax,
455
+ memorySwapMax: opts.memorySwapMax || opts.resources?.memorySwapMax,
456
+ resources: opts.resources,
457
+ schedule: {
458
+ every: opts.every || null,
459
+ calendar: opts.calendar || null,
460
+ onBoot: opts.onBoot || null,
461
+ onActive: opts.onActive || null,
462
+ persistent: Boolean(opts.persistent),
463
+ randomDelay: opts.randomDelay || null
464
+ }
465
+ });
466
+
467
+ return { servicePath, timerPath, serviceContent, timerContent };
468
+ }
469
+
470
+ /**
471
+ * Removes service, timer, and metadata files for a schedule.
472
+ * @param {string} name
473
+ * @returns {boolean}
474
+ */
475
+ export function deleteScheduleUnitFiles(name) {
476
+ const safeName = sanitizeServiceName(name);
477
+ deleteScheduleMetadata(safeName);
478
+ const servicePath = getUnitPath(safeName);
479
+ const timerPath = getTimerPath(safeName);
480
+
481
+ let removed = false;
482
+ if (fs.existsSync(timerPath)) {
483
+ fs.unlinkSync(timerPath);
484
+ removed = true;
485
+ }
486
+ if (fs.existsSync(servicePath)) {
487
+ fs.unlinkSync(servicePath);
488
+ removed = true;
489
+ }
490
+ return removed;
491
+ }
492
+
package/src/utils.js CHANGED
@@ -141,7 +141,7 @@ export function saveAppMetadata(meta) {
141
141
  const rawArgs = Array.isArray(meta.args) ? meta.args : (meta.script ? [meta.script] : []);
142
142
  const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
143
143
  const scriptPath = meta.script ? resolveAbsolutePath(meta.script) : (args[0] ? resolveAbsolutePath(args[0]) : command);
144
- const cwd = meta.cwd ? resolveAbsolutePath(meta.cwd) : path.dirname(scriptPath || command);
144
+ const cwd = meta.cwd ? resolveAbsolutePath(meta.cwd) : (meta.script ? path.dirname(resolveAbsolutePath(meta.script)) : process.cwd());
145
145
 
146
146
  // Extract memory resources if present
147
147
  const resources = meta.resources || {};
@@ -402,3 +402,256 @@ export function formatTable(data, columns) {
402
402
 
403
403
  return lines.join('\n');
404
404
  }
405
+
406
+ /**
407
+ * Returns the directory for schedule metadata (~/.config/unitup/schedules).
408
+ * @returns {string}
409
+ */
410
+ export function getSchedulesDir() {
411
+ return path.join(getUnitupDir(), 'schedules');
412
+ }
413
+
414
+ /**
415
+ * Returns the filepath for a specific schedule's metadata JSON file.
416
+ * @param {string} name
417
+ * @returns {string}
418
+ */
419
+ export function getScheduleMetadataPath(name) {
420
+ const safeName = sanitizeServiceName(name);
421
+ return path.join(getSchedulesDir(), `${safeName}.json`);
422
+ }
423
+
424
+ /**
425
+ * Saves metadata JSON for a schedule.
426
+ * @param {object} meta
427
+ * @returns {object}
428
+ */
429
+ export function saveScheduleMetadata(meta) {
430
+ const safeName = sanitizeServiceName(meta.name);
431
+ const dir = getSchedulesDir();
432
+ if (!fs.existsSync(dir)) {
433
+ fs.mkdirSync(dir, { recursive: true });
434
+ }
435
+ const filePath = getScheduleMetadataPath(safeName);
436
+
437
+ const command = meta.command ? resolveAbsolutePath(meta.command) : process.execPath;
438
+ const rawArgs = Array.isArray(meta.args) ? meta.args : [];
439
+ const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
440
+
441
+ const resources = meta.resources || {};
442
+ if (meta.memoryHigh) resources.memoryHigh = validateMemorySize(meta.memoryHigh, 'MemoryHigh');
443
+ if (meta.memoryMax) resources.memoryMax = validateMemorySize(meta.memoryMax, 'MemoryMax');
444
+ if (meta.memorySwapMax) resources.memorySwapMax = validateMemorySize(meta.memorySwapMax, 'MemorySwapMax');
445
+
446
+ const payload = {
447
+ name: safeName,
448
+ group: meta.group || 'default',
449
+ type: 'timer',
450
+ runtime: meta.runtime || 'node',
451
+ command,
452
+ args,
453
+ cwd: meta.cwd ? resolveAbsolutePath(meta.cwd) : process.cwd(),
454
+ schedule: {
455
+ every: meta.schedule?.every || null,
456
+ calendar: meta.schedule?.calendar || null,
457
+ onBoot: meta.schedule?.onBoot || null,
458
+ onActive: meta.schedule?.onActive || null,
459
+ persistent: Boolean(meta.schedule?.persistent),
460
+ randomDelay: meta.schedule?.randomDelay || null
461
+ },
462
+ ...(Object.keys(resources).length > 0 ? { resources } : {}),
463
+ serviceUnit: `unitup-${safeName}.service`,
464
+ timerUnit: `unitup-${safeName}.timer`
465
+ };
466
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
467
+ return payload;
468
+ }
469
+
470
+ /**
471
+ * Reads metadata JSON for a schedule.
472
+ * @param {string} name
473
+ * @returns {object|null}
474
+ */
475
+ export function readScheduleMetadata(name) {
476
+ try {
477
+ const filePath = getScheduleMetadataPath(name);
478
+ if (!fs.existsSync(filePath)) return null;
479
+ const content = fs.readFileSync(filePath, 'utf8');
480
+ return JSON.parse(content);
481
+ } catch {
482
+ return null;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Deletes metadata JSON for a schedule if it exists.
488
+ * @param {string} name
489
+ * @returns {boolean}
490
+ */
491
+ export function deleteScheduleMetadata(name) {
492
+ try {
493
+ const filePath = getScheduleMetadataPath(name);
494
+ if (fs.existsSync(filePath)) {
495
+ fs.unlinkSync(filePath);
496
+ return true;
497
+ }
498
+ } catch {}
499
+ return false;
500
+ }
501
+
502
+ /**
503
+ * Returns the full systemd timer filename for a schedule name.
504
+ * @param {string} name
505
+ * @returns {string}
506
+ */
507
+ export function getTimerFilename(name) {
508
+ const safeName = sanitizeServiceName(name);
509
+ return `unitup-${safeName}.timer`;
510
+ }
511
+
512
+ /**
513
+ * Validates systemd time span duration strings (e.g., 30s, 10m, 2h, 1d).
514
+ * @param {string|number} val
515
+ * @param {string} [paramName]
516
+ * @returns {string}
517
+ */
518
+ export function validateDuration(val, paramName = 'Duration') {
519
+ if (val === null || val === undefined || val === '') {
520
+ throw new Error(`${paramName} cannot be empty.`);
521
+ }
522
+
523
+ const str = String(val).trim();
524
+
525
+ // Prevent shell injection and unsafe characters
526
+ if (/[;&|$`"'\n\r\t]/.test(str)) {
527
+ throw new Error(`${paramName} contains invalid characters or shell injection attempt: "${str}".`);
528
+ }
529
+
530
+ // Reject negative numbers
531
+ if (str.startsWith('-')) {
532
+ throw new Error(`${paramName} cannot be negative: "${str}".`);
533
+ }
534
+
535
+ const tokens = str.split(/\s+/);
536
+ const tokenRegex = /^(\d+(?:\.\d+)?)(us|usec|ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?|w|week|weeks?|mth|months?|y|year|years?)$/i;
537
+
538
+ for (const token of tokens) {
539
+ if (!tokenRegex.test(token)) {
540
+ throw new Error(`Invalid ${paramName} format: "${str}". Expected values like 30s, 10m, 2h, 1d.`);
541
+ }
542
+ }
543
+
544
+ return str;
545
+ }
546
+
547
+ /**
548
+ * Formats a future date/timestamp into relative human readable string (e.g. "in 12 minutes", "tomorrow").
549
+ * @param {string|number|Date} dateVal
550
+ * @returns {string}
551
+ */
552
+ export function formatFutureTime(dateVal) {
553
+ if (!dateVal || dateVal === 'n/a' || dateVal === '0' || dateVal === 'infinity') {
554
+ return 'n/a';
555
+ }
556
+ const date = new Date(typeof dateVal === 'number' && dateVal > 1e12 ? dateVal : (typeof dateVal === 'number' ? dateVal / 1000 : dateVal));
557
+ if (isNaN(date.getTime())) {
558
+ return String(dateVal);
559
+ }
560
+ const diffMs = date.getTime() - Date.now();
561
+ if (diffMs <= 0) return 'imminent';
562
+
563
+ const seconds = Math.floor(diffMs / 1000);
564
+ if (seconds < 60) return `in ${seconds} seconds`;
565
+
566
+ const minutes = Math.floor(seconds / 60);
567
+ if (minutes < 60) return `in ${minutes} minute${minutes === 1 ? '' : 's'}`;
568
+
569
+ const hours = Math.floor(minutes / 60);
570
+ if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`;
571
+
572
+ const days = Math.floor(hours / 24);
573
+ if (days === 1) return 'tomorrow';
574
+ return `in ${days} days`;
575
+ }
576
+
577
+ /**
578
+ * Returns the filepath to the global unitup config JSON file (~/.config/unitup/config.json).
579
+ * @returns {string}
580
+ */
581
+ export function getConfigFilepath() {
582
+ return path.join(getUnitupDir(), 'config.json');
583
+ }
584
+
585
+ /**
586
+ * Reads global unitup configuration JSON file.
587
+ * @returns {object}
588
+ */
589
+ export function readGlobalConfig() {
590
+ try {
591
+ const file = getConfigFilepath();
592
+ if (!fs.existsSync(file)) return {};
593
+ const content = fs.readFileSync(file, 'utf8');
594
+ return JSON.parse(content) || {};
595
+ } catch {
596
+ return {};
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Saves global unitup configuration JSON file.
602
+ * @param {object} config
603
+ * @returns {object}
604
+ */
605
+ export function saveGlobalConfig(config) {
606
+ const dir = getUnitupDir();
607
+ if (!fs.existsSync(dir)) {
608
+ fs.mkdirSync(dir, { recursive: true });
609
+ }
610
+ const file = getConfigFilepath();
611
+ const current = readGlobalConfig();
612
+ const updated = { ...current, ...config };
613
+ fs.writeFileSync(file, JSON.stringify(updated, null, 2), 'utf8');
614
+ return updated;
615
+ }
616
+
617
+ /**
618
+ * Resolves effective memory limits considering explicit options, env vars, and global config defaults (default: 1G).
619
+ * @param {object} opts
620
+ * @returns {{ memoryHigh?: string, memoryMax?: string, memorySwapMax?: string }}
621
+ */
622
+ export function resolveEffectiveMemoryLimits(opts = {}) {
623
+ const memHigh = opts.memoryHigh || opts.resources?.memoryHigh;
624
+ const memMax = opts.memoryMax || opts.resources?.memoryMax;
625
+ const memSwapMax = opts.memorySwapMax || opts.resources?.memorySwapMax;
626
+
627
+ if (memHigh || memMax || memSwapMax) {
628
+ return {
629
+ ...(memHigh ? { memoryHigh: validateMemorySize(memHigh, 'MemoryHigh') } : {}),
630
+ ...(memMax ? { memoryMax: validateMemorySize(memMax, 'MemoryMax') } : {}),
631
+ ...(memSwapMax ? { memorySwapMax: validateMemorySize(memSwapMax, 'MemorySwapMax') } : {})
632
+ };
633
+ }
634
+
635
+ let defaultMem = opts.defaultMemory;
636
+
637
+ if (!defaultMem && process.env.UNITUP_DEFAULT_MEMORY) {
638
+ defaultMem = process.env.UNITUP_DEFAULT_MEMORY;
639
+ }
640
+
641
+ if (!defaultMem) {
642
+ const cfg = readGlobalConfig();
643
+ if (cfg.defaultMemory) {
644
+ defaultMem = cfg.defaultMemory;
645
+ }
646
+ }
647
+
648
+ if (defaultMem) {
649
+ const sizeStr = (typeof defaultMem === 'boolean' || defaultMem === 'true' || defaultMem === '') ? '1G' : String(defaultMem);
650
+ const validSize = validateMemorySize(sizeStr, 'Default memory limit');
651
+ return { memoryMax: validSize };
652
+ }
653
+
654
+ return {};
655
+ }
656
+
657
+