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