unitup 0.0.8 → 0.0.9
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 +21 -9
- package/index.d.ts +90 -1
- package/package.json +1 -1
- package/src/cli.js +162 -60
- package/src/index.js +17 -1
- package/src/systemd.js +135 -4
- package/src/unit.js +14 -2
- package/src/utils.js +85 -0
package/README.md
CHANGED
|
@@ -78,18 +78,30 @@ npm install unitup
|
|
|
78
78
|
# 1. Verify system readiness
|
|
79
79
|
unitup doctor
|
|
80
80
|
|
|
81
|
-
# 2. Add
|
|
82
|
-
unitup add server.js --name api --env-file .env --start
|
|
81
|
+
# 2. Add Node.js scripts and assign to groups
|
|
82
|
+
unitup add server.js --name api --group myproject --env-file .env --start
|
|
83
|
+
unitup add worker.js --name worker --group myproject --start
|
|
83
84
|
|
|
84
|
-
# 3.
|
|
85
|
-
unitup
|
|
85
|
+
# 3. List all services with status, PID, uptime, restarts
|
|
86
|
+
unitup list
|
|
87
|
+
# or alias
|
|
88
|
+
unitup ls --group myproject
|
|
86
89
|
|
|
87
|
-
# 4.
|
|
88
|
-
unitup
|
|
90
|
+
# 4. Perform batch control operations on a group using @group syntax
|
|
91
|
+
unitup restart @myproject
|
|
92
|
+
unitup stop @myproject
|
|
89
93
|
|
|
90
|
-
# 5.
|
|
91
|
-
unitup
|
|
92
|
-
|
|
94
|
+
# 5. Inspect application configuration and status (excluding secrets)
|
|
95
|
+
unitup inspect api
|
|
96
|
+
|
|
97
|
+
# 6. Check for failed services
|
|
98
|
+
unitup failures
|
|
99
|
+
|
|
100
|
+
# 7. Stream logs or view clean console output
|
|
101
|
+
unitup logs api -c --follow
|
|
102
|
+
|
|
103
|
+
# 8. Remove a service or an entire group
|
|
104
|
+
unitup remove @myproject
|
|
93
105
|
```
|
|
94
106
|
|
|
95
107
|
---
|
package/index.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface CreateServiceOptions {
|
|
|
14
14
|
*/
|
|
15
15
|
name: string;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Optional group name (e.g. 'myproject').
|
|
19
|
+
* @default 'default'
|
|
20
|
+
*/
|
|
21
|
+
group?: string;
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Absolute or relative path to the target Node.js script.
|
|
19
25
|
*/
|
|
@@ -154,6 +160,11 @@ export interface ListServiceItem {
|
|
|
154
160
|
*/
|
|
155
161
|
name: string;
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Group name.
|
|
165
|
+
*/
|
|
166
|
+
group: string;
|
|
167
|
+
|
|
157
168
|
/**
|
|
158
169
|
* Status overview ('running', 'stopped', 'failed', etc.).
|
|
159
170
|
*/
|
|
@@ -163,6 +174,64 @@ export interface ListServiceItem {
|
|
|
163
174
|
* Enabled status ('yes' or 'no').
|
|
164
175
|
*/
|
|
165
176
|
enabled: string;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Main PID or '-'.
|
|
180
|
+
*/
|
|
181
|
+
pid: string;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Formatted uptime string.
|
|
185
|
+
*/
|
|
186
|
+
uptime: string;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Restart count.
|
|
190
|
+
*/
|
|
191
|
+
restarts: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* App metadata saved in ~/.config/unitup/apps/<name>.json.
|
|
196
|
+
*/
|
|
197
|
+
export interface AppMetadata {
|
|
198
|
+
name: string;
|
|
199
|
+
unit: string;
|
|
200
|
+
group: string;
|
|
201
|
+
script: string;
|
|
202
|
+
cwd: string;
|
|
203
|
+
node: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Detailed application inspection summary (unitup inspect).
|
|
208
|
+
*/
|
|
209
|
+
export interface InspectInfo {
|
|
210
|
+
name: string;
|
|
211
|
+
unit: string;
|
|
212
|
+
unitPath: string;
|
|
213
|
+
group: string;
|
|
214
|
+
script: string;
|
|
215
|
+
cwd: string;
|
|
216
|
+
node: string;
|
|
217
|
+
status: string;
|
|
218
|
+
activeState: string;
|
|
219
|
+
subState: string;
|
|
220
|
+
pid: string;
|
|
221
|
+
restarts: string;
|
|
222
|
+
started: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Failed service failure report item (unitup failures).
|
|
227
|
+
*/
|
|
228
|
+
export interface FailureItem {
|
|
229
|
+
name: string;
|
|
230
|
+
group: string;
|
|
231
|
+
status: string;
|
|
232
|
+
exitCode: string;
|
|
233
|
+
restarts: string;
|
|
234
|
+
uptime: string;
|
|
166
235
|
}
|
|
167
236
|
|
|
168
237
|
/**
|
|
@@ -347,8 +416,24 @@ export function getServiceStatusRaw(name: string): Promise<string>;
|
|
|
347
416
|
|
|
348
417
|
/**
|
|
349
418
|
* Lists all unitup-*.service user units.
|
|
419
|
+
* Optionally filter by group name.
|
|
420
|
+
*/
|
|
421
|
+
export function listServices(options?: { group?: string }): Promise<ListServiceItem[]>;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Inspects a service's configuration and status (unitup inspect).
|
|
425
|
+
*/
|
|
426
|
+
export function inspectService(name: string): Promise<InspectInfo>;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Retrieves all currently failed services (unitup failures).
|
|
430
|
+
*/
|
|
431
|
+
export function getServiceFailures(): Promise<FailureItem[]>;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Returns array of service names belonging to a group (@groupName).
|
|
350
435
|
*/
|
|
351
|
-
export function
|
|
436
|
+
export function getServicesByGroup(groupName: string): Promise<string[]>;
|
|
352
437
|
|
|
353
438
|
/**
|
|
354
439
|
* Fetches or streams journalctl logs for a service.
|
|
@@ -380,6 +465,10 @@ export function parseUnitContent(content: string): ParsedUnitContent;
|
|
|
380
465
|
export function sanitizeServiceName(name: string): string;
|
|
381
466
|
export function getUnitFilename(name: string): string;
|
|
382
467
|
export function getServiceNameFromUnit(unitFilename: string): string;
|
|
468
|
+
export function readAppMetadata(name: string): AppMetadata | null;
|
|
469
|
+
export function getAppMetadataPath(name: string): string;
|
|
470
|
+
export function getAppsDir(): string;
|
|
471
|
+
export function getUnitupDir(): string;
|
|
383
472
|
|
|
384
473
|
// ---------------------------------------------------------------------------
|
|
385
474
|
// Runner Override Helpers for Testing
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
runJournalctlLogs,
|
|
14
14
|
isLinux,
|
|
15
15
|
isSystemctlAvailable,
|
|
16
|
-
findNodeExecutable
|
|
16
|
+
findNodeExecutable,
|
|
17
|
+
getServicesByGroup,
|
|
18
|
+
inspectService,
|
|
19
|
+
getServiceFailures
|
|
17
20
|
} from './systemd.js';
|
|
18
21
|
import { sanitizeServiceName, formatTable } from './utils.js';
|
|
19
22
|
|
|
@@ -28,6 +31,7 @@ export function parseArgs(argv) {
|
|
|
28
31
|
positionals: [],
|
|
29
32
|
flags: {
|
|
30
33
|
name: '',
|
|
34
|
+
group: '',
|
|
31
35
|
node: '',
|
|
32
36
|
cwd: '',
|
|
33
37
|
env: [],
|
|
@@ -68,6 +72,12 @@ export function parseArgs(argv) {
|
|
|
68
72
|
} else if (arg.startsWith('--name=')) {
|
|
69
73
|
result.flags.name = arg.slice(7);
|
|
70
74
|
i++;
|
|
75
|
+
} else if (arg === '--group') {
|
|
76
|
+
result.flags.group = argv[i + 1] || '';
|
|
77
|
+
i += 2;
|
|
78
|
+
} else if (arg.startsWith('--group=')) {
|
|
79
|
+
result.flags.group = arg.slice(8);
|
|
80
|
+
i++;
|
|
71
81
|
} else if (arg === '--cwd') {
|
|
72
82
|
result.flags.cwd = argv[i + 1] || '';
|
|
73
83
|
i += 2;
|
|
@@ -148,16 +158,20 @@ unitup - Minimal systemd user service wrapper for Node.js scripts
|
|
|
148
158
|
Usage:
|
|
149
159
|
unitup doctor Run system readiness check
|
|
150
160
|
unitup add <script> [options] Add script as systemd user service
|
|
151
|
-
unitup start <name>
|
|
152
|
-
unitup stop <name>
|
|
153
|
-
unitup restart <name>
|
|
154
|
-
unitup status <name>
|
|
155
|
-
unitup logs <name>
|
|
156
|
-
unitup
|
|
157
|
-
unitup
|
|
161
|
+
unitup start <name|@group> Start a service (--enable to enable on boot)
|
|
162
|
+
unitup stop <name|@group> Stop a service
|
|
163
|
+
unitup restart <name|@group> Restart a service
|
|
164
|
+
unitup status <name|@group> Show status summary (--raw for full status)
|
|
165
|
+
unitup logs <name|@group> Show journalctl logs (-f/--follow, -n/--lines N, -c/--cat)
|
|
166
|
+
unitup inspect <name> View detailed app configuration and status
|
|
167
|
+
unitup failures List all failed services with exit code & restarts
|
|
168
|
+
unitup remove <name|@group> Stop, disable and delete a service
|
|
169
|
+
unitup list / unitup ls List all services (--group <group>)
|
|
158
170
|
|
|
159
171
|
Add Options:
|
|
160
172
|
--name <name> Service name (default: script name)
|
|
173
|
+
--group <group> Assign service to a group (default: default)
|
|
174
|
+
--node <path> Custom Node.js executable path
|
|
161
175
|
--cwd <path> Working directory
|
|
162
176
|
--restart <policy> Restart policy (on-failure, always, etc. Default: on-failure)
|
|
163
177
|
--env KEY=value Environment variable (can be specified multiple times)
|
|
@@ -166,13 +180,26 @@ Add Options:
|
|
|
166
180
|
--start Enable and start service immediately after creation
|
|
167
181
|
|
|
168
182
|
Examples:
|
|
169
|
-
unitup add server.js --name api --
|
|
170
|
-
unitup
|
|
171
|
-
unitup
|
|
172
|
-
unitup
|
|
183
|
+
unitup add server.js --name api --group myproject --start
|
|
184
|
+
unitup list --group myproject
|
|
185
|
+
unitup start @myproject
|
|
186
|
+
unitup inspect api
|
|
187
|
+
unitup failures
|
|
188
|
+
unitup remove @myproject
|
|
173
189
|
`);
|
|
174
190
|
}
|
|
175
191
|
|
|
192
|
+
async function resolveTargetNames(target) {
|
|
193
|
+
if (target && target.startsWith('@')) {
|
|
194
|
+
const list = await getServicesByGroup(target);
|
|
195
|
+
if (list.length === 0) {
|
|
196
|
+
throw new Error(`No services found in group "${target}".`);
|
|
197
|
+
}
|
|
198
|
+
return list;
|
|
199
|
+
}
|
|
200
|
+
return [target];
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
/**
|
|
177
204
|
* Main CLI runner entrypoint.
|
|
178
205
|
*
|
|
@@ -241,6 +268,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
241
268
|
|
|
242
269
|
const res = await addService({
|
|
243
270
|
name,
|
|
271
|
+
group: flags.group || 'default',
|
|
244
272
|
script: absScriptPath,
|
|
245
273
|
nodePath: resolvedNode,
|
|
246
274
|
cwd: flags.cwd ? path.resolve(process.cwd(), flags.cwd) : path.dirname(absScriptPath),
|
|
@@ -261,90 +289,164 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
261
289
|
}
|
|
262
290
|
|
|
263
291
|
case 'start': {
|
|
264
|
-
const
|
|
265
|
-
if (!
|
|
266
|
-
throw new Error('Service name is required.\nExample: unitup start api');
|
|
292
|
+
const nameArg = positionals[0];
|
|
293
|
+
if (!nameArg) {
|
|
294
|
+
throw new Error('Service name or @group is required.\nExample: unitup start api or unitup start @myproject');
|
|
295
|
+
}
|
|
296
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
297
|
+
for (const name of targetNames) {
|
|
298
|
+
await startService(name, flags.enable);
|
|
299
|
+
console.log(`✓ Service "${sanitizeServiceName(name)}" ${flags.enable ? 'enabled & ' : ''}started.`);
|
|
267
300
|
}
|
|
268
|
-
await startService(name, flags.enable);
|
|
269
|
-
console.log(`✓ Service "${sanitizeServiceName(name)}" ${flags.enable ? 'enabled & ' : ''}started.`);
|
|
270
301
|
break;
|
|
271
302
|
}
|
|
272
303
|
|
|
273
304
|
case 'stop': {
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
276
|
-
throw new Error('Service name is required.\nExample: unitup stop api');
|
|
305
|
+
const nameArg = positionals[0];
|
|
306
|
+
if (!nameArg) {
|
|
307
|
+
throw new Error('Service name or @group is required.\nExample: unitup stop api or unitup stop @myproject');
|
|
308
|
+
}
|
|
309
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
310
|
+
for (const name of targetNames) {
|
|
311
|
+
await stopService(name);
|
|
312
|
+
console.log(`✓ Service "${sanitizeServiceName(name)}" stopped.`);
|
|
277
313
|
}
|
|
278
|
-
await stopService(name);
|
|
279
|
-
console.log(`✓ Service "${sanitizeServiceName(name)}" stopped.`);
|
|
280
314
|
break;
|
|
281
315
|
}
|
|
282
316
|
|
|
283
317
|
case 'restart': {
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
286
|
-
throw new Error('Service name is required.\nExample: unitup restart api');
|
|
318
|
+
const nameArg = positionals[0];
|
|
319
|
+
if (!nameArg) {
|
|
320
|
+
throw new Error('Service name or @group is required.\nExample: unitup restart api or unitup restart @myproject');
|
|
321
|
+
}
|
|
322
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
323
|
+
for (const name of targetNames) {
|
|
324
|
+
await restartService(name);
|
|
325
|
+
console.log(`✓ Service "${sanitizeServiceName(name)}" restarted.`);
|
|
287
326
|
}
|
|
288
|
-
await restartService(name);
|
|
289
|
-
console.log(`✓ Service "${sanitizeServiceName(name)}" restarted.`);
|
|
290
327
|
break;
|
|
291
328
|
}
|
|
292
329
|
|
|
293
330
|
case 'status': {
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
296
|
-
throw new Error('Service name is required.\nExample: unitup status api');
|
|
331
|
+
const nameArg = positionals[0];
|
|
332
|
+
if (!nameArg) {
|
|
333
|
+
throw new Error('Service name or @group is required.\nExample: unitup status api or unitup status @myproject');
|
|
297
334
|
}
|
|
298
335
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
336
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
337
|
+
for (const name of targetNames) {
|
|
338
|
+
if (flags.raw) {
|
|
339
|
+
const raw = await getServiceStatusRaw(name);
|
|
340
|
+
console.log(raw);
|
|
341
|
+
} else {
|
|
342
|
+
const status = await getServiceStatus(name);
|
|
343
|
+
console.log(`${status.name}\n`);
|
|
344
|
+
console.log(`Status: ${status.status}`);
|
|
345
|
+
console.log(`PID: ${status.pid}`);
|
|
346
|
+
console.log(`Started: ${status.started}`);
|
|
347
|
+
console.log(`Restarts: ${status.restarts}`);
|
|
348
|
+
console.log(`Script: ${status.script}`);
|
|
349
|
+
console.log(`Working directory: ${status.cwd}`);
|
|
350
|
+
}
|
|
311
351
|
}
|
|
312
352
|
break;
|
|
313
353
|
}
|
|
314
354
|
|
|
315
355
|
case 'logs': {
|
|
316
|
-
const
|
|
317
|
-
if (!
|
|
318
|
-
throw new Error('Service name is required.\nExample: unitup logs api');
|
|
356
|
+
const nameArg = positionals[0];
|
|
357
|
+
if (!nameArg) {
|
|
358
|
+
throw new Error('Service name or @group is required.\nExample: unitup logs api or unitup logs @myproject');
|
|
319
359
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
360
|
+
|
|
361
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
362
|
+
for (const name of targetNames) {
|
|
363
|
+
if (targetNames.length > 1) {
|
|
364
|
+
console.log(`=== Logs for ${name} ===`);
|
|
365
|
+
}
|
|
366
|
+
const output = await runJournalctlLogs(name, {
|
|
367
|
+
follow: flags.follow,
|
|
368
|
+
lines: flags.lines,
|
|
369
|
+
cat: flags.cat,
|
|
370
|
+
output: flags.output
|
|
371
|
+
});
|
|
372
|
+
if (typeof output === 'string') {
|
|
373
|
+
console.log(output);
|
|
374
|
+
}
|
|
328
375
|
}
|
|
329
376
|
break;
|
|
330
377
|
}
|
|
331
378
|
|
|
332
379
|
case 'remove': {
|
|
333
|
-
const
|
|
334
|
-
if (!
|
|
335
|
-
throw new Error('Service name is required.\nExample: unitup remove api');
|
|
380
|
+
const nameArg = positionals[0];
|
|
381
|
+
if (!nameArg) {
|
|
382
|
+
throw new Error('Service name or @group is required.\nExample: unitup remove api or unitup remove @myproject');
|
|
383
|
+
}
|
|
384
|
+
const targetNames = await resolveTargetNames(nameArg);
|
|
385
|
+
for (const name of targetNames) {
|
|
386
|
+
await removeService(name);
|
|
387
|
+
console.log(`✓ Service "${sanitizeServiceName(name)}" removed.`);
|
|
336
388
|
}
|
|
337
|
-
await removeService(name);
|
|
338
|
-
console.log(`✓ Service "${sanitizeServiceName(name)}" removed.`);
|
|
339
389
|
break;
|
|
340
390
|
}
|
|
341
391
|
|
|
392
|
+
case 'ls':
|
|
342
393
|
case 'list': {
|
|
343
|
-
const services = await listServices();
|
|
394
|
+
const services = await listServices({ group: flags.group });
|
|
395
|
+
if (services.length === 0) {
|
|
396
|
+
if (flags.group) {
|
|
397
|
+
console.log(`No services found in group "${flags.group}".`);
|
|
398
|
+
} else {
|
|
399
|
+
console.log('No unitup user services found.');
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
344
404
|
const table = formatTable(services, [
|
|
345
405
|
{ key: 'name', label: 'NAME' },
|
|
406
|
+
{ key: 'group', label: 'GROUP' },
|
|
407
|
+
{ key: 'status', label: 'STATUS' },
|
|
408
|
+
{ key: 'pid', label: 'PID' },
|
|
409
|
+
{ key: 'uptime', label: 'UPTIME' },
|
|
410
|
+
{ key: 'restarts', label: 'RESTARTS' }
|
|
411
|
+
]);
|
|
412
|
+
console.log(table);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case 'inspect': {
|
|
417
|
+
const nameArg = positionals[0];
|
|
418
|
+
if (!nameArg) {
|
|
419
|
+
throw new Error('Service name is required.\nExample: unitup inspect api');
|
|
420
|
+
}
|
|
421
|
+
const info = await inspectService(nameArg);
|
|
422
|
+
console.log(`App: ${info.name}`);
|
|
423
|
+
console.log(`Group: ${info.group}`);
|
|
424
|
+
console.log(`Status: ${info.status} (${info.activeState}/${info.subState})`);
|
|
425
|
+
console.log(`PID: ${info.pid}`);
|
|
426
|
+
console.log(`Restarts: ${info.restarts}`);
|
|
427
|
+
console.log(`Uptime: ${info.started}`);
|
|
428
|
+
console.log(`Script: ${info.script}`);
|
|
429
|
+
console.log(`Working directory: ${info.cwd}`);
|
|
430
|
+
console.log(`Node path: ${info.node}`);
|
|
431
|
+
console.log(`Unit file: ${info.unit}`);
|
|
432
|
+
console.log(`Unit path: ${info.unitPath}`);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case 'failures': {
|
|
437
|
+
const failures = await getServiceFailures();
|
|
438
|
+
if (failures.length === 0) {
|
|
439
|
+
console.log('✓ No failed services.');
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const table = formatTable(failures, [
|
|
444
|
+
{ key: 'name', label: 'NAME' },
|
|
445
|
+
{ key: 'group', label: 'GROUP' },
|
|
346
446
|
{ key: 'status', label: 'STATUS' },
|
|
347
|
-
{ key: '
|
|
447
|
+
{ key: 'exitCode', label: 'EXIT_CODE' },
|
|
448
|
+
{ key: 'restarts', label: 'RESTARTS' },
|
|
449
|
+
{ key: 'uptime', label: 'UPTIME' }
|
|
348
450
|
]);
|
|
349
451
|
console.log(table);
|
|
350
452
|
break;
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
getServiceStatus,
|
|
8
8
|
getServiceStatusRaw,
|
|
9
9
|
listServices,
|
|
10
|
+
inspectService,
|
|
11
|
+
getServiceFailures,
|
|
12
|
+
getServicesByGroup,
|
|
10
13
|
runJournalctlLogs,
|
|
11
14
|
isLinux,
|
|
12
15
|
isSystemctlAvailable,
|
|
@@ -28,7 +31,11 @@ import {
|
|
|
28
31
|
import {
|
|
29
32
|
sanitizeServiceName,
|
|
30
33
|
getUnitFilename,
|
|
31
|
-
getServiceNameFromUnit
|
|
34
|
+
getServiceNameFromUnit,
|
|
35
|
+
readAppMetadata,
|
|
36
|
+
getAppMetadataPath,
|
|
37
|
+
getAppsDir,
|
|
38
|
+
getUnitupDir
|
|
32
39
|
} from './utils.js';
|
|
33
40
|
|
|
34
41
|
export {
|
|
@@ -42,8 +49,17 @@ export {
|
|
|
42
49
|
getServiceStatus,
|
|
43
50
|
getServiceStatusRaw,
|
|
44
51
|
listServices,
|
|
52
|
+
inspectService,
|
|
53
|
+
getServiceFailures,
|
|
54
|
+
getServicesByGroup,
|
|
45
55
|
runJournalctlLogs as getServiceLogs,
|
|
46
56
|
|
|
57
|
+
// Metadata helpers
|
|
58
|
+
readAppMetadata,
|
|
59
|
+
getAppMetadataPath,
|
|
60
|
+
getAppsDir,
|
|
61
|
+
getUnitupDir,
|
|
62
|
+
|
|
47
63
|
// System & Doctor checks
|
|
48
64
|
isSystemctlAvailable as isSystemdAvailable,
|
|
49
65
|
isSystemctlAvailable,
|
package/src/systemd.js
CHANGED
|
@@ -5,7 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
import {
|
|
6
6
|
sanitizeServiceName,
|
|
7
7
|
getUnitFilename,
|
|
8
|
-
formatRelativeTime
|
|
8
|
+
formatRelativeTime,
|
|
9
|
+
readAppMetadata
|
|
9
10
|
} from './utils.js';
|
|
10
11
|
import {
|
|
11
12
|
getUserUnitDir,
|
|
@@ -289,6 +290,17 @@ export async function addService(opts) {
|
|
|
289
290
|
}
|
|
290
291
|
|
|
291
292
|
export async function startService(name, enable = false) {
|
|
293
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
294
|
+
const list = await getServicesByGroup(name);
|
|
295
|
+
if (list.length === 0) {
|
|
296
|
+
throw new Error(`No services found in group "${name}".`);
|
|
297
|
+
}
|
|
298
|
+
for (const s of list) {
|
|
299
|
+
await startService(s, enable);
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
292
304
|
const safeName = sanitizeServiceName(name);
|
|
293
305
|
if (!unitFileExists(safeName)) {
|
|
294
306
|
throw new Error(`Service "${safeName}" does not exist.\nCreate it with: unitup add <script> --name ${safeName}`);
|
|
@@ -306,6 +318,17 @@ export async function startService(name, enable = false) {
|
|
|
306
318
|
}
|
|
307
319
|
|
|
308
320
|
export async function stopService(name) {
|
|
321
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
322
|
+
const list = await getServicesByGroup(name);
|
|
323
|
+
if (list.length === 0) {
|
|
324
|
+
throw new Error(`No services found in group "${name}".`);
|
|
325
|
+
}
|
|
326
|
+
for (const s of list) {
|
|
327
|
+
await stopService(s);
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
309
332
|
const safeName = sanitizeServiceName(name);
|
|
310
333
|
if (!unitFileExists(safeName)) {
|
|
311
334
|
throw new Error(`Service "${safeName}" does not exist.`);
|
|
@@ -319,6 +342,17 @@ export async function stopService(name) {
|
|
|
319
342
|
}
|
|
320
343
|
|
|
321
344
|
export async function restartService(name) {
|
|
345
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
346
|
+
const list = await getServicesByGroup(name);
|
|
347
|
+
if (list.length === 0) {
|
|
348
|
+
throw new Error(`No services found in group "${name}".`);
|
|
349
|
+
}
|
|
350
|
+
for (const s of list) {
|
|
351
|
+
await restartService(s);
|
|
352
|
+
}
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
const safeName = sanitizeServiceName(name);
|
|
323
357
|
if (!unitFileExists(safeName)) {
|
|
324
358
|
throw new Error(`Service "${safeName}" does not exist.`);
|
|
@@ -332,6 +366,17 @@ export async function restartService(name) {
|
|
|
332
366
|
}
|
|
333
367
|
|
|
334
368
|
export async function removeService(name) {
|
|
369
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
370
|
+
const list = await getServicesByGroup(name);
|
|
371
|
+
if (list.length === 0) {
|
|
372
|
+
throw new Error(`No services found in group "${name}".`);
|
|
373
|
+
}
|
|
374
|
+
for (const s of list) {
|
|
375
|
+
await removeService(s);
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
335
380
|
const safeName = sanitizeServiceName(name);
|
|
336
381
|
if (!unitFileExists(safeName)) {
|
|
337
382
|
throw new Error(`Service "${safeName}" does not exist.`);
|
|
@@ -435,11 +480,35 @@ export async function getServiceStatus(name) {
|
|
|
435
480
|
};
|
|
436
481
|
}
|
|
437
482
|
|
|
438
|
-
export async function
|
|
483
|
+
export async function getServicesByGroup(groupName) {
|
|
484
|
+
const cleanGroup = groupName.startsWith('@') ? groupName.slice(1) : groupName;
|
|
485
|
+
const units = listUnitFiles();
|
|
486
|
+
const matched = [];
|
|
487
|
+
for (const unit of units) {
|
|
488
|
+
const meta = readAppMetadata(unit.name);
|
|
489
|
+
const itemGroup = meta?.group || 'default';
|
|
490
|
+
if (itemGroup.toLowerCase() === cleanGroup.toLowerCase()) {
|
|
491
|
+
matched.push(unit.name);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return matched;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function listServices(filterOpts = {}) {
|
|
439
498
|
const units = listUnitFiles();
|
|
440
499
|
const result = [];
|
|
500
|
+
const targetGroup = filterOpts.group
|
|
501
|
+
? (filterOpts.group.startsWith('@') ? filterOpts.group.slice(1) : filterOpts.group).toLowerCase()
|
|
502
|
+
: null;
|
|
441
503
|
|
|
442
504
|
for (const unit of units) {
|
|
505
|
+
const meta = readAppMetadata(unit.name);
|
|
506
|
+
const group = meta?.group || 'default';
|
|
507
|
+
|
|
508
|
+
if (targetGroup && group.toLowerCase() !== targetGroup) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
443
512
|
try {
|
|
444
513
|
const show = await getServiceShow(unit.name);
|
|
445
514
|
const activeState = show.ActiveState || 'inactive';
|
|
@@ -458,17 +527,31 @@ export async function listServices() {
|
|
|
458
527
|
}
|
|
459
528
|
|
|
460
529
|
const enabled = unitFileState.startsWith('enabled') ? 'yes' : 'no';
|
|
530
|
+
const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
|
|
531
|
+
const restarts = show.NRestarts ?? '0';
|
|
532
|
+
const startedRaw = show.ActiveEnterTimestamp;
|
|
533
|
+
const uptime = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
|
|
534
|
+
? formatRelativeTime(startedRaw)
|
|
535
|
+
: 'never';
|
|
461
536
|
|
|
462
537
|
result.push({
|
|
463
538
|
name: unit.name,
|
|
539
|
+
group,
|
|
464
540
|
status,
|
|
465
|
-
enabled
|
|
541
|
+
enabled,
|
|
542
|
+
pid,
|
|
543
|
+
uptime,
|
|
544
|
+
restarts
|
|
466
545
|
});
|
|
467
546
|
} catch {
|
|
468
547
|
result.push({
|
|
469
548
|
name: unit.name,
|
|
549
|
+
group,
|
|
470
550
|
status: 'unknown',
|
|
471
|
-
enabled: 'unknown'
|
|
551
|
+
enabled: 'unknown',
|
|
552
|
+
pid: '-',
|
|
553
|
+
uptime: 'never',
|
|
554
|
+
restarts: '0'
|
|
472
555
|
});
|
|
473
556
|
}
|
|
474
557
|
}
|
|
@@ -476,6 +559,54 @@ export async function listServices() {
|
|
|
476
559
|
return result;
|
|
477
560
|
}
|
|
478
561
|
|
|
562
|
+
export async function inspectService(name) {
|
|
563
|
+
const safeName = sanitizeServiceName(name);
|
|
564
|
+
if (!unitFileExists(safeName)) {
|
|
565
|
+
throw new Error(`Service "${safeName}" does not exist.`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const meta = readAppMetadata(safeName);
|
|
569
|
+
const statusObj = await getServiceStatus(safeName);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
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,
|
|
579
|
+
status: statusObj.status,
|
|
580
|
+
activeState: statusObj.activeState,
|
|
581
|
+
subState: statusObj.subState,
|
|
582
|
+
pid: statusObj.pid,
|
|
583
|
+
restarts: statusObj.restarts,
|
|
584
|
+
started: statusObj.started
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export async function getServiceFailures() {
|
|
589
|
+
const allServices = await listServices();
|
|
590
|
+
const failures = [];
|
|
591
|
+
|
|
592
|
+
for (const item of allServices) {
|
|
593
|
+
if (item.status === 'failed') {
|
|
594
|
+
const show = await getServiceShow(item.name);
|
|
595
|
+
const exitCode = show.ExecMainStatus || show.ExecMainCode || '1';
|
|
596
|
+
failures.push({
|
|
597
|
+
name: item.name,
|
|
598
|
+
group: item.group,
|
|
599
|
+
status: 'failed',
|
|
600
|
+
exitCode: String(exitCode),
|
|
601
|
+
restarts: item.restarts,
|
|
602
|
+
uptime: item.uptime
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return failures;
|
|
608
|
+
}
|
|
609
|
+
|
|
479
610
|
export async function runJournalctlLogs(name, opts = {}) {
|
|
480
611
|
const safeName = sanitizeServiceName(name);
|
|
481
612
|
const unitFilename = getUnitFilename(safeName);
|
package/src/unit.js
CHANGED
|
@@ -4,9 +4,12 @@ import os from 'node:os';
|
|
|
4
4
|
import {
|
|
5
5
|
sanitizeServiceName,
|
|
6
6
|
getUnitFilename,
|
|
7
|
+
getServiceNameFromUnit,
|
|
7
8
|
formatSystemdEnv,
|
|
8
9
|
escapeExecArg,
|
|
9
|
-
resolveAbsolutePath
|
|
10
|
+
resolveAbsolutePath,
|
|
11
|
+
saveAppMetadata,
|
|
12
|
+
deleteAppMetadata
|
|
10
13
|
} from './utils.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -130,6 +133,13 @@ export function writeUnitFile(opts) {
|
|
|
130
133
|
const content = generateUnitContent({ ...opts, name: safeName });
|
|
131
134
|
|
|
132
135
|
fs.writeFileSync(unitPath, content, 'utf8');
|
|
136
|
+
saveAppMetadata({
|
|
137
|
+
name: safeName,
|
|
138
|
+
group: opts.group || 'default',
|
|
139
|
+
script: opts.script,
|
|
140
|
+
cwd: opts.cwd,
|
|
141
|
+
node: opts.nodePath
|
|
142
|
+
});
|
|
133
143
|
|
|
134
144
|
return { path: unitPath, content };
|
|
135
145
|
}
|
|
@@ -141,7 +151,9 @@ export function writeUnitFile(opts) {
|
|
|
141
151
|
* @returns {boolean} True if file existed and was removed
|
|
142
152
|
*/
|
|
143
153
|
export function deleteUnitFile(name) {
|
|
144
|
-
const
|
|
154
|
+
const safeName = sanitizeServiceName(name);
|
|
155
|
+
deleteAppMetadata(safeName);
|
|
156
|
+
const unitPath = getUnitPath(safeName);
|
|
145
157
|
if (fs.existsSync(unitPath)) {
|
|
146
158
|
fs.unlinkSync(unitPath);
|
|
147
159
|
return true;
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the base unitup config directory (~/.config/unitup).
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function getUnitupDir() {
|
|
10
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
11
|
+
return path.join(configHome, 'unitup');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the directory for app metadata (~/.config/unitup/apps).
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function getAppsDir() {
|
|
19
|
+
return path.join(getUnitupDir(), 'apps');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the filepath for a specific service's metadata JSON file.
|
|
24
|
+
* @param {string} name
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function getAppMetadataPath(name) {
|
|
28
|
+
const safeName = sanitizeServiceName(name);
|
|
29
|
+
return path.join(getAppsDir(), `${safeName}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Saves metadata JSON for a service.
|
|
34
|
+
* @param {object} meta
|
|
35
|
+
* @returns {object}
|
|
36
|
+
*/
|
|
37
|
+
export function saveAppMetadata(meta) {
|
|
38
|
+
const safeName = sanitizeServiceName(meta.name);
|
|
39
|
+
const dir = getAppsDir();
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const filePath = getAppMetadataPath(safeName);
|
|
44
|
+
const scriptPath = resolveAbsolutePath(meta.script);
|
|
45
|
+
const payload = {
|
|
46
|
+
name: safeName,
|
|
47
|
+
unit: getUnitFilename(safeName),
|
|
48
|
+
group: meta.group || 'default',
|
|
49
|
+
script: scriptPath,
|
|
50
|
+
cwd: meta.cwd ? resolveAbsolutePath(meta.cwd) : path.dirname(scriptPath),
|
|
51
|
+
node: meta.node ? resolveAbsolutePath(meta.node) : process.execPath
|
|
52
|
+
};
|
|
53
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
54
|
+
return payload;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reads metadata JSON for a service.
|
|
59
|
+
* @param {string} name
|
|
60
|
+
* @returns {object|null}
|
|
61
|
+
*/
|
|
62
|
+
export function readAppMetadata(name) {
|
|
63
|
+
try {
|
|
64
|
+
const filePath = getAppMetadataPath(name);
|
|
65
|
+
if (!fs.existsSync(filePath)) return null;
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
67
|
+
return JSON.parse(content);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deletes metadata JSON for a service if it exists.
|
|
75
|
+
* @param {string} name
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function deleteAppMetadata(name) {
|
|
79
|
+
try {
|
|
80
|
+
const filePath = getAppMetadataPath(name);
|
|
81
|
+
if (fs.existsSync(filePath)) {
|
|
82
|
+
fs.unlinkSync(filePath);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
3
88
|
|
|
4
89
|
/**
|
|
5
90
|
* Sanitizes a service name to ensure it is safe for systemd unit filenames.
|