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
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
- sanitizeServiceName,
6
- getUnitFilename,
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
- validateDuration,
11
- formatFutureTime,
12
- formatRelativeTime,
25
+ sanitizeServiceName,
13
26
  saveScheduleMetadata,
14
- readScheduleMetadata,
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 { valid: true, warning: 'systemd-analyze is not available; calendar expression could not be fully validated by systemd.' };
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
- let rawNext = timerProps.NextElapseUSecRealtime || timerProps.NextElapseUSec;
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 (timerProps.NextElapseUSecMonotonic && timerProps.NextElapseUSecMonotonic !== '0' && timerProps.NextElapseUSecMonotonic !== '18446744073709551615') {
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
- let rawLast = timerProps.LastTriggerUSec;
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,63 @@
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
+ port: opts.port !== undefined ? opts.port : projectCfg.port,
52
+ deploy: {
53
+ ...(projectCfg.deploy && typeof projectCfg.deploy === 'object' ? projectCfg.deploy : {}),
54
+ ...(opts.deploy && typeof opts.deploy === 'object' ? opts.deploy : {}),
55
+ ...(opts.zeroDowntime !== undefined ? { zeroDowntime: !!opts.zeroDowntime } : {}),
56
+ ...(opts.ready !== undefined ? { ready: opts.ready } : {}),
57
+ ...(opts.drain !== undefined ? { drain: opts.drain } : {})
58
+ },
59
+ ...opts,
60
+ env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
61
+ cwd: targetCwd
62
+ };
63
+ }
@@ -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,245 @@
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 {
6
+ getUnitupDir,
7
+ parseDurationMs,
8
+ resolveEffectiveMemoryLimits,
9
+ resolveWorkingDirectory,
10
+ sanitizeServiceName
11
+ } from '../utils.js';
12
+
13
+ /**
14
+ * Resolves a binary or command name to an absolute executable path.
15
+ * Supports Windows PATHEXT extensions (.exe, .cmd, .bat, etc.) and Unix executable permissions.
16
+ *
17
+ * @param {string} binaryName
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.cwd]
20
+ * @param {string} [opts.pathEnv]
21
+ * @returns {string|null}
22
+ */
23
+ export function resolveExecutable(binaryName, opts = {}) {
24
+ if (!binaryName || typeof binaryName !== 'string') {
25
+ return null;
26
+ }
27
+
28
+ const trimmed = binaryName.trim();
29
+ if (!trimmed) return null;
30
+
31
+ const baseDir = opts.cwd || process.cwd();
32
+ const isWin = process.platform === 'win32';
33
+ const pathext = isWin
34
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.WSC;.MSI').split(';').filter(Boolean)
35
+ : [''];
36
+
37
+ // Helper to check if a specific file is executable
38
+ function isExecutableFile(targetPath) {
39
+ try {
40
+ if (!fs.existsSync(targetPath)) return false;
41
+ const stat = fs.statSync(targetPath);
42
+ if (!stat.isFile()) return false;
43
+ if (isWin) return true;
44
+ fs.accessSync(targetPath, fs.constants.X_OK);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // 1. If path is absolute or explicit relative path (starts with ./, ../, /)
52
+ if (path.isAbsolute(trimmed) || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~/')) {
53
+ const expanded = trimmed.startsWith('~/')
54
+ ? path.join(process.env.HOME || process.env.USERPROFILE || '', trimmed.slice(2))
55
+ : path.resolve(baseDir, trimmed);
56
+
57
+ if (isExecutableFile(expanded)) {
58
+ return fs.realpathSync(expanded);
59
+ }
60
+
61
+ if (isWin) {
62
+ for (const ext of pathext) {
63
+ const withExt = expanded + ext.toLowerCase();
64
+ if (isExecutableFile(withExt)) {
65
+ return fs.realpathSync(withExt);
66
+ }
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ // 2. Look in system PATH
74
+ const envPath = opts.pathEnv || process.env.PATH || '';
75
+ const searchDirs = envPath.split(path.delimiter).filter(Boolean);
76
+
77
+ for (const dir of searchDirs) {
78
+ const candidate = path.join(dir, trimmed);
79
+ if (isExecutableFile(candidate)) {
80
+ try {
81
+ return fs.realpathSync(candidate);
82
+ } catch {
83
+ return candidate;
84
+ }
85
+ }
86
+
87
+ if (isWin) {
88
+ for (const ext of pathext) {
89
+ const candidateWithExt = candidate + ext.toLowerCase();
90
+ if (isExecutableFile(candidateWithExt)) {
91
+ try {
92
+ return fs.realpathSync(candidateWithExt);
93
+ } catch {
94
+ return candidateWithExt;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Normalizes input service options into a complete, validated NormalizedServiceConfig object.
106
+ *
107
+ * @param {object} rawOpts
108
+ * @returns {Promise<object>} NormalizedServiceConfig
109
+ */
110
+ export async function normalizeServiceConfig(rawOpts = {}) {
111
+ if (!rawOpts || typeof rawOpts !== 'object') {
112
+ throw new InvalidServiceConfigError('Service configuration options must be an object.');
113
+ }
114
+
115
+ const name = rawOpts.name ? sanitizeServiceName(rawOpts.name) : '';
116
+ if (!name) {
117
+ throw new InvalidServiceConfigError('Service name is required.', 'name');
118
+ }
119
+
120
+ const cwd = resolveWorkingDirectory(rawOpts);
121
+ const runtimeConfig = await resolveRuntimeConfig({
122
+ ...rawOpts,
123
+ name,
124
+ cwd
125
+ });
126
+
127
+ const command = runtimeConfig.command;
128
+ if (!command) {
129
+ throw new ExecutableNotFoundError(
130
+ rawOpts.command || rawOpts.script || 'executable',
131
+ `Executable could not be resolved for service "${name}".`
132
+ );
133
+ }
134
+
135
+ const args = Array.isArray(runtimeConfig.args) ? [...runtimeConfig.args] : [];
136
+ const displayName = rawOpts.displayName || rawOpts.name || name;
137
+ const description = rawOpts.description || `unitup service: ${name}`;
138
+ const group = rawOpts.group || 'default';
139
+ const system = !!rawOpts.system;
140
+ const autostart = rawOpts.autostart !== undefined ? !!rawOpts.autostart : true;
141
+ const shutdownTimeout =
142
+ rawOpts.shutdownTimeout !== undefined &&
143
+ !Number.isNaN(Number(rawOpts.shutdownTimeout)) &&
144
+ Number(rawOpts.shutdownTimeout) > 0
145
+ ? Number(rawOpts.shutdownTimeout)
146
+ : 10000;
147
+
148
+ // Environment normalization
149
+ const env = {};
150
+ if (rawOpts.env && typeof rawOpts.env === 'object') {
151
+ for (const [k, v] of Object.entries(rawOpts.env)) {
152
+ if (v !== undefined && v !== null) {
153
+ env[k] = String(v);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Restart policy normalization
159
+ let restartPolicy = 'on-failure';
160
+ let restartEnabled = true;
161
+ let restartDelay = 3000;
162
+ let maxRetries = null;
163
+ let resetAfter = null;
164
+
165
+ if (typeof rawOpts.restart === 'string') {
166
+ restartPolicy = rawOpts.restart;
167
+ restartEnabled = restartPolicy !== 'no';
168
+ } else if (rawOpts.restart && typeof rawOpts.restart === 'object') {
169
+ restartEnabled = rawOpts.restart.enabled !== false;
170
+ restartPolicy = rawOpts.restart.policy || (restartEnabled ? 'on-failure' : 'no');
171
+ if (rawOpts.restart.delay !== undefined && !Number.isNaN(Number(rawOpts.restart.delay))) {
172
+ restartDelay = Number(rawOpts.restart.delay);
173
+ }
174
+ if (rawOpts.restart.maxRetries !== undefined && !Number.isNaN(Number(rawOpts.restart.maxRetries))) {
175
+ maxRetries = Number(rawOpts.restart.maxRetries);
176
+ }
177
+ if (rawOpts.restart.resetAfter !== undefined && !Number.isNaN(Number(rawOpts.restart.resetAfter))) {
178
+ resetAfter = Number(rawOpts.restart.resetAfter);
179
+ }
180
+ }
181
+
182
+ // Logs normalization
183
+ const unitupDir = getUnitupDir();
184
+ const logsDir = path.join(unitupDir, 'logs');
185
+ const defaultStdout = path.join(logsDir, `${name}.log`);
186
+ const defaultStderr = path.join(logsDir, `${name}-error.log`);
187
+
188
+ const logs = {
189
+ stdout: rawOpts.logs?.stdout || rawOpts.stdout || defaultStdout,
190
+ stderr: rawOpts.logs?.stderr || rawOpts.stderr || defaultStderr
191
+ };
192
+
193
+ // Memory and resource limits
194
+ const resources = resolveEffectiveMemoryLimits(rawOpts);
195
+
196
+ // Port and Deploy normalization
197
+ const port =
198
+ rawOpts.port !== undefined && rawOpts.port !== null && rawOpts.port !== '' ? Number(rawOpts.port) : undefined;
199
+
200
+ const rawDeploy = rawOpts.deploy && typeof rawOpts.deploy === 'object' ? rawOpts.deploy : {};
201
+ const zeroDowntime = !!(rawOpts.deploy === true || rawDeploy.zeroDowntime || rawOpts.zeroDowntime);
202
+ const readyPath =
203
+ typeof rawDeploy.ready === 'string'
204
+ ? rawDeploy.ready
205
+ : typeof rawOpts.ready === 'string'
206
+ ? rawOpts.ready
207
+ : undefined;
208
+ const drainRaw = rawDeploy.drain !== undefined ? rawDeploy.drain : rawOpts.drain;
209
+ const drainTimeout = drainRaw !== undefined ? parseDurationMs(drainRaw) : 10000;
210
+ const deploy = {
211
+ zeroDowntime,
212
+ ready: readyPath,
213
+ drain: drainRaw,
214
+ drainTimeout
215
+ };
216
+
217
+ return {
218
+ name,
219
+ displayName,
220
+ description,
221
+ runtime: runtimeConfig.runtime || 'custom',
222
+ command,
223
+ args,
224
+ script: rawOpts.script ? path.resolve(cwd, rawOpts.script) : undefined,
225
+ cwd,
226
+ port,
227
+ deploy,
228
+ env,
229
+ envFile: rawOpts.envFile ? path.resolve(cwd, rawOpts.envFile) : undefined,
230
+ autostart,
231
+ restart: {
232
+ enabled: restartEnabled,
233
+ policy: restartPolicy,
234
+ delay: restartDelay,
235
+ maxRetries,
236
+ resetAfter
237
+ },
238
+ logs,
239
+ group,
240
+ system,
241
+ shutdownTimeout,
242
+ resources,
243
+ raw: rawOpts
244
+ };
245
+ }