unitup 0.0.8 → 0.0.10
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/README.md +398 -171
- package/docs/index.html +531 -0
- package/index.d.ts +204 -134
- package/package.json +4 -1
- package/src/cli.js +393 -90
- package/src/doctor.js +73 -14
- package/src/index.js +32 -1
- package/src/runtimes/bun.js +46 -0
- package/src/runtimes/common.js +73 -0
- package/src/runtimes/deno.js +46 -0
- package/src/runtimes/elixir.js +46 -0
- package/src/runtimes/go.js +46 -0
- package/src/runtimes/index.js +155 -0
- package/src/runtimes/native.js +38 -0
- package/src/runtimes/node.js +57 -0
- package/src/runtimes/php.js +46 -0
- package/src/runtimes/python.js +46 -0
- package/src/runtimes/ruby.js +46 -0
- package/src/runtimes/shell.js +46 -0
- package/src/systemd.js +349 -25
- package/src/unit.js +72 -22
- package/src/utils.js +220 -0
package/src/unit.js
CHANGED
|
@@ -4,9 +4,12 @@ import os from 'node:os';
|
|
|
4
4
|
import {
|
|
5
5
|
sanitizeServiceName,
|
|
6
6
|
getUnitFilename,
|
|
7
|
+
getServiceNameFromUnit,
|
|
7
8
|
formatSystemdEnv,
|
|
8
9
|
escapeExecArg,
|
|
9
|
-
resolveAbsolutePath
|
|
10
|
+
resolveAbsolutePath,
|
|
11
|
+
saveAppMetadata,
|
|
12
|
+
deleteAppMetadata
|
|
10
13
|
} from './utils.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -49,31 +52,41 @@ export function unitFileExists(name) {
|
|
|
49
52
|
*
|
|
50
53
|
* @param {Object} opts
|
|
51
54
|
* @param {string} opts.name - Service name
|
|
52
|
-
* @param {string} opts.
|
|
55
|
+
* @param {string} [opts.command] - Absolute path to binary executable
|
|
56
|
+
* @param {Array<string>} [opts.args] - Command arguments
|
|
57
|
+
* @param {string} [opts.script] - Absolute path to script (legacy support)
|
|
53
58
|
* @param {string} [opts.cwd] - Working directory path
|
|
54
|
-
* @param {string} [opts.nodePath] - Absolute path to Node executable
|
|
59
|
+
* @param {string} [opts.nodePath] - Absolute path to Node executable (legacy support)
|
|
55
60
|
* @param {Record<string, string>} [opts.env] - Environment variables object
|
|
56
61
|
* @param {string} [opts.envFile] - Path to environment file
|
|
57
62
|
* @param {string} [opts.restart] - Restart policy (default: 'on-failure')
|
|
58
|
-
* @param {Array<string>} [opts.args] - Extra script arguments
|
|
59
63
|
* @returns {string}
|
|
60
64
|
*/
|
|
61
65
|
export function generateUnitContent(opts) {
|
|
62
66
|
const safeName = sanitizeServiceName(opts.name);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const nodeExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
|
|
66
|
-
const restartPolicy = opts.restart || 'on-failure';
|
|
67
|
+
let commandExec = '';
|
|
68
|
+
let execArgs = [];
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
execArgs.
|
|
70
|
+
if (opts.command) {
|
|
71
|
+
commandExec = resolveAbsolutePath(opts.command);
|
|
72
|
+
execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
|
|
73
|
+
} else if (opts.script) {
|
|
74
|
+
const scriptPath = resolveAbsolutePath(opts.script);
|
|
75
|
+
commandExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
|
|
76
|
+
execArgs = [scriptPath, ...(Array.isArray(opts.args) ? opts.args : [])];
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error('Either "command" or "script" must be provided to generate systemd unit file.');
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
const
|
|
81
|
+
const scriptOrCmdPath = opts.script ? resolveAbsolutePath(opts.script) : (execArgs[0] && (execArgs[0].startsWith('/') || execArgs[0].startsWith('./')) ? resolveAbsolutePath(execArgs[0]) : commandExec);
|
|
82
|
+
const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptOrCmdPath || commandExec);
|
|
83
|
+
const restartPolicy = opts.restart || 'on-failure';
|
|
84
|
+
|
|
85
|
+
const execStartTokens = [commandExec, ...execArgs];
|
|
86
|
+
const execStartLine = execStartTokens.map(escapeExecArg).join(' ');
|
|
74
87
|
|
|
75
|
-
const
|
|
76
|
-
const defaultPath = [
|
|
88
|
+
const binDir = path.dirname(commandExec);
|
|
89
|
+
const defaultPath = [binDir, '/usr/local/bin', '/usr/bin', '/bin']
|
|
77
90
|
.filter((p, i, self) => p && self.indexOf(p) === i)
|
|
78
91
|
.join(':');
|
|
79
92
|
|
|
@@ -93,6 +106,17 @@ export function generateUnitContent(opts) {
|
|
|
93
106
|
'StandardError=journal'
|
|
94
107
|
];
|
|
95
108
|
|
|
109
|
+
const memHigh = opts.memoryHigh || opts.resources?.memoryHigh;
|
|
110
|
+
const memMax = opts.memoryMax || opts.resources?.memoryMax;
|
|
111
|
+
const memSwapMax = opts.memorySwapMax || opts.resources?.memorySwapMax;
|
|
112
|
+
|
|
113
|
+
if (memHigh || memMax || memSwapMax) {
|
|
114
|
+
serviceLines.push('MemoryAccounting=yes');
|
|
115
|
+
if (memHigh) serviceLines.push(`MemoryHigh=${memHigh}`);
|
|
116
|
+
if (memMax) serviceLines.push(`MemoryMax=${memMax}`);
|
|
117
|
+
if (memSwapMax) serviceLines.push(`MemorySwapMax=${memSwapMax}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
|
|
97
121
|
if (!envObj.PATH) {
|
|
98
122
|
envObj.PATH = defaultPath;
|
|
@@ -130,6 +154,20 @@ export function writeUnitFile(opts) {
|
|
|
130
154
|
const content = generateUnitContent({ ...opts, name: safeName });
|
|
131
155
|
|
|
132
156
|
fs.writeFileSync(unitPath, content, 'utf8');
|
|
157
|
+
saveAppMetadata({
|
|
158
|
+
name: safeName,
|
|
159
|
+
group: opts.group || 'default',
|
|
160
|
+
runtime: opts.runtime || 'node',
|
|
161
|
+
command: opts.command,
|
|
162
|
+
args: opts.args,
|
|
163
|
+
script: opts.script,
|
|
164
|
+
cwd: opts.cwd,
|
|
165
|
+
node: opts.nodePath,
|
|
166
|
+
memoryHigh: opts.memoryHigh || opts.resources?.memoryHigh,
|
|
167
|
+
memoryMax: opts.memoryMax || opts.resources?.memoryMax,
|
|
168
|
+
memorySwapMax: opts.memorySwapMax || opts.resources?.memorySwapMax,
|
|
169
|
+
resources: opts.resources
|
|
170
|
+
});
|
|
133
171
|
|
|
134
172
|
return { path: unitPath, content };
|
|
135
173
|
}
|
|
@@ -141,7 +179,9 @@ export function writeUnitFile(opts) {
|
|
|
141
179
|
* @returns {boolean} True if file existed and was removed
|
|
142
180
|
*/
|
|
143
181
|
export function deleteUnitFile(name) {
|
|
144
|
-
const
|
|
182
|
+
const safeName = sanitizeServiceName(name);
|
|
183
|
+
deleteAppMetadata(safeName);
|
|
184
|
+
const unitPath = getUnitPath(safeName);
|
|
145
185
|
if (fs.existsSync(unitPath)) {
|
|
146
186
|
fs.unlinkSync(unitPath);
|
|
147
187
|
return true;
|
|
@@ -178,10 +218,10 @@ export function listUnitFiles() {
|
|
|
178
218
|
}
|
|
179
219
|
|
|
180
220
|
/**
|
|
181
|
-
* Parses basic information (Script, WorkingDirectory) from a unit file content.
|
|
221
|
+
* Parses basic information (Command, Arguments, Script, WorkingDirectory) from a unit file content.
|
|
182
222
|
*
|
|
183
223
|
* @param {string} content
|
|
184
|
-
* @returns {{ script?: string, cwd?: string, restart?: string }}
|
|
224
|
+
* @returns {{ command?: string, args?: string[], script?: string, cwd?: string, restart?: string }}
|
|
185
225
|
*/
|
|
186
226
|
export function parseUnitContent(content) {
|
|
187
227
|
const result = {};
|
|
@@ -194,15 +234,25 @@ export function parseUnitContent(content) {
|
|
|
194
234
|
result.cwd = trimmed.slice(17).trim();
|
|
195
235
|
} else if (trimmed.startsWith('ExecStart=')) {
|
|
196
236
|
const execVal = trimmed.slice(10).trim();
|
|
197
|
-
// Extract script from ExecStart (usually second token if node executable is first)
|
|
198
237
|
const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
result.
|
|
238
|
+
const cleanParts = parts.map(p => p.replace(/^"|"$/g, ''));
|
|
239
|
+
if (cleanParts.length > 0) {
|
|
240
|
+
result.command = cleanParts[0];
|
|
241
|
+
result.args = cleanParts.slice(1);
|
|
242
|
+
if (cleanParts.length >= 2) {
|
|
243
|
+
result.script = cleanParts[1];
|
|
244
|
+
} else {
|
|
245
|
+
result.script = cleanParts[0];
|
|
246
|
+
}
|
|
203
247
|
}
|
|
204
248
|
} else if (trimmed.startsWith('Restart=')) {
|
|
205
249
|
result.restart = trimmed.slice(8).trim();
|
|
250
|
+
} else if (trimmed.startsWith('MemoryHigh=')) {
|
|
251
|
+
result.memoryHigh = trimmed.slice(11).trim();
|
|
252
|
+
} else if (trimmed.startsWith('MemoryMax=')) {
|
|
253
|
+
result.memoryMax = trimmed.slice(10).trim();
|
|
254
|
+
} else if (trimmed.startsWith('MemorySwapMax=')) {
|
|
255
|
+
result.memorySwapMax = trimmed.slice(14).trim();
|
|
206
256
|
}
|
|
207
257
|
}
|
|
208
258
|
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,225 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the base unitup config directory (~/.config/unitup).
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function getUnitupDir() {
|
|
10
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
11
|
+
return path.join(configHome, 'unitup');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the directory for app metadata (~/.config/unitup/apps).
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function getAppsDir() {
|
|
19
|
+
return path.join(getUnitupDir(), 'apps');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the filepath for a specific service's metadata JSON file.
|
|
24
|
+
* @param {string} name
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function getAppMetadataPath(name) {
|
|
28
|
+
const safeName = sanitizeServiceName(name);
|
|
29
|
+
return path.join(getAppsDir(), `${safeName}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates systemd memory limit sizes (128K, 256M, 1G, 2T, infinity, max, or raw bytes).
|
|
34
|
+
* @param {string|number} val
|
|
35
|
+
* @param {string} paramName
|
|
36
|
+
* @returns {string} Normalized memory limit string
|
|
37
|
+
*/
|
|
38
|
+
export function validateMemorySize(val, paramName = 'Memory limit') {
|
|
39
|
+
if (val === null || val === undefined || val === '') {
|
|
40
|
+
throw new Error(`${paramName} cannot be empty.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const str = String(val).trim();
|
|
44
|
+
|
|
45
|
+
// Prevent shell injection and unsafe characters
|
|
46
|
+
if (/[;&|$`"'\n\r\t ]/.test(str)) {
|
|
47
|
+
throw new Error(`${paramName} contains invalid characters or shell injection attempt: "${str}".`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reject negative numbers
|
|
51
|
+
if (str.startsWith('-')) {
|
|
52
|
+
throw new Error(`${paramName} cannot be negative: "${str}".`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lower = str.toLowerCase();
|
|
56
|
+
if (lower === 'infinity' || lower === 'max') {
|
|
57
|
+
return 'infinity';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Matches numbers with optional K, M, G, T, P unit suffix (with optional 'b' or 'B')
|
|
61
|
+
const match = str.match(/^(\d+(?:\.\d+)?)\s*([kmgtp]?[bb]?)?$/i);
|
|
62
|
+
if (!match) {
|
|
63
|
+
throw new Error(`Invalid ${paramName} format: "${str}". Expected values like 128K, 256M, 1G, or infinity.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const num = match[1];
|
|
67
|
+
const unit = (match[2] || '').toUpperCase().replace(/B$/, '');
|
|
68
|
+
|
|
69
|
+
return `${num}${unit}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Formats byte values or systemd memory limit markers into human readable strings.
|
|
74
|
+
* @param {number|string} bytes
|
|
75
|
+
* @returns {string} Formatted memory string e.g. "284 MB", "infinity", "unavailable"
|
|
76
|
+
*/
|
|
77
|
+
export function formatMemoryBytes(bytes) {
|
|
78
|
+
if (bytes === null || bytes === undefined || bytes === '' || bytes === 'unavailable' || bytes === '[unavailable]' || bytes === '-') {
|
|
79
|
+
return 'unavailable';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const str = String(bytes).trim().toLowerCase();
|
|
83
|
+
if (str === 'infinity' || str === 'max' || str === '18446744073709551615') {
|
|
84
|
+
return 'infinity';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const num = Number(bytes);
|
|
88
|
+
if (Number.isNaN(num) || num < 0) {
|
|
89
|
+
// If it's already a formatted string like "400M" or "400 MB"
|
|
90
|
+
if (/^\d+\s*[a-zA-Z]+$/.test(str)) {
|
|
91
|
+
const match = str.match(/^(\d+)\s*([a-zA-Z]+)$/);
|
|
92
|
+
if (match) {
|
|
93
|
+
const u = match[2].toUpperCase().replace(/B$/, '');
|
|
94
|
+
return `${match[1]} ${u === 'M' ? 'MB' : u === 'G' ? 'GB' : u === 'K' ? 'KB' : u}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return 'unavailable';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (num === 0) return '0 MB';
|
|
101
|
+
|
|
102
|
+
const kb = 1024;
|
|
103
|
+
const mb = kb * 1024;
|
|
104
|
+
const gb = mb * 1024;
|
|
105
|
+
const tb = gb * 1024;
|
|
106
|
+
|
|
107
|
+
if (num >= tb) {
|
|
108
|
+
const val = num / tb;
|
|
109
|
+
return `${Number.isInteger(val) ? val : val.toFixed(1)} TB`;
|
|
110
|
+
}
|
|
111
|
+
if (num >= gb) {
|
|
112
|
+
const val = num / gb;
|
|
113
|
+
return `${Number.isInteger(val) ? val : val.toFixed(1)} GB`;
|
|
114
|
+
}
|
|
115
|
+
if (num >= mb) {
|
|
116
|
+
const val = num / mb;
|
|
117
|
+
return `${Number.isInteger(val) ? val : Math.round(val)} MB`;
|
|
118
|
+
}
|
|
119
|
+
if (num >= kb) {
|
|
120
|
+
const val = num / kb;
|
|
121
|
+
return `${Number.isInteger(val) ? val : Math.round(val)} KB`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return `${num} B`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Saves metadata JSON for a service.
|
|
129
|
+
* @param {object} meta
|
|
130
|
+
* @returns {object}
|
|
131
|
+
*/
|
|
132
|
+
export function saveAppMetadata(meta) {
|
|
133
|
+
const safeName = sanitizeServiceName(meta.name);
|
|
134
|
+
const dir = getAppsDir();
|
|
135
|
+
if (!fs.existsSync(dir)) {
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
const filePath = getAppMetadataPath(safeName);
|
|
139
|
+
|
|
140
|
+
const command = meta.command ? resolveAbsolutePath(meta.command) : (meta.node ? resolveAbsolutePath(meta.node) : process.execPath);
|
|
141
|
+
const rawArgs = Array.isArray(meta.args) ? meta.args : (meta.script ? [meta.script] : []);
|
|
142
|
+
const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
|
|
143
|
+
const scriptPath = meta.script ? resolveAbsolutePath(meta.script) : (args[0] ? resolveAbsolutePath(args[0]) : command);
|
|
144
|
+
const cwd = meta.cwd ? resolveAbsolutePath(meta.cwd) : path.dirname(scriptPath || command);
|
|
145
|
+
|
|
146
|
+
// Extract memory resources if present
|
|
147
|
+
const resources = meta.resources || {};
|
|
148
|
+
if (meta.memoryHigh) resources.memoryHigh = validateMemorySize(meta.memoryHigh, 'MemoryHigh');
|
|
149
|
+
if (meta.memoryMax) resources.memoryMax = validateMemorySize(meta.memoryMax, 'MemoryMax');
|
|
150
|
+
if (meta.memorySwapMax) resources.memorySwapMax = validateMemorySize(meta.memorySwapMax, 'MemorySwapMax');
|
|
151
|
+
|
|
152
|
+
const payload = {
|
|
153
|
+
name: safeName,
|
|
154
|
+
unit: getUnitFilename(safeName),
|
|
155
|
+
runtime: meta.runtime || 'node',
|
|
156
|
+
command,
|
|
157
|
+
args,
|
|
158
|
+
cwd,
|
|
159
|
+
group: meta.group || 'default',
|
|
160
|
+
// Optional resources section
|
|
161
|
+
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
|
162
|
+
// Backward compatibility fields
|
|
163
|
+
script: scriptPath,
|
|
164
|
+
node: meta.node ? resolveAbsolutePath(meta.node) : command
|
|
165
|
+
};
|
|
166
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
167
|
+
return payload;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reads metadata JSON for a service.
|
|
172
|
+
* @param {string} name
|
|
173
|
+
* @returns {object|null}
|
|
174
|
+
*/
|
|
175
|
+
export function readAppMetadata(name) {
|
|
176
|
+
try {
|
|
177
|
+
const filePath = getAppMetadataPath(name);
|
|
178
|
+
if (!fs.existsSync(filePath)) return null;
|
|
179
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
180
|
+
const data = JSON.parse(content);
|
|
181
|
+
if (!data) return null;
|
|
182
|
+
|
|
183
|
+
// Normalization for legacy metadata format
|
|
184
|
+
const command = data.command || data.node || process.execPath;
|
|
185
|
+
const args = data.args || (data.script ? [data.script] : []);
|
|
186
|
+
const runtime = data.runtime || 'node';
|
|
187
|
+
const script = data.script || args[0] || command;
|
|
188
|
+
const cwd = data.cwd || (script ? path.dirname(script) : process.cwd());
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...data,
|
|
192
|
+
name: data.name || sanitizeServiceName(name),
|
|
193
|
+
unit: data.unit || getUnitFilename(name),
|
|
194
|
+
runtime,
|
|
195
|
+
command,
|
|
196
|
+
args,
|
|
197
|
+
cwd,
|
|
198
|
+
group: data.group || 'default',
|
|
199
|
+
resources: data.resources || undefined,
|
|
200
|
+
script,
|
|
201
|
+
node: data.node || command
|
|
202
|
+
};
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Deletes metadata JSON for a service if it exists.
|
|
210
|
+
* @param {string} name
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function deleteAppMetadata(name) {
|
|
214
|
+
try {
|
|
215
|
+
const filePath = getAppMetadataPath(name);
|
|
216
|
+
if (fs.existsSync(filePath)) {
|
|
217
|
+
fs.unlinkSync(filePath);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
} catch {}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
3
223
|
|
|
4
224
|
/**
|
|
5
225
|
* Sanitizes a service name to ensure it is safe for systemd unit filenames.
|