unitup 0.0.12 → 0.1.0

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
  /**
@@ -106,9 +111,10 @@ export function generateUnitContent(opts) {
106
111
  'StandardError=journal'
107
112
  ];
108
113
 
109
- const memHigh = opts.memoryHigh || opts.resources?.memoryHigh;
110
- const memMax = opts.memoryMax || opts.resources?.memoryMax;
111
- const memSwapMax = opts.memorySwapMax || opts.resources?.memorySwapMax;
114
+ const effectiveLimits = resolveEffectiveMemoryLimits(opts);
115
+ const memHigh = effectiveLimits.memoryHigh;
116
+ const memMax = effectiveLimits.memoryMax;
117
+ const memSwapMax = effectiveLimits.memorySwapMax;
112
118
 
113
119
  if (memHigh || memMax || memSwapMax) {
114
120
  serviceLines.push('MemoryAccounting=yes');
@@ -258,3 +264,223 @@ export function parseUnitContent(content) {
258
264
 
259
265
  return result;
260
266
  }
267
+
268
+ /**
269
+ * Returns the full file path for a given timer unit.
270
+ * @param {string} name
271
+ * @returns {string}
272
+ */
273
+ export function getTimerPath(name) {
274
+ const filename = getTimerFilename(name);
275
+ return path.join(getUserUnitDir(), filename);
276
+ }
277
+
278
+ /**
279
+ * Checks if a timer unit file exists for the given schedule name.
280
+ * @param {string} name
281
+ * @returns {boolean}
282
+ */
283
+ export function timerFileExists(name) {
284
+ const timerPath = getTimerPath(name);
285
+ return fs.existsSync(timerPath);
286
+ }
287
+
288
+ /**
289
+ * Generates systemd service unit content for a scheduled oneshot task.
290
+ * @param {Object} opts
291
+ * @returns {string}
292
+ */
293
+ export function generateScheduleServiceContent(opts) {
294
+ const safeName = sanitizeServiceName(opts.name);
295
+ let commandExec = '';
296
+ let execArgs = [];
297
+
298
+ if (opts.command) {
299
+ commandExec = resolveAbsolutePath(opts.command);
300
+ execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
301
+ } else if (opts.script) {
302
+ const scriptPath = resolveAbsolutePath(opts.script);
303
+ commandExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
304
+ execArgs = [scriptPath, ...(Array.isArray(opts.args) ? opts.args : [])];
305
+ } else {
306
+ throw new Error('Either "command" or "script" must be provided to generate scheduled unit file.');
307
+ }
308
+
309
+ const scriptOrCmdPath = opts.script ? resolveAbsolutePath(opts.script) : (execArgs[0] && (execArgs[0].startsWith('/') || execArgs[0].startsWith('./')) ? resolveAbsolutePath(execArgs[0]) : commandExec);
310
+ const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptOrCmdPath || commandExec);
311
+
312
+ const execStartTokens = [commandExec, ...execArgs];
313
+ const execStartLine = execStartTokens.map(escapeExecArg).join(' ');
314
+
315
+ const binDir = path.dirname(commandExec);
316
+ const defaultPath = [binDir, '/usr/local/bin', '/usr/bin', '/bin']
317
+ .filter((p, i, self) => p && self.indexOf(p) === i)
318
+ .join(':');
319
+
320
+ const serviceLines = [
321
+ '[Unit]',
322
+ `Description=unitup scheduled task: ${safeName}`,
323
+ 'After=network.target',
324
+ '',
325
+ '[Service]',
326
+ 'Type=oneshot',
327
+ `SyslogIdentifier=unitup-${safeName}`,
328
+ `WorkingDirectory=${cwd}`,
329
+ `ExecStart=${execStartLine}`,
330
+ 'StandardOutput=journal',
331
+ 'StandardError=journal'
332
+ ];
333
+
334
+ const effectiveLimits = resolveEffectiveMemoryLimits(opts);
335
+ const memHigh = effectiveLimits.memoryHigh;
336
+ const memMax = effectiveLimits.memoryMax;
337
+ const memSwapMax = effectiveLimits.memorySwapMax;
338
+
339
+ if (memHigh || memMax || memSwapMax) {
340
+ serviceLines.push('MemoryAccounting=yes');
341
+ if (memHigh) serviceLines.push(`MemoryHigh=${memHigh}`);
342
+ if (memMax) serviceLines.push(`MemoryMax=${memMax}`);
343
+ if (memSwapMax) serviceLines.push(`MemorySwapMax=${memSwapMax}`);
344
+ }
345
+
346
+ const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
347
+ if (!envObj.PATH) {
348
+ envObj.PATH = defaultPath;
349
+ }
350
+
351
+ if (opts.envFile) {
352
+ const absEnvFile = resolveAbsolutePath(opts.envFile);
353
+ serviceLines.push(`EnvironmentFile=${absEnvFile}`);
354
+ }
355
+
356
+ for (const [key, val] of Object.entries(envObj)) {
357
+ serviceLines.push(`Environment=${formatSystemdEnv(key, val)}`);
358
+ }
359
+
360
+ serviceLines.push('');
361
+ return serviceLines.join('\n');
362
+ }
363
+
364
+ /**
365
+ * Generates systemd timer unit content.
366
+ * @param {Object} opts
367
+ * @returns {string}
368
+ */
369
+ export function generateTimerContent(opts) {
370
+ const safeName = sanitizeServiceName(opts.name);
371
+
372
+ const timerLines = [
373
+ '[Unit]',
374
+ `Description=unitup timer: ${safeName}`,
375
+ '',
376
+ '[Timer]'
377
+ ];
378
+
379
+ if (opts.every) {
380
+ const validEvery = validateDuration(opts.every, '--every');
381
+ const onActive = opts.onActive ? validateDuration(opts.onActive, '--on-active') : validEvery;
382
+ timerLines.push(`OnActiveSec=${onActive}`);
383
+ timerLines.push(`OnUnitActiveSec=${validEvery}`);
384
+ } else if (opts.onActive) {
385
+ const validActive = validateDuration(opts.onActive, '--on-active');
386
+ timerLines.push(`OnActiveSec=${validActive}`);
387
+ }
388
+
389
+ if (opts.calendar) {
390
+ if (typeof opts.calendar !== 'string' || !opts.calendar.trim()) {
391
+ throw new Error('Calendar expression cannot be empty.');
392
+ }
393
+ timerLines.push(`OnCalendar=${opts.calendar.trim()}`);
394
+ }
395
+
396
+ if (opts.onBoot) {
397
+ const validBoot = validateDuration(opts.onBoot, '--on-boot');
398
+ timerLines.push(`OnBootSec=${validBoot}`);
399
+ }
400
+
401
+ if (opts.randomDelay) {
402
+ const validDelay = validateDuration(opts.randomDelay, '--random-delay');
403
+ timerLines.push(`RandomizedDelaySec=${validDelay}`);
404
+ }
405
+
406
+ if (opts.persistent) {
407
+ timerLines.push('Persistent=true');
408
+ }
409
+
410
+ const serviceFilename = getUnitFilename(safeName);
411
+ timerLines.push(`Unit=${serviceFilename}`);
412
+
413
+ timerLines.push('', '[Install]', 'WantedBy=timers.target', '');
414
+
415
+ return timerLines.join('\n');
416
+ }
417
+
418
+ /**
419
+ * Creates or updates systemd service and timer files for a schedule on disk.
420
+ * @param {Object} opts
421
+ * @returns {{ servicePath: string, timerPath: string, serviceContent: string, timerContent: string }}
422
+ */
423
+ export function writeScheduleUnitFiles(opts) {
424
+ const safeName = sanitizeServiceName(opts.name);
425
+ const unitDir = getUserUnitDir();
426
+
427
+ if (!fs.existsSync(unitDir)) {
428
+ fs.mkdirSync(unitDir, { recursive: true });
429
+ }
430
+
431
+ const servicePath = getUnitPath(safeName);
432
+ const timerPath = getTimerPath(safeName);
433
+
434
+ const serviceContent = generateScheduleServiceContent({ ...opts, name: safeName });
435
+ const timerContent = generateTimerContent({ ...opts, name: safeName });
436
+
437
+ fs.writeFileSync(servicePath, serviceContent, 'utf8');
438
+ fs.writeFileSync(timerPath, timerContent, 'utf8');
439
+
440
+ saveScheduleMetadata({
441
+ name: safeName,
442
+ group: opts.group || 'default',
443
+ runtime: opts.runtime || 'node',
444
+ command: opts.command,
445
+ args: opts.args,
446
+ cwd: opts.cwd,
447
+ memoryHigh: opts.memoryHigh || opts.resources?.memoryHigh,
448
+ memoryMax: opts.memoryMax || opts.resources?.memoryMax,
449
+ memorySwapMax: opts.memorySwapMax || opts.resources?.memorySwapMax,
450
+ resources: opts.resources,
451
+ schedule: {
452
+ every: opts.every || null,
453
+ calendar: opts.calendar || null,
454
+ onBoot: opts.onBoot || null,
455
+ onActive: opts.onActive || null,
456
+ persistent: Boolean(opts.persistent),
457
+ randomDelay: opts.randomDelay || null
458
+ }
459
+ });
460
+
461
+ return { servicePath, timerPath, serviceContent, timerContent };
462
+ }
463
+
464
+ /**
465
+ * Removes service, timer, and metadata files for a schedule.
466
+ * @param {string} name
467
+ * @returns {boolean}
468
+ */
469
+ export function deleteScheduleUnitFiles(name) {
470
+ const safeName = sanitizeServiceName(name);
471
+ deleteScheduleMetadata(safeName);
472
+ const servicePath = getUnitPath(safeName);
473
+ const timerPath = getTimerPath(safeName);
474
+
475
+ let removed = false;
476
+ if (fs.existsSync(timerPath)) {
477
+ fs.unlinkSync(timerPath);
478
+ removed = true;
479
+ }
480
+ if (fs.existsSync(servicePath)) {
481
+ fs.unlinkSync(servicePath);
482
+ removed = true;
483
+ }
484
+ return removed;
485
+ }
486
+
package/src/utils.js CHANGED
@@ -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
+