unitup 0.0.12 → 0.1.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.
@@ -0,0 +1,462 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import {
4
+ sanitizeServiceName,
5
+ getUnitFilename,
6
+ getTimerFilename,
7
+ resolveAbsolutePath,
8
+ validateDuration,
9
+ formatFutureTime,
10
+ formatRelativeTime,
11
+ saveScheduleMetadata,
12
+ readScheduleMetadata,
13
+ deleteScheduleMetadata,
14
+ getSchedulesDir
15
+ } from './utils.js';
16
+ import {
17
+ getUserUnitDir,
18
+ getUnitPath,
19
+ getTimerPath,
20
+ timerFileExists,
21
+ unitFileExists,
22
+ writeScheduleUnitFiles,
23
+ deleteScheduleUnitFiles
24
+ } from './unit.js';
25
+ import { runCommand } from './systemd.js';
26
+ import { detectRuntime, resolveRuntimeConfig } from './runtimes/index.js';
27
+
28
+ /**
29
+ * Validates a systemd calendar expression using systemd-analyze if available.
30
+ *
31
+ * @param {string} expression
32
+ * @returns {Promise<{ valid: boolean, error?: string, warning?: string }>}
33
+ */
34
+ export async function validateCalendar(expression) {
35
+ if (!expression || typeof expression !== 'string' || !expression.trim()) {
36
+ throw new Error('Calendar expression cannot be empty.');
37
+ }
38
+
39
+ const expr = expression.trim();
40
+ if (/[;&|$`\n\r]/.test(expr)) {
41
+ throw new Error(`Calendar expression contains invalid characters or shell injection attempt: "${expr}".`);
42
+ }
43
+
44
+ try {
45
+ const res = await runCommand('systemd-analyze', ['calendar', expr]);
46
+ if (res.code === 0) {
47
+ return { valid: true };
48
+ }
49
+ if (res.stderr && res.stderr.includes('Failed to parse calendar expression')) {
50
+ throw new Error(`Invalid calendar expression "${expr}": ${res.stderr.trim()}`);
51
+ }
52
+ // If systemd-analyze binary is missing
53
+ if (res.code === 127 || (res.stderr && res.stderr.includes('not found'))) {
54
+ return { valid: true, warning: 'systemd-analyze is not available; calendar expression could not be fully validated by systemd.' };
55
+ }
56
+ } catch (err) {
57
+ if (err.message && err.message.startsWith('Invalid calendar expression')) {
58
+ throw err;
59
+ }
60
+ }
61
+
62
+ return { valid: true };
63
+ }
64
+
65
+ /**
66
+ * Creates a systemd timer-based schedule.
67
+ *
68
+ * @param {Object} opts
69
+ * @returns {Promise<Object>}
70
+ */
71
+ export async function createSchedule(opts = {}) {
72
+ if (opts.every && opts.calendar) {
73
+ throw new Error('Cannot use both --every and --calendar in the same schedule.');
74
+ }
75
+
76
+ if (!opts.every && !opts.calendar && !opts.onBoot && !opts.onActive) {
77
+ throw new Error('At least one scheduling option (--every, --calendar, --on-boot, or --on-active) is required.');
78
+ }
79
+
80
+ if (opts.every) {
81
+ validateDuration(opts.every, '--every');
82
+ }
83
+ if (opts.onBoot) {
84
+ validateDuration(opts.onBoot, '--on-boot');
85
+ }
86
+ if (opts.onActive) {
87
+ validateDuration(opts.onActive, '--on-active');
88
+ }
89
+ if (opts.randomDelay) {
90
+ validateDuration(opts.randomDelay, '--random-delay');
91
+ }
92
+ if (opts.calendar) {
93
+ await validateCalendar(opts.calendar);
94
+ }
95
+
96
+ let rawName = opts.name;
97
+ if (!rawName) {
98
+ if (opts.script) {
99
+ rawName = path.basename(opts.script, path.extname(opts.script));
100
+ } else if (opts.command) {
101
+ rawName = path.basename(opts.command);
102
+ } else {
103
+ throw new Error('Schedule requires a name, script, or command.');
104
+ }
105
+ }
106
+
107
+ const safeName = sanitizeServiceName(rawName);
108
+
109
+ let commandExec = '';
110
+ let execArgs = [];
111
+ let detectedRuntime = opts.runtime || 'node';
112
+ let scriptPath = '';
113
+
114
+ if (opts.command) {
115
+ commandExec = resolveAbsolutePath(opts.command);
116
+ execArgs = Array.isArray(opts.args) ? opts.args : [];
117
+ if (opts.script) {
118
+ scriptPath = resolveAbsolutePath(opts.script);
119
+ }
120
+ } else if (opts.script) {
121
+ scriptPath = resolveAbsolutePath(opts.script);
122
+ const runtimeInfo = detectRuntime(scriptPath, opts.runtime);
123
+ detectedRuntime = runtimeInfo.runtime;
124
+
125
+ const resolved = await resolveRuntimeConfig({
126
+ runtime: detectedRuntime,
127
+ script: scriptPath,
128
+ command: opts.command,
129
+ args: opts.args || [],
130
+ runtimeArgs: opts.runtimeArgs || [],
131
+ customNodePath: opts.node
132
+ });
133
+
134
+ commandExec = resolved.command;
135
+ execArgs = resolved.args;
136
+ } else {
137
+ throw new Error('Either script or command must be provided to create schedule.');
138
+ }
139
+
140
+ const cwd = opts.cwd
141
+ ? resolveAbsolutePath(opts.cwd)
142
+ : scriptPath
143
+ ? path.dirname(scriptPath)
144
+ : path.dirname(commandExec);
145
+
146
+ const envObj = {};
147
+ if (opts.env) {
148
+ if (Array.isArray(opts.env)) {
149
+ for (const item of opts.env) {
150
+ const idx = item.indexOf('=');
151
+ if (idx > 0) {
152
+ const key = item.slice(0, idx).trim();
153
+ const val = item.slice(idx + 1).trim();
154
+ envObj[key] = val;
155
+ }
156
+ }
157
+ } else if (typeof opts.env === 'object') {
158
+ Object.assign(envObj, opts.env);
159
+ }
160
+ }
161
+
162
+ const scheduleOpts = {
163
+ name: safeName,
164
+ runtime: detectedRuntime,
165
+ command: commandExec,
166
+ args: execArgs,
167
+ script: scriptPath,
168
+ cwd,
169
+ env: envObj,
170
+ envFile: opts.envFile ? resolveAbsolutePath(opts.envFile) : undefined,
171
+ group: opts.group || 'default',
172
+ memoryHigh: opts.memoryHigh,
173
+ memoryMax: opts.memoryMax,
174
+ memorySwapMax: opts.memorySwapMax,
175
+ resources: opts.resources,
176
+ every: opts.every,
177
+ calendar: opts.calendar,
178
+ onBoot: opts.onBoot,
179
+ onActive: opts.onActive,
180
+ randomDelay: opts.randomDelay,
181
+ persistent: opts.persistent
182
+ };
183
+
184
+ const unitFiles = writeScheduleUnitFiles(scheduleOpts);
185
+
186
+ if (opts.start || opts.enable) {
187
+ await runCommand('systemctl', ['--user', 'daemon-reload']);
188
+ await runCommand('systemctl', ['--user', 'enable', getTimerFilename(safeName)]);
189
+ await runCommand('systemctl', ['--user', 'start', getTimerFilename(safeName)]);
190
+ }
191
+
192
+ return {
193
+ name: safeName,
194
+ group: opts.group || 'default',
195
+ type: 'timer',
196
+ runtime: detectedRuntime,
197
+ command: commandExec,
198
+ args: execArgs,
199
+ cwd,
200
+ schedule: {
201
+ every: opts.every || null,
202
+ calendar: opts.calendar || null,
203
+ onBoot: opts.onBoot || null,
204
+ onActive: opts.onActive || null,
205
+ persistent: Boolean(opts.persistent),
206
+ randomDelay: opts.randomDelay || null
207
+ },
208
+ serviceUnit: `unitup-${safeName}.service`,
209
+ timerUnit: `unitup-${safeName}.timer`,
210
+ servicePath: unitFiles.servicePath,
211
+ timerPath: unitFiles.timerPath
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Enables a timer schedule.
217
+ *
218
+ * @param {string} name
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ export async function enableSchedule(name) {
222
+ const safeName = sanitizeServiceName(name);
223
+ if (!timerFileExists(safeName)) {
224
+ throw new Error(`Schedule timer unit up for "${name}" does not exist.`);
225
+ }
226
+ await runCommand('systemctl', ['--user', 'daemon-reload']);
227
+ const res = await runCommand('systemctl', ['--user', 'enable', '--now', getTimerFilename(safeName)]);
228
+ return res.code === 0;
229
+ }
230
+
231
+ /**
232
+ * Disables a timer schedule.
233
+ *
234
+ * @param {string} name
235
+ * @returns {Promise<boolean>}
236
+ */
237
+ export async function disableSchedule(name) {
238
+ const safeName = sanitizeServiceName(name);
239
+ if (!timerFileExists(safeName)) {
240
+ throw new Error(`Schedule timer unit up for "${name}" does not exist.`);
241
+ }
242
+ const res = await runCommand('systemctl', ['--user', 'disable', '--now', getTimerFilename(safeName)]);
243
+ return res.code === 0;
244
+ }
245
+
246
+ /**
247
+ * Manually runs a scheduled service unit without altering timer schedule.
248
+ *
249
+ * @param {string} name
250
+ * @returns {Promise<boolean>}
251
+ */
252
+ export async function runSchedule(name) {
253
+ const safeName = sanitizeServiceName(name);
254
+ if (!unitFileExists(safeName)) {
255
+ throw new Error(`Schedule service unit for "${name}" does not exist.`);
256
+ }
257
+ const res = await runCommand('systemctl', ['--user', 'start', getUnitFilename(safeName)]);
258
+ return res.code === 0;
259
+ }
260
+
261
+ /**
262
+ * Removes a timer schedule and its service/timer files.
263
+ *
264
+ * @param {string} name
265
+ * @param {Object} [opts]
266
+ * @param {boolean} [opts.force]
267
+ * @returns {Promise<boolean>}
268
+ */
269
+ export async function removeSchedule(name, opts = {}) {
270
+ const safeName = sanitizeServiceName(name);
271
+ const timerName = getTimerFilename(safeName);
272
+ const serviceName = getUnitFilename(safeName);
273
+
274
+ // 1. Disable timer unit
275
+ await runCommand('systemctl', ['--user', 'disable', '--now', timerName]);
276
+
277
+ // 2. Check if service is running
278
+ const activeRes = await runCommand('systemctl', ['--user', 'is-active', serviceName]);
279
+ const isRunning = activeRes.stdout.trim() === 'active';
280
+
281
+ if (isRunning) {
282
+ if (!opts.force) {
283
+ throw new Error(`Service ${serviceName} is currently running. Use --force to stop and remove.`);
284
+ }
285
+ await runCommand('systemctl', ['--user', 'stop', serviceName]);
286
+ }
287
+
288
+ // 3. Delete files
289
+ deleteScheduleUnitFiles(safeName);
290
+
291
+ // 4. Reload daemon & reset failed
292
+ await runCommand('systemctl', ['--user', 'daemon-reload']);
293
+ await runCommand('systemctl', ['--user', 'reset-failed']);
294
+
295
+ return true;
296
+ }
297
+
298
+ /**
299
+ * Parses systemctl show properties output.
300
+ *
301
+ * @param {string} stdout
302
+ * @returns {Record<string, string>}
303
+ */
304
+ function parseSystemctlShow(stdout) {
305
+ const props = {};
306
+ if (!stdout) return props;
307
+ const lines = stdout.split('\n');
308
+ for (const line of lines) {
309
+ const idx = line.indexOf('=');
310
+ if (idx > 0) {
311
+ const k = line.slice(0, idx).trim();
312
+ const v = line.slice(idx + 1).trim();
313
+ props[k] = v;
314
+ }
315
+ }
316
+ return props;
317
+ }
318
+
319
+ /**
320
+ * Returns detailed status of a schedule.
321
+ *
322
+ * @param {string} name
323
+ * @returns {Promise<Object>}
324
+ */
325
+ export async function getScheduleStatus(name) {
326
+ const safeName = sanitizeServiceName(name);
327
+ const meta = readScheduleMetadata(safeName);
328
+
329
+ const timerFilename = getTimerFilename(safeName);
330
+ const serviceFilename = getUnitFilename(safeName);
331
+
332
+ const showTimerRes = await runCommand('systemctl', [
333
+ '--user',
334
+ 'show',
335
+ timerFilename,
336
+ '--property=ActiveState,SubState,NextElapseUSecRealtime,NextElapseUSec,LastTriggerUSec,Unit,Triggers,UnitFileState'
337
+ ]);
338
+
339
+ const showServiceRes = await runCommand('systemctl', [
340
+ '--user',
341
+ 'show',
342
+ serviceFilename,
343
+ '--property=ActiveState,SubState,ExecMainStatus,ExecMainCode'
344
+ ]);
345
+
346
+ const timerProps = parseSystemctlShow(showTimerRes.stdout);
347
+ const serviceProps = parseSystemctlShow(showServiceRes.stdout);
348
+
349
+ // Determine schedule description string
350
+ let scheduleExpr = '';
351
+ if (meta?.schedule?.every) {
352
+ scheduleExpr = `every ${meta.schedule.every}`;
353
+ } else if (meta?.schedule?.calendar) {
354
+ scheduleExpr = meta.schedule.calendar;
355
+ } else if (meta?.schedule?.onBoot) {
356
+ scheduleExpr = `on-boot ${meta.schedule.onBoot}`;
357
+ } else if (meta?.schedule?.onActive) {
358
+ scheduleExpr = `on-active ${meta.schedule.onActive}`;
359
+ } else {
360
+ scheduleExpr = 'custom timer';
361
+ }
362
+
363
+ const activeState = timerProps.ActiveState || 'unknown';
364
+ const unitFileState = timerProps.UnitFileState || 'unknown';
365
+
366
+ let status = 'waiting';
367
+ if (serviceProps.ActiveState === 'active') {
368
+ status = 'running';
369
+ } else if (activeState === 'inactive' || unitFileState === 'disabled') {
370
+ status = 'disabled';
371
+ } else if (timerProps.SubState) {
372
+ status = timerProps.SubState;
373
+ }
374
+
375
+ // Parse NextElapse timestamp
376
+ let rawNext = timerProps.NextElapseUSecRealtime || timerProps.NextElapseUSec;
377
+ if (!rawNext || rawNext === '0' || rawNext === '18446744073709551615') {
378
+ rawNext = 'n/a';
379
+ }
380
+
381
+ let nextRun = 'n/a';
382
+ if (rawNext !== 'n/a') {
383
+ const num = Number(rawNext);
384
+ if (!isNaN(num) && num > 0) {
385
+ nextRun = formatFutureTime(Math.floor(num / 1000));
386
+ } else {
387
+ nextRun = formatFutureTime(rawNext);
388
+ }
389
+ }
390
+
391
+ // Parse LastTrigger timestamp
392
+ let rawLast = timerProps.LastTriggerUSec;
393
+ let lastRun = 'never';
394
+ if (rawLast && rawLast !== '0' && rawLast !== 'n/a') {
395
+ const num = Number(rawLast);
396
+ if (!isNaN(num) && num > 0) {
397
+ lastRun = formatRelativeTime(Math.floor(num / 1000));
398
+ } else {
399
+ lastRun = formatRelativeTime(rawLast);
400
+ }
401
+ }
402
+
403
+ return {
404
+ name: safeName,
405
+ group: meta?.group || 'default',
406
+ schedule: scheduleExpr,
407
+ nextRun,
408
+ lastRun,
409
+ status,
410
+ activeState,
411
+ subState: timerProps.SubState || 'unknown',
412
+ unitFileState,
413
+ serviceActiveState: serviceProps.ActiveState || 'unknown',
414
+ metadata: meta,
415
+ serviceUnit: serviceFilename,
416
+ timerUnit: timerFilename
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Lists all schedules and their status.
422
+ *
423
+ * @param {string} [group]
424
+ * @returns {Promise<Array<Object>>}
425
+ */
426
+ export async function listSchedules(group) {
427
+ const dir = getSchedulesDir();
428
+ const scheduleNames = new Set();
429
+
430
+ if (fs.existsSync(dir)) {
431
+ const files = fs.readdirSync(dir);
432
+ for (const f of files) {
433
+ if (f.endsWith('.json')) {
434
+ scheduleNames.add(f.slice(0, -5));
435
+ }
436
+ }
437
+ }
438
+
439
+ const unitDir = getUserUnitDir();
440
+ if (fs.existsSync(unitDir)) {
441
+ const files = fs.readdirSync(unitDir);
442
+ for (const f of files) {
443
+ if (f.startsWith('unitup-') && f.endsWith('.timer')) {
444
+ scheduleNames.add(f.slice(7, -6));
445
+ }
446
+ }
447
+ }
448
+
449
+ const result = [];
450
+ for (const name of scheduleNames) {
451
+ try {
452
+ const st = await getScheduleStatus(name);
453
+ if (!group || st.group === group) {
454
+ result.push(st);
455
+ }
456
+ } catch {
457
+ // ignore individual parse errors
458
+ }
459
+ }
460
+
461
+ return result;
462
+ }
package/src/systemd.js CHANGED
@@ -904,3 +904,129 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
904
904
 
905
905
  return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
906
906
  }
907
+
908
+ /**
909
+ * Retrieves memory usage overview for a single service or schedule task.
910
+ *
911
+ * @param {string} name
912
+ * @returns {Promise<Object>}
913
+ */
914
+ export async function getServiceMemoryUsage(name) {
915
+ const safeName = sanitizeServiceName(name);
916
+ const unitFilename = getUnitFilename(safeName);
917
+
918
+ const res = await runCommand('systemctl', [
919
+ '--user',
920
+ 'show',
921
+ unitFilename,
922
+ '--property=ActiveState,SubState,MainPID,MemoryCurrent,MemoryPeak,MemoryHigh,MemoryMax,MemorySwapMax'
923
+ ]);
924
+
925
+ const props = {};
926
+ if (res.stdout) {
927
+ for (const line of res.stdout.split('\n')) {
928
+ const idx = line.indexOf('=');
929
+ if (idx > 0) {
930
+ props[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
931
+ }
932
+ }
933
+ }
934
+
935
+ const { readScheduleMetadata } = await import('./utils.js');
936
+ const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
937
+ const { formatMemoryBytes, resolveEffectiveMemoryLimits } = await import('./utils.js');
938
+
939
+ const effective = resolveEffectiveMemoryLimits(meta || {});
940
+
941
+ const memoryCurrentBytes = Number(props.MemoryCurrent);
942
+ const rawBytes = !isNaN(memoryCurrentBytes) && memoryCurrentBytes > 0 && memoryCurrentBytes < 1e15 ? memoryCurrentBytes : 0;
943
+
944
+ const status = props.ActiveState === 'active' ? (props.SubState === 'running' ? 'running' : props.SubState || 'active') : (props.ActiveState || 'stopped');
945
+
946
+ return {
947
+ name: safeName,
948
+ group: meta?.group || 'default',
949
+ type: meta?.type === 'timer' ? 'timer' : 'service',
950
+ status,
951
+ pid: props.MainPID && props.MainPID !== '0' ? props.MainPID : '-',
952
+ memoryBytes: rawBytes,
953
+ memory: formatMemoryBytes(props.MemoryCurrent),
954
+ memoryPeak: formatMemoryBytes(props.MemoryPeak),
955
+ memoryHigh: formatMemoryBytes(props.MemoryHigh || effective.memoryHigh),
956
+ memoryMax: formatMemoryBytes(props.MemoryMax || effective.memoryMax),
957
+ memorySwapMax: formatMemoryBytes(props.MemorySwapMax || effective.memorySwapMax)
958
+ };
959
+ }
960
+
961
+ /**
962
+ * Retrieves aggregated memory usage overview for all unitup services and schedules.
963
+ *
964
+ * @param {Object} [opts]
965
+ * @param {string} [opts.group]
966
+ * @returns {Promise<Object>}
967
+ */
968
+ export async function getAllServicesMemoryUsage(opts = {}) {
969
+ const { formatMemoryBytes } = await import('./utils.js');
970
+ const services = await listServices({ group: opts.group });
971
+ const { listSchedules } = await import('./schedule.js');
972
+ const schedules = await listSchedules(opts.group);
973
+
974
+ const items = [];
975
+ let totalBytes = 0;
976
+ const processedNames = new Set();
977
+
978
+ for (const svc of services) {
979
+ processedNames.add(svc.name);
980
+ try {
981
+ const mem = await getServiceMemoryUsage(svc.name);
982
+ items.push({
983
+ name: svc.name,
984
+ group: svc.group || 'default',
985
+ type: 'service',
986
+ status: svc.status,
987
+ pid: svc.pid,
988
+ memory: mem.memory,
989
+ memoryPeak: mem.memoryPeak,
990
+ memoryMax: mem.memoryMax,
991
+ memoryHigh: mem.memoryHigh,
992
+ memoryBytes: mem.memoryBytes
993
+ });
994
+ totalBytes += mem.memoryBytes;
995
+ } catch {
996
+ // ignore
997
+ }
998
+ }
999
+
1000
+ for (const sched of schedules) {
1001
+ if (!processedNames.has(sched.name)) {
1002
+ processedNames.add(sched.name);
1003
+ try {
1004
+ const mem = await getServiceMemoryUsage(sched.name);
1005
+ items.push({
1006
+ name: sched.name,
1007
+ group: sched.group || 'default',
1008
+ type: 'timer',
1009
+ status: sched.status,
1010
+ pid: mem.pid,
1011
+ memory: mem.memory,
1012
+ memoryPeak: mem.memoryPeak,
1013
+ memoryMax: mem.memoryMax,
1014
+ memoryHigh: mem.memoryHigh,
1015
+ memoryBytes: mem.memoryBytes
1016
+ });
1017
+ totalBytes += mem.memoryBytes;
1018
+ } catch {
1019
+ // ignore
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ const runningCount = items.filter(i => i.status === 'running' || i.status === 'active').length;
1025
+
1026
+ return {
1027
+ items,
1028
+ totalBytes,
1029
+ totalMemory: formatMemoryBytes(totalBytes),
1030
+ runningCount
1031
+ };
1032
+ }