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/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.script - Absolute path to script
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
- const scriptPath = resolveAbsolutePath(opts.script);
64
- const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptPath);
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
- const execArgs = [nodeExec, scriptPath];
69
- if (Array.isArray(opts.args) && opts.args.length > 0) {
70
- execArgs.push(...opts.args);
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 execStartLine = execArgs.map(escapeExecArg).join(' ');
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 nodeBinDir = path.dirname(nodeExec);
76
- const defaultPath = [nodeBinDir, '/usr/local/bin', '/usr/bin', '/bin']
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 unitPath = getUnitPath(name);
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
- if (parts.length >= 2) {
200
- result.script = parts[1].replace(/^"|"$/g, '');
201
- } else if (parts.length === 1) {
202
- result.script = parts[0].replace(/^"|"$/g, '');
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.