unitup 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/bin/unitup.js +4 -0
- package/index.d.ts +378 -0
- package/package.json +28 -0
- package/src/cli.js +351 -0
- package/src/doctor.js +109 -0
- package/src/index.js +70 -0
- package/src/systemd.js +506 -0
- package/src/unit.js +207 -0
- package/src/utils.js +184 -0
package/src/systemd.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
sanitizeServiceName,
|
|
7
|
+
getUnitFilename,
|
|
8
|
+
formatRelativeTime
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
import {
|
|
11
|
+
getUserUnitDir,
|
|
12
|
+
getUnitPath,
|
|
13
|
+
unitFileExists,
|
|
14
|
+
deleteUnitFile,
|
|
15
|
+
listUnitFiles,
|
|
16
|
+
writeUnitFile,
|
|
17
|
+
parseUnitContent
|
|
18
|
+
} from './unit.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default runner for executing commands via child_process.execFile.
|
|
22
|
+
* Can be mocked during testing.
|
|
23
|
+
*
|
|
24
|
+
* @type {(cmd: string, args: string[], opts?: any) => Promise<{ stdout: string, stderr: string, code: number }>}
|
|
25
|
+
*/
|
|
26
|
+
let commandRunner = defaultExecFileRunner;
|
|
27
|
+
|
|
28
|
+
function defaultExecFileRunner(cmd, args, opts = {}) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
execFile(cmd, args, { ...opts, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
31
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
32
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', code });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Overrides the internal command runner for test mocking.
|
|
39
|
+
*
|
|
40
|
+
* @param {typeof commandRunner} runner
|
|
41
|
+
*/
|
|
42
|
+
export function setCommandRunner(runner) {
|
|
43
|
+
commandRunner = runner;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resets the command runner to default child_process.execFile implementation.
|
|
48
|
+
*/
|
|
49
|
+
export function resetCommandRunner() {
|
|
50
|
+
commandRunner = defaultExecFileRunner;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Executes a command safely without invoking a shell.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} cmd
|
|
57
|
+
* @param {string[]} args
|
|
58
|
+
* @param {any} [opts]
|
|
59
|
+
*/
|
|
60
|
+
export async function runCommand(cmd, args, opts) {
|
|
61
|
+
return commandRunner(cmd, args, opts);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// System Diagnostics & Doctor checks
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export function isLinux() {
|
|
69
|
+
return process.platform === 'linux';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function isSystemctlAvailable() {
|
|
73
|
+
try {
|
|
74
|
+
const res = await runCommand('systemctl', ['--version']);
|
|
75
|
+
return res.code === 0;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function isSystemdPID1() {
|
|
82
|
+
if (!isLinux()) return false;
|
|
83
|
+
try {
|
|
84
|
+
if (fs.existsSync('/run/systemd/system')) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (fs.existsSync('/proc/1/comm')) {
|
|
88
|
+
const comm = fs.readFileSync('/proc/1/comm', 'utf8').trim();
|
|
89
|
+
return comm === 'systemd' || comm === 'init';
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function isUserSystemdAvailable() {
|
|
98
|
+
try {
|
|
99
|
+
const res = await runCommand('systemctl', ['--user', 'is-system-running']);
|
|
100
|
+
// exit code 0 (running), 1 (degraded) or stderr non-fatal means user bus is accessible
|
|
101
|
+
if (res.stderr && res.stderr.includes('Failed to connect to bus')) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return res.code === 0 || res.code === 1 || res.stdout.length > 0;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function isUserUnitDirWritable() {
|
|
111
|
+
const dir = getUserUnitDir();
|
|
112
|
+
try {
|
|
113
|
+
if (!fs.existsSync(dir)) {
|
|
114
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Finds and validates Node.js executable path safely using process.execPath, which, or command -v.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} [customPath]
|
|
127
|
+
* @returns {Promise<string|null>}
|
|
128
|
+
*/
|
|
129
|
+
export async function findNodeExecutable(customPath) {
|
|
130
|
+
if (customPath) {
|
|
131
|
+
const absPath = path.resolve(process.cwd(), customPath);
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(absPath)) {
|
|
134
|
+
fs.accessSync(absPath, fs.constants.X_OK);
|
|
135
|
+
return absPath;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 1. Try process.execPath
|
|
144
|
+
if (process.execPath && fs.existsSync(process.execPath)) {
|
|
145
|
+
try {
|
|
146
|
+
fs.accessSync(process.execPath, fs.constants.X_OK);
|
|
147
|
+
return process.execPath;
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Try which node
|
|
154
|
+
try {
|
|
155
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
156
|
+
const res = await runCommand(whichCmd, ['node']);
|
|
157
|
+
if (res.code === 0 && res.stdout.trim()) {
|
|
158
|
+
const p = res.stdout.trim().split('\n')[0].trim();
|
|
159
|
+
if (fs.existsSync(p)) {
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks detailed Node.js runtime diagnostics.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} [customNodePath]
|
|
174
|
+
* @returns {Promise<Object>}
|
|
175
|
+
*/
|
|
176
|
+
export async function checkNodeDiagnostics(customNodePath) {
|
|
177
|
+
const result = {
|
|
178
|
+
found: false,
|
|
179
|
+
executable: false,
|
|
180
|
+
execPath: '',
|
|
181
|
+
whichPath: '',
|
|
182
|
+
version: '',
|
|
183
|
+
inPath: false,
|
|
184
|
+
error: null,
|
|
185
|
+
solution: null
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Check which node
|
|
189
|
+
try {
|
|
190
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
191
|
+
const res = await runCommand(whichCmd, ['node']);
|
|
192
|
+
if (res.code === 0 && res.stdout.trim()) {
|
|
193
|
+
result.whichPath = res.stdout.trim().split('\n')[0].trim();
|
|
194
|
+
result.inPath = true;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
result.inPath = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const resolvedNode = await findNodeExecutable(customNodePath);
|
|
201
|
+
if (!resolvedNode) {
|
|
202
|
+
if (result.whichPath && fs.existsSync(result.whichPath)) {
|
|
203
|
+
result.execPath = result.whichPath;
|
|
204
|
+
result.found = true;
|
|
205
|
+
result.executable = false;
|
|
206
|
+
result.error = 'Node.js binary exists but is not executable';
|
|
207
|
+
result.solution = `Check permissions:\n chmod +x $(which node)`;
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
result.found = false;
|
|
212
|
+
result.error = 'Node.js not found in PATH';
|
|
213
|
+
result.solution = `Install Node.js:\n https://nodejs.org\nor via package manager:\n sudo apt install nodejs`;
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
result.execPath = resolvedNode;
|
|
218
|
+
result.found = true;
|
|
219
|
+
|
|
220
|
+
// Test executing node -v
|
|
221
|
+
try {
|
|
222
|
+
const res = await runCommand(resolvedNode, ['-v']);
|
|
223
|
+
if (res.code === 0 && res.stdout.trim()) {
|
|
224
|
+
result.version = res.stdout.trim();
|
|
225
|
+
result.executable = true;
|
|
226
|
+
} else {
|
|
227
|
+
result.executable = false;
|
|
228
|
+
result.error = 'Node.js binary exists but fails execution';
|
|
229
|
+
result.solution = `Check Node.js installation or explicit path:\n unitup add app.js --node ${resolvedNode}`;
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
result.executable = false;
|
|
233
|
+
result.error = `Node.js execution failed: ${err.message}`;
|
|
234
|
+
result.solution = `Try setting explicit path:\n unitup add app.js --node ${resolvedNode}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function checkUserLinger() {
|
|
241
|
+
const username = os.userInfo().username;
|
|
242
|
+
// Method 1: check /var/lib/systemd/linger/<user>
|
|
243
|
+
try {
|
|
244
|
+
if (fs.existsSync(`/var/lib/systemd/linger/${username}`)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore
|
|
249
|
+
}
|
|
250
|
+
// Method 2: loginctl show-user $USER --property=Linger
|
|
251
|
+
try {
|
|
252
|
+
const res = await runCommand('loginctl', ['show-user', username, '--property=Linger']);
|
|
253
|
+
if (res.code === 0 && res.stdout.includes('Linger=yes')) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// ignore
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Systemd Operations
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export async function daemonReload() {
|
|
267
|
+
const res = await runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
268
|
+
if (res.code !== 0) {
|
|
269
|
+
throw new Error(`Failed to reload systemd daemon: ${res.stderr || res.stdout}`);
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function resetFailed() {
|
|
275
|
+
await runCommand('systemctl', ['--user', 'reset-failed']);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function addService(opts) {
|
|
279
|
+
const safeName = sanitizeServiceName(opts.name);
|
|
280
|
+
const { path: unitPath } = writeUnitFile({ ...opts, name: safeName });
|
|
281
|
+
|
|
282
|
+
await daemonReload();
|
|
283
|
+
|
|
284
|
+
if (opts.start) {
|
|
285
|
+
await startService(safeName, true);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { name: safeName, unitPath };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function startService(name, enable = false) {
|
|
292
|
+
const safeName = sanitizeServiceName(name);
|
|
293
|
+
if (!unitFileExists(safeName)) {
|
|
294
|
+
throw new Error(`Service "${safeName}" does not exist.\nCreate it with: unitup add <script> --name ${safeName}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const args = enable
|
|
298
|
+
? ['--user', 'enable', '--now', getUnitFilename(safeName)]
|
|
299
|
+
: ['--user', 'start', getUnitFilename(safeName)];
|
|
300
|
+
|
|
301
|
+
const res = await runCommand('systemctl', args);
|
|
302
|
+
if (res.code !== 0) {
|
|
303
|
+
throw new Error(`Failed to start service "${safeName}": ${res.stderr || res.stdout}`);
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function stopService(name) {
|
|
309
|
+
const safeName = sanitizeServiceName(name);
|
|
310
|
+
if (!unitFileExists(safeName)) {
|
|
311
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const res = await runCommand('systemctl', ['--user', 'stop', getUnitFilename(safeName)]);
|
|
315
|
+
if (res.code !== 0) {
|
|
316
|
+
throw new Error(`Failed to stop service "${safeName}": ${res.stderr || res.stdout}`);
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function restartService(name) {
|
|
322
|
+
const safeName = sanitizeServiceName(name);
|
|
323
|
+
if (!unitFileExists(safeName)) {
|
|
324
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const res = await runCommand('systemctl', ['--user', 'restart', getUnitFilename(safeName)]);
|
|
328
|
+
if (res.code !== 0) {
|
|
329
|
+
throw new Error(`Failed to restart service "${safeName}": ${res.stderr || res.stdout}`);
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function removeService(name) {
|
|
335
|
+
const safeName = sanitizeServiceName(name);
|
|
336
|
+
if (!unitFileExists(safeName)) {
|
|
337
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Attempt disable and stop
|
|
341
|
+
await runCommand('systemctl', ['--user', 'disable', '--now', getUnitFilename(safeName)]);
|
|
342
|
+
|
|
343
|
+
deleteUnitFile(safeName);
|
|
344
|
+
|
|
345
|
+
await daemonReload();
|
|
346
|
+
await resetFailed();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function getServiceShow(name) {
|
|
351
|
+
const safeName = sanitizeServiceName(name);
|
|
352
|
+
const unitFilename = getUnitFilename(safeName);
|
|
353
|
+
|
|
354
|
+
const res = await runCommand('systemctl', ['--user', 'show', unitFilename]);
|
|
355
|
+
const info = {};
|
|
356
|
+
|
|
357
|
+
if (res.stdout) {
|
|
358
|
+
const lines = res.stdout.split('\n');
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
const idx = line.indexOf('=');
|
|
361
|
+
if (idx !== -1) {
|
|
362
|
+
const key = line.slice(0, idx).trim();
|
|
363
|
+
const val = line.slice(idx + 1).trim();
|
|
364
|
+
info[key] = val;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return info;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function getServiceStatusRaw(name) {
|
|
373
|
+
const safeName = sanitizeServiceName(name);
|
|
374
|
+
const res = await runCommand('systemctl', ['--user', 'status', getUnitFilename(safeName)]);
|
|
375
|
+
return res.stdout || res.stderr;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function getServiceStatus(name) {
|
|
379
|
+
const safeName = sanitizeServiceName(name);
|
|
380
|
+
if (!unitFileExists(safeName)) {
|
|
381
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const show = await getServiceShow(safeName);
|
|
385
|
+
|
|
386
|
+
// Read unit file content for script and working dir
|
|
387
|
+
let script = 'unknown';
|
|
388
|
+
let cwd = 'unknown';
|
|
389
|
+
|
|
390
|
+
const unitPath = getUnitPath(safeName);
|
|
391
|
+
try {
|
|
392
|
+
const content = fs.readFileSync(unitPath, 'utf8');
|
|
393
|
+
const parsed = parseUnitContent(content);
|
|
394
|
+
if (parsed.script) script = parsed.script;
|
|
395
|
+
if (parsed.cwd) cwd = parsed.cwd;
|
|
396
|
+
} catch {
|
|
397
|
+
// ignore
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let status = 'stopped';
|
|
401
|
+
const activeState = show.ActiveState || 'unknown';
|
|
402
|
+
const subState = show.SubState || 'unknown';
|
|
403
|
+
|
|
404
|
+
if (activeState === 'active' && subState === 'running') {
|
|
405
|
+
status = 'running';
|
|
406
|
+
} else if (activeState === 'active') {
|
|
407
|
+
status = subState;
|
|
408
|
+
} else if (activeState === 'failed') {
|
|
409
|
+
status = 'failed';
|
|
410
|
+
} else if (activeState === 'inactive') {
|
|
411
|
+
status = 'stopped';
|
|
412
|
+
} else {
|
|
413
|
+
status = activeState;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
|
|
417
|
+
const restarts = show.NRestarts ?? '0';
|
|
418
|
+
const startedRaw = show.ActiveEnterTimestamp;
|
|
419
|
+
const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
|
|
420
|
+
? formatRelativeTime(startedRaw)
|
|
421
|
+
: 'never';
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
name: safeName,
|
|
425
|
+
unitFile: getUnitFilename(safeName),
|
|
426
|
+
status,
|
|
427
|
+
activeState,
|
|
428
|
+
subState,
|
|
429
|
+
pid,
|
|
430
|
+
restarts,
|
|
431
|
+
started,
|
|
432
|
+
startedRaw,
|
|
433
|
+
script,
|
|
434
|
+
cwd
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function listServices() {
|
|
439
|
+
const units = listUnitFiles();
|
|
440
|
+
const result = [];
|
|
441
|
+
|
|
442
|
+
for (const unit of units) {
|
|
443
|
+
try {
|
|
444
|
+
const show = await getServiceShow(unit.name);
|
|
445
|
+
const activeState = show.ActiveState || 'inactive';
|
|
446
|
+
const subState = show.SubState || 'dead';
|
|
447
|
+
const unitFileState = show.UnitFileState || 'unknown';
|
|
448
|
+
|
|
449
|
+
let status = 'stopped';
|
|
450
|
+
if (activeState === 'active' && subState === 'running') {
|
|
451
|
+
status = 'running';
|
|
452
|
+
} else if (activeState === 'failed') {
|
|
453
|
+
status = 'failed';
|
|
454
|
+
} else if (activeState === 'active') {
|
|
455
|
+
status = subState;
|
|
456
|
+
} else {
|
|
457
|
+
status = 'stopped';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const enabled = unitFileState.startsWith('enabled') ? 'yes' : 'no';
|
|
461
|
+
|
|
462
|
+
result.push({
|
|
463
|
+
name: unit.name,
|
|
464
|
+
status,
|
|
465
|
+
enabled
|
|
466
|
+
});
|
|
467
|
+
} catch {
|
|
468
|
+
result.push({
|
|
469
|
+
name: unit.name,
|
|
470
|
+
status: 'unknown',
|
|
471
|
+
enabled: 'unknown'
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function runJournalctlLogs(name, opts = {}) {
|
|
480
|
+
const safeName = sanitizeServiceName(name);
|
|
481
|
+
const unitFilename = getUnitFilename(safeName);
|
|
482
|
+
|
|
483
|
+
const args = ['--user', '-u', unitFilename];
|
|
484
|
+
|
|
485
|
+
if (opts.follow) {
|
|
486
|
+
args.push('-f');
|
|
487
|
+
} else {
|
|
488
|
+
args.push('--no-pager');
|
|
489
|
+
const lines = opts.lines ? String(opts.lines) : '100';
|
|
490
|
+
args.push('-n', lines);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (opts.follow) {
|
|
494
|
+
// Spawn stream for follow mode
|
|
495
|
+
try {
|
|
496
|
+
const child = spawn('journalctl', args, { stdio: 'inherit', shell: false });
|
|
497
|
+
child.on('error', () => {});
|
|
498
|
+
return child;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
const res = await runCommand('journalctl', args);
|
|
504
|
+
return res.stdout || res.stderr || 'No logs found.';
|
|
505
|
+
}
|
|
506
|
+
}
|
package/src/unit.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import {
|
|
5
|
+
sanitizeServiceName,
|
|
6
|
+
getUnitFilename,
|
|
7
|
+
formatSystemdEnv,
|
|
8
|
+
escapeExecArg,
|
|
9
|
+
resolveAbsolutePath
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns the path to the systemd user service directory (~/.config/systemd/user).
|
|
14
|
+
*
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function getUserUnitDir() {
|
|
18
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
19
|
+
if (xdgConfig) {
|
|
20
|
+
return path.join(xdgConfig, 'systemd', 'user');
|
|
21
|
+
}
|
|
22
|
+
return path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the full file path for a given service unit.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function getUnitPath(name) {
|
|
32
|
+
const filename = getUnitFilename(name);
|
|
33
|
+
return path.join(getUserUnitDir(), filename);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks if a unit file exists for the given service name.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function unitFileExists(name) {
|
|
43
|
+
const unitPath = getUnitPath(name);
|
|
44
|
+
return fs.existsSync(unitPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generates the text content of a systemd unit file.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} opts
|
|
51
|
+
* @param {string} opts.name - Service name
|
|
52
|
+
* @param {string} opts.script - Absolute path to script
|
|
53
|
+
* @param {string} [opts.cwd] - Working directory path
|
|
54
|
+
* @param {string} [opts.nodePath] - Absolute path to Node executable
|
|
55
|
+
* @param {Record<string, string>} [opts.env] - Environment variables object
|
|
56
|
+
* @param {string} [opts.envFile] - Path to environment file
|
|
57
|
+
* @param {string} [opts.restart] - Restart policy (default: 'on-failure')
|
|
58
|
+
* @param {Array<string>} [opts.args] - Extra script arguments
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function generateUnitContent(opts) {
|
|
62
|
+
const safeName = sanitizeServiceName(opts.name);
|
|
63
|
+
const scriptPath = resolveAbsolutePath(opts.script);
|
|
64
|
+
const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptPath);
|
|
65
|
+
const nodeExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
|
|
66
|
+
const restartPolicy = opts.restart || 'on-failure';
|
|
67
|
+
|
|
68
|
+
const execArgs = [nodeExec, scriptPath];
|
|
69
|
+
if (Array.isArray(opts.args) && opts.args.length > 0) {
|
|
70
|
+
execArgs.push(...opts.args);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const execStartLine = execArgs.map(escapeExecArg).join(' ');
|
|
74
|
+
|
|
75
|
+
const nodeBinDir = path.dirname(nodeExec);
|
|
76
|
+
const defaultPath = [nodeBinDir, '/usr/local/bin', '/usr/bin', '/bin']
|
|
77
|
+
.filter((p, i, self) => p && self.indexOf(p) === i)
|
|
78
|
+
.join(':');
|
|
79
|
+
|
|
80
|
+
const serviceLines = [
|
|
81
|
+
'[Unit]',
|
|
82
|
+
`Description=unitup service: ${safeName}`,
|
|
83
|
+
'After=network.target',
|
|
84
|
+
'',
|
|
85
|
+
'[Service]',
|
|
86
|
+
'Type=simple',
|
|
87
|
+
`WorkingDirectory=${cwd}`,
|
|
88
|
+
`ExecStart=${execStartLine}`,
|
|
89
|
+
`Restart=${restartPolicy}`,
|
|
90
|
+
'RestartSec=3'
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
|
|
94
|
+
if (!envObj.PATH) {
|
|
95
|
+
envObj.PATH = defaultPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (opts.envFile) {
|
|
99
|
+
const absEnvFile = resolveAbsolutePath(opts.envFile);
|
|
100
|
+
serviceLines.push(`EnvironmentFile=${absEnvFile}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const [key, val] of Object.entries(envObj)) {
|
|
104
|
+
serviceLines.push(`Environment=${formatSystemdEnv(key, val)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
serviceLines.push('', '[Install]', 'WantedBy=default.target', '');
|
|
108
|
+
|
|
109
|
+
return serviceLines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Creates or updates a systemd unit file on disk.
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} opts - Same options as generateUnitContent
|
|
116
|
+
* @returns {{ path: string, content: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function writeUnitFile(opts) {
|
|
119
|
+
const safeName = sanitizeServiceName(opts.name);
|
|
120
|
+
const unitDir = getUserUnitDir();
|
|
121
|
+
|
|
122
|
+
if (!fs.existsSync(unitDir)) {
|
|
123
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const unitPath = getUnitPath(safeName);
|
|
127
|
+
const content = generateUnitContent({ ...opts, name: safeName });
|
|
128
|
+
|
|
129
|
+
fs.writeFileSync(unitPath, content, 'utf8');
|
|
130
|
+
|
|
131
|
+
return { path: unitPath, content };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Removes a unit file from disk.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} name
|
|
138
|
+
* @returns {boolean} True if file existed and was removed
|
|
139
|
+
*/
|
|
140
|
+
export function deleteUnitFile(name) {
|
|
141
|
+
const unitPath = getUnitPath(name);
|
|
142
|
+
if (fs.existsSync(unitPath)) {
|
|
143
|
+
fs.unlinkSync(unitPath);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Lists all unitup-*.service files in the user unit directory.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Array<{ name: string, filename: string, path: string }>}
|
|
153
|
+
*/
|
|
154
|
+
export function listUnitFiles() {
|
|
155
|
+
const unitDir = getUserUnitDir();
|
|
156
|
+
if (!fs.existsSync(unitDir)) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const files = fs.readdirSync(unitDir);
|
|
161
|
+
const units = [];
|
|
162
|
+
|
|
163
|
+
for (const filename of files) {
|
|
164
|
+
if (filename.startsWith('unitup-') && filename.endsWith('.service')) {
|
|
165
|
+
const name = filename.slice(7, -8);
|
|
166
|
+
units.push({
|
|
167
|
+
name,
|
|
168
|
+
filename,
|
|
169
|
+
path: path.join(unitDir, filename)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return units;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parses basic information (Script, WorkingDirectory) from a unit file content.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} content
|
|
181
|
+
* @returns {{ script?: string, cwd?: string, restart?: string }}
|
|
182
|
+
*/
|
|
183
|
+
export function parseUnitContent(content) {
|
|
184
|
+
const result = {};
|
|
185
|
+
if (!content) return result;
|
|
186
|
+
|
|
187
|
+
const lines = content.split('\n');
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
const trimmed = line.trim();
|
|
190
|
+
if (trimmed.startsWith('WorkingDirectory=')) {
|
|
191
|
+
result.cwd = trimmed.slice(17).trim();
|
|
192
|
+
} else if (trimmed.startsWith('ExecStart=')) {
|
|
193
|
+
const execVal = trimmed.slice(10).trim();
|
|
194
|
+
// Extract script from ExecStart (usually second token if node executable is first)
|
|
195
|
+
const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
196
|
+
if (parts.length >= 2) {
|
|
197
|
+
result.script = parts[1].replace(/^"|"$/g, '');
|
|
198
|
+
} else if (parts.length === 1) {
|
|
199
|
+
result.script = parts[0].replace(/^"|"$/g, '');
|
|
200
|
+
}
|
|
201
|
+
} else if (trimmed.startsWith('Restart=')) {
|
|
202
|
+
result.restart = trimmed.slice(8).trim();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return result;
|
|
207
|
+
}
|