systemd-ts 0.0.0 → 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.
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +755 -39
- package/dist/index.mjs +469 -189
- package/dist/test.d.mts +2 -2
- package/dist/test.mjs +3 -3
- package/package.json +15 -11
package/dist/index.mjs
CHANGED
|
@@ -1,27 +1,55 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`ExecStart`,
|
|
10
|
-
`ExecStop`,
|
|
11
|
-
`ExecReload`
|
|
12
|
-
];
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
//#region src/main/executable.ts
|
|
14
9
|
const currentModulePath = fileURLToPath(import.meta.url);
|
|
10
|
+
/**
|
|
11
|
+
* An immutable description of a runnable module entrypoint that can be used in
|
|
12
|
+
* executable-valued systemd unit directives.
|
|
13
|
+
*
|
|
14
|
+
* `Executable` captures three pieces of information:
|
|
15
|
+
* - the absolute runtime binary that should launch the module
|
|
16
|
+
* - the absolute path to the module itself
|
|
17
|
+
* - any additional arguments that should be passed after the module path
|
|
18
|
+
*
|
|
19
|
+
* Most consumers will create one via {@link defineExecutable} and then pass it
|
|
20
|
+
* into a {@link SystemdService} directive such as `ExecStart`, `ExecStop`, or
|
|
21
|
+
* `ExecReload`.
|
|
22
|
+
*/
|
|
15
23
|
var Executable = class {
|
|
24
|
+
/** Additional arguments passed after the module path. */
|
|
16
25
|
args;
|
|
26
|
+
/** Absolute path to the module that should be executed. */
|
|
17
27
|
modulePath;
|
|
28
|
+
/** Absolute path to the runtime binary that should launch the module. */
|
|
18
29
|
runtimeEntrypoint;
|
|
30
|
+
/**
|
|
31
|
+
* Creates an executable description.
|
|
32
|
+
*
|
|
33
|
+
* When `modulePath` is omitted, the calling module is inferred from the
|
|
34
|
+
* current stack so `defineExecutable()` can be used inline from the module
|
|
35
|
+
* that should become runnable.
|
|
36
|
+
*
|
|
37
|
+
* When `runtimeEntrypoint` is omitted, the current process executable is
|
|
38
|
+
* used. This works well for the common case where the same runtime that is
|
|
39
|
+
* evaluating your module should also be used by systemd.
|
|
40
|
+
*/
|
|
19
41
|
constructor(options = {}) {
|
|
20
42
|
this.runtimeEntrypoint = options.runtimeEntrypoint ?? process.execPath;
|
|
21
43
|
this.modulePath = options.modulePath ?? inferCallerModulePath();
|
|
22
44
|
this.args = Object.freeze([...options.args ?? []]);
|
|
23
45
|
Object.freeze(this);
|
|
24
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns the executable as raw command parts.
|
|
49
|
+
*
|
|
50
|
+
* The first element is always the runtime entrypoint, followed by the module
|
|
51
|
+
* path and any configured arguments.
|
|
52
|
+
*/
|
|
25
53
|
toCommandParts() {
|
|
26
54
|
return [
|
|
27
55
|
this.runtimeEntrypoint,
|
|
@@ -29,16 +57,102 @@ var Executable = class {
|
|
|
29
57
|
...this.args
|
|
30
58
|
];
|
|
31
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Renders the executable as a shell-quoted command string suitable for
|
|
62
|
+
* executable-valued systemd directives such as `ExecStart=`.
|
|
63
|
+
*/
|
|
32
64
|
toExecStart() {
|
|
33
|
-
return this.toCommandParts().map(shellQuote).join(` `);
|
|
65
|
+
return this.toCommandParts().map(shellQuote$1).join(` `);
|
|
34
66
|
}
|
|
35
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* Defines a module as a runnable executable and returns its immutable
|
|
70
|
+
* `Executable` description.
|
|
71
|
+
*
|
|
72
|
+
* This helper is designed for the pattern:
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* export default defineExecutable(async () => {
|
|
76
|
+
* // do work here
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* When the defining module is executed as the main entrypoint, `fn` is invoked.
|
|
81
|
+
* When the module is merely imported, `fn` is not run and only the executable
|
|
82
|
+
* description is returned.
|
|
83
|
+
*
|
|
84
|
+
* Pass `options.runtimeEntrypoint` to override the default runtime binary when
|
|
85
|
+
* the current process is not the exact runtime you want systemd to use.
|
|
86
|
+
*/
|
|
87
|
+
function defineExecutable(fn, options = {}) {
|
|
88
|
+
const executable = new Executable(options);
|
|
89
|
+
if (isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
return executable;
|
|
94
|
+
}
|
|
95
|
+
function inferCallerModulePath() {
|
|
96
|
+
const stack = (/* @__PURE__ */ new Error()).stack ?? ``;
|
|
97
|
+
for (const line of stack.split(`\n`).slice(1)) {
|
|
98
|
+
const candidate = extractStackPath(line);
|
|
99
|
+
if (candidate === void 0 || candidate === currentModulePath) continue;
|
|
100
|
+
return candidate;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`);
|
|
103
|
+
}
|
|
104
|
+
function extractStackPath(line) {
|
|
105
|
+
const fileUrlMatch = line.match(/(file:\/\/\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
|
|
106
|
+
if (fileUrlMatch !== null) return fileURLToPath(fileUrlMatch[1]);
|
|
107
|
+
const pathMatch = line.match(/(\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
|
|
108
|
+
if (pathMatch !== null) return pathMatch[1];
|
|
109
|
+
}
|
|
110
|
+
function isMainModule(modulePath) {
|
|
111
|
+
const mainArg = process.argv[1];
|
|
112
|
+
if (mainArg === void 0) return false;
|
|
113
|
+
return normalizeFilePath(mainArg) === normalizeFilePath(modulePath);
|
|
114
|
+
}
|
|
115
|
+
function shellQuote$1(value) {
|
|
116
|
+
return `'${value.replaceAll(`'`, `'\\''`)}'`;
|
|
117
|
+
}
|
|
118
|
+
function normalizeFilePath(path) {
|
|
119
|
+
try {
|
|
120
|
+
return realpathSync(path);
|
|
121
|
+
} catch {
|
|
122
|
+
return path;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/main/systemd-service.ts
|
|
127
|
+
/**
|
|
128
|
+
* An immutable definition of a `.service` unit.
|
|
129
|
+
*
|
|
130
|
+
* `SystemdService` is a pure value object: it captures the intended unit name
|
|
131
|
+
* and section contents, but it does not write files or talk to `systemd`
|
|
132
|
+
* directly. Operational actions such as installation or startup belong on
|
|
133
|
+
* {@link Systemd}.
|
|
134
|
+
*
|
|
135
|
+
* Source:
|
|
136
|
+
* - https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
|
|
137
|
+
*/
|
|
36
138
|
var SystemdService = class {
|
|
139
|
+
/** Optional `[Install]` section for enable-time relationships. */
|
|
37
140
|
install;
|
|
141
|
+
/** The normalized base unit name, without the `.service` suffix. */
|
|
38
142
|
name;
|
|
143
|
+
/** The fully frozen original options used to construct this service. */
|
|
39
144
|
options;
|
|
145
|
+
/** The `[Service]` section payload. */
|
|
40
146
|
service;
|
|
147
|
+
/** Optional `[Unit]` section metadata and dependency configuration. */
|
|
41
148
|
unit;
|
|
149
|
+
/**
|
|
150
|
+
* Creates an immutable service definition.
|
|
151
|
+
*
|
|
152
|
+
* The constructor preserves literal types where possible and rejects unknown
|
|
153
|
+
* directive names within the provided sections, while still allowing custom
|
|
154
|
+
* `X-...` extension directives.
|
|
155
|
+
*/
|
|
42
156
|
constructor(options) {
|
|
43
157
|
this.options = freezeUnitOptions(options);
|
|
44
158
|
this.name = normalizeUnitName(options.name, `.service`);
|
|
@@ -47,9 +161,17 @@ var SystemdService = class {
|
|
|
47
161
|
this.install = cloneUnitSection(options.install);
|
|
48
162
|
Object.freeze(this);
|
|
49
163
|
}
|
|
164
|
+
/** The canonical unit filename, including the `.service` suffix. */
|
|
50
165
|
get filename() {
|
|
51
166
|
return `${this.name}.service`;
|
|
52
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Renders the service as a complete unit file.
|
|
170
|
+
*
|
|
171
|
+
* Rendering also validates executable-valued directives such as `ExecStart`
|
|
172
|
+
* and `ExecStop`, ensuring they use absolute runtime entrypoints as required
|
|
173
|
+
* by systemd.
|
|
174
|
+
*/
|
|
53
175
|
render() {
|
|
54
176
|
validateServiceSection(this.service);
|
|
55
177
|
return renderUnitFile([
|
|
@@ -59,14 +181,39 @@ var SystemdService = class {
|
|
|
59
181
|
]);
|
|
60
182
|
}
|
|
61
183
|
};
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/main/systemd-timer.ts
|
|
186
|
+
/**
|
|
187
|
+
* An immutable definition of a `.timer` unit.
|
|
188
|
+
*
|
|
189
|
+
* Like {@link SystemdService}, this is a pure value object. It models the timer
|
|
190
|
+
* configuration and its attachment target, but does not write files or interact
|
|
191
|
+
* with the service manager directly.
|
|
192
|
+
*
|
|
193
|
+
* Source:
|
|
194
|
+
* - https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
|
|
195
|
+
*/
|
|
62
196
|
var SystemdTimer = class {
|
|
197
|
+
/** Optional `[Install]` section for enable-time relationships. */
|
|
63
198
|
install;
|
|
199
|
+
/** The normalized base unit name, without the `.timer` suffix. */
|
|
64
200
|
name;
|
|
201
|
+
/** The fully frozen original options used to construct this timer. */
|
|
65
202
|
options;
|
|
203
|
+
/** The attached service basename inferred from `targetUnit`. */
|
|
66
204
|
targetServiceName;
|
|
205
|
+
/** The unit name this timer activates, explicit or implicit. */
|
|
67
206
|
targetUnit;
|
|
207
|
+
/** The `[Timer]` section payload. */
|
|
68
208
|
timer;
|
|
209
|
+
/** Optional `[Unit]` section metadata and dependency configuration. */
|
|
69
210
|
unit;
|
|
211
|
+
/**
|
|
212
|
+
* Creates an immutable timer definition.
|
|
213
|
+
*
|
|
214
|
+
* If no explicit `timer.Unit` is provided, the target defaults to the service
|
|
215
|
+
* with the same basename, matching systemd's native timer behavior.
|
|
216
|
+
*/
|
|
70
217
|
constructor(options) {
|
|
71
218
|
this.options = freezeUnitOptions(options);
|
|
72
219
|
this.name = normalizeUnitName(options.name, `.timer`);
|
|
@@ -77,9 +224,11 @@ var SystemdTimer = class {
|
|
|
77
224
|
this.targetServiceName = normalizeUnitName(this.targetUnit, `.service`);
|
|
78
225
|
Object.freeze(this);
|
|
79
226
|
}
|
|
227
|
+
/** The canonical unit filename, including the `.timer` suffix. */
|
|
80
228
|
get filename() {
|
|
81
229
|
return `${this.name}.timer`;
|
|
82
230
|
}
|
|
231
|
+
/** Renders the timer as a complete unit file. */
|
|
83
232
|
render() {
|
|
84
233
|
return renderUnitFile([
|
|
85
234
|
[`Unit`, this.unit],
|
|
@@ -88,8 +237,238 @@ var SystemdTimer = class {
|
|
|
88
237
|
]);
|
|
89
238
|
}
|
|
90
239
|
};
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/main/internal.ts
|
|
242
|
+
var internal_exports = /* @__PURE__ */ __exportAll({
|
|
243
|
+
assertInstallableTogether: () => assertInstallableTogether,
|
|
244
|
+
cloneUnitSection: () => cloneUnitSection,
|
|
245
|
+
defaultCommandExecutor: () => defaultCommandExecutor,
|
|
246
|
+
defaultUnitDirForScope: () => defaultUnitDirForScope,
|
|
247
|
+
fileExists: () => fileExists,
|
|
248
|
+
freezeUnitOptions: () => freezeUnitOptions,
|
|
249
|
+
normalizeUnitName: () => normalizeUnitName,
|
|
250
|
+
parseStartResult: () => parseStartResult,
|
|
251
|
+
renderUnitFile: () => renderUnitFile,
|
|
252
|
+
resolveTimerTargetUnit: () => resolveTimerTargetUnit,
|
|
253
|
+
sendNotify: () => sendNotify,
|
|
254
|
+
shellQuote: () => shellQuote,
|
|
255
|
+
validateServiceSection: () => validateServiceSection
|
|
256
|
+
});
|
|
257
|
+
const EXEC_DIRECTIVE_KEYS = [
|
|
258
|
+
`ExecCondition`,
|
|
259
|
+
`ExecReload`,
|
|
260
|
+
`ExecReloadPost`,
|
|
261
|
+
`ExecStart`,
|
|
262
|
+
`ExecStartPost`,
|
|
263
|
+
`ExecStartPre`,
|
|
264
|
+
`ExecStop`,
|
|
265
|
+
`ExecStopPost`
|
|
266
|
+
];
|
|
267
|
+
const execFileAsync = promisify(execFile);
|
|
268
|
+
function normalizeUnitName(name, suffix) {
|
|
269
|
+
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
|
|
270
|
+
}
|
|
271
|
+
function resolveTimerTargetUnit(options) {
|
|
272
|
+
const explicitTarget = options.timer[`Unit`];
|
|
273
|
+
if (typeof explicitTarget === `string` && explicitTarget.length > 0) return explicitTarget;
|
|
274
|
+
return `${normalizeUnitName(options.name, `.timer`)}.service`;
|
|
275
|
+
}
|
|
276
|
+
function defaultUnitDirForScope(scope) {
|
|
277
|
+
if (scope === `user`) return `${process.env[`HOME`] ?? `~`}/.config/systemd/user`;
|
|
278
|
+
return `/etc/systemd/system`;
|
|
279
|
+
}
|
|
280
|
+
function freezeUnitOptions(options) {
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
...options,
|
|
283
|
+
...options.unit === void 0 ? {} : { unit: cloneUnitSection(options.unit) },
|
|
284
|
+
...options.install === void 0 ? {} : { install: cloneUnitSection(options.install) },
|
|
285
|
+
...hasServiceSection(options) ? { service: cloneUnitSection(options.service) } : {},
|
|
286
|
+
...hasTimerSection(options) ? { timer: cloneUnitSection(options.timer) } : {}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function cloneUnitSection(section) {
|
|
290
|
+
if (section === void 0) return section;
|
|
291
|
+
const entries = Object.entries(section).map(([key, value]) => {
|
|
292
|
+
if (isUnitValueList(value)) return [key, Object.freeze([...value])];
|
|
293
|
+
return [key, value];
|
|
294
|
+
});
|
|
295
|
+
return Object.freeze(Object.fromEntries(entries));
|
|
296
|
+
}
|
|
297
|
+
function validateServiceSection(service) {
|
|
298
|
+
for (const key of EXEC_DIRECTIVE_KEYS) assertAbsoluteExecValue(key, service[key]);
|
|
299
|
+
}
|
|
300
|
+
function assertInstallableTogether(units) {
|
|
301
|
+
const installedServices = new Set(units.filter((unit) => unit instanceof SystemdService).map((unit) => unit.name));
|
|
302
|
+
if (installedServices.size === 0) return;
|
|
303
|
+
for (const unit of units) {
|
|
304
|
+
if (!(unit instanceof SystemdTimer)) continue;
|
|
305
|
+
if (!installedServices.has(unit.targetServiceName)) throw new Error(`Cannot install ${unit.filename} alongside unrelated services: expected ${unit.targetUnit}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function parseStartResult(unit, output) {
|
|
309
|
+
const properties = Object.fromEntries(output.split(`\n`).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
|
|
310
|
+
const separatorIndex = line.indexOf(`=`);
|
|
311
|
+
if (separatorIndex === -1) return [line, ``];
|
|
312
|
+
return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
|
|
313
|
+
}));
|
|
314
|
+
return {
|
|
315
|
+
activeState: properties[`ActiveState`] ?? `unknown`,
|
|
316
|
+
execMainStatus: properties[`ExecMainStatus`] === void 0 || properties[`ExecMainStatus`] === `` ? void 0 : Number(properties[`ExecMainStatus`]),
|
|
317
|
+
result: properties[`Result`] ?? `unknown`,
|
|
318
|
+
subState: properties[`SubState`] ?? `unknown`,
|
|
319
|
+
unit: properties[`Id`] ?? unit
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function renderUnitFile(sections) {
|
|
323
|
+
const renderedSections = sections.flatMap(([sectionName, section]) => {
|
|
324
|
+
if (section === void 0) return [];
|
|
325
|
+
const lines = Object.entries(section).flatMap(([key, value]) => {
|
|
326
|
+
if (value === void 0) return [];
|
|
327
|
+
if (isUnitValueList(value)) return value.map((entry) => `${key}=${stringifyUnitValue(entry)}`);
|
|
328
|
+
return `${key}=${stringifyUnitValue(value)}`;
|
|
329
|
+
});
|
|
330
|
+
if (lines.length === 0) return [];
|
|
331
|
+
return [
|
|
332
|
+
`[${sectionName}]`,
|
|
333
|
+
...lines,
|
|
334
|
+
``
|
|
335
|
+
];
|
|
336
|
+
}).join(`\n`);
|
|
337
|
+
return renderedSections.endsWith(`\n`) ? renderedSections : `${renderedSections}\n`;
|
|
338
|
+
}
|
|
339
|
+
function shellQuote(value) {
|
|
340
|
+
return `'${value.replaceAll(`'`, `'\\''`)}'`;
|
|
341
|
+
}
|
|
342
|
+
async function defaultCommandExecutor(command, args) {
|
|
343
|
+
const result = await execFileAsync(command, [...args]);
|
|
344
|
+
return {
|
|
345
|
+
stderr: result.stderr,
|
|
346
|
+
stdout: result.stdout
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function fileExists(path) {
|
|
350
|
+
try {
|
|
351
|
+
await access(path);
|
|
352
|
+
return true;
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function sendNotify(state, options) {
|
|
358
|
+
const args = [state];
|
|
359
|
+
if (options.pid !== void 0) args.push(`MAINPID=${options.pid}`);
|
|
360
|
+
if (options.status !== void 0) args.push(`STATUS=${options.status}`);
|
|
361
|
+
if (options.executor !== void 0) {
|
|
362
|
+
const command = buildNotifyShellCommand(args, options.socketPath);
|
|
363
|
+
await options.executor(`bash`, [`-lc`, command]);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
await execFileAsync(`systemd-notify`, args, { env: {
|
|
367
|
+
...process.env,
|
|
368
|
+
...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
|
|
369
|
+
} });
|
|
370
|
+
}
|
|
371
|
+
function hasServiceSection(options) {
|
|
372
|
+
return `service` in options;
|
|
373
|
+
}
|
|
374
|
+
function hasTimerSection(options) {
|
|
375
|
+
return `timer` in options;
|
|
376
|
+
}
|
|
377
|
+
function assertAbsoluteExecValue(key, value) {
|
|
378
|
+
if (isUnitValueList(value)) {
|
|
379
|
+
for (const entry of value) assertAbsoluteExecEntry(key, entry);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
assertAbsoluteExecEntry(key, value);
|
|
383
|
+
}
|
|
384
|
+
function assertAbsoluteExecEntry(key, value) {
|
|
385
|
+
if (typeof value === `string` && value.length > 0 && !isAbsoluteExecCommand(value)) throw new Error(`${key} must use an absolute executable path for systemd`);
|
|
386
|
+
if (value instanceof Executable && !value.runtimeEntrypoint.startsWith(`/`)) throw new Error(`${key} must use an absolute executable path for systemd`);
|
|
387
|
+
}
|
|
388
|
+
function stringifyUnitValue(value) {
|
|
389
|
+
if (value instanceof Executable) return value.toExecStart();
|
|
390
|
+
if (typeof value === `boolean`) return value ? `true` : `false`;
|
|
391
|
+
return String(value);
|
|
392
|
+
}
|
|
393
|
+
function isUnitValueList(value) {
|
|
394
|
+
return Array.isArray(value);
|
|
395
|
+
}
|
|
396
|
+
function isAbsoluteExecCommand(value) {
|
|
397
|
+
let index = 0;
|
|
398
|
+
let hasPrivilegePrefix = false;
|
|
399
|
+
while (index < value.length) {
|
|
400
|
+
const prefix = value[index];
|
|
401
|
+
if (prefix === `@` || prefix === `-` || prefix === `:`) {
|
|
402
|
+
index += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (prefix === `+`) {
|
|
406
|
+
if (hasPrivilegePrefix) return false;
|
|
407
|
+
hasPrivilegePrefix = true;
|
|
408
|
+
index += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (prefix === `!`) {
|
|
412
|
+
if (hasPrivilegePrefix) return false;
|
|
413
|
+
hasPrivilegePrefix = true;
|
|
414
|
+
index += value[index + 1] === `!` ? 2 : 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
return value[index] === `/`;
|
|
420
|
+
}
|
|
421
|
+
function buildNotifyShellCommand(args, socketPath) {
|
|
422
|
+
return `${socketPath === void 0 ? `` : `NOTIFY_SOCKET=${shellQuote(socketPath)} `}${[`systemd-notify`, ...args].map(shellQuote).join(` `)}`;
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/main/notify.ts
|
|
426
|
+
/**
|
|
427
|
+
* Helpers for sending `sd_notify` state updates to systemd.
|
|
428
|
+
*
|
|
429
|
+
* These helpers send notification payloads to the socket identified by
|
|
430
|
+
* `NOTIFY_SOCKET`, or to `options.socketPath` when one is provided explicitly.
|
|
431
|
+
* They are useful both in real services and in tests that want to observe
|
|
432
|
+
* readiness or watchdog traffic directly.
|
|
433
|
+
*
|
|
434
|
+
* Source:
|
|
435
|
+
* - https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html
|
|
436
|
+
* - https://www.freedesktop.org/software/systemd/man/latest/systemd-notify.html
|
|
437
|
+
*/
|
|
438
|
+
const notify = {
|
|
439
|
+
/**
|
|
440
|
+
* Sends `READY=1` to systemd.
|
|
441
|
+
*
|
|
442
|
+
* Use this when a `Type=notify` service has completed its startup work and is
|
|
443
|
+
* ready to be considered fully started. If `options.status` is provided, it is
|
|
444
|
+
* sent as an additional `STATUS=...` field.
|
|
445
|
+
*/
|
|
446
|
+
async ready(options = {}) {
|
|
447
|
+
await sendNotify(`READY=1`, options);
|
|
448
|
+
},
|
|
449
|
+
/**
|
|
450
|
+
* Sends `WATCHDOG=1` to systemd.
|
|
451
|
+
*
|
|
452
|
+
* Use this when a service configured with `WatchdogSec=` needs to emit a
|
|
453
|
+
* watchdog heartbeat. If `options.pid` is provided, it is sent as
|
|
454
|
+
* `MAINPID=...`, and `options.status` is forwarded as `STATUS=...`.
|
|
455
|
+
*/
|
|
456
|
+
async watchdog(options = {}) {
|
|
457
|
+
await sendNotify(`WATCHDOG=1`, options);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/main/systemd.ts
|
|
462
|
+
/**
|
|
463
|
+
* The result of installing one or more units with {@link Systemd.install}.
|
|
464
|
+
*
|
|
465
|
+
* It records the installation directory and the on-disk path associated with
|
|
466
|
+
* each installed unit.
|
|
467
|
+
*/
|
|
91
468
|
var SystemdInstallResult = class {
|
|
469
|
+
/** The directory units were written into. */
|
|
92
470
|
directory;
|
|
471
|
+
/** The installed units together with their resolved on-disk paths. */
|
|
93
472
|
installed;
|
|
94
473
|
pathByFilename;
|
|
95
474
|
constructor(directory, installed) {
|
|
@@ -98,17 +477,39 @@ var SystemdInstallResult = class {
|
|
|
98
477
|
this.pathByFilename = new Map(installed.map((entry) => [entry.unit.filename, entry.path]));
|
|
99
478
|
Object.freeze(this);
|
|
100
479
|
}
|
|
480
|
+
/** Returns the installed on-disk path for a previously installed unit. */
|
|
101
481
|
pathFor(unit) {
|
|
102
482
|
const path = this.pathByFilename.get(unit.filename);
|
|
103
483
|
if (path === void 0) throw new Error(`No installed path is recorded for ${unit.filename}`);
|
|
104
484
|
return path;
|
|
105
485
|
}
|
|
106
486
|
};
|
|
487
|
+
/**
|
|
488
|
+
* A configured interface to a specific systemd environment.
|
|
489
|
+
*
|
|
490
|
+
* `Systemd` combines unit-file materialization with a command execution
|
|
491
|
+
* strategy. It knows which scope it is targeting, where unit files should be
|
|
492
|
+
* written, and how `systemctl` should be invoked.
|
|
493
|
+
*
|
|
494
|
+
* This abstraction is intentionally close to `systemctl` concepts while still
|
|
495
|
+
* accepting full unit definitions instead of loose unit-name strings.
|
|
496
|
+
*/
|
|
107
497
|
var Systemd = class {
|
|
498
|
+
/** Command executor used for `systemctl` and related subprocess calls. */
|
|
108
499
|
executor;
|
|
500
|
+
/** Whether units should be linked into systemd before manager operations. */
|
|
109
501
|
linkUnits;
|
|
502
|
+
/** The target manager scope, either `system` or `user`. */
|
|
110
503
|
scope;
|
|
504
|
+
/** The directory used when materializing unit files. */
|
|
111
505
|
unitDir;
|
|
506
|
+
/**
|
|
507
|
+
* Creates a configured `Systemd` facade.
|
|
508
|
+
*
|
|
509
|
+
* By default, this targets the system scope and `/etc/systemd/system`. Use
|
|
510
|
+
* `scope: "user"` or an explicit `unitDir` to target a different manager or
|
|
511
|
+
* unit-file location.
|
|
512
|
+
*/
|
|
112
513
|
constructor(options = {}) {
|
|
113
514
|
this.scope = options.scope ?? `system`;
|
|
114
515
|
this.unitDir = options.unitDir ?? defaultUnitDirForScope(this.scope);
|
|
@@ -116,10 +517,18 @@ var Systemd = class {
|
|
|
116
517
|
this.executor = options.executor ?? defaultCommandExecutor;
|
|
117
518
|
Object.freeze(this);
|
|
118
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Renders and writes one or more units into this instance's `unitDir`.
|
|
522
|
+
*
|
|
523
|
+
* This is intentionally a file-materialization step. It does not enable or
|
|
524
|
+
* start the units on its own. When both timers and services are installed
|
|
525
|
+
* together, compile-time and runtime attachment checks ensure the timer points
|
|
526
|
+
* at one of the accompanying services.
|
|
527
|
+
*/
|
|
119
528
|
async install(...units) {
|
|
120
529
|
if (units.length === 0) throw new Error(`Systemd.install() requires at least one service or timer`);
|
|
121
530
|
assertInstallableTogether(units);
|
|
122
|
-
await
|
|
531
|
+
await writeUnitDirectory(this.unitDir);
|
|
123
532
|
const installed = [];
|
|
124
533
|
for (const unit of units) {
|
|
125
534
|
const path = join(this.unitDir, unit.filename);
|
|
@@ -131,6 +540,13 @@ var Systemd = class {
|
|
|
131
540
|
}
|
|
132
541
|
return new SystemdInstallResult(this.unitDir, installed);
|
|
133
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Enables one or more units via `systemctl enable`.
|
|
545
|
+
*
|
|
546
|
+
* When `linkUnits` is enabled, this first links installed unit files into the
|
|
547
|
+
* target manager and reloads systemd so the units are visible before the
|
|
548
|
+
* enable operation runs.
|
|
549
|
+
*/
|
|
134
550
|
async enable(...units) {
|
|
135
551
|
if (units.length === 0) throw new Error(`Systemd.enable() requires at least one service or timer`);
|
|
136
552
|
const scopeArgs = this.scopeArgs();
|
|
@@ -141,6 +557,13 @@ var Systemd = class {
|
|
|
141
557
|
unit.filename
|
|
142
558
|
]);
|
|
143
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Starts a unit and returns a parsed `systemctl show` snapshot of its final
|
|
562
|
+
* observed state.
|
|
563
|
+
*
|
|
564
|
+
* For oneshot services, a successful result commonly means `ActiveState` is
|
|
565
|
+
* already back to `inactive` by the time the status snapshot is collected.
|
|
566
|
+
*/
|
|
144
567
|
async start(unit) {
|
|
145
568
|
const scopeArgs = this.scopeArgs();
|
|
146
569
|
await this.prepareUnits(scopeArgs, [unit]);
|
|
@@ -157,22 +580,30 @@ var Systemd = class {
|
|
|
157
580
|
]);
|
|
158
581
|
return parseStartResult(unit.filename, status.stdout);
|
|
159
582
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
583
|
+
/**
|
|
584
|
+
* Reads recent output for a managed unit.
|
|
585
|
+
*
|
|
586
|
+
* If the unit is configured with file-backed `StandardOutput` or
|
|
587
|
+
* `StandardError`, this reads directly from that file. Otherwise it falls back
|
|
588
|
+
* to `systemctl status --lines ...`.
|
|
589
|
+
*/
|
|
590
|
+
async logs(unit, options) {
|
|
591
|
+
const fileLogPath = resolveUnitLogPath(unit);
|
|
592
|
+
if (fileLogPath !== void 0) return tailLines(await readFile(fileLogPath, `utf8`), options?.lines ?? 50);
|
|
163
593
|
const scopeArgs = this.scopeArgs();
|
|
164
|
-
const lines =
|
|
594
|
+
const lines = options?.lines ?? 50;
|
|
165
595
|
const command = [
|
|
166
596
|
`systemctl`,
|
|
167
597
|
...scopeArgs,
|
|
168
598
|
`status`,
|
|
169
|
-
|
|
599
|
+
unit.filename,
|
|
170
600
|
`--no-pager`,
|
|
171
601
|
`--lines`,
|
|
172
602
|
String(lines)
|
|
173
603
|
].map(shellQuote).join(` `);
|
|
174
604
|
return (await this.executor(`bash`, [`-lc`, `${command} || true`])).stdout;
|
|
175
605
|
}
|
|
606
|
+
/** Returns the on-disk unit-file path this instance uses for the given unit. */
|
|
176
607
|
pathFor(unit) {
|
|
177
608
|
return join(this.unitDir, unit.filename);
|
|
178
609
|
}
|
|
@@ -202,6 +633,17 @@ var Systemd = class {
|
|
|
202
633
|
return [...paths];
|
|
203
634
|
}
|
|
204
635
|
};
|
|
636
|
+
let lazyDefaultSystemd;
|
|
637
|
+
/**
|
|
638
|
+
* Returns the lazily created default `Systemd` instance.
|
|
639
|
+
*
|
|
640
|
+
* The default instance uses the library defaults for scope, unit directory, and
|
|
641
|
+
* command execution.
|
|
642
|
+
*/
|
|
643
|
+
function defaultSystemd() {
|
|
644
|
+
lazyDefaultSystemd ??= new Systemd();
|
|
645
|
+
return lazyDefaultSystemd;
|
|
646
|
+
}
|
|
205
647
|
function resolveUnitLogPath(unit) {
|
|
206
648
|
if (!(unit instanceof SystemdService)) return;
|
|
207
649
|
for (const key of [`StandardOutput`, `StandardError`]) {
|
|
@@ -217,177 +659,15 @@ function parseFileLogPath(value) {
|
|
|
217
659
|
function tailLines(output, lines) {
|
|
218
660
|
return output.split(`\n`).slice(-lines).join(`\n`);
|
|
219
661
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
lazyDefaultSystemd ??= new Systemd();
|
|
223
|
-
return lazyDefaultSystemd;
|
|
224
|
-
}
|
|
225
|
-
const notify = {
|
|
226
|
-
async ready(options = {}) {
|
|
227
|
-
await sendNotify(`READY=1`, options);
|
|
228
|
-
},
|
|
229
|
-
async watchdog(options = {}) {
|
|
230
|
-
await sendNotify(`WATCHDOG=1`, options);
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
function defineExecutable(fn, options = {}) {
|
|
234
|
-
const executable = new Executable(options);
|
|
235
|
-
if (isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
|
|
236
|
-
process.exitCode = 1;
|
|
237
|
-
throw error;
|
|
238
|
-
});
|
|
239
|
-
return executable;
|
|
240
|
-
}
|
|
241
|
-
function normalizeUnitName(name, suffix) {
|
|
242
|
-
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
|
|
243
|
-
}
|
|
244
|
-
function resolveTimerTargetUnit(options) {
|
|
245
|
-
const explicitTarget = options.timer[`Unit`];
|
|
246
|
-
if (typeof explicitTarget === `string` && explicitTarget.length > 0) return explicitTarget;
|
|
247
|
-
return `${normalizeUnitName(options.name, `.timer`)}.service`;
|
|
248
|
-
}
|
|
249
|
-
function defaultUnitDirForScope(scope) {
|
|
250
|
-
if (scope === `user`) return `${process.env[`HOME`] ?? `~`}/.config/systemd/user`;
|
|
251
|
-
return `/etc/systemd/system`;
|
|
252
|
-
}
|
|
253
|
-
function freezeUnitOptions(options) {
|
|
254
|
-
return Object.freeze({
|
|
255
|
-
...options,
|
|
256
|
-
...options.unit === void 0 ? {} : { unit: cloneUnitSection(options.unit) },
|
|
257
|
-
...options.install === void 0 ? {} : { install: cloneUnitSection(options.install) },
|
|
258
|
-
...hasServiceSection(options) ? { service: cloneUnitSection(options.service) } : {},
|
|
259
|
-
...hasTimerSection(options) ? { timer: cloneUnitSection(options.timer) } : {}
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
function hasServiceSection(options) {
|
|
263
|
-
return `service` in options;
|
|
264
|
-
}
|
|
265
|
-
function hasTimerSection(options) {
|
|
266
|
-
return `timer` in options;
|
|
267
|
-
}
|
|
268
|
-
function cloneUnitSection(section) {
|
|
269
|
-
if (section === void 0) return section;
|
|
270
|
-
const entries = Object.entries(section).map(([key, value]) => {
|
|
271
|
-
if (isUnitValueList(value)) return [key, Object.freeze([...value])];
|
|
272
|
-
return [key, value];
|
|
273
|
-
});
|
|
274
|
-
return Object.freeze(Object.fromEntries(entries));
|
|
275
|
-
}
|
|
276
|
-
function validateServiceSection(service) {
|
|
277
|
-
for (const key of REQUIRED_EXEC_KEYS) {
|
|
278
|
-
const value = service[key];
|
|
279
|
-
if (typeof value === `string` && value.length > 0 && !value.startsWith(`/`)) throw new Error(`${key} must use an absolute executable path for systemd`);
|
|
280
|
-
if (value instanceof Executable && !value.runtimeEntrypoint.startsWith(`/`)) throw new Error(`${key} must use an absolute executable path for systemd`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
function assertInstallableTogether(units) {
|
|
284
|
-
const installedServices = new Set(units.filter((unit) => unit instanceof SystemdService).map((unit) => unit.name));
|
|
285
|
-
if (installedServices.size === 0) return;
|
|
286
|
-
for (const unit of units) {
|
|
287
|
-
if (!(unit instanceof SystemdTimer)) continue;
|
|
288
|
-
if (!installedServices.has(unit.targetServiceName)) throw new Error(`Cannot install ${unit.filename} alongside unrelated services: expected ${unit.targetUnit}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
function parseStartResult(unit, output) {
|
|
292
|
-
const properties = Object.fromEntries(output.split(`\n`).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
|
|
293
|
-
const separatorIndex = line.indexOf(`=`);
|
|
294
|
-
if (separatorIndex === -1) return [line, ``];
|
|
295
|
-
return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
|
|
296
|
-
}));
|
|
297
|
-
return {
|
|
298
|
-
activeState: properties[`ActiveState`] ?? `unknown`,
|
|
299
|
-
execMainStatus: properties[`ExecMainStatus`] === void 0 || properties[`ExecMainStatus`] === `` ? void 0 : Number(properties[`ExecMainStatus`]),
|
|
300
|
-
result: properties[`Result`] ?? `unknown`,
|
|
301
|
-
subState: properties[`SubState`] ?? `unknown`,
|
|
302
|
-
unit: properties[`Id`] ?? unit
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
function renderUnitFile(sections) {
|
|
306
|
-
const renderedSections = sections.flatMap(([sectionName, section]) => {
|
|
307
|
-
if (section === void 0) return [];
|
|
308
|
-
const lines = Object.entries(section).flatMap(([key, value]) => {
|
|
309
|
-
if (value === void 0) return [];
|
|
310
|
-
if (isUnitValueList(value)) return value.map((entry) => `${key}=${stringifyUnitValue(entry)}`);
|
|
311
|
-
return `${key}=${stringifyUnitValue(value)}`;
|
|
312
|
-
});
|
|
313
|
-
if (lines.length === 0) return [];
|
|
314
|
-
return [
|
|
315
|
-
`[${sectionName}]`,
|
|
316
|
-
...lines,
|
|
317
|
-
``
|
|
318
|
-
];
|
|
319
|
-
}).join(`\n`);
|
|
320
|
-
return renderedSections.endsWith(`\n`) ? renderedSections : `${renderedSections}\n`;
|
|
321
|
-
}
|
|
322
|
-
function stringifyUnitValue(value) {
|
|
323
|
-
if (value instanceof Executable) return value.toExecStart();
|
|
324
|
-
if (typeof value === `boolean`) return value ? `true` : `false`;
|
|
325
|
-
return String(value);
|
|
326
|
-
}
|
|
327
|
-
function isUnitValueList(value) {
|
|
328
|
-
return Array.isArray(value);
|
|
329
|
-
}
|
|
330
|
-
function inferCallerModulePath() {
|
|
331
|
-
const stack = (/* @__PURE__ */ new Error()).stack ?? ``;
|
|
332
|
-
for (const line of stack.split(`\n`).slice(1)) {
|
|
333
|
-
const candidate = extractStackPath(line);
|
|
334
|
-
if (candidate === void 0 || candidate === currentModulePath) continue;
|
|
335
|
-
return candidate;
|
|
336
|
-
}
|
|
337
|
-
throw new Error(`Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`);
|
|
338
|
-
}
|
|
339
|
-
function extractStackPath(line) {
|
|
340
|
-
const fileUrlMatch = line.match(/(file:\/\/\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
|
|
341
|
-
if (fileUrlMatch !== null) return fileURLToPath(fileUrlMatch[1]);
|
|
342
|
-
const pathMatch = line.match(/(\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
|
|
343
|
-
if (pathMatch !== null) return pathMatch[1];
|
|
344
|
-
}
|
|
345
|
-
function isMainModule(modulePath) {
|
|
346
|
-
const mainArg = process.argv[1];
|
|
347
|
-
if (mainArg === void 0) return false;
|
|
348
|
-
return normalizeFilePath(mainArg) === normalizeFilePath(modulePath);
|
|
349
|
-
}
|
|
350
|
-
function shellQuote(value) {
|
|
351
|
-
return `'${value.replaceAll(`'`, `'\\''`)}'`;
|
|
352
|
-
}
|
|
353
|
-
function normalizeFilePath(path) {
|
|
354
|
-
try {
|
|
355
|
-
return realpathSync(path);
|
|
356
|
-
} catch {
|
|
357
|
-
return path;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
async function defaultCommandExecutor(command, args) {
|
|
361
|
-
const result = await execFileAsync(command, [...args]);
|
|
362
|
-
return {
|
|
363
|
-
stderr: result.stderr,
|
|
364
|
-
stdout: result.stdout
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
async function fileExists(path) {
|
|
368
|
-
try {
|
|
369
|
-
await access(path);
|
|
370
|
-
return true;
|
|
371
|
-
} catch {
|
|
372
|
-
return false;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
async function sendNotify(state, options) {
|
|
376
|
-
const args = [state];
|
|
377
|
-
if (options.pid !== void 0) args.push(`MAINPID=${options.pid}`);
|
|
378
|
-
if (options.status !== void 0) args.push(`STATUS=${options.status}`);
|
|
379
|
-
if (options.executor !== void 0) {
|
|
380
|
-
const command = buildNotifyShellCommand(args, options.socketPath);
|
|
381
|
-
await options.executor(`bash`, [`-lc`, command]);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
await execFileAsync(`systemd-notify`, args, { env: {
|
|
385
|
-
...process.env,
|
|
386
|
-
...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
|
|
387
|
-
} });
|
|
388
|
-
}
|
|
389
|
-
function buildNotifyShellCommand(args, socketPath) {
|
|
390
|
-
return `${socketPath === void 0 ? `` : `NOTIFY_SOCKET=${shellQuote(socketPath)} `}${[`systemd-notify`, ...args].map(shellQuote).join(` `)}`;
|
|
662
|
+
async function writeUnitDirectory(path) {
|
|
663
|
+
await mkdir(path, { recursive: true });
|
|
391
664
|
}
|
|
392
665
|
//#endregion
|
|
393
|
-
|
|
666
|
+
//#region src/main/index.ts
|
|
667
|
+
/**
|
|
668
|
+
* Internal helpers are exposed for advanced use, but they are not a stable public API.
|
|
669
|
+
* Expect breaking changes here outside the package's normal compatibility guarantees.
|
|
670
|
+
*/
|
|
671
|
+
const Internal = internal_exports;
|
|
672
|
+
//#endregion
|
|
673
|
+
export { Executable, Internal, Systemd, SystemdInstallResult, SystemdService, SystemdTimer, defaultSystemd, defineExecutable, notify };
|