unitup 0.1.5 → 0.2.0

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.
@@ -0,0 +1,723 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {
5
+ PermissionRequiredError,
6
+ ServiceAlreadyExistsError,
7
+ ServiceNotFoundError,
8
+ ServiceStartError,
9
+ ServiceStopError
10
+ } from '../errors.js';
11
+ import { readServiceLogs } from '../logs.js';
12
+ import {
13
+ deleteAppMetadata,
14
+ formatRelativeTime,
15
+ getAppsDir,
16
+ getUnitupDir,
17
+ readAppMetadata,
18
+ sanitizeServiceName,
19
+ saveAppMetadata
20
+ } from '../utils.js';
21
+ import { ServiceAdapter } from './base.js';
22
+
23
+ /**
24
+ * Escapes XML special characters.
25
+ * @param {string} str
26
+ * @returns {string}
27
+ */
28
+ function escapeXml(str) {
29
+ return String(str)
30
+ .replace(/&/g, '&')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&apos;');
35
+ }
36
+
37
+ /**
38
+ * Generates XML plist for a dict item.
39
+ */
40
+ function dictToPlistXml(dict) {
41
+ const lines = ['<dict>'];
42
+ for (const [key, value] of Object.entries(dict)) {
43
+ lines.push(` <key>${escapeXml(key)}</key>`);
44
+ if (typeof value === 'boolean') {
45
+ lines.push(` <${value ? 'true' : 'false'}/>`);
46
+ } else if (typeof value === 'number') {
47
+ lines.push(` <integer>${value}</integer>`);
48
+ } else if (typeof value === 'string') {
49
+ lines.push(` <string>${escapeXml(value)}</string>`);
50
+ } else if (Array.isArray(value)) {
51
+ lines.push(' <array>');
52
+ for (const item of value) {
53
+ if (typeof item === 'boolean') {
54
+ lines.push(` <${item ? 'true' : 'false'}/>`);
55
+ } else if (typeof item === 'number') {
56
+ lines.push(` <integer>${item}</integer>`);
57
+ } else {
58
+ lines.push(` <string>${escapeXml(item)}</string>`);
59
+ }
60
+ }
61
+ lines.push(' </array>');
62
+ } else if (value && typeof value === 'object') {
63
+ lines.push(' <dict>');
64
+ for (const [subKey, subVal] of Object.entries(value)) {
65
+ lines.push(` <key>${escapeXml(subKey)}</key>`);
66
+ if (typeof subVal === 'boolean') {
67
+ lines.push(` <${subVal ? 'true' : 'false'}/>`);
68
+ } else if (typeof subVal === 'number') {
69
+ lines.push(` <integer>${subVal}</integer>`);
70
+ } else {
71
+ lines.push(` <string>${escapeXml(subVal)}</string>`);
72
+ }
73
+ }
74
+ lines.push(' </dict>');
75
+ }
76
+ }
77
+ lines.push('</dict>');
78
+ return lines.join('\n');
79
+ }
80
+
81
+ /**
82
+ * macOS launchd service adapter.
83
+ */
84
+ export class MacOSAdapter extends ServiceAdapter {
85
+ get name() {
86
+ return 'macos';
87
+ }
88
+
89
+ get capabilities() {
90
+ return {
91
+ serviceManager: 'launchd',
92
+ supports: {
93
+ install: true,
94
+ uninstall: true,
95
+ start: true,
96
+ stop: true,
97
+ restart: true,
98
+ enable: true,
99
+ disable: true,
100
+ status: true,
101
+ logs: true,
102
+ restartPolicy: true,
103
+ userServices: true,
104
+ systemServices: true,
105
+ memoryLimits: false,
106
+ schedule: false
107
+ }
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Returns the launchd label for a service.
113
+ * @param {string} name
114
+ * @returns {string}
115
+ */
116
+ getLabel(name) {
117
+ const safeName = sanitizeServiceName(name);
118
+ return `dev.unitup.${safeName}`;
119
+ }
120
+
121
+ /**
122
+ * Returns the plist directory (user LaunchAgents or system LaunchDaemons).
123
+ * @param {boolean} [system=false]
124
+ * @returns {string}
125
+ */
126
+ getPlistDir(system = false) {
127
+ if (system) {
128
+ return '/Library/LaunchDaemons';
129
+ }
130
+ const home = os.homedir();
131
+ return path.join(home, 'Library', 'LaunchAgents');
132
+ }
133
+
134
+ /**
135
+ * Returns the plist file path for a service.
136
+ * @param {string} name
137
+ * @param {boolean} [system=false]
138
+ * @returns {string}
139
+ */
140
+ getPlistPath(name, system = false) {
141
+ const label = this.getLabel(name);
142
+ return path.join(this.getPlistDir(system), `${label}.plist`);
143
+ }
144
+
145
+ /**
146
+ * Gets the launchctl domain target (gui/<uid> or system).
147
+ * @param {boolean} [system=false]
148
+ * @returns {string}
149
+ */
150
+ getDomain(system = false) {
151
+ if (system) return 'system';
152
+ let uid = 501;
153
+ if (typeof process.getuid === 'function') {
154
+ uid = process.getuid();
155
+ } else {
156
+ try {
157
+ const u = os.userInfo();
158
+ if (u && typeof u.uid === 'number' && u.uid >= 0) {
159
+ uid = u.uid;
160
+ }
161
+ } catch {
162
+ // ignore
163
+ }
164
+ }
165
+ return `gui/${uid}`;
166
+ }
167
+
168
+ /**
169
+ * Generates .plist XML content for a service from normalized config.
170
+ *
171
+ * @param {object} config - NormalizedServiceConfig
172
+ * @returns {string}
173
+ */
174
+ generateService(config) {
175
+ const safeName = sanitizeServiceName(config.name);
176
+ const label = this.getLabel(safeName);
177
+
178
+ const programArguments = [config.command, ...(config.args || [])];
179
+
180
+ // Ensure log directory exists
181
+ const stdoutLog = config.logs?.stdout || path.join(getUnitupDir(), 'logs', `${safeName}.log`);
182
+ const stderrLog = config.logs?.stderr || path.join(getUnitupDir(), 'logs', `${safeName}-error.log`);
183
+
184
+ const dict = {
185
+ Label: label,
186
+ ProgramArguments: programArguments,
187
+ WorkingDirectory: config.cwd || process.cwd(),
188
+ RunAtLoad: config.autostart !== false,
189
+ StandardOutPath: stdoutLog,
190
+ StandardErrorPath: stderrLog
191
+ };
192
+
193
+ // Restart policy
194
+ if (config.restart?.enabled !== false && config.restart?.policy !== 'no') {
195
+ dict.KeepAlive = {
196
+ SuccessfulExit: false,
197
+ Crashed: true
198
+ };
199
+ if (typeof config.restart?.delay === 'number' && config.restart.delay > 0) {
200
+ dict.ThrottleInterval = Math.ceil(config.restart.delay / 1000);
201
+ }
202
+ } else {
203
+ dict.KeepAlive = false;
204
+ }
205
+
206
+ // Environment variables
207
+ const envObj = { ...(config.env || {}) };
208
+ const binDir = path.dirname(config.command);
209
+ if (!envObj.PATH) {
210
+ envObj.PATH = [binDir, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH || '']
211
+ .filter((p, i, self) => p && self.indexOf(p) === i)
212
+ .join(':');
213
+ }
214
+
215
+ if (Object.keys(envObj).length > 0) {
216
+ dict.EnvironmentVariables = envObj;
217
+ }
218
+
219
+ return `<?xml version="1.0" encoding="UTF-8"?>
220
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
221
+ <plist version="1.0">
222
+ ${dictToPlistXml(dict)}
223
+ </plist>
224
+ `;
225
+ }
226
+
227
+ /**
228
+ * Installs a service as a launchd agent/daemon.
229
+ *
230
+ * @param {object} config - NormalizedServiceConfig
231
+ * @param {object} [options]
232
+ * @returns {Promise<object>}
233
+ */
234
+ async install(config, options = {}) {
235
+ const safeName = sanitizeServiceName(config.name);
236
+ const system = !!(options.system || config.system);
237
+
238
+ if (system && process.getuid && process.getuid() !== 0) {
239
+ throw new PermissionRequiredError(
240
+ 'Installing a system-level launch daemon requires administrator privileges.\n\nTry:\n sudo unitup install --system',
241
+ 'install-system'
242
+ );
243
+ }
244
+
245
+ const plistDir = this.getPlistDir(system);
246
+ if (!fs.existsSync(plistDir)) {
247
+ fs.mkdirSync(plistDir, { recursive: true });
248
+ }
249
+
250
+ // Ensure logs dir exists
251
+ const logsDir = path.dirname(config.logs?.stdout || path.join(getUnitupDir(), 'logs', `${safeName}.log`));
252
+ if (!fs.existsSync(logsDir)) {
253
+ fs.mkdirSync(logsDir, { recursive: true });
254
+ }
255
+
256
+ const plistPath = this.getPlistPath(safeName, system);
257
+ const label = this.getLabel(safeName);
258
+ const domain = this.getDomain(system);
259
+
260
+ const exists = fs.existsSync(plistPath);
261
+ let wasRunning = false;
262
+
263
+ if (exists) {
264
+ const currentStatus = await this.status(safeName, { system });
265
+ if (currentStatus.state === 'running') {
266
+ wasRunning = true;
267
+ if (!options.force && !config.raw?.force) {
268
+ throw new ServiceAlreadyExistsError(safeName);
269
+ }
270
+ await this.stop(safeName, { system });
271
+ }
272
+
273
+ // Try bootout existing service before overwriting
274
+ try {
275
+ await this.run('launchctl', ['bootout', `${domain}/${label}`]);
276
+ } catch {
277
+ try {
278
+ await this.run('launchctl', ['unload', '-w', plistPath]);
279
+ } catch {
280
+ // ignore
281
+ }
282
+ }
283
+ }
284
+
285
+ const plistContent = this.generateService(config);
286
+ fs.writeFileSync(plistPath, plistContent, 'utf8');
287
+
288
+ // Save unitup metadata
289
+ saveAppMetadata({
290
+ ...config,
291
+ name: safeName,
292
+ system,
293
+ plistPath,
294
+ label
295
+ });
296
+
297
+ // Bootstrap into launchd
298
+ let bootstrapSuccess = false;
299
+ try {
300
+ const res = await this.run('launchctl', ['bootstrap', domain, plistPath]);
301
+ if (res.code === 0) {
302
+ bootstrapSuccess = true;
303
+ }
304
+ } catch {
305
+ // fallback
306
+ }
307
+
308
+ if (!bootstrapSuccess) {
309
+ try {
310
+ await this.run('launchctl', ['load', '-w', plistPath]);
311
+ } catch {
312
+ // ignore load errors if already registered
313
+ }
314
+ }
315
+
316
+ if (options.start || config.raw?.start || wasRunning) {
317
+ await this.start(safeName, { system });
318
+ }
319
+
320
+ return {
321
+ name: safeName,
322
+ unitPath: plistPath,
323
+ label,
324
+ system
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Uninstalls a service.
330
+ *
331
+ * @param {string} name
332
+ * @param {object} [options]
333
+ * @returns {Promise<boolean>}
334
+ */
335
+ async uninstall(name, options = {}) {
336
+ const safeName = sanitizeServiceName(name);
337
+ const system = !!options.system;
338
+ const plistPath = this.getPlistPath(safeName, system);
339
+ const label = this.getLabel(safeName);
340
+ const domain = this.getDomain(system);
341
+
342
+ if (!fs.existsSync(plistPath)) {
343
+ throw new ServiceNotFoundError(safeName);
344
+ }
345
+
346
+ const currentStatus = await this.status(safeName, { system });
347
+ if (currentStatus.state === 'running' && !options.force) {
348
+ throw new Error(
349
+ `Service "${safeName}" is currently running.\n` +
350
+ `Use --force (-f) to remove running services, or stop it first:\n` +
351
+ ` unitup stop ${safeName}`
352
+ );
353
+ }
354
+
355
+ // Stop and bootout
356
+ try {
357
+ await this.run('launchctl', ['bootout', `${domain}/${label}`]);
358
+ } catch {
359
+ try {
360
+ await this.run('launchctl', ['unload', '-w', plistPath]);
361
+ } catch {
362
+ // ignore
363
+ }
364
+ }
365
+
366
+ // Delete plist file
367
+ try {
368
+ if (fs.existsSync(plistPath)) {
369
+ fs.unlinkSync(plistPath);
370
+ }
371
+ } catch (err) {
372
+ throw new Error(`Failed to remove plist file "${plistPath}": ${err.message}`);
373
+ }
374
+
375
+ deleteAppMetadata(safeName);
376
+ return true;
377
+ }
378
+
379
+ /**
380
+ * Starts a service.
381
+ *
382
+ * @param {string} name
383
+ * @param {object} [options]
384
+ * @returns {Promise<boolean>}
385
+ */
386
+ async start(name, options = {}) {
387
+ const safeName = sanitizeServiceName(name);
388
+ const system = !!options.system;
389
+ const label = this.getLabel(safeName);
390
+ const domain = this.getDomain(system);
391
+ const plistPath = this.getPlistPath(safeName, system);
392
+
393
+ if (!fs.existsSync(plistPath)) {
394
+ throw new ServiceNotFoundError(safeName);
395
+ }
396
+
397
+ // 1. Try kickstart
398
+ let res = await this.run('launchctl', ['kickstart', '-k', `${domain}/${label}`]);
399
+ if (res.code === 0) return true;
400
+
401
+ // 2. Fallback to start
402
+ res = await this.run('launchctl', ['start', label]);
403
+ if (res.code === 0) return true;
404
+
405
+ // 3. Fallback: try bootstrap then kickstart
406
+ await this.run('launchctl', ['bootstrap', domain, plistPath]);
407
+ res = await this.run('launchctl', ['kickstart', '-k', `${domain}/${label}`]);
408
+ if (res.code === 0) return true;
409
+
410
+ throw new ServiceStartError(safeName, res.stderr || res.stdout);
411
+ }
412
+
413
+ /**
414
+ * Stops a service.
415
+ *
416
+ * @param {string} name
417
+ * @param {object} [options]
418
+ * @returns {Promise<boolean>}
419
+ */
420
+ async stop(name, options = {}) {
421
+ const safeName = sanitizeServiceName(name);
422
+ const system = !!options.system;
423
+ const label = this.getLabel(safeName);
424
+ const domain = this.getDomain(system);
425
+ const plistPath = this.getPlistPath(safeName, system);
426
+
427
+ if (!fs.existsSync(plistPath)) {
428
+ throw new ServiceNotFoundError(safeName);
429
+ }
430
+
431
+ // 1. Try kill SIGTERM
432
+ let res = await this.run('launchctl', ['kill', 'SIGTERM', `${domain}/${label}`]);
433
+ if (res.code === 0) return true;
434
+
435
+ // 2. Fallback to stop
436
+ res = await this.run('launchctl', ['stop', label]);
437
+ if (res.code === 0) return true;
438
+
439
+ return true;
440
+ }
441
+
442
+ /**
443
+ * Restarts a service.
444
+ *
445
+ * @param {string} name
446
+ * @param {object} [options]
447
+ * @returns {Promise<boolean>}
448
+ */
449
+ async restart(name, options = {}) {
450
+ const safeName = sanitizeServiceName(name);
451
+ const system = !!options.system;
452
+ const label = this.getLabel(safeName);
453
+ const domain = this.getDomain(system);
454
+
455
+ // Try kickstart with -k (kill existing and restart)
456
+ const res = await this.run('launchctl', ['kickstart', '-k', `${domain}/${label}`]);
457
+ if (res.code === 0) return true;
458
+
459
+ // Fallback: stop then start
460
+ await this.stop(safeName, options);
461
+ return this.start(safeName, options);
462
+ }
463
+
464
+ /**
465
+ * Enables a service.
466
+ *
467
+ * @param {string} name
468
+ * @param {object} [options]
469
+ * @returns {Promise<boolean>}
470
+ */
471
+ async enable(name, options = {}) {
472
+ const safeName = sanitizeServiceName(name);
473
+ const system = !!options.system;
474
+ const label = this.getLabel(safeName);
475
+ const domain = this.getDomain(system);
476
+
477
+ const res = await this.run('launchctl', ['enable', `${domain}/${label}`]);
478
+ return res.code === 0;
479
+ }
480
+
481
+ /**
482
+ * Disables a service.
483
+ *
484
+ * @param {string} name
485
+ * @param {object} [options]
486
+ * @returns {Promise<boolean>}
487
+ */
488
+ async disable(name, options = {}) {
489
+ const safeName = sanitizeServiceName(name);
490
+ const system = !!options.system;
491
+ const label = this.getLabel(safeName);
492
+ const domain = this.getDomain(system);
493
+
494
+ const res = await this.run('launchctl', ['disable', `${domain}/${label}`]);
495
+ return res.code === 0;
496
+ }
497
+
498
+ /**
499
+ * Gets normalized status for a service.
500
+ *
501
+ * @param {string} name
502
+ * @param {object} [options]
503
+ * @returns {Promise<object>}
504
+ */
505
+ async status(name, options = {}) {
506
+ const safeName = sanitizeServiceName(name);
507
+ const system = !!options.system;
508
+ const label = this.getLabel(safeName);
509
+ const domain = this.getDomain(system);
510
+ const plistPath = this.getPlistPath(safeName, system);
511
+
512
+ const installed = fs.existsSync(plistPath);
513
+ const meta = readAppMetadata(safeName) || {};
514
+
515
+ let state = 'stopped';
516
+ let pid = '-';
517
+ let lastExitCode = null;
518
+ const details = {};
519
+
520
+ if (installed) {
521
+ // 1. Try launchctl print
522
+ const printRes = await this.run('launchctl', ['print', `${domain}/${label}`]);
523
+ if (printRes.code === 0 && printRes.stdout) {
524
+ details.raw = printRes.stdout;
525
+ const pidMatch = printRes.stdout.match(/pid\s*=\s*(\d+)/i);
526
+ if (pidMatch && pidMatch[1] && pidMatch[1] !== '0') {
527
+ pid = pidMatch[1];
528
+ state = 'running';
529
+ }
530
+
531
+ const stateMatch = printRes.stdout.match(/state\s*=\s*([a-zA-Z]+)/i);
532
+ if (stateMatch) {
533
+ const rawState = stateMatch[1].toLowerCase();
534
+ if (rawState === 'running') state = 'running';
535
+ else if (rawState === 'waiting') state = 'stopped';
536
+ }
537
+
538
+ const exitMatch = printRes.stdout.match(/last exit code\s*=\s*(\d+)/i);
539
+ if (exitMatch) {
540
+ lastExitCode = exitMatch[1];
541
+ if (lastExitCode !== '0' && state !== 'running') {
542
+ state = 'failed';
543
+ }
544
+ }
545
+ } else {
546
+ // 2. Fallback to launchctl list
547
+ const listRes = await this.run('launchctl', ['list', label]);
548
+ if (listRes.code === 0 && listRes.stdout) {
549
+ details.raw = listRes.stdout;
550
+ const pidMatch = listRes.stdout.match(/"PID"\s*=\s*(\d+);/i);
551
+ if (pidMatch && pidMatch[1]) {
552
+ pid = pidMatch[1];
553
+ state = 'running';
554
+ }
555
+ const exitMatch = listRes.stdout.match(/"LastExitStatus"\s*=\s*(\d+);/i);
556
+ if (exitMatch) {
557
+ lastExitCode = exitMatch[1];
558
+ if (lastExitCode !== '0' && state !== 'running') {
559
+ state = 'failed';
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ const command = meta.command || process.execPath;
567
+ const argsList = meta.args || (meta.script ? [meta.script] : []);
568
+
569
+ return {
570
+ name: safeName,
571
+ installed,
572
+ state,
573
+ status: state, // for backward compatibility with table renderers
574
+ enabled: installed,
575
+ pid,
576
+ restarts: meta.restarts || '0',
577
+ started: meta.started ? formatRelativeTime(meta.started) : state === 'running' ? 'active' : 'never',
578
+ startedRaw: meta.started || null,
579
+ command,
580
+ arguments: argsList.join(' '),
581
+ args: argsList,
582
+ script: meta.script || argsList[0] || command,
583
+ cwd: meta.cwd || process.cwd(),
584
+ unitFile: path.basename(plistPath),
585
+ unitPath: plistPath,
586
+ platform: 'darwin',
587
+ manager: 'launchd',
588
+ details,
589
+ lastExitCode
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Inspects detailed configuration and runtime info for a service.
595
+ *
596
+ * @param {string} name
597
+ * @param {object} [options]
598
+ * @returns {Promise<object>}
599
+ */
600
+ async inspect(name, options = {}) {
601
+ const statusObj = await this.status(name, options);
602
+ const meta = readAppMetadata(name) || {};
603
+ return {
604
+ ...statusObj,
605
+ runtime: meta.runtime || 'node',
606
+ group: meta.group || 'default',
607
+ node: statusObj.command
608
+ };
609
+ }
610
+
611
+ /**
612
+ * Retrieves or streams logs for a service.
613
+ *
614
+ * @param {string} name
615
+ * @param {object} [options]
616
+ * @returns {Promise<any>}
617
+ */
618
+ async logs(name, options = {}) {
619
+ const safeName = sanitizeServiceName(name);
620
+ const meta = readAppMetadata(safeName);
621
+ const stdoutPath = meta?.logs?.stdout || path.join(getUnitupDir(), 'logs', `${safeName}.log`);
622
+ const stderrPath = meta?.logs?.stderr || path.join(getUnitupDir(), 'logs', `${safeName}-error.log`);
623
+
624
+ let paths = [stdoutPath, stderrPath];
625
+ if (options.output === 'stdout' || options.stdout) {
626
+ paths = [stdoutPath];
627
+ } else if (options.output === 'stderr' || options.stderr) {
628
+ paths = [stderrPath];
629
+ }
630
+
631
+ return readServiceLogs(paths, options);
632
+ }
633
+
634
+ /**
635
+ * Lists all unitup services on macOS.
636
+ *
637
+ * @param {object} [options]
638
+ * @returns {Promise<Array<object>>}
639
+ */
640
+ async list(options = {}) {
641
+ const appsDir = getAppsDir();
642
+ if (!fs.existsSync(appsDir)) return [];
643
+
644
+ const files = fs.readdirSync(appsDir).filter((f) => f.endsWith('.json'));
645
+ const result = [];
646
+ const targetGroup = options.group
647
+ ? (options.group.startsWith('@') ? options.group.slice(1) : options.group).toLowerCase()
648
+ : null;
649
+
650
+ for (const file of files) {
651
+ const serviceName = file.replace(/\.json$/, '');
652
+ const meta = readAppMetadata(serviceName);
653
+ if (!meta) continue;
654
+
655
+ const group = meta.group || 'default';
656
+ if (targetGroup && group.toLowerCase() !== targetGroup) {
657
+ continue;
658
+ }
659
+
660
+ try {
661
+ const stat = await this.status(serviceName, { system: meta.system });
662
+ const cmdBase = path.basename(stat.command || 'node');
663
+ const argsStr = (stat.args || []).map((a) => path.basename(a)).join(' ');
664
+ const commandSummary = (cmdBase + (argsStr ? ` ${argsStr}` : '')).trim();
665
+
666
+ result.push({
667
+ name: serviceName,
668
+ runtime: meta.runtime || 'node',
669
+ group,
670
+ status: stat.state,
671
+ enabled: stat.enabled ? 'yes' : 'no',
672
+ pid: stat.pid,
673
+ command: commandSummary,
674
+ uptime: stat.started,
675
+ restarts: stat.restarts || '0',
676
+ platform: 'darwin',
677
+ manager: 'launchd'
678
+ });
679
+ } catch {
680
+ result.push({
681
+ name: serviceName,
682
+ runtime: meta.runtime || 'node',
683
+ group,
684
+ status: 'unknown',
685
+ enabled: 'unknown',
686
+ pid: '-',
687
+ command: 'unknown',
688
+ uptime: 'never',
689
+ restarts: '0',
690
+ platform: 'darwin',
691
+ manager: 'launchd'
692
+ });
693
+ }
694
+ }
695
+
696
+ return result;
697
+ }
698
+
699
+ isInstalled(name, options = {}) {
700
+ const safeName = sanitizeServiceName(name);
701
+ const system = !!options.system;
702
+ return fs.existsSync(this.getPlistPath(safeName, system));
703
+ }
704
+
705
+ async failures(options = {}) {
706
+ const all = await this.list(options);
707
+ const failed = [];
708
+ for (const item of all) {
709
+ if (item.status === 'failed') {
710
+ const stat = await this.status(item.name);
711
+ failed.push({
712
+ name: item.name,
713
+ group: item.group,
714
+ status: 'failed',
715
+ exitCode: stat.lastExitCode || '1',
716
+ restarts: stat.restarts || '0',
717
+ uptime: stat.started || 'never'
718
+ });
719
+ }
720
+ }
721
+ return failed;
722
+ }
723
+ }