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/index.mjs CHANGED
@@ -1,27 +1,55 @@
1
- import { execFile } from "node:child_process";
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
- //#region src/index.ts
8
- const REQUIRED_EXEC_KEYS = [
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 mkdir(this.unitDir, { recursive: true });
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
- async logs(_unit, _options) {
161
- const fileLogPath = resolveUnitLogPath(_unit);
162
- if (fileLogPath !== void 0) return tailLines(await readFile(fileLogPath, `utf8`), _options?.lines ?? 50);
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 = _options?.lines ?? 50;
594
+ const lines = options?.lines ?? 50;
165
595
  const command = [
166
596
  `systemctl`,
167
597
  ...scopeArgs,
168
598
  `status`,
169
- _unit.filename,
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
- let lazyDefaultSystemd;
221
- function defaultSystemd() {
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
- export { Executable, Systemd, SystemdInstallResult, SystemdService, SystemdTimer, defaultSystemd, defineExecutable, notify };
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 };