unitup 0.0.1
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/LICENSE +21 -0
- package/README.md +387 -0
- package/bin/unitup.js +4 -0
- package/index.d.ts +378 -0
- package/package.json +28 -0
- package/src/cli.js +351 -0
- package/src/doctor.js +109 -0
- package/src/index.js +70 -0
- package/src/systemd.js +506 -0
- package/src/unit.js +207 -0
- package/src/utils.js +184 -0
package/src/utils.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitizes a service name to ensure it is safe for systemd unit filenames.
|
|
6
|
+
* - Converts to lowercase.
|
|
7
|
+
* - Replaces spaces with hyphens.
|
|
8
|
+
* - Strips any characters outside [a-z0-9_-].
|
|
9
|
+
* - Prevents path traversal and empty results.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} name
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeServiceName(name) {
|
|
15
|
+
if (!name || typeof name !== 'string') {
|
|
16
|
+
throw new Error('Service name must be a non-empty string.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Strip leading unitup- if already present to avoid double prefixing
|
|
20
|
+
let clean = name.trim();
|
|
21
|
+
if (clean.startsWith('unitup-')) {
|
|
22
|
+
clean = clean.slice(7);
|
|
23
|
+
}
|
|
24
|
+
if (clean.endsWith('.service')) {
|
|
25
|
+
clean = clean.slice(0, -8);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
clean = clean
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/\s+/g, '-')
|
|
31
|
+
.replace(/[^a-z0-9_-]/g, '')
|
|
32
|
+
.replace(/-+/g, '-');
|
|
33
|
+
|
|
34
|
+
if (!clean) {
|
|
35
|
+
throw new Error(`Invalid service name "${name}". Name must contain alphanumeric characters.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return clean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns the full systemd unit filename for a service name.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function getUnitFilename(name) {
|
|
48
|
+
const safeName = sanitizeServiceName(name);
|
|
49
|
+
return `unitup-${safeName}.service`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extracts the user-friendly service name from a unit filename (e.g., unitup-api.service -> api).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} unitFilename
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function getServiceNameFromUnit(unitFilename) {
|
|
59
|
+
let base = path.basename(unitFilename);
|
|
60
|
+
if (base.endsWith('.service')) {
|
|
61
|
+
base = base.slice(0, -8);
|
|
62
|
+
}
|
|
63
|
+
if (base.startsWith('unitup-')) {
|
|
64
|
+
base = base.slice(7);
|
|
65
|
+
}
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves a file path to an absolute path, expanding tilde (~) if present.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} filepath
|
|
73
|
+
* @param {string} [baseDir]
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function resolveAbsolutePath(filepath, baseDir = process.cwd()) {
|
|
77
|
+
if (!filepath) return '';
|
|
78
|
+
let p = filepath;
|
|
79
|
+
if (p.startsWith('~/') || p === '~') {
|
|
80
|
+
p = path.join(os.homedir(), p.slice(1));
|
|
81
|
+
}
|
|
82
|
+
return path.resolve(baseDir, p);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Escapes values for systemd Environment= line entries.
|
|
87
|
+
* E.g. KEY="val with spaces"
|
|
88
|
+
*
|
|
89
|
+
* @param {string} key
|
|
90
|
+
* @param {string} value
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function formatSystemdEnv(key, value) {
|
|
94
|
+
// Validate key to be alphanumeric + underscore
|
|
95
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
96
|
+
throw new Error(`Invalid environment variable key: "${key}"`);
|
|
97
|
+
}
|
|
98
|
+
const strVal = String(value);
|
|
99
|
+
// Systemd environment escaping: quote value if it contains space, quote, or special chars
|
|
100
|
+
const escaped = strVal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '$$$$');
|
|
101
|
+
return `${key}="${escaped}"`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Escapes an argument for an ExecStart directive in systemd unit file.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} arg
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function escapeExecArg(arg) {
|
|
111
|
+
const str = String(arg);
|
|
112
|
+
if (!str) return '""';
|
|
113
|
+
// If argument contains spaces, quotes, or backslashes, wrap in double quotes and escape internal quotes/backslashes
|
|
114
|
+
if (/[\s"'\\]/.test(str)) {
|
|
115
|
+
return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
116
|
+
}
|
|
117
|
+
return str;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Formats a timestamp string into a human readable "X ago" or date string.
|
|
122
|
+
*
|
|
123
|
+
* @param {string|number} timestamp
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function formatRelativeTime(timestamp) {
|
|
127
|
+
if (!timestamp || timestamp === 'n/a' || timestamp === '0') {
|
|
128
|
+
return 'unknown';
|
|
129
|
+
}
|
|
130
|
+
const date = new Date(timestamp);
|
|
131
|
+
if (isNaN(date.getTime())) {
|
|
132
|
+
return String(timestamp);
|
|
133
|
+
}
|
|
134
|
+
const diffMs = Date.now() - date.getTime();
|
|
135
|
+
if (diffMs < 0) return 'just now';
|
|
136
|
+
|
|
137
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
138
|
+
if (seconds < 60) return `${seconds} seconds ago`;
|
|
139
|
+
|
|
140
|
+
const minutes = Math.floor(seconds / 60);
|
|
141
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
142
|
+
|
|
143
|
+
const hours = Math.floor(minutes / 60);
|
|
144
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
145
|
+
|
|
146
|
+
const days = Math.floor(hours / 24);
|
|
147
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Formats data rows into a text table.
|
|
152
|
+
*
|
|
153
|
+
* @param {Array<Object>} data
|
|
154
|
+
* @param {Array<{ key: string, label: string }>} columns
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function formatTable(data, columns) {
|
|
158
|
+
if (!data || data.length === 0) {
|
|
159
|
+
return 'No services found.';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const widths = columns.map(col => col.label.length);
|
|
163
|
+
|
|
164
|
+
for (const row of data) {
|
|
165
|
+
columns.forEach((col, idx) => {
|
|
166
|
+
const val = String(row[col.key] ?? '');
|
|
167
|
+
if (val.length > widths[idx]) {
|
|
168
|
+
widths[idx] = val.length;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headerLine = columns.map((col, idx) => col.label.padEnd(widths[idx])).join(' ');
|
|
174
|
+
const lines = [headerLine];
|
|
175
|
+
|
|
176
|
+
for (const row of data) {
|
|
177
|
+
const line = columns
|
|
178
|
+
.map((col, idx) => String(row[col.key] ?? '').padEnd(widths[idx]))
|
|
179
|
+
.join(' ');
|
|
180
|
+
lines.push(line);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return lines.join('\n');
|
|
184
|
+
}
|