unitup 0.0.9 → 0.0.11
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 +401 -181
- package/docs/index.html +544 -0
- package/index.d.ts +140 -153
- package/package.json +18 -4
- package/src/cli.js +259 -55
- 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 +262 -30
- package/src/unit.js +59 -21
- package/src/utils.js +139 -4
- package/tsconfig.json +13 -0
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,36 @@ export async function resetFailed() {
|
|
|
278
280
|
|
|
279
281
|
export async function addService(opts) {
|
|
280
282
|
const safeName = sanitizeServiceName(opts.name);
|
|
281
|
-
|
|
283
|
+
|
|
284
|
+
if (unitFileExists(safeName)) {
|
|
285
|
+
try {
|
|
286
|
+
const show = await getServiceShow(safeName);
|
|
287
|
+
if (show.ActiveState === 'active') {
|
|
288
|
+
if (!opts.force) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Service "${safeName}" is currently running.\n` +
|
|
291
|
+
`Use --force (-f) to overwrite running services, or stop it first:\n` +
|
|
292
|
+
` unitup stop ${safeName}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (err.message.includes('currently running')) {
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const runtimeConfig = await resolveRuntimeConfig({ ...opts, name: safeName });
|
|
304
|
+
|
|
305
|
+
const { path: unitPath } = writeUnitFile({
|
|
306
|
+
...opts,
|
|
307
|
+
name: safeName,
|
|
308
|
+
runtime: runtimeConfig.runtime,
|
|
309
|
+
command: runtimeConfig.command,
|
|
310
|
+
args: runtimeConfig.args,
|
|
311
|
+
cwd: opts.cwd
|
|
312
|
+
});
|
|
282
313
|
|
|
283
314
|
await daemonReload();
|
|
284
315
|
|
|
@@ -365,14 +396,16 @@ export async function restartService(name) {
|
|
|
365
396
|
return true;
|
|
366
397
|
}
|
|
367
398
|
|
|
368
|
-
export async function removeService(name) {
|
|
399
|
+
export async function removeService(name, opts = {}) {
|
|
400
|
+
const force = typeof opts === 'boolean' ? opts : !!opts?.force;
|
|
401
|
+
|
|
369
402
|
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
370
403
|
const list = await getServicesByGroup(name);
|
|
371
404
|
if (list.length === 0) {
|
|
372
405
|
throw new Error(`No services found in group "${name}".`);
|
|
373
406
|
}
|
|
374
407
|
for (const s of list) {
|
|
375
|
-
await removeService(s);
|
|
408
|
+
await removeService(s, { force });
|
|
376
409
|
}
|
|
377
410
|
return true;
|
|
378
411
|
}
|
|
@@ -382,6 +415,23 @@ export async function removeService(name) {
|
|
|
382
415
|
throw new Error(`Service "${safeName}" does not exist.`);
|
|
383
416
|
}
|
|
384
417
|
|
|
418
|
+
try {
|
|
419
|
+
const show = await getServiceShow(safeName);
|
|
420
|
+
if (show.ActiveState === 'active') {
|
|
421
|
+
if (!force) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Service "${safeName}" is currently running.\n` +
|
|
424
|
+
`Use --force (-f) to remove running services, or stop it first:\n` +
|
|
425
|
+
` unitup stop ${safeName}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
if (err.message.includes('currently running')) {
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
385
435
|
// Attempt disable and stop
|
|
386
436
|
await runCommand('systemctl', ['--user', 'disable', '--now', getUnitFilename(safeName)]);
|
|
387
437
|
|
|
@@ -431,11 +481,12 @@ export async function getServiceStatus(name) {
|
|
|
431
481
|
// Read unit file content for script and working dir
|
|
432
482
|
let script = 'unknown';
|
|
433
483
|
let cwd = 'unknown';
|
|
484
|
+
let parsed = {};
|
|
434
485
|
|
|
435
486
|
const unitPath = getUnitPath(safeName);
|
|
436
487
|
try {
|
|
437
488
|
const content = fs.readFileSync(unitPath, 'utf8');
|
|
438
|
-
|
|
489
|
+
parsed = parseUnitContent(content);
|
|
439
490
|
if (parsed.script) script = parsed.script;
|
|
440
491
|
if (parsed.cwd) cwd = parsed.cwd;
|
|
441
492
|
} catch {
|
|
@@ -465,6 +516,17 @@ export async function getServiceStatus(name) {
|
|
|
465
516
|
? formatRelativeTime(startedRaw)
|
|
466
517
|
: 'never';
|
|
467
518
|
|
|
519
|
+
const meta = readAppMetadata(safeName);
|
|
520
|
+
const command = meta?.command || parsed.command || meta?.node || process.execPath;
|
|
521
|
+
const argsList = meta?.args || (parsed.script ? [parsed.script] : []);
|
|
522
|
+
|
|
523
|
+
const { formatMemoryBytes } = await import('./utils.js');
|
|
524
|
+
const memoryCurrent = formatMemoryBytes(show.MemoryCurrent);
|
|
525
|
+
const memoryPeak = formatMemoryBytes(show.MemoryPeak);
|
|
526
|
+
const memoryHigh = formatMemoryBytes(show.MemoryHigh || meta?.resources?.memoryHigh || parsed.memoryHigh);
|
|
527
|
+
const memoryMax = formatMemoryBytes(show.MemoryMax || meta?.resources?.memoryMax || parsed.memoryMax);
|
|
528
|
+
const memorySwapMax = formatMemoryBytes(show.MemorySwapMax || meta?.resources?.memorySwapMax || parsed.memorySwapMax);
|
|
529
|
+
|
|
468
530
|
return {
|
|
469
531
|
name: safeName,
|
|
470
532
|
unitFile: getUnitFilename(safeName),
|
|
@@ -475,8 +537,16 @@ export async function getServiceStatus(name) {
|
|
|
475
537
|
restarts,
|
|
476
538
|
started,
|
|
477
539
|
startedRaw,
|
|
540
|
+
command,
|
|
541
|
+
arguments: argsList.join(' '),
|
|
542
|
+
args: argsList,
|
|
478
543
|
script,
|
|
479
|
-
cwd
|
|
544
|
+
cwd,
|
|
545
|
+
memory: memoryCurrent,
|
|
546
|
+
memoryPeak,
|
|
547
|
+
memoryHigh,
|
|
548
|
+
memoryMax,
|
|
549
|
+
memorySwapMax
|
|
480
550
|
};
|
|
481
551
|
}
|
|
482
552
|
|
|
@@ -504,11 +574,29 @@ export async function listServices(filterOpts = {}) {
|
|
|
504
574
|
for (const unit of units) {
|
|
505
575
|
const meta = readAppMetadata(unit.name);
|
|
506
576
|
const group = meta?.group || 'default';
|
|
577
|
+
const runtime = meta?.runtime || 'node';
|
|
507
578
|
|
|
508
579
|
if (targetGroup && group.toLowerCase() !== targetGroup) {
|
|
509
580
|
continue;
|
|
510
581
|
}
|
|
511
582
|
|
|
583
|
+
let commandSummary = '';
|
|
584
|
+
if (meta?.command) {
|
|
585
|
+
if (runtime === 'native') {
|
|
586
|
+
commandSummary = './' + path.basename(meta.command);
|
|
587
|
+
} else {
|
|
588
|
+
const cmdBase = path.basename(meta.command);
|
|
589
|
+
const argsStr = (meta.args || [])
|
|
590
|
+
.map(a => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
|
|
591
|
+
.join(' ');
|
|
592
|
+
commandSummary = (cmdBase + (argsStr ? ' ' + argsStr : '')).trim();
|
|
593
|
+
}
|
|
594
|
+
} else if (meta?.script) {
|
|
595
|
+
commandSummary = `node ${path.basename(meta.script)}`;
|
|
596
|
+
} else {
|
|
597
|
+
commandSummary = 'unknown';
|
|
598
|
+
}
|
|
599
|
+
|
|
512
600
|
try {
|
|
513
601
|
const show = await getServiceShow(unit.name);
|
|
514
602
|
const activeState = show.ActiveState || 'inactive';
|
|
@@ -536,20 +624,24 @@ export async function listServices(filterOpts = {}) {
|
|
|
536
624
|
|
|
537
625
|
result.push({
|
|
538
626
|
name: unit.name,
|
|
627
|
+
runtime,
|
|
539
628
|
group,
|
|
540
629
|
status,
|
|
541
630
|
enabled,
|
|
542
631
|
pid,
|
|
632
|
+
command: commandSummary,
|
|
543
633
|
uptime,
|
|
544
634
|
restarts
|
|
545
635
|
});
|
|
546
636
|
} catch {
|
|
547
637
|
result.push({
|
|
548
638
|
name: unit.name,
|
|
639
|
+
runtime,
|
|
549
640
|
group,
|
|
550
641
|
status: 'unknown',
|
|
551
642
|
enabled: 'unknown',
|
|
552
643
|
pid: '-',
|
|
644
|
+
command: commandSummary,
|
|
553
645
|
uptime: 'never',
|
|
554
646
|
restarts: '0'
|
|
555
647
|
});
|
|
@@ -568,20 +660,33 @@ export async function inspectService(name) {
|
|
|
568
660
|
const meta = readAppMetadata(safeName);
|
|
569
661
|
const statusObj = await getServiceStatus(safeName);
|
|
570
662
|
|
|
663
|
+
const command = meta?.command || statusObj.command || statusObj.node || process.execPath;
|
|
664
|
+
const argsList = meta?.args || (statusObj.script ? [statusObj.script] : []);
|
|
665
|
+
|
|
571
666
|
return {
|
|
572
667
|
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,
|
|
668
|
+
runtime: meta?.runtime || 'node',
|
|
579
669
|
status: statusObj.status,
|
|
580
670
|
activeState: statusObj.activeState,
|
|
581
671
|
subState: statusObj.subState,
|
|
672
|
+
command,
|
|
673
|
+
arguments: argsList.join(' '),
|
|
674
|
+
args: argsList,
|
|
675
|
+
cwd: meta?.cwd || statusObj.cwd,
|
|
676
|
+
unit: getUnitFilename(safeName),
|
|
677
|
+
unitPath: getUnitPath(safeName),
|
|
678
|
+
group: meta?.group || 'default',
|
|
582
679
|
pid: statusObj.pid,
|
|
583
680
|
restarts: statusObj.restarts,
|
|
584
|
-
started: statusObj.started
|
|
681
|
+
started: statusObj.started,
|
|
682
|
+
memory: statusObj.memory,
|
|
683
|
+
memoryPeak: statusObj.memoryPeak,
|
|
684
|
+
memoryHigh: statusObj.memoryHigh,
|
|
685
|
+
memoryMax: statusObj.memoryMax,
|
|
686
|
+
memorySwapMax: statusObj.memorySwapMax,
|
|
687
|
+
// Legacy fields for backward compatibility
|
|
688
|
+
script: meta?.script || statusObj.script,
|
|
689
|
+
node: meta?.node || command
|
|
585
690
|
};
|
|
586
691
|
}
|
|
587
692
|
|
|
@@ -610,6 +715,15 @@ export async function getServiceFailures() {
|
|
|
610
715
|
export async function runJournalctlLogs(name, opts = {}) {
|
|
611
716
|
const safeName = sanitizeServiceName(name);
|
|
612
717
|
const unitFilename = getUnitFilename(safeName);
|
|
718
|
+
|
|
719
|
+
if (opts.diskUsage) {
|
|
720
|
+
const res = await runCommand('journalctl', ['--disk-usage']);
|
|
721
|
+
if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root'))) {
|
|
722
|
+
throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
|
|
723
|
+
}
|
|
724
|
+
return res.stdout || res.stderr;
|
|
725
|
+
}
|
|
726
|
+
|
|
613
727
|
const syslogId = `unitup-${safeName}`;
|
|
614
728
|
|
|
615
729
|
const candidateArgSets = [
|
|
@@ -640,19 +754,27 @@ export async function runJournalctlLogs(name, opts = {}) {
|
|
|
640
754
|
}
|
|
641
755
|
|
|
642
756
|
const workingCandidate = chosenCandidate || ['--user', '-u', unitFilename];
|
|
757
|
+
const filterArgs = [...workingCandidate];
|
|
758
|
+
|
|
759
|
+
if (opts.since) filterArgs.push(`--since=${opts.since}`);
|
|
760
|
+
if (opts.until) filterArgs.push(`--until=${opts.until}`);
|
|
761
|
+
if (opts.priority) filterArgs.push(`--priority=${opts.priority}`);
|
|
762
|
+
if (opts.grep) filterArgs.push(`--grep=${opts.grep}`);
|
|
763
|
+
if (opts.boot) filterArgs.push('--boot');
|
|
764
|
+
|
|
765
|
+
if (opts.json) {
|
|
766
|
+
filterArgs.push('-o', 'json');
|
|
767
|
+
} else if (opts.cat || opts.output === 'cat') {
|
|
768
|
+
filterArgs.push('-o', 'cat');
|
|
769
|
+
} else if (opts.output) {
|
|
770
|
+
filterArgs.push('-o', opts.output);
|
|
771
|
+
}
|
|
643
772
|
|
|
644
773
|
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
|
-
}
|
|
774
|
+
filterArgs.push('-f');
|
|
775
|
+
if (opts.lines) filterArgs.push('-n', String(opts.lines));
|
|
654
776
|
try {
|
|
655
|
-
const child = spawn('journalctl',
|
|
777
|
+
const child = spawn('journalctl', filterArgs, { stdio: 'inherit', shell: false });
|
|
656
778
|
child.on('error', () => {});
|
|
657
779
|
return child;
|
|
658
780
|
} catch {
|
|
@@ -660,15 +782,125 @@ export async function runJournalctlLogs(name, opts = {}) {
|
|
|
660
782
|
}
|
|
661
783
|
}
|
|
662
784
|
|
|
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
|
-
}
|
|
785
|
+
filterArgs.push('--no-pager');
|
|
669
786
|
const lines = opts.lines ? String(opts.lines) : '100';
|
|
670
|
-
|
|
787
|
+
filterArgs.push('-n', lines);
|
|
671
788
|
|
|
672
|
-
const res = await runCommand('journalctl',
|
|
789
|
+
const res = await runCommand('journalctl', filterArgs);
|
|
673
790
|
return res.stdout || res.stderr || 'No logs found.';
|
|
674
791
|
}
|
|
792
|
+
|
|
793
|
+
export async function setServiceLimits(name, options = {}) {
|
|
794
|
+
const safeName = sanitizeServiceName(name);
|
|
795
|
+
if (!unitFileExists(safeName)) {
|
|
796
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const meta = readAppMetadata(safeName) || {};
|
|
800
|
+
const { validateMemorySize } = await import('./utils.js');
|
|
801
|
+
|
|
802
|
+
let memoryHigh = options.memoryHigh;
|
|
803
|
+
let memoryMax = options.memoryMax;
|
|
804
|
+
let memorySwapMax = options.memorySwapMax;
|
|
805
|
+
|
|
806
|
+
const resources = { ...(meta.resources || {}) };
|
|
807
|
+
|
|
808
|
+
if (options.resetMemory) {
|
|
809
|
+
delete resources.memoryHigh;
|
|
810
|
+
delete resources.memoryMax;
|
|
811
|
+
delete resources.memorySwapMax;
|
|
812
|
+
memoryHigh = undefined;
|
|
813
|
+
memoryMax = undefined;
|
|
814
|
+
memorySwapMax = undefined;
|
|
815
|
+
} else {
|
|
816
|
+
if (memoryHigh !== undefined) {
|
|
817
|
+
memoryHigh = validateMemorySize(memoryHigh, 'MemoryHigh');
|
|
818
|
+
resources.memoryHigh = memoryHigh;
|
|
819
|
+
}
|
|
820
|
+
if (memoryMax !== undefined) {
|
|
821
|
+
memoryMax = validateMemorySize(memoryMax, 'MemoryMax');
|
|
822
|
+
resources.memoryMax = memoryMax;
|
|
823
|
+
}
|
|
824
|
+
if (memorySwapMax !== undefined) {
|
|
825
|
+
memorySwapMax = validateMemorySize(memorySwapMax, 'MemorySwapMax');
|
|
826
|
+
resources.memorySwapMax = memorySwapMax;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const updatedMeta = {
|
|
831
|
+
...meta,
|
|
832
|
+
name: safeName,
|
|
833
|
+
resources: Object.keys(resources).length > 0 ? resources : undefined,
|
|
834
|
+
memoryHigh: resources.memoryHigh,
|
|
835
|
+
memoryMax: resources.memoryMax,
|
|
836
|
+
memorySwapMax: resources.memorySwapMax
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
writeUnitFile(updatedMeta);
|
|
840
|
+
|
|
841
|
+
await runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
842
|
+
|
|
843
|
+
const status = await getServiceStatus(safeName);
|
|
844
|
+
if (status.status === 'running') {
|
|
845
|
+
const setPropsArgs = ['--user', 'set-property', getUnitFilename(safeName)];
|
|
846
|
+
if (options.resetMemory) {
|
|
847
|
+
setPropsArgs.push('MemoryHigh=infinity', 'MemoryMax=infinity', 'MemorySwapMax=infinity');
|
|
848
|
+
} else {
|
|
849
|
+
if (resources.memoryHigh) setPropsArgs.push(`MemoryHigh=${resources.memoryHigh}`);
|
|
850
|
+
if (resources.memoryMax) setPropsArgs.push(`MemoryMax=${resources.memoryMax}`);
|
|
851
|
+
if (resources.memorySwapMax) setPropsArgs.push(`MemorySwapMax=${resources.memorySwapMax}`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (setPropsArgs.length > 3) {
|
|
855
|
+
try {
|
|
856
|
+
await runCommand('systemctl', setPropsArgs);
|
|
857
|
+
} catch {
|
|
858
|
+
// Graceful fallback if runtime property setting fails
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return await inspectService(safeName);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export async function executeJournalctlMaintenance(action, opts = {}) {
|
|
867
|
+
const args = [];
|
|
868
|
+
if (action === 'disk-usage') {
|
|
869
|
+
args.push('--disk-usage');
|
|
870
|
+
} else if (action === 'rotate') {
|
|
871
|
+
args.push('--rotate');
|
|
872
|
+
} else if (action === 'vacuum') {
|
|
873
|
+
const { validateMemorySize } = await import('./utils.js');
|
|
874
|
+
if (opts.size) {
|
|
875
|
+
const validSize = validateMemorySize(opts.size, 'Vacuum size');
|
|
876
|
+
args.push(`--vacuum-size=${validSize}`);
|
|
877
|
+
} else if (opts.time) {
|
|
878
|
+
const timeStr = String(opts.time).trim();
|
|
879
|
+
if (/[;&|$`"'\n\r\t ]/.test(timeStr)) {
|
|
880
|
+
throw new Error(`Vacuum time contains invalid characters or shell injection: "${timeStr}".`);
|
|
881
|
+
}
|
|
882
|
+
args.push(`--vacuum-time=${timeStr}`);
|
|
883
|
+
} else if (opts.files) {
|
|
884
|
+
const filesNum = parseInt(opts.files, 10);
|
|
885
|
+
if (Number.isNaN(filesNum) || filesNum <= 0) {
|
|
886
|
+
throw new Error(`Invalid vacuum files count: "${opts.files}". Must be a positive integer.`);
|
|
887
|
+
}
|
|
888
|
+
args.push(`--vacuum-files=${filesNum}`);
|
|
889
|
+
} else {
|
|
890
|
+
throw new Error('Vacuum action requires one of --size, --time, or --files.');
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
throw new Error(`Unknown journal maintenance action: "${action}".`);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (opts.dryRun) {
|
|
897
|
+
return `[dry-run] Would execute: journalctl ${args.join(' ')}`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const res = await runCommand('journalctl', args);
|
|
901
|
+
if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root') || res.stderr.toLowerCase().includes('access denied'))) {
|
|
902
|
+
throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
|
|
906
|
+
}
|
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
|
|