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/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
+ }