unitup 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/systemd.js CHANGED
@@ -264,6 +264,8 @@ export async function checkUserLinger() {
264
264
  // Systemd Operations
265
265
  // ---------------------------------------------------------------------------
266
266
 
267
+ import { resolveRuntimeConfig } from './runtimes/index.js';
268
+
267
269
  export async function daemonReload() {
268
270
  const res = await runCommand('systemctl', ['--user', 'daemon-reload']);
269
271
  if (res.code !== 0) {
@@ -278,7 +280,16 @@ export async function resetFailed() {
278
280
 
279
281
  export async function addService(opts) {
280
282
  const safeName = sanitizeServiceName(opts.name);
281
- const { path: unitPath } = writeUnitFile({ ...opts, name: safeName });
283
+ const runtimeConfig = await resolveRuntimeConfig({ ...opts, name: safeName });
284
+
285
+ const { path: unitPath } = writeUnitFile({
286
+ ...opts,
287
+ name: safeName,
288
+ runtime: runtimeConfig.runtime,
289
+ command: runtimeConfig.command,
290
+ args: runtimeConfig.args,
291
+ cwd: opts.cwd
292
+ });
282
293
 
283
294
  await daemonReload();
284
295
 
@@ -431,11 +442,12 @@ export async function getServiceStatus(name) {
431
442
  // Read unit file content for script and working dir
432
443
  let script = 'unknown';
433
444
  let cwd = 'unknown';
445
+ let parsed = {};
434
446
 
435
447
  const unitPath = getUnitPath(safeName);
436
448
  try {
437
449
  const content = fs.readFileSync(unitPath, 'utf8');
438
- const parsed = parseUnitContent(content);
450
+ parsed = parseUnitContent(content);
439
451
  if (parsed.script) script = parsed.script;
440
452
  if (parsed.cwd) cwd = parsed.cwd;
441
453
  } catch {
@@ -465,6 +477,17 @@ export async function getServiceStatus(name) {
465
477
  ? formatRelativeTime(startedRaw)
466
478
  : 'never';
467
479
 
480
+ const meta = readAppMetadata(safeName);
481
+ const command = meta?.command || parsed.command || meta?.node || process.execPath;
482
+ const argsList = meta?.args || (parsed.script ? [parsed.script] : []);
483
+
484
+ const { formatMemoryBytes } = await import('./utils.js');
485
+ const memoryCurrent = formatMemoryBytes(show.MemoryCurrent);
486
+ const memoryPeak = formatMemoryBytes(show.MemoryPeak);
487
+ const memoryHigh = formatMemoryBytes(show.MemoryHigh || meta?.resources?.memoryHigh || parsed.memoryHigh);
488
+ const memoryMax = formatMemoryBytes(show.MemoryMax || meta?.resources?.memoryMax || parsed.memoryMax);
489
+ const memorySwapMax = formatMemoryBytes(show.MemorySwapMax || meta?.resources?.memorySwapMax || parsed.memorySwapMax);
490
+
468
491
  return {
469
492
  name: safeName,
470
493
  unitFile: getUnitFilename(safeName),
@@ -475,8 +498,16 @@ export async function getServiceStatus(name) {
475
498
  restarts,
476
499
  started,
477
500
  startedRaw,
501
+ command,
502
+ arguments: argsList.join(' '),
503
+ args: argsList,
478
504
  script,
479
- cwd
505
+ cwd,
506
+ memory: memoryCurrent,
507
+ memoryPeak,
508
+ memoryHigh,
509
+ memoryMax,
510
+ memorySwapMax
480
511
  };
481
512
  }
482
513
 
@@ -504,11 +535,29 @@ export async function listServices(filterOpts = {}) {
504
535
  for (const unit of units) {
505
536
  const meta = readAppMetadata(unit.name);
506
537
  const group = meta?.group || 'default';
538
+ const runtime = meta?.runtime || 'node';
507
539
 
508
540
  if (targetGroup && group.toLowerCase() !== targetGroup) {
509
541
  continue;
510
542
  }
511
543
 
544
+ let commandSummary = '';
545
+ if (meta?.command) {
546
+ if (runtime === 'native') {
547
+ commandSummary = './' + path.basename(meta.command);
548
+ } else {
549
+ const cmdBase = path.basename(meta.command);
550
+ const argsStr = (meta.args || [])
551
+ .map(a => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
552
+ .join(' ');
553
+ commandSummary = (cmdBase + (argsStr ? ' ' + argsStr : '')).trim();
554
+ }
555
+ } else if (meta?.script) {
556
+ commandSummary = `node ${path.basename(meta.script)}`;
557
+ } else {
558
+ commandSummary = 'unknown';
559
+ }
560
+
512
561
  try {
513
562
  const show = await getServiceShow(unit.name);
514
563
  const activeState = show.ActiveState || 'inactive';
@@ -536,20 +585,24 @@ export async function listServices(filterOpts = {}) {
536
585
 
537
586
  result.push({
538
587
  name: unit.name,
588
+ runtime,
539
589
  group,
540
590
  status,
541
591
  enabled,
542
592
  pid,
593
+ command: commandSummary,
543
594
  uptime,
544
595
  restarts
545
596
  });
546
597
  } catch {
547
598
  result.push({
548
599
  name: unit.name,
600
+ runtime,
549
601
  group,
550
602
  status: 'unknown',
551
603
  enabled: 'unknown',
552
604
  pid: '-',
605
+ command: commandSummary,
553
606
  uptime: 'never',
554
607
  restarts: '0'
555
608
  });
@@ -568,20 +621,33 @@ export async function inspectService(name) {
568
621
  const meta = readAppMetadata(safeName);
569
622
  const statusObj = await getServiceStatus(safeName);
570
623
 
624
+ const command = meta?.command || statusObj.command || statusObj.node || process.execPath;
625
+ const argsList = meta?.args || (statusObj.script ? [statusObj.script] : []);
626
+
571
627
  return {
572
628
  name: safeName,
573
- unit: getUnitFilename(safeName),
574
- unitPath: getUnitPath(safeName),
575
- group: meta?.group || 'default',
576
- script: meta?.script || statusObj.script,
577
- cwd: meta?.cwd || statusObj.cwd,
578
- node: meta?.node || process.execPath,
629
+ runtime: meta?.runtime || 'node',
579
630
  status: statusObj.status,
580
631
  activeState: statusObj.activeState,
581
632
  subState: statusObj.subState,
633
+ command,
634
+ arguments: argsList.join(' '),
635
+ args: argsList,
636
+ cwd: meta?.cwd || statusObj.cwd,
637
+ unit: getUnitFilename(safeName),
638
+ unitPath: getUnitPath(safeName),
639
+ group: meta?.group || 'default',
582
640
  pid: statusObj.pid,
583
641
  restarts: statusObj.restarts,
584
- started: statusObj.started
642
+ started: statusObj.started,
643
+ memory: statusObj.memory,
644
+ memoryPeak: statusObj.memoryPeak,
645
+ memoryHigh: statusObj.memoryHigh,
646
+ memoryMax: statusObj.memoryMax,
647
+ memorySwapMax: statusObj.memorySwapMax,
648
+ // Legacy fields for backward compatibility
649
+ script: meta?.script || statusObj.script,
650
+ node: meta?.node || command
585
651
  };
586
652
  }
587
653
 
@@ -610,6 +676,15 @@ export async function getServiceFailures() {
610
676
  export async function runJournalctlLogs(name, opts = {}) {
611
677
  const safeName = sanitizeServiceName(name);
612
678
  const unitFilename = getUnitFilename(safeName);
679
+
680
+ if (opts.diskUsage) {
681
+ const res = await runCommand('journalctl', ['--disk-usage']);
682
+ if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root'))) {
683
+ throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
684
+ }
685
+ return res.stdout || res.stderr;
686
+ }
687
+
613
688
  const syslogId = `unitup-${safeName}`;
614
689
 
615
690
  const candidateArgSets = [
@@ -640,19 +715,27 @@ export async function runJournalctlLogs(name, opts = {}) {
640
715
  }
641
716
 
642
717
  const workingCandidate = chosenCandidate || ['--user', '-u', unitFilename];
718
+ const filterArgs = [...workingCandidate];
719
+
720
+ if (opts.since) filterArgs.push(`--since=${opts.since}`);
721
+ if (opts.until) filterArgs.push(`--until=${opts.until}`);
722
+ if (opts.priority) filterArgs.push(`--priority=${opts.priority}`);
723
+ if (opts.grep) filterArgs.push(`--grep=${opts.grep}`);
724
+ if (opts.boot) filterArgs.push('--boot');
725
+
726
+ if (opts.json) {
727
+ filterArgs.push('-o', 'json');
728
+ } else if (opts.cat || opts.output === 'cat') {
729
+ filterArgs.push('-o', 'cat');
730
+ } else if (opts.output) {
731
+ filterArgs.push('-o', opts.output);
732
+ }
643
733
 
644
734
  if (opts.follow) {
645
- const followArgs = [...workingCandidate, '-f'];
646
- if (opts.lines) {
647
- followArgs.push('-n', String(opts.lines));
648
- }
649
- if (opts.cat || opts.output === 'cat') {
650
- followArgs.push('-o', 'cat');
651
- } else if (opts.output) {
652
- followArgs.push('-o', opts.output);
653
- }
735
+ filterArgs.push('-f');
736
+ if (opts.lines) filterArgs.push('-n', String(opts.lines));
654
737
  try {
655
- const child = spawn('journalctl', followArgs, { stdio: 'inherit', shell: false });
738
+ const child = spawn('journalctl', filterArgs, { stdio: 'inherit', shell: false });
656
739
  child.on('error', () => {});
657
740
  return child;
658
741
  } catch {
@@ -660,15 +743,125 @@ export async function runJournalctlLogs(name, opts = {}) {
660
743
  }
661
744
  }
662
745
 
663
- const staticArgs = [...workingCandidate, '--no-pager'];
664
- if (opts.cat || opts.output === 'cat') {
665
- staticArgs.push('-o', 'cat');
666
- } else if (opts.output) {
667
- staticArgs.push('-o', opts.output);
668
- }
746
+ filterArgs.push('--no-pager');
669
747
  const lines = opts.lines ? String(opts.lines) : '100';
670
- staticArgs.push('-n', lines);
748
+ filterArgs.push('-n', lines);
671
749
 
672
- const res = await runCommand('journalctl', staticArgs);
750
+ const res = await runCommand('journalctl', filterArgs);
673
751
  return res.stdout || res.stderr || 'No logs found.';
674
752
  }
753
+
754
+ export async function setServiceLimits(name, options = {}) {
755
+ const safeName = sanitizeServiceName(name);
756
+ if (!unitFileExists(safeName)) {
757
+ throw new Error(`Service "${safeName}" does not exist.`);
758
+ }
759
+
760
+ const meta = readAppMetadata(safeName) || {};
761
+ const { validateMemorySize } = await import('./utils.js');
762
+
763
+ let memoryHigh = options.memoryHigh;
764
+ let memoryMax = options.memoryMax;
765
+ let memorySwapMax = options.memorySwapMax;
766
+
767
+ const resources = { ...(meta.resources || {}) };
768
+
769
+ if (options.resetMemory) {
770
+ delete resources.memoryHigh;
771
+ delete resources.memoryMax;
772
+ delete resources.memorySwapMax;
773
+ memoryHigh = undefined;
774
+ memoryMax = undefined;
775
+ memorySwapMax = undefined;
776
+ } else {
777
+ if (memoryHigh !== undefined) {
778
+ memoryHigh = validateMemorySize(memoryHigh, 'MemoryHigh');
779
+ resources.memoryHigh = memoryHigh;
780
+ }
781
+ if (memoryMax !== undefined) {
782
+ memoryMax = validateMemorySize(memoryMax, 'MemoryMax');
783
+ resources.memoryMax = memoryMax;
784
+ }
785
+ if (memorySwapMax !== undefined) {
786
+ memorySwapMax = validateMemorySize(memorySwapMax, 'MemorySwapMax');
787
+ resources.memorySwapMax = memorySwapMax;
788
+ }
789
+ }
790
+
791
+ const updatedMeta = {
792
+ ...meta,
793
+ name: safeName,
794
+ resources: Object.keys(resources).length > 0 ? resources : undefined,
795
+ memoryHigh: resources.memoryHigh,
796
+ memoryMax: resources.memoryMax,
797
+ memorySwapMax: resources.memorySwapMax
798
+ };
799
+
800
+ writeUnitFile(updatedMeta);
801
+
802
+ await runCommand('systemctl', ['--user', 'daemon-reload']);
803
+
804
+ const status = await getServiceStatus(safeName);
805
+ if (status.status === 'running') {
806
+ const setPropsArgs = ['--user', 'set-property', getUnitFilename(safeName)];
807
+ if (options.resetMemory) {
808
+ setPropsArgs.push('MemoryHigh=infinity', 'MemoryMax=infinity', 'MemorySwapMax=infinity');
809
+ } else {
810
+ if (resources.memoryHigh) setPropsArgs.push(`MemoryHigh=${resources.memoryHigh}`);
811
+ if (resources.memoryMax) setPropsArgs.push(`MemoryMax=${resources.memoryMax}`);
812
+ if (resources.memorySwapMax) setPropsArgs.push(`MemorySwapMax=${resources.memorySwapMax}`);
813
+ }
814
+
815
+ if (setPropsArgs.length > 3) {
816
+ try {
817
+ await runCommand('systemctl', setPropsArgs);
818
+ } catch {
819
+ // Graceful fallback if runtime property setting fails
820
+ }
821
+ }
822
+ }
823
+
824
+ return await inspectService(safeName);
825
+ }
826
+
827
+ export async function executeJournalctlMaintenance(action, opts = {}) {
828
+ const args = [];
829
+ if (action === 'disk-usage') {
830
+ args.push('--disk-usage');
831
+ } else if (action === 'rotate') {
832
+ args.push('--rotate');
833
+ } else if (action === 'vacuum') {
834
+ const { validateMemorySize } = await import('./utils.js');
835
+ if (opts.size) {
836
+ const validSize = validateMemorySize(opts.size, 'Vacuum size');
837
+ args.push(`--vacuum-size=${validSize}`);
838
+ } else if (opts.time) {
839
+ const timeStr = String(opts.time).trim();
840
+ if (/[;&|$`"'\n\r\t ]/.test(timeStr)) {
841
+ throw new Error(`Vacuum time contains invalid characters or shell injection: "${timeStr}".`);
842
+ }
843
+ args.push(`--vacuum-time=${timeStr}`);
844
+ } else if (opts.files) {
845
+ const filesNum = parseInt(opts.files, 10);
846
+ if (Number.isNaN(filesNum) || filesNum <= 0) {
847
+ throw new Error(`Invalid vacuum files count: "${opts.files}". Must be a positive integer.`);
848
+ }
849
+ args.push(`--vacuum-files=${filesNum}`);
850
+ } else {
851
+ throw new Error('Vacuum action requires one of --size, --time, or --files.');
852
+ }
853
+ } else {
854
+ throw new Error(`Unknown journal maintenance action: "${action}".`);
855
+ }
856
+
857
+ if (opts.dryRun) {
858
+ return `[dry-run] Would execute: journalctl ${args.join(' ')}`;
859
+ }
860
+
861
+ const res = await runCommand('journalctl', args);
862
+ if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root') || res.stderr.toLowerCase().includes('access denied'))) {
863
+ throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
864
+ }
865
+
866
+ return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
867
+ }
package/src/unit.js CHANGED
@@ -52,31 +52,41 @@ export function unitFileExists(name) {
52
52
  *
53
53
  * @param {Object} opts
54
54
  * @param {string} opts.name - Service name
55
- * @param {string} opts.script - Absolute path to script
55
+ * @param {string} [opts.command] - Absolute path to binary executable
56
+ * @param {Array<string>} [opts.args] - Command arguments
57
+ * @param {string} [opts.script] - Absolute path to script (legacy support)
56
58
  * @param {string} [opts.cwd] - Working directory path
57
- * @param {string} [opts.nodePath] - Absolute path to Node executable
59
+ * @param {string} [opts.nodePath] - Absolute path to Node executable (legacy support)
58
60
  * @param {Record<string, string>} [opts.env] - Environment variables object
59
61
  * @param {string} [opts.envFile] - Path to environment file
60
62
  * @param {string} [opts.restart] - Restart policy (default: 'on-failure')
61
- * @param {Array<string>} [opts.args] - Extra script arguments
62
63
  * @returns {string}
63
64
  */
64
65
  export function generateUnitContent(opts) {
65
66
  const safeName = sanitizeServiceName(opts.name);
66
- const scriptPath = resolveAbsolutePath(opts.script);
67
- const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptPath);
68
- const nodeExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
69
- const restartPolicy = opts.restart || 'on-failure';
67
+ let commandExec = '';
68
+ let execArgs = [];
70
69
 
71
- const execArgs = [nodeExec, scriptPath];
72
- if (Array.isArray(opts.args) && opts.args.length > 0) {
73
- execArgs.push(...opts.args);
70
+ if (opts.command) {
71
+ commandExec = resolveAbsolutePath(opts.command);
72
+ execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
73
+ } else if (opts.script) {
74
+ const scriptPath = resolveAbsolutePath(opts.script);
75
+ commandExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
76
+ execArgs = [scriptPath, ...(Array.isArray(opts.args) ? opts.args : [])];
77
+ } else {
78
+ throw new Error('Either "command" or "script" must be provided to generate systemd unit file.');
74
79
  }
75
80
 
76
- const execStartLine = execArgs.map(escapeExecArg).join(' ');
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);
83
+ const restartPolicy = opts.restart || 'on-failure';
84
+
85
+ const execStartTokens = [commandExec, ...execArgs];
86
+ const execStartLine = execStartTokens.map(escapeExecArg).join(' ');
77
87
 
78
- const nodeBinDir = path.dirname(nodeExec);
79
- const defaultPath = [nodeBinDir, '/usr/local/bin', '/usr/bin', '/bin']
88
+ const binDir = path.dirname(commandExec);
89
+ const defaultPath = [binDir, '/usr/local/bin', '/usr/bin', '/bin']
80
90
  .filter((p, i, self) => p && self.indexOf(p) === i)
81
91
  .join(':');
82
92
 
@@ -96,6 +106,17 @@ export function generateUnitContent(opts) {
96
106
  'StandardError=journal'
97
107
  ];
98
108
 
109
+ const memHigh = opts.memoryHigh || opts.resources?.memoryHigh;
110
+ const memMax = opts.memoryMax || opts.resources?.memoryMax;
111
+ const memSwapMax = opts.memorySwapMax || opts.resources?.memorySwapMax;
112
+
113
+ if (memHigh || memMax || memSwapMax) {
114
+ serviceLines.push('MemoryAccounting=yes');
115
+ if (memHigh) serviceLines.push(`MemoryHigh=${memHigh}`);
116
+ if (memMax) serviceLines.push(`MemoryMax=${memMax}`);
117
+ if (memSwapMax) serviceLines.push(`MemorySwapMax=${memSwapMax}`);
118
+ }
119
+
99
120
  const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
100
121
  if (!envObj.PATH) {
101
122
  envObj.PATH = defaultPath;
@@ -136,9 +157,16 @@ export function writeUnitFile(opts) {
136
157
  saveAppMetadata({
137
158
  name: safeName,
138
159
  group: opts.group || 'default',
160
+ runtime: opts.runtime || 'node',
161
+ command: opts.command,
162
+ args: opts.args,
139
163
  script: opts.script,
140
164
  cwd: opts.cwd,
141
- node: opts.nodePath
165
+ node: opts.nodePath,
166
+ memoryHigh: opts.memoryHigh || opts.resources?.memoryHigh,
167
+ memoryMax: opts.memoryMax || opts.resources?.memoryMax,
168
+ memorySwapMax: opts.memorySwapMax || opts.resources?.memorySwapMax,
169
+ resources: opts.resources
142
170
  });
143
171
 
144
172
  return { path: unitPath, content };
@@ -190,10 +218,10 @@ export function listUnitFiles() {
190
218
  }
191
219
 
192
220
  /**
193
- * Parses basic information (Script, WorkingDirectory) from a unit file content.
221
+ * Parses basic information (Command, Arguments, Script, WorkingDirectory) from a unit file content.
194
222
  *
195
223
  * @param {string} content
196
- * @returns {{ script?: string, cwd?: string, restart?: string }}
224
+ * @returns {{ command?: string, args?: string[], script?: string, cwd?: string, restart?: string }}
197
225
  */
198
226
  export function parseUnitContent(content) {
199
227
  const result = {};
@@ -206,15 +234,25 @@ export function parseUnitContent(content) {
206
234
  result.cwd = trimmed.slice(17).trim();
207
235
  } else if (trimmed.startsWith('ExecStart=')) {
208
236
  const execVal = trimmed.slice(10).trim();
209
- // Extract script from ExecStart (usually second token if node executable is first)
210
237
  const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
211
- if (parts.length >= 2) {
212
- result.script = parts[1].replace(/^"|"$/g, '');
213
- } else if (parts.length === 1) {
214
- result.script = parts[0].replace(/^"|"$/g, '');
238
+ const cleanParts = parts.map(p => p.replace(/^"|"$/g, ''));
239
+ if (cleanParts.length > 0) {
240
+ result.command = cleanParts[0];
241
+ result.args = cleanParts.slice(1);
242
+ if (cleanParts.length >= 2) {
243
+ result.script = cleanParts[1];
244
+ } else {
245
+ result.script = cleanParts[0];
246
+ }
215
247
  }
216
248
  } else if (trimmed.startsWith('Restart=')) {
217
249
  result.restart = trimmed.slice(8).trim();
250
+ } else if (trimmed.startsWith('MemoryHigh=')) {
251
+ result.memoryHigh = trimmed.slice(11).trim();
252
+ } else if (trimmed.startsWith('MemoryMax=')) {
253
+ result.memoryMax = trimmed.slice(10).trim();
254
+ } else if (trimmed.startsWith('MemorySwapMax=')) {
255
+ result.memorySwapMax = trimmed.slice(14).trim();
218
256
  }
219
257
  }
220
258
 
package/src/utils.js CHANGED
@@ -29,6 +29,101 @@ export function getAppMetadataPath(name) {
29
29
  return path.join(getAppsDir(), `${safeName}.json`);
30
30
  }
31
31
 
32
+ /**
33
+ * Validates systemd memory limit sizes (128K, 256M, 1G, 2T, infinity, max, or raw bytes).
34
+ * @param {string|number} val
35
+ * @param {string} paramName
36
+ * @returns {string} Normalized memory limit string
37
+ */
38
+ export function validateMemorySize(val, paramName = 'Memory limit') {
39
+ if (val === null || val === undefined || val === '') {
40
+ throw new Error(`${paramName} cannot be empty.`);
41
+ }
42
+
43
+ const str = String(val).trim();
44
+
45
+ // Prevent shell injection and unsafe characters
46
+ if (/[;&|$`"'\n\r\t ]/.test(str)) {
47
+ throw new Error(`${paramName} contains invalid characters or shell injection attempt: "${str}".`);
48
+ }
49
+
50
+ // Reject negative numbers
51
+ if (str.startsWith('-')) {
52
+ throw new Error(`${paramName} cannot be negative: "${str}".`);
53
+ }
54
+
55
+ const lower = str.toLowerCase();
56
+ if (lower === 'infinity' || lower === 'max') {
57
+ return 'infinity';
58
+ }
59
+
60
+ // Matches numbers with optional K, M, G, T, P unit suffix (with optional 'b' or 'B')
61
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*([kmgtp]?[bb]?)?$/i);
62
+ if (!match) {
63
+ throw new Error(`Invalid ${paramName} format: "${str}". Expected values like 128K, 256M, 1G, or infinity.`);
64
+ }
65
+
66
+ const num = match[1];
67
+ const unit = (match[2] || '').toUpperCase().replace(/B$/, '');
68
+
69
+ return `${num}${unit}`;
70
+ }
71
+
72
+ /**
73
+ * Formats byte values or systemd memory limit markers into human readable strings.
74
+ * @param {number|string} bytes
75
+ * @returns {string} Formatted memory string e.g. "284 MB", "infinity", "unavailable"
76
+ */
77
+ export function formatMemoryBytes(bytes) {
78
+ if (bytes === null || bytes === undefined || bytes === '' || bytes === 'unavailable' || bytes === '[unavailable]' || bytes === '-') {
79
+ return 'unavailable';
80
+ }
81
+
82
+ const str = String(bytes).trim().toLowerCase();
83
+ if (str === 'infinity' || str === 'max' || str === '18446744073709551615') {
84
+ return 'infinity';
85
+ }
86
+
87
+ const num = Number(bytes);
88
+ if (Number.isNaN(num) || num < 0) {
89
+ // If it's already a formatted string like "400M" or "400 MB"
90
+ if (/^\d+\s*[a-zA-Z]+$/.test(str)) {
91
+ const match = str.match(/^(\d+)\s*([a-zA-Z]+)$/);
92
+ if (match) {
93
+ const u = match[2].toUpperCase().replace(/B$/, '');
94
+ return `${match[1]} ${u === 'M' ? 'MB' : u === 'G' ? 'GB' : u === 'K' ? 'KB' : u}`;
95
+ }
96
+ }
97
+ return 'unavailable';
98
+ }
99
+
100
+ if (num === 0) return '0 MB';
101
+
102
+ const kb = 1024;
103
+ const mb = kb * 1024;
104
+ const gb = mb * 1024;
105
+ const tb = gb * 1024;
106
+
107
+ if (num >= tb) {
108
+ const val = num / tb;
109
+ return `${Number.isInteger(val) ? val : val.toFixed(1)} TB`;
110
+ }
111
+ if (num >= gb) {
112
+ const val = num / gb;
113
+ return `${Number.isInteger(val) ? val : val.toFixed(1)} GB`;
114
+ }
115
+ if (num >= mb) {
116
+ const val = num / mb;
117
+ return `${Number.isInteger(val) ? val : Math.round(val)} MB`;
118
+ }
119
+ if (num >= kb) {
120
+ const val = num / kb;
121
+ return `${Number.isInteger(val) ? val : Math.round(val)} KB`;
122
+ }
123
+
124
+ return `${num} B`;
125
+ }
126
+
32
127
  /**
33
128
  * Saves metadata JSON for a service.
34
129
  * @param {object} meta
@@ -41,14 +136,32 @@ export function saveAppMetadata(meta) {
41
136
  fs.mkdirSync(dir, { recursive: true });
42
137
  }
43
138
  const filePath = getAppMetadataPath(safeName);
44
- const scriptPath = resolveAbsolutePath(meta.script);
139
+
140
+ const command = meta.command ? resolveAbsolutePath(meta.command) : (meta.node ? resolveAbsolutePath(meta.node) : process.execPath);
141
+ const rawArgs = Array.isArray(meta.args) ? meta.args : (meta.script ? [meta.script] : []);
142
+ const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
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);
145
+
146
+ // Extract memory resources if present
147
+ const resources = meta.resources || {};
148
+ if (meta.memoryHigh) resources.memoryHigh = validateMemorySize(meta.memoryHigh, 'MemoryHigh');
149
+ if (meta.memoryMax) resources.memoryMax = validateMemorySize(meta.memoryMax, 'MemoryMax');
150
+ if (meta.memorySwapMax) resources.memorySwapMax = validateMemorySize(meta.memorySwapMax, 'MemorySwapMax');
151
+
45
152
  const payload = {
46
153
  name: safeName,
47
154
  unit: getUnitFilename(safeName),
155
+ runtime: meta.runtime || 'node',
156
+ command,
157
+ args,
158
+ cwd,
48
159
  group: meta.group || 'default',
160
+ // Optional resources section
161
+ ...(Object.keys(resources).length > 0 ? { resources } : {}),
162
+ // Backward compatibility fields
49
163
  script: scriptPath,
50
- cwd: meta.cwd ? resolveAbsolutePath(meta.cwd) : path.dirname(scriptPath),
51
- node: meta.node ? resolveAbsolutePath(meta.node) : process.execPath
164
+ node: meta.node ? resolveAbsolutePath(meta.node) : command
52
165
  };
53
166
  fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
54
167
  return payload;
@@ -64,7 +177,29 @@ export function readAppMetadata(name) {
64
177
  const filePath = getAppMetadataPath(name);
65
178
  if (!fs.existsSync(filePath)) return null;
66
179
  const content = fs.readFileSync(filePath, 'utf8');
67
- return JSON.parse(content);
180
+ const data = JSON.parse(content);
181
+ if (!data) return null;
182
+
183
+ // Normalization for legacy metadata format
184
+ const command = data.command || data.node || process.execPath;
185
+ const args = data.args || (data.script ? [data.script] : []);
186
+ const runtime = data.runtime || 'node';
187
+ const script = data.script || args[0] || command;
188
+ const cwd = data.cwd || (script ? path.dirname(script) : process.cwd());
189
+
190
+ return {
191
+ ...data,
192
+ name: data.name || sanitizeServiceName(name),
193
+ unit: data.unit || getUnitFilename(name),
194
+ runtime,
195
+ command,
196
+ args,
197
+ cwd,
198
+ group: data.group || 'default',
199
+ resources: data.resources || undefined,
200
+ script,
201
+ node: data.node || command
202
+ };
68
203
  } catch {
69
204
  return null;
70
205
  }