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 CHANGED
@@ -78,18 +78,30 @@ npm install unitup
78
78
  # 1. Verify system readiness
79
79
  unitup doctor
80
80
 
81
- # 2. Add your Node.js script as a user service and start it immediately
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. Check service status
85
- unitup status api
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. Stream real-time journalctl logs
88
- unitup logs api --follow
90
+ # 4. Perform batch control operations on a group using @group syntax
91
+ unitup restart @myproject
92
+ unitup stop @myproject
89
93
 
90
- # 5. Stop or remove service when no longer needed
91
- unitup stop api
92
- unitup remove api
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 listServices(): Promise<ListServiceItem[]>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitup",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Minimal systemd user service wrapper for Node.js scripts",
5
5
  "main": "src/index.js",
6
6
  "types": "./index.d.ts",
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> Start a service (--enable to enable on boot)
152
- unitup stop <name> Stop a service
153
- unitup restart <name> Restart a service
154
- unitup status <name> Show status summary (--raw for full status)
155
- unitup logs <name> Show journalctl logs (--follow, --lines N)
156
- unitup remove <name> Stop, disable and delete a service
157
- unitup list List all unitup user services
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 --env NODE_ENV=production --start
170
- unitup status api
171
- unitup logs api --follow
172
- unitup remove api
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 name = positionals[0];
265
- if (!name) {
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 name = positionals[0];
275
- if (!name) {
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 name = positionals[0];
285
- if (!name) {
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 name = positionals[0];
295
- if (!name) {
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
- if (flags.raw) {
300
- const raw = await getServiceStatusRaw(name);
301
- console.log(raw);
302
- } else {
303
- const status = await getServiceStatus(name);
304
- console.log(`${status.name}\n`);
305
- console.log(`Status: ${status.status}`);
306
- console.log(`PID: ${status.pid}`);
307
- console.log(`Started: ${status.started}`);
308
- console.log(`Restarts: ${status.restarts}`);
309
- console.log(`Script: ${status.script}`);
310
- console.log(`Working directory: ${status.cwd}`);
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 name = positionals[0];
317
- if (!name) {
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
- const output = await runJournalctlLogs(name, {
321
- follow: flags.follow,
322
- lines: flags.lines,
323
- cat: flags.cat,
324
- output: flags.output
325
- });
326
- if (typeof output === 'string') {
327
- console.log(output);
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 name = positionals[0];
334
- if (!name) {
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: 'enabled', label: 'ENABLED' }
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 listServices() {
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 unitPath = getUnitPath(name);
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.