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