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/README.md +395 -180
- package/docs/index.html +531 -0
- package/index.d.ts +133 -152
- package/package.json +4 -1
- package/src/cli.js +254 -53
- package/src/doctor.js +73 -14
- package/src/index.js +16 -1
- package/src/runtimes/bun.js +46 -0
- package/src/runtimes/common.js +73 -0
- package/src/runtimes/deno.js +46 -0
- package/src/runtimes/elixir.js +46 -0
- package/src/runtimes/go.js +46 -0
- package/src/runtimes/index.js +155 -0
- package/src/runtimes/native.js +38 -0
- package/src/runtimes/node.js +57 -0
- package/src/runtimes/php.js +46 -0
- package/src/runtimes/python.js +46 -0
- package/src/runtimes/ruby.js +46 -0
- package/src/runtimes/shell.js +46 -0
- package/src/systemd.js +221 -28
- package/src/unit.js +59 -21
- package/src/utils.js +139 -4
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
748
|
+
filterArgs.push('-n', lines);
|
|
671
749
|
|
|
672
|
-
const res = await runCommand('journalctl',
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
execArgs.
|
|
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
|
|
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
|
|
79
|
-
const defaultPath = [
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
result.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|