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.
- package/CHANGELOG.md +5 -3
- 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 +1 -1
- package/src/runtimes/common.js +24 -6
- package/src/runtimes/deno.js +1 -1
- package/src/runtimes/elixir.js +1 -1
- package/src/runtimes/go.js +1 -1
- package/src/runtimes/index.js +7 -7
- package/src/runtimes/native.js +2 -4
- package/src/runtimes/node.js +1 -1
- package/src/runtimes/php.js +1 -1
- package/src/runtimes/python.js +1 -1
- package/src/runtimes/ruby.js +1 -1
- package/src/runtimes/shell.js +1 -1
- 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
package/src/schedule.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { detectRuntime, resolveRuntimeConfig } from './runtimes/index.js';
|
|
5
|
+
import { runCommand } from './systemd.js';
|
|
4
6
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
deleteScheduleUnitFiles,
|
|
8
|
+
getTimerPath,
|
|
9
|
+
getUnitPath,
|
|
10
|
+
getUserUnitDir,
|
|
11
|
+
timerFileExists,
|
|
12
|
+
unitFileExists,
|
|
13
|
+
writeScheduleUnitFiles
|
|
14
|
+
} from './unit.js';
|
|
15
|
+
import {
|
|
16
|
+
deleteScheduleMetadata,
|
|
17
|
+
formatFutureTime,
|
|
18
|
+
formatRelativeTime,
|
|
19
|
+
getSchedulesDir,
|
|
7
20
|
getTimerFilename,
|
|
21
|
+
getUnitFilename,
|
|
22
|
+
readScheduleMetadata,
|
|
8
23
|
resolveAbsolutePath,
|
|
9
24
|
resolveWorkingDirectory,
|
|
10
|
-
|
|
11
|
-
formatFutureTime,
|
|
12
|
-
formatRelativeTime,
|
|
25
|
+
sanitizeServiceName,
|
|
13
26
|
saveScheduleMetadata,
|
|
14
|
-
|
|
15
|
-
deleteScheduleMetadata,
|
|
16
|
-
getSchedulesDir
|
|
27
|
+
validateDuration
|
|
17
28
|
} from './utils.js';
|
|
18
|
-
import {
|
|
19
|
-
getUserUnitDir,
|
|
20
|
-
getUnitPath,
|
|
21
|
-
getTimerPath,
|
|
22
|
-
timerFileExists,
|
|
23
|
-
unitFileExists,
|
|
24
|
-
writeScheduleUnitFiles,
|
|
25
|
-
deleteScheduleUnitFiles
|
|
26
|
-
} from './unit.js';
|
|
27
|
-
import { runCommand } from './systemd.js';
|
|
28
|
-
import { detectRuntime, resolveRuntimeConfig } from './runtimes/index.js';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Validates a systemd calendar expression using systemd-analyze if available.
|
|
@@ -53,7 +53,10 @@ export async function validateCalendar(expression) {
|
|
|
53
53
|
}
|
|
54
54
|
// If systemd-analyze binary is missing
|
|
55
55
|
if (res.code === 127 || (res.stderr && res.stderr.includes('not found'))) {
|
|
56
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
valid: true,
|
|
58
|
+
warning: 'systemd-analyze is not available; calendar expression could not be fully validated by systemd.'
|
|
59
|
+
};
|
|
57
60
|
}
|
|
58
61
|
} catch (err) {
|
|
59
62
|
if (err.message && err.message.startsWith('Invalid calendar expression')) {
|
|
@@ -360,7 +363,7 @@ export async function getScheduleStatus(name) {
|
|
|
360
363
|
}
|
|
361
364
|
|
|
362
365
|
// Parse NextElapse timestamp
|
|
363
|
-
|
|
366
|
+
const rawNext = timerProps.NextElapseUSecRealtime || timerProps.NextElapseUSec;
|
|
364
367
|
let nextRun = 'n/a';
|
|
365
368
|
|
|
366
369
|
if (rawNext && rawNext !== '0' && rawNext !== '18446744073709551615') {
|
|
@@ -370,7 +373,11 @@ export async function getScheduleStatus(name) {
|
|
|
370
373
|
} else {
|
|
371
374
|
nextRun = formatFutureTime(rawNext);
|
|
372
375
|
}
|
|
373
|
-
} else if (
|
|
376
|
+
} else if (
|
|
377
|
+
timerProps.NextElapseUSecMonotonic &&
|
|
378
|
+
timerProps.NextElapseUSecMonotonic !== '0' &&
|
|
379
|
+
timerProps.NextElapseUSecMonotonic !== '18446744073709551615'
|
|
380
|
+
) {
|
|
374
381
|
const monoNum = Number(timerProps.NextElapseUSecMonotonic);
|
|
375
382
|
if (!isNaN(monoNum) && monoNum > 0) {
|
|
376
383
|
const currentMonoUSec = os.uptime() * 1000000;
|
|
@@ -385,7 +392,7 @@ export async function getScheduleStatus(name) {
|
|
|
385
392
|
}
|
|
386
393
|
|
|
387
394
|
// Parse LastTrigger timestamp
|
|
388
|
-
|
|
395
|
+
const rawLast = timerProps.LastTriggerUSec;
|
|
389
396
|
let lastRun = 'never';
|
|
390
397
|
if (rawLast && rawLast !== '0' && rawLast !== 'n/a') {
|
|
391
398
|
const num = Number(rawLast);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
findProjectConfig,
|
|
5
|
+
getConfigFilepath,
|
|
6
|
+
getUnitupDir,
|
|
7
|
+
readGlobalConfig,
|
|
8
|
+
readProjectConfig,
|
|
9
|
+
saveGlobalConfig,
|
|
10
|
+
saveProjectConfig
|
|
11
|
+
} from '../utils.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
findProjectConfig,
|
|
15
|
+
readProjectConfig,
|
|
16
|
+
saveProjectConfig,
|
|
17
|
+
readGlobalConfig,
|
|
18
|
+
saveGlobalConfig,
|
|
19
|
+
getConfigFilepath,
|
|
20
|
+
getUnitupDir
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Merges raw CLI options with any found project configuration.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} opts
|
|
27
|
+
* @returns {object} Merged options
|
|
28
|
+
*/
|
|
29
|
+
export function mergeConfigWithOptions(opts = {}) {
|
|
30
|
+
const targetCwd = opts.cwd ? path.resolve(process.cwd(), opts.cwd) : process.cwd();
|
|
31
|
+
const configPath = opts.config ? path.resolve(targetCwd, opts.config) : findProjectConfig(targetCwd);
|
|
32
|
+
const projectCfg = configPath ? readProjectConfig(configPath) : null;
|
|
33
|
+
|
|
34
|
+
if (!projectCfg) {
|
|
35
|
+
return { ...opts, cwd: targetCwd };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: opts.name || projectCfg.name,
|
|
40
|
+
group: opts.group || projectCfg.group || 'default',
|
|
41
|
+
script: opts.script || projectCfg.script,
|
|
42
|
+
command: opts.command || projectCfg.command,
|
|
43
|
+
runtime: opts.runtime || projectCfg.runtime,
|
|
44
|
+
runtimeArgs: opts.runtimeArgs && opts.runtimeArgs.length > 0 ? opts.runtimeArgs : projectCfg.runtimeArgs || [],
|
|
45
|
+
args: opts.args && opts.args.length > 0 ? opts.args : projectCfg.args || [],
|
|
46
|
+
envFile: opts.envFile || projectCfg.envFile,
|
|
47
|
+
restart: opts.restart && opts.restart !== 'on-failure' ? opts.restart : projectCfg.restart || 'on-failure',
|
|
48
|
+
memoryHigh: opts.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '',
|
|
49
|
+
memoryMax: opts.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '',
|
|
50
|
+
memorySwapMax: opts.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '',
|
|
51
|
+
...opts,
|
|
52
|
+
env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
|
|
53
|
+
cwd: targetCwd
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { getAdapter } from '../platform/index.js';
|
|
2
|
+
import { readAppMetadata, readScheduleMetadata, sanitizeServiceName } from '../utils.js';
|
|
3
|
+
import { mergeConfigWithOptions } from './config.js';
|
|
4
|
+
import { normalizeServiceConfig } from './normalize.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ServiceManager orchestrates service operations across platforms.
|
|
8
|
+
*/
|
|
9
|
+
export class ServiceManager {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} [opts]
|
|
12
|
+
* @param {string} [opts.platform]
|
|
13
|
+
* @param {import('../platform/base.js').ServiceAdapter} [opts.adapter]
|
|
14
|
+
*/
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
this.platformName = opts.platform;
|
|
17
|
+
this._adapter = opts.adapter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get adapter() {
|
|
21
|
+
return this._adapter || getAdapter(this.platformName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gets the platform capabilities.
|
|
26
|
+
*/
|
|
27
|
+
get capabilities() {
|
|
28
|
+
return this.adapter.capabilities;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generates the native service file/config from options (dry-run mode).
|
|
33
|
+
*
|
|
34
|
+
* @param {object} rawOpts
|
|
35
|
+
* @returns {Promise<string|object>}
|
|
36
|
+
*/
|
|
37
|
+
async generate(rawOpts = {}) {
|
|
38
|
+
const merged = mergeConfigWithOptions(rawOpts);
|
|
39
|
+
const normalized = await normalizeServiceConfig(merged);
|
|
40
|
+
return this.adapter.generateService(normalized);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Installs/registers a service.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} rawOpts
|
|
47
|
+
* @returns {Promise<object>}
|
|
48
|
+
*/
|
|
49
|
+
async install(rawOpts = {}) {
|
|
50
|
+
const merged = mergeConfigWithOptions(rawOpts);
|
|
51
|
+
const normalized = await normalizeServiceConfig(merged);
|
|
52
|
+
return this.adapter.install(normalized, {
|
|
53
|
+
force: !!rawOpts.force,
|
|
54
|
+
start: !!rawOpts.start,
|
|
55
|
+
system: !!rawOpts.system
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Uninstalls/removes a service.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} name
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
65
|
+
*/
|
|
66
|
+
async uninstall(name, options = {}) {
|
|
67
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
68
|
+
const all = await this.list({ group: name });
|
|
69
|
+
if (all.length === 0) {
|
|
70
|
+
throw new Error(`No services found in group "${name}".`);
|
|
71
|
+
}
|
|
72
|
+
for (const item of all) {
|
|
73
|
+
await this.uninstall(item.name, options);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return this.adapter.uninstall(name, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Starts a service.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @param {object} [options]
|
|
85
|
+
* @returns {Promise<boolean>}
|
|
86
|
+
*/
|
|
87
|
+
async start(name, options = {}) {
|
|
88
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
89
|
+
const all = await this.list({ group: name });
|
|
90
|
+
if (all.length === 0) {
|
|
91
|
+
throw new Error(`No services found in group "${name}".`);
|
|
92
|
+
}
|
|
93
|
+
for (const item of all) {
|
|
94
|
+
await this.start(item.name, options);
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return this.adapter.start(name, options);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stops a service.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} name
|
|
105
|
+
* @param {object} [options]
|
|
106
|
+
* @returns {Promise<boolean>}
|
|
107
|
+
*/
|
|
108
|
+
async stop(name, options = {}) {
|
|
109
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
110
|
+
const all = await this.list({ group: name });
|
|
111
|
+
if (all.length === 0) {
|
|
112
|
+
throw new Error(`No services found in group "${name}".`);
|
|
113
|
+
}
|
|
114
|
+
for (const item of all) {
|
|
115
|
+
await this.stop(item.name, options);
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return this.adapter.stop(name, options);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Restarts a service.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @param {object} [options]
|
|
127
|
+
* @returns {Promise<boolean>}
|
|
128
|
+
*/
|
|
129
|
+
async restart(name, options = {}) {
|
|
130
|
+
if (name && typeof name === 'string' && name.startsWith('@')) {
|
|
131
|
+
const all = await this.list({ group: name });
|
|
132
|
+
if (all.length === 0) {
|
|
133
|
+
throw new Error(`No services found in group "${name}".`);
|
|
134
|
+
}
|
|
135
|
+
for (const item of all) {
|
|
136
|
+
await this.restart(item.name, options);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return this.adapter.restart(name, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Enables a service for automatic startup.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} name
|
|
147
|
+
* @param {object} [options]
|
|
148
|
+
* @returns {Promise<boolean>}
|
|
149
|
+
*/
|
|
150
|
+
async enable(name, options = {}) {
|
|
151
|
+
return this.adapter.enable(name, options);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Disables automatic startup for a service.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} name
|
|
158
|
+
* @param {object} [options]
|
|
159
|
+
* @returns {Promise<boolean>}
|
|
160
|
+
*/
|
|
161
|
+
async disable(name, options = {}) {
|
|
162
|
+
return this.adapter.disable(name, options);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gets normalized status for a service.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} name
|
|
169
|
+
* @param {object} [options]
|
|
170
|
+
* @returns {Promise<object>}
|
|
171
|
+
*/
|
|
172
|
+
async status(name, options = {}) {
|
|
173
|
+
return this.adapter.status(name, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Inspects detailed configuration and runtime info for a service.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @param {object} [options]
|
|
181
|
+
* @returns {Promise<object>}
|
|
182
|
+
*/
|
|
183
|
+
async inspect(name, options = {}) {
|
|
184
|
+
return this.adapter.inspect(name, options);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Retrieves or streams logs for a service.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} name
|
|
191
|
+
* @param {object} [options]
|
|
192
|
+
* @returns {Promise<any>}
|
|
193
|
+
*/
|
|
194
|
+
async logs(name, options = {}) {
|
|
195
|
+
return this.adapter.logs(name, options);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Lists all services.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} [options]
|
|
202
|
+
* @returns {Promise<Array<object>>}
|
|
203
|
+
*/
|
|
204
|
+
async list(options = {}) {
|
|
205
|
+
return this.adapter.list(options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Lists failed services.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} [options]
|
|
212
|
+
* @returns {Promise<Array<object>>}
|
|
213
|
+
*/
|
|
214
|
+
async failures(options = {}) {
|
|
215
|
+
return this.adapter.failures(options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Checks if a service is installed.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} name
|
|
222
|
+
* @param {object} [options]
|
|
223
|
+
* @returns {Promise<boolean>|boolean}
|
|
224
|
+
*/
|
|
225
|
+
isInstalled(name, options = {}) {
|
|
226
|
+
return this.adapter.isInstalled(name, options);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Default global ServiceManager instance.
|
|
232
|
+
*/
|
|
233
|
+
export const defaultManager = new ServiceManager();
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ExecutableNotFoundError, InvalidServiceConfigError } from '../errors.js';
|
|
4
|
+
import { detectRuntime, resolveRuntimeConfig } from '../runtimes/index.js';
|
|
5
|
+
import { getUnitupDir, resolveEffectiveMemoryLimits, resolveWorkingDirectory, sanitizeServiceName } from '../utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves a binary or command name to an absolute executable path.
|
|
9
|
+
* Supports Windows PATHEXT extensions (.exe, .cmd, .bat, etc.) and Unix executable permissions.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} binaryName
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {string} [opts.cwd]
|
|
14
|
+
* @param {string} [opts.pathEnv]
|
|
15
|
+
* @returns {string|null}
|
|
16
|
+
*/
|
|
17
|
+
export function resolveExecutable(binaryName, opts = {}) {
|
|
18
|
+
if (!binaryName || typeof binaryName !== 'string') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = binaryName.trim();
|
|
23
|
+
if (!trimmed) return null;
|
|
24
|
+
|
|
25
|
+
const baseDir = opts.cwd || process.cwd();
|
|
26
|
+
const isWin = process.platform === 'win32';
|
|
27
|
+
const pathext = isWin
|
|
28
|
+
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.WSC;.MSI').split(';').filter(Boolean)
|
|
29
|
+
: [''];
|
|
30
|
+
|
|
31
|
+
// Helper to check if a specific file is executable
|
|
32
|
+
function isExecutableFile(targetPath) {
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(targetPath)) return false;
|
|
35
|
+
const stat = fs.statSync(targetPath);
|
|
36
|
+
if (!stat.isFile()) return false;
|
|
37
|
+
if (isWin) return true;
|
|
38
|
+
fs.accessSync(targetPath, fs.constants.X_OK);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 1. If path is absolute or explicit relative path (starts with ./, ../, /)
|
|
46
|
+
if (path.isAbsolute(trimmed) || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~/')) {
|
|
47
|
+
const expanded = trimmed.startsWith('~/')
|
|
48
|
+
? path.join(process.env.HOME || process.env.USERPROFILE || '', trimmed.slice(2))
|
|
49
|
+
: path.resolve(baseDir, trimmed);
|
|
50
|
+
|
|
51
|
+
if (isExecutableFile(expanded)) {
|
|
52
|
+
return fs.realpathSync(expanded);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isWin) {
|
|
56
|
+
for (const ext of pathext) {
|
|
57
|
+
const withExt = expanded + ext.toLowerCase();
|
|
58
|
+
if (isExecutableFile(withExt)) {
|
|
59
|
+
return fs.realpathSync(withExt);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Look in system PATH
|
|
68
|
+
const envPath = opts.pathEnv || process.env.PATH || '';
|
|
69
|
+
const searchDirs = envPath.split(path.delimiter).filter(Boolean);
|
|
70
|
+
|
|
71
|
+
for (const dir of searchDirs) {
|
|
72
|
+
const candidate = path.join(dir, trimmed);
|
|
73
|
+
if (isExecutableFile(candidate)) {
|
|
74
|
+
try {
|
|
75
|
+
return fs.realpathSync(candidate);
|
|
76
|
+
} catch {
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isWin) {
|
|
82
|
+
for (const ext of pathext) {
|
|
83
|
+
const candidateWithExt = candidate + ext.toLowerCase();
|
|
84
|
+
if (isExecutableFile(candidateWithExt)) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.realpathSync(candidateWithExt);
|
|
87
|
+
} catch {
|
|
88
|
+
return candidateWithExt;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalizes input service options into a complete, validated NormalizedServiceConfig object.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} rawOpts
|
|
102
|
+
* @returns {Promise<object>} NormalizedServiceConfig
|
|
103
|
+
*/
|
|
104
|
+
export async function normalizeServiceConfig(rawOpts = {}) {
|
|
105
|
+
if (!rawOpts || typeof rawOpts !== 'object') {
|
|
106
|
+
throw new InvalidServiceConfigError('Service configuration options must be an object.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const name = rawOpts.name ? sanitizeServiceName(rawOpts.name) : '';
|
|
110
|
+
if (!name) {
|
|
111
|
+
throw new InvalidServiceConfigError('Service name is required.', 'name');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const cwd = resolveWorkingDirectory(rawOpts);
|
|
115
|
+
const runtimeConfig = await resolveRuntimeConfig({
|
|
116
|
+
...rawOpts,
|
|
117
|
+
name,
|
|
118
|
+
cwd
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const command = runtimeConfig.command;
|
|
122
|
+
if (!command) {
|
|
123
|
+
throw new ExecutableNotFoundError(
|
|
124
|
+
rawOpts.command || rawOpts.script || 'executable',
|
|
125
|
+
`Executable could not be resolved for service "${name}".`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const args = Array.isArray(runtimeConfig.args) ? [...runtimeConfig.args] : [];
|
|
130
|
+
const displayName = rawOpts.displayName || rawOpts.name || name;
|
|
131
|
+
const description = rawOpts.description || `unitup service: ${name}`;
|
|
132
|
+
const group = rawOpts.group || 'default';
|
|
133
|
+
const system = !!rawOpts.system;
|
|
134
|
+
const autostart = rawOpts.autostart !== undefined ? !!rawOpts.autostart : true;
|
|
135
|
+
const shutdownTimeout =
|
|
136
|
+
rawOpts.shutdownTimeout !== undefined &&
|
|
137
|
+
!Number.isNaN(Number(rawOpts.shutdownTimeout)) &&
|
|
138
|
+
Number(rawOpts.shutdownTimeout) > 0
|
|
139
|
+
? Number(rawOpts.shutdownTimeout)
|
|
140
|
+
: 10000;
|
|
141
|
+
|
|
142
|
+
// Environment normalization
|
|
143
|
+
const env = {};
|
|
144
|
+
if (rawOpts.env && typeof rawOpts.env === 'object') {
|
|
145
|
+
for (const [k, v] of Object.entries(rawOpts.env)) {
|
|
146
|
+
if (v !== undefined && v !== null) {
|
|
147
|
+
env[k] = String(v);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Restart policy normalization
|
|
153
|
+
let restartPolicy = 'on-failure';
|
|
154
|
+
let restartEnabled = true;
|
|
155
|
+
let restartDelay = 3000;
|
|
156
|
+
let maxRetries = null;
|
|
157
|
+
let resetAfter = null;
|
|
158
|
+
|
|
159
|
+
if (typeof rawOpts.restart === 'string') {
|
|
160
|
+
restartPolicy = rawOpts.restart;
|
|
161
|
+
restartEnabled = restartPolicy !== 'no';
|
|
162
|
+
} else if (rawOpts.restart && typeof rawOpts.restart === 'object') {
|
|
163
|
+
restartEnabled = rawOpts.restart.enabled !== false;
|
|
164
|
+
restartPolicy = rawOpts.restart.policy || (restartEnabled ? 'on-failure' : 'no');
|
|
165
|
+
if (rawOpts.restart.delay !== undefined && !Number.isNaN(Number(rawOpts.restart.delay))) {
|
|
166
|
+
restartDelay = Number(rawOpts.restart.delay);
|
|
167
|
+
}
|
|
168
|
+
if (rawOpts.restart.maxRetries !== undefined && !Number.isNaN(Number(rawOpts.restart.maxRetries))) {
|
|
169
|
+
maxRetries = Number(rawOpts.restart.maxRetries);
|
|
170
|
+
}
|
|
171
|
+
if (rawOpts.restart.resetAfter !== undefined && !Number.isNaN(Number(rawOpts.restart.resetAfter))) {
|
|
172
|
+
resetAfter = Number(rawOpts.restart.resetAfter);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Logs normalization
|
|
177
|
+
const unitupDir = getUnitupDir();
|
|
178
|
+
const logsDir = path.join(unitupDir, 'logs');
|
|
179
|
+
const defaultStdout = path.join(logsDir, `${name}.log`);
|
|
180
|
+
const defaultStderr = path.join(logsDir, `${name}-error.log`);
|
|
181
|
+
|
|
182
|
+
const logs = {
|
|
183
|
+
stdout: rawOpts.logs?.stdout || rawOpts.stdout || defaultStdout,
|
|
184
|
+
stderr: rawOpts.logs?.stderr || rawOpts.stderr || defaultStderr
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Memory and resource limits
|
|
188
|
+
const resources = resolveEffectiveMemoryLimits(rawOpts);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
name,
|
|
192
|
+
displayName,
|
|
193
|
+
description,
|
|
194
|
+
runtime: runtimeConfig.runtime || 'custom',
|
|
195
|
+
command,
|
|
196
|
+
args,
|
|
197
|
+
script: rawOpts.script ? path.resolve(cwd, rawOpts.script) : undefined,
|
|
198
|
+
cwd,
|
|
199
|
+
env,
|
|
200
|
+
envFile: rawOpts.envFile ? path.resolve(cwd, rawOpts.envFile) : undefined,
|
|
201
|
+
autostart,
|
|
202
|
+
restart: {
|
|
203
|
+
enabled: restartEnabled,
|
|
204
|
+
policy: restartPolicy,
|
|
205
|
+
delay: restartDelay,
|
|
206
|
+
maxRetries,
|
|
207
|
+
resetAfter
|
|
208
|
+
},
|
|
209
|
+
logs,
|
|
210
|
+
group,
|
|
211
|
+
system,
|
|
212
|
+
shutdownTimeout,
|
|
213
|
+
resources,
|
|
214
|
+
raw: rawOpts
|
|
215
|
+
};
|
|
216
|
+
}
|