unitup 0.1.4 → 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.
- package/CHANGELOG.md +5 -0
- package/README.md +89 -8
- package/biome.json +0 -1
- package/docs/index.html +188 -234
- package/index.d.ts +336 -445
- package/package.json +9 -3
- package/src/cli.js +187 -101
- package/src/doctor.js +123 -56
- package/src/errors.js +131 -0
- package/src/index.js +132 -59
- package/src/logs.js +148 -0
- package/src/platform/base.js +211 -0
- package/src/platform/index.js +43 -0
- package/src/platform/linux.js +155 -0
- package/src/platform/macos.js +723 -0
- package/src/platform/windows-host.js +246 -0
- package/src/platform/windows.js +495 -0
- package/src/runtimes/bun.js +4 -3
- package/src/runtimes/common.js +24 -6
- package/src/runtimes/deno.js +4 -3
- package/src/runtimes/elixir.js +4 -3
- package/src/runtimes/go.js +4 -3
- package/src/runtimes/index.js +14 -11
- package/src/runtimes/native.js +4 -5
- package/src/runtimes/node.js +4 -3
- package/src/runtimes/php.js +4 -3
- package/src/runtimes/python.js +4 -3
- package/src/runtimes/ruby.js +4 -3
- package/src/runtimes/shell.js +4 -3
- package/src/schedule.js +31 -24
- package/src/service/config.js +55 -0
- package/src/service/manager.js +233 -0
- package/src/service/normalize.js +216 -0
- package/src/systemd.js +84 -38
- package/src/unit.js +12 -18
- package/src/utils.js +32 -18
- package/tsconfig.json +1 -1
- package/types-test/index.ts +158 -0
|
@@ -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, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|