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,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
|
+
}
|
package/src/systemd.js
CHANGED
|
@@ -3,22 +3,24 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import {
|
|
6
|
-
sanitizeServiceName,
|
|
7
|
-
getUnitFilename,
|
|
8
|
-
getTimerFilename,
|
|
9
|
-
formatRelativeTime,
|
|
10
|
-
readAppMetadata,
|
|
11
|
-
readScheduleMetadata
|
|
12
|
-
} from './utils.js';
|
|
13
|
-
import {
|
|
14
|
-
getUserUnitDir,
|
|
15
|
-
getUnitPath,
|
|
16
|
-
unitFileExists,
|
|
17
6
|
deleteUnitFile,
|
|
7
|
+
getUnitPath,
|
|
8
|
+
getUserUnitDir,
|
|
18
9
|
listUnitFiles,
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
parseUnitContent,
|
|
11
|
+
unitFileExists,
|
|
12
|
+
writeUnitFile
|
|
21
13
|
} from './unit.js';
|
|
14
|
+
import {
|
|
15
|
+
findProjectConfig,
|
|
16
|
+
formatRelativeTime,
|
|
17
|
+
getTimerFilename,
|
|
18
|
+
getUnitFilename,
|
|
19
|
+
readAppMetadata,
|
|
20
|
+
readProjectConfig,
|
|
21
|
+
readScheduleMetadata,
|
|
22
|
+
sanitizeServiceName
|
|
23
|
+
} from './utils.js';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Default runner for executing commands via child_process.execFile.
|
|
@@ -281,17 +283,44 @@ export async function resetFailed() {
|
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
export async function addService(opts) {
|
|
284
|
-
const
|
|
286
|
+
const targetCwd = opts.cwd ? path.resolve(process.cwd(), opts.cwd) : process.cwd();
|
|
287
|
+
const configPath = opts.config ? path.resolve(targetCwd, opts.config) : findProjectConfig(targetCwd);
|
|
288
|
+
const projectCfg = configPath ? readProjectConfig(configPath) : null;
|
|
289
|
+
|
|
290
|
+
let mergedOpts = { ...opts };
|
|
291
|
+
if (projectCfg) {
|
|
292
|
+
mergedOpts = {
|
|
293
|
+
name: opts.name || projectCfg.name,
|
|
294
|
+
group: opts.group || projectCfg.group || 'default',
|
|
295
|
+
script: opts.script || projectCfg.script,
|
|
296
|
+
command: opts.command || projectCfg.command,
|
|
297
|
+
runtime: opts.runtime || projectCfg.runtime,
|
|
298
|
+
runtimeArgs: opts.runtimeArgs && opts.runtimeArgs.length > 0 ? opts.runtimeArgs : projectCfg.runtimeArgs || [],
|
|
299
|
+
args: opts.args && opts.args.length > 0 ? opts.args : projectCfg.args || [],
|
|
300
|
+
envFile: opts.envFile || projectCfg.envFile,
|
|
301
|
+
restart: opts.restart && opts.restart !== 'on-failure' ? opts.restart : projectCfg.restart || 'on-failure',
|
|
302
|
+
memoryHigh: opts.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '',
|
|
303
|
+
memoryMax: opts.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '',
|
|
304
|
+
memorySwapMax: opts.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '',
|
|
305
|
+
...opts,
|
|
306
|
+
env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
|
|
307
|
+
cwd: targetCwd
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const safeName = sanitizeServiceName(mergedOpts.name);
|
|
312
|
+
let wasActive = false;
|
|
285
313
|
|
|
286
314
|
if (unitFileExists(safeName)) {
|
|
287
315
|
try {
|
|
288
316
|
const show = await getServiceShow(safeName);
|
|
289
317
|
if (show.ActiveState === 'active') {
|
|
290
|
-
|
|
318
|
+
wasActive = true;
|
|
319
|
+
if (!mergedOpts.force) {
|
|
291
320
|
throw new Error(
|
|
292
321
|
`Service "${safeName}" is currently running.\n` +
|
|
293
|
-
|
|
294
|
-
|
|
322
|
+
`Use --force (-f) to overwrite running services, or stop it first:\n` +
|
|
323
|
+
` unitup stop ${safeName}`
|
|
295
324
|
);
|
|
296
325
|
}
|
|
297
326
|
}
|
|
@@ -302,20 +331,22 @@ export async function addService(opts) {
|
|
|
302
331
|
}
|
|
303
332
|
}
|
|
304
333
|
|
|
305
|
-
const runtimeConfig = await resolveRuntimeConfig({ ...
|
|
334
|
+
const runtimeConfig = await resolveRuntimeConfig({ ...mergedOpts, name: safeName });
|
|
306
335
|
|
|
307
336
|
const { path: unitPath } = writeUnitFile({
|
|
308
|
-
...
|
|
337
|
+
...mergedOpts,
|
|
309
338
|
name: safeName,
|
|
310
339
|
runtime: runtimeConfig.runtime,
|
|
311
340
|
command: runtimeConfig.command,
|
|
312
341
|
args: runtimeConfig.args,
|
|
313
|
-
cwd:
|
|
342
|
+
cwd: mergedOpts.cwd
|
|
314
343
|
});
|
|
315
344
|
|
|
316
345
|
await daemonReload();
|
|
317
346
|
|
|
318
|
-
if (
|
|
347
|
+
if (wasActive && mergedOpts.force) {
|
|
348
|
+
await restartService(safeName);
|
|
349
|
+
} else if (mergedOpts.start) {
|
|
319
350
|
await startService(safeName, true);
|
|
320
351
|
}
|
|
321
352
|
|
|
@@ -423,8 +454,8 @@ export async function removeService(name, opts = {}) {
|
|
|
423
454
|
if (!force) {
|
|
424
455
|
throw new Error(
|
|
425
456
|
`Service "${safeName}" is currently running.\n` +
|
|
426
|
-
|
|
427
|
-
|
|
457
|
+
`Use --force (-f) to remove running services, or stop it first:\n` +
|
|
458
|
+
` unitup stop ${safeName}`
|
|
428
459
|
);
|
|
429
460
|
}
|
|
430
461
|
}
|
|
@@ -514,9 +545,7 @@ export async function getServiceStatus(name) {
|
|
|
514
545
|
const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
|
|
515
546
|
const restarts = show.NRestarts ?? '0';
|
|
516
547
|
const startedRaw = show.ActiveEnterTimestamp;
|
|
517
|
-
const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
|
|
518
|
-
? formatRelativeTime(startedRaw)
|
|
519
|
-
: 'never';
|
|
548
|
+
const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a' ? formatRelativeTime(startedRaw) : 'never';
|
|
520
549
|
|
|
521
550
|
const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
|
|
522
551
|
const command = meta?.command || parsed.command || meta?.node || process.execPath;
|
|
@@ -589,7 +618,7 @@ export async function listServices(filterOpts = {}) {
|
|
|
589
618
|
} else {
|
|
590
619
|
const cmdBase = path.basename(meta.command);
|
|
591
620
|
const argsStr = (meta.args || [])
|
|
592
|
-
.map(a => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
|
|
621
|
+
.map((a) => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
|
|
593
622
|
.join(' ');
|
|
594
623
|
commandSummary = (cmdBase + (argsStr ? ' ' + argsStr : '')).trim();
|
|
595
624
|
}
|
|
@@ -641,9 +670,8 @@ export async function listServices(filterOpts = {}) {
|
|
|
641
670
|
const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
|
|
642
671
|
const restarts = show.NRestarts ?? '0';
|
|
643
672
|
const startedRaw = show.ActiveEnterTimestamp;
|
|
644
|
-
const uptime =
|
|
645
|
-
? formatRelativeTime(startedRaw)
|
|
646
|
-
: 'never';
|
|
673
|
+
const uptime =
|
|
674
|
+
startedRaw && startedRaw !== '0' && startedRaw !== 'n/a' ? formatRelativeTime(startedRaw) : 'never';
|
|
647
675
|
|
|
648
676
|
result.push({
|
|
649
677
|
name: unit.name,
|
|
@@ -757,8 +785,13 @@ export async function runJournalctlLogs(name, opts = {}) {
|
|
|
757
785
|
|
|
758
786
|
if (opts.diskUsage) {
|
|
759
787
|
const res = await runCommand('journalctl', ['--disk-usage']);
|
|
760
|
-
if (
|
|
761
|
-
|
|
788
|
+
if (
|
|
789
|
+
res.code !== 0 &&
|
|
790
|
+
(res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root'))
|
|
791
|
+
) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
'Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.'
|
|
794
|
+
);
|
|
762
795
|
}
|
|
763
796
|
return res.stdout || res.stderr;
|
|
764
797
|
}
|
|
@@ -920,7 +953,7 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
|
|
|
920
953
|
}
|
|
921
954
|
args.push(`--vacuum-time=${timeStr}`);
|
|
922
955
|
} else if (opts.files) {
|
|
923
|
-
const filesNum = parseInt(opts.files, 10);
|
|
956
|
+
const filesNum = Number.parseInt(opts.files, 10);
|
|
924
957
|
if (Number.isNaN(filesNum) || filesNum <= 0) {
|
|
925
958
|
throw new Error(`Invalid vacuum files count: "${opts.files}". Must be a positive integer.`);
|
|
926
959
|
}
|
|
@@ -937,8 +970,15 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
|
|
|
937
970
|
}
|
|
938
971
|
|
|
939
972
|
const res = await runCommand('journalctl', args);
|
|
940
|
-
if (
|
|
941
|
-
|
|
973
|
+
if (
|
|
974
|
+
res.code !== 0 &&
|
|
975
|
+
(res.stderr.toLowerCase().includes('permission') ||
|
|
976
|
+
res.stderr.toLowerCase().includes('root') ||
|
|
977
|
+
res.stderr.toLowerCase().includes('access denied'))
|
|
978
|
+
) {
|
|
979
|
+
throw new Error(
|
|
980
|
+
'Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.'
|
|
981
|
+
);
|
|
942
982
|
}
|
|
943
983
|
|
|
944
984
|
return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
|
|
@@ -978,9 +1018,15 @@ export async function getServiceMemoryUsage(name) {
|
|
|
978
1018
|
const effective = resolveEffectiveMemoryLimits(meta || {});
|
|
979
1019
|
|
|
980
1020
|
const memoryCurrentBytes = Number(props.MemoryCurrent);
|
|
981
|
-
const rawBytes =
|
|
1021
|
+
const rawBytes =
|
|
1022
|
+
!isNaN(memoryCurrentBytes) && memoryCurrentBytes > 0 && memoryCurrentBytes < 1e15 ? memoryCurrentBytes : 0;
|
|
982
1023
|
|
|
983
|
-
const status =
|
|
1024
|
+
const status =
|
|
1025
|
+
props.ActiveState === 'active'
|
|
1026
|
+
? props.SubState === 'running'
|
|
1027
|
+
? 'running'
|
|
1028
|
+
: props.SubState || 'active'
|
|
1029
|
+
: props.ActiveState || 'stopped';
|
|
984
1030
|
|
|
985
1031
|
return {
|
|
986
1032
|
name: safeName,
|
|
@@ -1060,7 +1106,7 @@ export async function getAllServicesMemoryUsage(opts = {}) {
|
|
|
1060
1106
|
}
|
|
1061
1107
|
}
|
|
1062
1108
|
|
|
1063
|
-
const runningCount = items.filter(i => i.status === 'running' || i.status === 'active').length;
|
|
1109
|
+
const runningCount = items.filter((i) => i.status === 'running' || i.status === 'active').length;
|
|
1064
1110
|
|
|
1065
1111
|
return {
|
|
1066
1112
|
items,
|
package/src/unit.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
getTimerFilename,
|
|
8
|
-
getServiceNameFromUnit,
|
|
9
|
-
formatSystemdEnv,
|
|
5
|
+
deleteAppMetadata,
|
|
6
|
+
deleteScheduleMetadata,
|
|
10
7
|
escapeExecArg,
|
|
8
|
+
formatSystemdEnv,
|
|
9
|
+
getServiceNameFromUnit,
|
|
10
|
+
getTimerFilename,
|
|
11
|
+
getUnitFilename,
|
|
11
12
|
resolveAbsolutePath,
|
|
13
|
+
resolveEffectiveMemoryLimits,
|
|
12
14
|
resolveWorkingDirectory,
|
|
15
|
+
sanitizeServiceName,
|
|
13
16
|
saveAppMetadata,
|
|
14
|
-
deleteAppMetadata,
|
|
15
17
|
saveScheduleMetadata,
|
|
16
|
-
|
|
17
|
-
validateDuration,
|
|
18
|
-
resolveEffectiveMemoryLimits
|
|
18
|
+
validateDuration
|
|
19
19
|
} from './utils.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -241,7 +241,7 @@ export function parseUnitContent(content) {
|
|
|
241
241
|
} else if (trimmed.startsWith('ExecStart=')) {
|
|
242
242
|
const execVal = trimmed.slice(10).trim();
|
|
243
243
|
const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
244
|
-
const cleanParts = parts.map(p => p.replace(/^"|"$/g, ''));
|
|
244
|
+
const cleanParts = parts.map((p) => p.replace(/^"|"$/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
|
|
245
245
|
if (cleanParts.length > 0) {
|
|
246
246
|
result.command = cleanParts[0];
|
|
247
247
|
result.args = cleanParts.slice(1);
|
|
@@ -368,12 +368,7 @@ export function generateScheduleServiceContent(opts) {
|
|
|
368
368
|
export function generateTimerContent(opts) {
|
|
369
369
|
const safeName = sanitizeServiceName(opts.name);
|
|
370
370
|
|
|
371
|
-
const timerLines = [
|
|
372
|
-
'[Unit]',
|
|
373
|
-
`Description=unitup timer: ${safeName}`,
|
|
374
|
-
'',
|
|
375
|
-
'[Timer]'
|
|
376
|
-
];
|
|
371
|
+
const timerLines = ['[Unit]', `Description=unitup timer: ${safeName}`, '', '[Timer]'];
|
|
377
372
|
|
|
378
373
|
if (opts.every) {
|
|
379
374
|
const validEvery = validateDuration(opts.every, '--every');
|
|
@@ -482,4 +477,3 @@ export function deleteScheduleUnitFiles(name) {
|
|
|
482
477
|
}
|
|
483
478
|
return removed;
|
|
484
479
|
}
|
|
485
|
-
|
package/src/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import os from 'node:os';
|
|
3
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Returns the base unitup config directory (~/.config/unitup).
|
|
@@ -75,7 +75,14 @@ export function validateMemorySize(val, paramName = 'Memory limit') {
|
|
|
75
75
|
* @returns {string} Formatted memory string e.g. "284 MB", "infinity", "unavailable"
|
|
76
76
|
*/
|
|
77
77
|
export function formatMemoryBytes(bytes) {
|
|
78
|
-
if (
|
|
78
|
+
if (
|
|
79
|
+
bytes === null ||
|
|
80
|
+
bytes === undefined ||
|
|
81
|
+
bytes === '' ||
|
|
82
|
+
bytes === 'unavailable' ||
|
|
83
|
+
bytes === '[unavailable]' ||
|
|
84
|
+
bytes === '-'
|
|
85
|
+
) {
|
|
79
86
|
return 'unavailable';
|
|
80
87
|
}
|
|
81
88
|
|
|
@@ -137,10 +144,16 @@ export function saveAppMetadata(meta) {
|
|
|
137
144
|
}
|
|
138
145
|
const filePath = getAppMetadataPath(safeName);
|
|
139
146
|
|
|
140
|
-
const command = meta.command
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
const command = meta.command
|
|
148
|
+
? resolveAbsolutePath(meta.command)
|
|
149
|
+
: meta.node
|
|
150
|
+
? resolveAbsolutePath(meta.node)
|
|
151
|
+
: process.execPath;
|
|
152
|
+
const rawArgs = Array.isArray(meta.args) ? meta.args : meta.script ? [meta.script] : [];
|
|
153
|
+
const args = rawArgs.map((a) =>
|
|
154
|
+
a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/') ? resolveAbsolutePath(a) : a
|
|
155
|
+
);
|
|
156
|
+
const scriptPath = meta.script ? resolveAbsolutePath(meta.script) : args[0] ? resolveAbsolutePath(args[0]) : command;
|
|
144
157
|
const cwd = resolveWorkingDirectory(meta);
|
|
145
158
|
|
|
146
159
|
// Extract memory resources if present
|
|
@@ -414,7 +427,7 @@ export function formatTable(data, columns) {
|
|
|
414
427
|
return 'No services found.';
|
|
415
428
|
}
|
|
416
429
|
|
|
417
|
-
const widths = columns.map(col => col.label.length);
|
|
430
|
+
const widths = columns.map((col) => col.label.length);
|
|
418
431
|
|
|
419
432
|
for (const row of data) {
|
|
420
433
|
columns.forEach((col, idx) => {
|
|
@@ -429,9 +442,7 @@ export function formatTable(data, columns) {
|
|
|
429
442
|
const lines = [headerLine];
|
|
430
443
|
|
|
431
444
|
for (const row of data) {
|
|
432
|
-
const line = columns
|
|
433
|
-
.map((col, idx) => String(row[col.key] ?? '').padEnd(widths[idx]))
|
|
434
|
-
.join(' ');
|
|
445
|
+
const line = columns.map((col, idx) => String(row[col.key] ?? '').padEnd(widths[idx])).join(' ');
|
|
435
446
|
lines.push(line);
|
|
436
447
|
}
|
|
437
448
|
|
|
@@ -471,7 +482,9 @@ export function saveScheduleMetadata(meta) {
|
|
|
471
482
|
|
|
472
483
|
const command = meta.command ? resolveAbsolutePath(meta.command) : process.execPath;
|
|
473
484
|
const rawArgs = Array.isArray(meta.args) ? meta.args : [];
|
|
474
|
-
const args = rawArgs.map(
|
|
485
|
+
const args = rawArgs.map((a) =>
|
|
486
|
+
a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/') ? resolveAbsolutePath(a) : a
|
|
487
|
+
);
|
|
475
488
|
|
|
476
489
|
const resources = meta.resources || {};
|
|
477
490
|
if (meta.memoryHigh) resources.memoryHigh = validateMemorySize(meta.memoryHigh, 'MemoryHigh');
|
|
@@ -578,7 +591,8 @@ export function validateDuration(val, paramName = 'Duration') {
|
|
|
578
591
|
}
|
|
579
592
|
|
|
580
593
|
const tokens = str.split(/\s+/);
|
|
581
|
-
const tokenRegex =
|
|
594
|
+
const tokenRegex =
|
|
595
|
+
/^(\d+(?:\.\d+)?)(us|usec|ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?|w|week|weeks?|mth|months?|y|year|years?)$/i;
|
|
582
596
|
|
|
583
597
|
for (const token of tokens) {
|
|
584
598
|
if (!tokenRegex.test(token)) {
|
|
@@ -598,7 +612,9 @@ export function formatFutureTime(dateVal) {
|
|
|
598
612
|
if (!dateVal || dateVal === 'n/a' || dateVal === '0' || dateVal === 'infinity') {
|
|
599
613
|
return 'n/a';
|
|
600
614
|
}
|
|
601
|
-
const date = new Date(
|
|
615
|
+
const date = new Date(
|
|
616
|
+
typeof dateVal === 'number' && dateVal > 1e12 ? dateVal : typeof dateVal === 'number' ? dateVal / 1000 : dateVal
|
|
617
|
+
);
|
|
602
618
|
if (isNaN(date.getTime())) {
|
|
603
619
|
return String(dateVal);
|
|
604
620
|
}
|
|
@@ -691,7 +707,8 @@ export function resolveEffectiveMemoryLimits(opts = {}) {
|
|
|
691
707
|
}
|
|
692
708
|
|
|
693
709
|
if (defaultMem) {
|
|
694
|
-
const sizeStr =
|
|
710
|
+
const sizeStr =
|
|
711
|
+
typeof defaultMem === 'boolean' || defaultMem === 'true' || defaultMem === '' ? '1G' : String(defaultMem);
|
|
695
712
|
const validSize = validateMemorySize(sizeStr, 'Default memory limit');
|
|
696
713
|
return { memoryMax: validSize };
|
|
697
714
|
}
|
|
@@ -757,6 +774,3 @@ export function saveProjectConfig(dirPathOrFile = process.cwd(), config = {}) {
|
|
|
757
774
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
758
775
|
return filePath;
|
|
759
776
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
package/tsconfig.json
CHANGED