systemd-ts 0.1.0 → 0.2.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
@@ -5,6 +5,174 @@ import { execFile } from "node:child_process";
5
5
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { promisify } from "node:util";
7
7
  import { join } from "node:path";
8
+ //#region src/main/errors.ts
9
+ var SystemdTsError = class extends Error {
10
+ code;
11
+ constructor(code, message, options = {}) {
12
+ super(message, { cause: options.cause });
13
+ this.code = code;
14
+ this.name = new.target.name;
15
+ }
16
+ };
17
+ var InvalidExecDirectiveError = class extends SystemdTsError {
18
+ directive;
19
+ constructor(directive, options = {}) {
20
+ super(`SYSTEMD_TS_INVALID_EXEC_DIRECTIVE`, `${directive} must use an absolute executable path for systemd`, options);
21
+ this.directive = directive;
22
+ }
23
+ };
24
+ var NoUnitsProvidedError = class extends SystemdTsError {
25
+ operation;
26
+ constructor(operation, options = {}) {
27
+ super(`SYSTEMD_TS_NO_UNITS_PROVIDED`, `${operation} requires at least one service or timer`, options);
28
+ this.operation = operation;
29
+ }
30
+ };
31
+ var ExecutableInferenceError = class extends SystemdTsError {
32
+ constructor(options = {}) {
33
+ super(`SYSTEMD_TS_EXECUTABLE_INFERENCE`, `Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`, options);
34
+ }
35
+ };
36
+ var UnitMaterializationError = class extends SystemdTsError {
37
+ operation;
38
+ reason;
39
+ unitName;
40
+ unitPath;
41
+ constructor(message, options = {}) {
42
+ super(`SYSTEMD_TS_UNIT_MATERIALIZATION`, message, options);
43
+ this.operation = options.operation;
44
+ this.reason = options.reason;
45
+ this.unitName = options.unitName;
46
+ this.unitPath = options.unitPath;
47
+ }
48
+ };
49
+ var UnitEnableError = class extends SystemdTsError {
50
+ args;
51
+ command;
52
+ environmentReason;
53
+ exitCode;
54
+ stage;
55
+ stderr;
56
+ stdout;
57
+ unitName;
58
+ unitPath;
59
+ constructor(message, options = {}) {
60
+ super(`SYSTEMD_TS_UNIT_ENABLE`, message, options);
61
+ const details = extractCommandErrorDetails(options.cause);
62
+ this.args = options.args;
63
+ this.command = options.command;
64
+ this.environmentReason = options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
65
+ this.exitCode = details.exitCode;
66
+ this.stage = options.stage;
67
+ this.stderr = details.stderr;
68
+ this.stdout = details.stdout;
69
+ this.unitName = options.unitName;
70
+ this.unitPath = options.unitPath;
71
+ }
72
+ };
73
+ var UnitStartError = class extends SystemdTsError {
74
+ args;
75
+ command;
76
+ diagnostics;
77
+ environmentReason;
78
+ exitCode;
79
+ stage;
80
+ stderr;
81
+ stdout;
82
+ unitName;
83
+ unitPath;
84
+ constructor(message, options = {}) {
85
+ super(`SYSTEMD_TS_UNIT_START`, message, options);
86
+ const details = extractCommandErrorDetails(options.cause);
87
+ this.args = options.args;
88
+ this.command = options.command;
89
+ this.diagnostics = options.diagnostics;
90
+ this.environmentReason = options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
91
+ this.exitCode = details.exitCode;
92
+ this.stage = options.stage;
93
+ this.stderr = details.stderr;
94
+ this.stdout = details.stdout;
95
+ this.unitName = options.unitName;
96
+ this.unitPath = options.unitPath;
97
+ }
98
+ };
99
+ var UnitLogsReadError = class extends SystemdTsError {
100
+ args;
101
+ command;
102
+ environmentReason;
103
+ exitCode;
104
+ reason;
105
+ stage;
106
+ stderr;
107
+ stdout;
108
+ unitName;
109
+ unitPath;
110
+ constructor(message, options = {}) {
111
+ super(`SYSTEMD_TS_UNIT_LOGS`, message, options);
112
+ const details = extractCommandErrorDetails(options.cause);
113
+ this.args = options.args;
114
+ this.command = options.command;
115
+ this.environmentReason = options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
116
+ this.exitCode = details.exitCode;
117
+ this.reason = options.reason;
118
+ this.stage = options.stage;
119
+ this.stderr = details.stderr;
120
+ this.stdout = details.stdout;
121
+ this.unitName = options.unitName;
122
+ this.unitPath = options.unitPath;
123
+ }
124
+ };
125
+ var NotifySendError = class extends SystemdTsError {
126
+ args;
127
+ command;
128
+ environmentReason;
129
+ exitCode;
130
+ reason;
131
+ stage;
132
+ stderr;
133
+ stdout;
134
+ constructor(message, options = {}) {
135
+ super(`SYSTEMD_TS_NOTIFY_SEND`, message, options);
136
+ const details = extractCommandErrorDetails(options.cause);
137
+ this.args = options.args;
138
+ this.command = options.command;
139
+ this.environmentReason = options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
140
+ this.exitCode = details.exitCode;
141
+ this.reason = options.reason;
142
+ this.stage = options.stage;
143
+ this.stderr = details.stderr;
144
+ this.stdout = details.stdout;
145
+ }
146
+ };
147
+ function classifyCommandEnvironmentReason(cause) {
148
+ if (cause === null || typeof cause !== `object`) return;
149
+ const record = cause;
150
+ if (record[`code`] === `ENOENT`) return `missing-command`;
151
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM`) return `permission-denied`;
152
+ const combinedOutput = [record[`stderr`], record[`stdout`]].filter((value) => typeof value === `string` && value.length > 0).join(`\n`);
153
+ if (combinedOutput.includes(`Failed to connect to bus`) || combinedOutput.includes(`System has not been booted with systemd`) || combinedOutput.includes(`Failed to get D-Bus connection`)) return `manager-unavailable`;
154
+ }
155
+ function classifyMaterializationReason(cause) {
156
+ if (cause === null || typeof cause !== `object`) return;
157
+ const record = cause;
158
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM` || record[`code`] === `EROFS`) return `permission-denied`;
159
+ if (record[`code`] === `EEXIST` || record[`code`] === `ENOTDIR` || record[`code`] === `EISDIR`) return `invalid-unit-directory`;
160
+ return `file-system-failed`;
161
+ }
162
+ function extractCommandErrorDetails(cause) {
163
+ if (cause === null || typeof cause !== `object`) return {
164
+ exitCode: void 0,
165
+ stderr: void 0,
166
+ stdout: void 0
167
+ };
168
+ const record = cause;
169
+ return {
170
+ exitCode: typeof record[`code`] === `number` ? record[`code`] : void 0,
171
+ stderr: typeof record[`stderr`] === `string` ? record[`stderr`] : void 0,
172
+ stdout: typeof record[`stdout`] === `string` ? record[`stdout`] : void 0
173
+ };
174
+ }
175
+ //#endregion
8
176
  //#region src/main/executable.ts
9
177
  const currentModulePath = fileURLToPath(import.meta.url);
10
178
  /**
@@ -23,6 +191,8 @@ const currentModulePath = fileURLToPath(import.meta.url);
23
191
  var Executable = class {
24
192
  /** Additional arguments passed after the module path. */
25
193
  args;
194
+ /** Inference issue captured when no explicit module path was provided. */
195
+ inferenceError;
26
196
  /** Absolute path to the module that should be executed. */
27
197
  modulePath;
28
198
  /** Absolute path to the runtime binary that should launch the module. */
@@ -40,7 +210,9 @@ var Executable = class {
40
210
  */
41
211
  constructor(options = {}) {
42
212
  this.runtimeEntrypoint = options.runtimeEntrypoint ?? process.execPath;
43
- this.modulePath = options.modulePath ?? inferCallerModulePath();
213
+ const inferredModulePath = options.modulePath ?? tryInferCallerModulePath();
214
+ this.inferenceError = inferredModulePath === void 0 && options.modulePath === void 0 ? new ExecutableInferenceError() : void 0;
215
+ this.modulePath = inferredModulePath ?? ``;
44
216
  this.args = Object.freeze([...options.args ?? []]);
45
217
  Object.freeze(this);
46
218
  }
@@ -51,18 +223,30 @@ var Executable = class {
51
223
  * path and any configured arguments.
52
224
  */
53
225
  toCommandParts() {
54
- return [
55
- this.runtimeEntrypoint,
56
- this.modulePath,
57
- ...this.args
58
- ];
226
+ if (this.inferenceError !== void 0) return {
227
+ ok: false,
228
+ error: this.inferenceError
229
+ };
230
+ return {
231
+ ok: true,
232
+ value: [
233
+ this.runtimeEntrypoint,
234
+ this.modulePath,
235
+ ...this.args
236
+ ]
237
+ };
59
238
  }
60
239
  /**
61
240
  * Renders the executable as a shell-quoted command string suitable for
62
241
  * executable-valued systemd directives such as `ExecStart=`.
63
242
  */
64
243
  toExecStart() {
65
- return this.toCommandParts().map(shellQuote$1).join(` `);
244
+ const commandParts = this.toCommandParts();
245
+ if (!commandParts.ok) return commandParts;
246
+ return {
247
+ ok: true,
248
+ value: commandParts.value.map(shellQuote$1).join(` `)
249
+ };
66
250
  }
67
251
  };
68
252
  /**
@@ -86,20 +270,19 @@ var Executable = class {
86
270
  */
87
271
  function defineExecutable(fn, options = {}) {
88
272
  const executable = new Executable(options);
89
- if (isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
273
+ if (executable.inferenceError === void 0 && isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
90
274
  process.exitCode = 1;
91
275
  throw error;
92
276
  });
93
277
  return executable;
94
278
  }
95
- function inferCallerModulePath() {
279
+ function tryInferCallerModulePath() {
96
280
  const stack = (/* @__PURE__ */ new Error()).stack ?? ``;
97
281
  for (const line of stack.split(`\n`).slice(1)) {
98
282
  const candidate = extractStackPath(line);
99
283
  if (candidate === void 0 || candidate === currentModulePath) continue;
100
284
  return candidate;
101
285
  }
102
- throw new Error(`Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`);
103
286
  }
104
287
  function extractStackPath(line) {
105
288
  const fileUrlMatch = line.match(/(file:\/\/\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
@@ -123,131 +306,15 @@ function normalizeFilePath(path) {
123
306
  }
124
307
  }
125
308
  //#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
- */
138
- var SystemdService = class {
139
- /** Optional `[Install]` section for enable-time relationships. */
140
- install;
141
- /** The normalized base unit name, without the `.service` suffix. */
142
- name;
143
- /** The fully frozen original options used to construct this service. */
144
- options;
145
- /** The `[Service]` section payload. */
146
- service;
147
- /** Optional `[Unit]` section metadata and dependency configuration. */
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
- */
156
- constructor(options) {
157
- this.options = freezeUnitOptions(options);
158
- this.name = normalizeUnitName(options.name, `.service`);
159
- this.unit = cloneUnitSection(options.unit);
160
- this.service = cloneUnitSection(options.service) ?? {};
161
- this.install = cloneUnitSection(options.install);
162
- Object.freeze(this);
163
- }
164
- /** The canonical unit filename, including the `.service` suffix. */
165
- get filename() {
166
- return `${this.name}.service`;
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
- */
175
- render() {
176
- validateServiceSection(this.service);
177
- return renderUnitFile([
178
- [`Unit`, this.unit],
179
- [`Service`, this.service],
180
- [`Install`, this.install]
181
- ]);
182
- }
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
- */
196
- var SystemdTimer = class {
197
- /** Optional `[Install]` section for enable-time relationships. */
198
- install;
199
- /** The normalized base unit name, without the `.timer` suffix. */
200
- name;
201
- /** The fully frozen original options used to construct this timer. */
202
- options;
203
- /** The attached service basename inferred from `targetUnit`. */
204
- targetServiceName;
205
- /** The unit name this timer activates, explicit or implicit. */
206
- targetUnit;
207
- /** The `[Timer]` section payload. */
208
- timer;
209
- /** Optional `[Unit]` section metadata and dependency configuration. */
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
- */
217
- constructor(options) {
218
- this.options = freezeUnitOptions(options);
219
- this.name = normalizeUnitName(options.name, `.timer`);
220
- this.unit = cloneUnitSection(options.unit);
221
- this.timer = cloneUnitSection(options.timer) ?? {};
222
- this.install = cloneUnitSection(options.install);
223
- this.targetUnit = resolveTimerTargetUnit(options);
224
- this.targetServiceName = normalizeUnitName(this.targetUnit, `.service`);
225
- Object.freeze(this);
226
- }
227
- /** The canonical unit filename, including the `.timer` suffix. */
228
- get filename() {
229
- return `${this.name}.timer`;
230
- }
231
- /** Renders the timer as a complete unit file. */
232
- render() {
233
- return renderUnitFile([
234
- [`Unit`, this.unit],
235
- [`Timer`, this.timer],
236
- [`Install`, this.install]
237
- ]);
238
- }
239
- };
240
- //#endregion
241
309
  //#region src/main/internal.ts
242
310
  var internal_exports = /* @__PURE__ */ __exportAll({
243
- assertInstallableTogether: () => assertInstallableTogether,
244
311
  cloneUnitSection: () => cloneUnitSection,
245
312
  defaultCommandExecutor: () => defaultCommandExecutor,
246
313
  defaultUnitDirForScope: () => defaultUnitDirForScope,
247
314
  fileExists: () => fileExists,
248
315
  freezeUnitOptions: () => freezeUnitOptions,
249
316
  normalizeUnitName: () => normalizeUnitName,
250
- parseStartResult: () => parseStartResult,
317
+ parseStartStatus: () => parseStartStatus,
251
318
  renderUnitFile: () => renderUnitFile,
252
319
  resolveTimerTargetUnit: () => resolveTimerTargetUnit,
253
320
  sendNotify: () => sendNotify,
@@ -295,17 +362,16 @@ function cloneUnitSection(section) {
295
362
  return Object.freeze(Object.fromEntries(entries));
296
363
  }
297
364
  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}`);
365
+ for (const key of EXEC_DIRECTIVE_KEYS) {
366
+ const validation = assertAbsoluteExecValue(key, service[key]);
367
+ if (!validation.ok) return validation;
306
368
  }
369
+ return {
370
+ ok: true,
371
+ value: void 0
372
+ };
307
373
  }
308
- function parseStartResult(unit, output) {
374
+ function parseStartStatus(unit, output) {
309
375
  const properties = Object.fromEntries(output.split(`\n`).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
310
376
  const separatorIndex = line.indexOf(`=`);
311
377
  if (separatorIndex === -1) return [line, ``];
@@ -320,21 +386,41 @@ function parseStartResult(unit, output) {
320
386
  };
321
387
  }
322
388
  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`;
389
+ try {
390
+ const renderedSections = sections.flatMap(([sectionName, section]) => {
391
+ if (section === void 0) return [];
392
+ const lines = [];
393
+ for (const [key, value] of Object.entries(section)) {
394
+ if (value === void 0) continue;
395
+ if (isUnitValueList(value)) {
396
+ for (const entry of value) {
397
+ const rendered = stringifyUnitValue(entry);
398
+ if (!rendered.ok) throw rendered.error;
399
+ lines.push(`${key}=${rendered.value}`);
400
+ }
401
+ continue;
402
+ }
403
+ const rendered = stringifyUnitValue(value);
404
+ if (!rendered.ok) throw rendered.error;
405
+ lines.push(`${key}=${rendered.value}`);
406
+ }
407
+ if (lines.length === 0) return [];
408
+ return [
409
+ `[${sectionName}]`,
410
+ ...lines,
411
+ ``
412
+ ];
413
+ }).join(`\n`);
414
+ return {
415
+ ok: true,
416
+ value: renderedSections.endsWith(`\n`) ? renderedSections : `${renderedSections}\n`
417
+ };
418
+ } catch (error) {
419
+ return {
420
+ ok: false,
421
+ error: error instanceof ExecutableInferenceError ? error : new ExecutableInferenceError({ cause: error })
422
+ };
423
+ }
338
424
  }
339
425
  function shellQuote(value) {
340
426
  return `'${value.replaceAll(`'`, `'\\''`)}'`;
@@ -360,13 +446,33 @@ async function sendNotify(state, options) {
360
446
  if (options.status !== void 0) args.push(`STATUS=${options.status}`);
361
447
  if (options.executor !== void 0) {
362
448
  const command = buildNotifyShellCommand(args, options.socketPath);
363
- await options.executor(`bash`, [`-lc`, command]);
449
+ try {
450
+ await options.executor(`bash`, [`-lc`, command]);
451
+ } catch (cause) {
452
+ throw new NotifySendError(`Failed to send systemd notification through the configured executor`, {
453
+ args: [`-lc`, command],
454
+ cause,
455
+ command: `bash`,
456
+ reason: `executor-failed`,
457
+ stage: `executor`
458
+ });
459
+ }
364
460
  return;
365
461
  }
366
- await execFileAsync(`systemd-notify`, args, { env: {
367
- ...process.env,
368
- ...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
369
- } });
462
+ try {
463
+ await execFileAsync(`systemd-notify`, args, { env: {
464
+ ...process.env,
465
+ ...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
466
+ } });
467
+ } catch (cause) {
468
+ throw new NotifySendError(`Failed to send systemd notification with systemd-notify`, {
469
+ args,
470
+ cause,
471
+ command: `systemd-notify`,
472
+ reason: `systemd-notify-failed`,
473
+ stage: `systemd-notify`
474
+ });
475
+ }
370
476
  }
371
477
  function hasServiceSection(options) {
372
478
  return `service` in options;
@@ -376,19 +482,41 @@ function hasTimerSection(options) {
376
482
  }
377
483
  function assertAbsoluteExecValue(key, value) {
378
484
  if (isUnitValueList(value)) {
379
- for (const entry of value) assertAbsoluteExecEntry(key, entry);
380
- return;
485
+ for (const entry of value) {
486
+ const validation = assertAbsoluteExecEntry(key, entry);
487
+ if (!validation.ok) return validation;
488
+ }
489
+ return {
490
+ ok: true,
491
+ value: void 0
492
+ };
381
493
  }
382
- assertAbsoluteExecEntry(key, value);
494
+ return assertAbsoluteExecEntry(key, value);
383
495
  }
384
496
  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`);
497
+ if (typeof value === `string` && value.length > 0 && !isAbsoluteExecCommand(value)) return {
498
+ ok: false,
499
+ error: new InvalidExecDirectiveError(key)
500
+ };
501
+ if (value instanceof Executable && !value.runtimeEntrypoint.startsWith(`/`)) return {
502
+ ok: false,
503
+ error: new InvalidExecDirectiveError(key)
504
+ };
505
+ return {
506
+ ok: true,
507
+ value: void 0
508
+ };
387
509
  }
388
510
  function stringifyUnitValue(value) {
389
511
  if (value instanceof Executable) return value.toExecStart();
390
- if (typeof value === `boolean`) return value ? `true` : `false`;
391
- return String(value);
512
+ if (typeof value === `boolean`) return {
513
+ ok: true,
514
+ value: value ? `true` : `false`
515
+ };
516
+ return {
517
+ ok: true,
518
+ value: String(value)
519
+ };
392
520
  }
393
521
  function isUnitValueList(value) {
394
522
  return Array.isArray(value);
@@ -444,7 +572,21 @@ const notify = {
444
572
  * sent as an additional `STATUS=...` field.
445
573
  */
446
574
  async ready(options = {}) {
447
- await sendNotify(`READY=1`, options);
575
+ try {
576
+ await sendNotify(`READY=1`, options);
577
+ return {
578
+ ok: true,
579
+ value: void 0
580
+ };
581
+ } catch (error) {
582
+ return {
583
+ ok: false,
584
+ error: error instanceof NotifySendError ? error : new NotifySendError(`Failed to send READY=1 notification`, {
585
+ cause: error,
586
+ stage: `ready`
587
+ })
588
+ };
589
+ }
448
590
  },
449
591
  /**
450
592
  * Sends `WATCHDOG=1` to systemd.
@@ -454,35 +596,157 @@ const notify = {
454
596
  * `MAINPID=...`, and `options.status` is forwarded as `STATUS=...`.
455
597
  */
456
598
  async watchdog(options = {}) {
457
- await sendNotify(`WATCHDOG=1`, options);
599
+ try {
600
+ await sendNotify(`WATCHDOG=1`, options);
601
+ return {
602
+ ok: true,
603
+ value: void 0
604
+ };
605
+ } catch (error) {
606
+ return {
607
+ ok: false,
608
+ error: error instanceof NotifySendError ? error : new NotifySendError(`Failed to send WATCHDOG=1 notification`, {
609
+ cause: error,
610
+ stage: `watchdog`
611
+ })
612
+ };
613
+ }
614
+ }
615
+ };
616
+ //#endregion
617
+ //#region src/main/systemd-service.ts
618
+ /**
619
+ * An immutable definition of a `.service` unit.
620
+ *
621
+ * `SystemdService` is a pure value object: it captures the intended unit name
622
+ * and section contents, but it does not write files or talk to `systemd`
623
+ * directly. Operational actions such as installation or startup belong on
624
+ * {@link Systemd}.
625
+ *
626
+ * Source:
627
+ * - https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
628
+ */
629
+ var SystemdService = class {
630
+ /** Optional `[Install]` section for enable-time relationships. */
631
+ install;
632
+ /** The normalized base unit name, without the `.service` suffix. */
633
+ name;
634
+ /** The fully frozen original options used to construct this service. */
635
+ options;
636
+ /** The `[Service]` section payload. */
637
+ service;
638
+ /** Optional `[Unit]` section metadata and dependency configuration. */
639
+ unit;
640
+ /**
641
+ * Creates an immutable service definition.
642
+ *
643
+ * The constructor preserves literal types where possible and rejects unknown
644
+ * directive names within the provided sections, while still allowing custom
645
+ * `X-...` extension directives.
646
+ */
647
+ constructor(options) {
648
+ this.options = freezeUnitOptions(options);
649
+ this.name = normalizeUnitName(options.name, `.service`);
650
+ this.unit = cloneUnitSection(options.unit);
651
+ this.service = cloneUnitSection(options.service) ?? {};
652
+ this.install = cloneUnitSection(options.install);
653
+ Object.freeze(this);
654
+ }
655
+ /** The canonical unit filename, including the `.service` suffix. */
656
+ get filename() {
657
+ return `${this.name}.service`;
658
+ }
659
+ /**
660
+ * Renders the service as a complete unit file.
661
+ *
662
+ * Rendering also validates executable-valued directives such as `ExecStart`
663
+ * and `ExecStop`, ensuring they use absolute runtime entrypoints as required
664
+ * by systemd.
665
+ */
666
+ render() {
667
+ const validation = validateServiceSection(this.service);
668
+ if (!validation.ok) return validation;
669
+ return renderUnitFile([
670
+ [`Unit`, this.unit],
671
+ [`Service`, this.service],
672
+ [`Install`, this.install]
673
+ ]);
674
+ }
675
+ };
676
+ //#endregion
677
+ //#region src/main/systemd-timer.ts
678
+ /**
679
+ * An immutable definition of a `.timer` unit.
680
+ *
681
+ * Like {@link SystemdService}, this is a pure value object. It models the timer
682
+ * configuration and its attachment target, but does not write files or interact
683
+ * with the service manager directly.
684
+ *
685
+ * Source:
686
+ * - https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
687
+ */
688
+ var SystemdTimer = class {
689
+ /** Optional `[Install]` section for enable-time relationships. */
690
+ install;
691
+ /** The normalized base unit name, without the `.timer` suffix. */
692
+ name;
693
+ /** The fully frozen original options used to construct this timer. */
694
+ options;
695
+ /** The attached service basename inferred from `targetUnit`. */
696
+ targetServiceName;
697
+ /** The unit name this timer activates, explicit or implicit. */
698
+ targetUnit;
699
+ /** The `[Timer]` section payload. */
700
+ timer;
701
+ /** Optional `[Unit]` section metadata and dependency configuration. */
702
+ unit;
703
+ /**
704
+ * Creates an immutable timer definition.
705
+ *
706
+ * If no explicit `timer.Unit` is provided, the target defaults to the service
707
+ * with the same basename, matching systemd's native timer behavior.
708
+ */
709
+ constructor(options) {
710
+ this.options = freezeUnitOptions(options);
711
+ this.name = normalizeUnitName(options.name, `.timer`);
712
+ this.unit = cloneUnitSection(options.unit);
713
+ this.timer = cloneUnitSection(options.timer) ?? {};
714
+ this.install = cloneUnitSection(options.install);
715
+ this.targetUnit = resolveTimerTargetUnit(options);
716
+ this.targetServiceName = normalizeUnitName(this.targetUnit, `.service`);
717
+ Object.freeze(this);
718
+ }
719
+ /** The canonical unit filename, including the `.timer` suffix. */
720
+ get filename() {
721
+ return `${this.name}.timer`;
722
+ }
723
+ /** Renders the timer as a complete unit file. */
724
+ render() {
725
+ return renderUnitFile([
726
+ [`Unit`, this.unit],
727
+ [`Timer`, this.timer],
728
+ [`Install`, this.install]
729
+ ]);
458
730
  }
459
731
  };
460
732
  //#endregion
461
733
  //#region src/main/systemd.ts
462
734
  /**
463
- * The result of installing one or more units with {@link Systemd.install}.
735
+ * A successful systemd unit materialization.
464
736
  *
465
- * It records the installation directory and the on-disk path associated with
466
- * each installed unit.
737
+ * It records the target directory and the on-disk path associated with each
738
+ * materialized unit.
467
739
  */
468
- var SystemdInstallResult = class {
740
+ var SystemdMaterialization = class {
469
741
  /** The directory units were written into. */
470
742
  directory;
471
- /** The installed units together with their resolved on-disk paths. */
472
- installed;
473
- pathByFilename;
474
- constructor(directory, installed) {
743
+ /** The materialized units together with their resolved on-disk paths. */
744
+ materialized;
745
+ constructor(directory, materialized) {
475
746
  this.directory = directory;
476
- this.installed = Object.freeze([...installed]);
477
- this.pathByFilename = new Map(installed.map((entry) => [entry.unit.filename, entry.path]));
747
+ this.materialized = Object.freeze([...materialized]);
478
748
  Object.freeze(this);
479
749
  }
480
- /** Returns the installed on-disk path for a previously installed unit. */
481
- pathFor(unit) {
482
- const path = this.pathByFilename.get(unit.filename);
483
- if (path === void 0) throw new Error(`No installed path is recorded for ${unit.filename}`);
484
- return path;
485
- }
486
750
  };
487
751
  /**
488
752
  * A configured interface to a specific systemd environment.
@@ -521,41 +785,50 @@ var Systemd = class {
521
785
  * Renders and writes one or more units into this instance's `unitDir`.
522
786
  *
523
787
  * 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.
788
+ * start the units on its own.
527
789
  */
528
- async install(...units) {
529
- if (units.length === 0) throw new Error(`Systemd.install() requires at least one service or timer`);
530
- assertInstallableTogether(units);
531
- await writeUnitDirectory(this.unitDir);
532
- const installed = [];
533
- for (const unit of units) {
534
- const path = join(this.unitDir, unit.filename);
535
- await writeFile(path, unit.render(), `utf8`);
536
- installed.push({
537
- path,
538
- unit
539
- });
540
- }
541
- return new SystemdInstallResult(this.unitDir, installed);
790
+ async materialize(...units) {
791
+ return this.materializeUnits(units);
542
792
  }
543
793
  /**
544
794
  * Enables one or more units via `systemctl enable`.
545
795
  *
546
- * When `linkUnits` is enabled, this first links installed unit files into the
796
+ * When `linkUnits` is enabled, this first links materialized unit files into the
547
797
  * target manager and reloads systemd so the units are visible before the
548
798
  * enable operation runs.
549
799
  */
550
800
  async enable(...units) {
551
- if (units.length === 0) throw new Error(`Systemd.enable() requires at least one service or timer`);
801
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.enable()`));
552
802
  const scopeArgs = this.scopeArgs();
553
- await this.prepareUnits(scopeArgs, units);
554
- for (const unit of units) await this.executor(`systemctl`, [
555
- ...scopeArgs,
556
- `enable`,
557
- unit.filename
558
- ]);
803
+ try {
804
+ await this.prepareUnits(scopeArgs, units, `enable`);
805
+ } catch (cause) {
806
+ if (cause instanceof UnitEnableError) return err(cause);
807
+ return err(new UnitEnableError(`Failed to prepare units for enable`, {
808
+ cause,
809
+ stage: `prepare`,
810
+ unitName: units[0]?.filename
811
+ }));
812
+ }
813
+ for (const unit of units) {
814
+ const args = [
815
+ ...scopeArgs,
816
+ `enable`,
817
+ unit.filename
818
+ ];
819
+ try {
820
+ await this.executor(`systemctl`, args);
821
+ } catch (cause) {
822
+ return err(new UnitEnableError(`Failed to enable ${unit.filename}`, {
823
+ args,
824
+ cause,
825
+ command: `systemctl`,
826
+ stage: `enable`,
827
+ unitName: unit.filename
828
+ }));
829
+ }
830
+ }
831
+ return ok(void 0);
559
832
  }
560
833
  /**
561
834
  * Starts a unit and returns a parsed `systemctl show` snapshot of its final
@@ -566,19 +839,54 @@ var Systemd = class {
566
839
  */
567
840
  async start(unit) {
568
841
  const scopeArgs = this.scopeArgs();
569
- await this.prepareUnits(scopeArgs, [unit]);
570
- await this.executor(`systemctl`, [
842
+ try {
843
+ await this.prepareUnits(scopeArgs, [unit], `start`);
844
+ } catch (cause) {
845
+ if (cause instanceof UnitStartError) return err(cause);
846
+ return err(new UnitStartError(`Failed to prepare ${unit.filename} for start`, {
847
+ cause,
848
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
849
+ stage: `prepare`,
850
+ unitName: unit.filename
851
+ }));
852
+ }
853
+ const startArgs = [
571
854
  ...scopeArgs,
572
855
  `start`,
573
856
  unit.filename
574
- ]);
575
- const status = await this.executor(`systemctl`, [
857
+ ];
858
+ try {
859
+ await this.executor(`systemctl`, startArgs);
860
+ } catch (cause) {
861
+ return err(new UnitStartError(`Failed to start ${unit.filename}`, {
862
+ args: startArgs,
863
+ cause,
864
+ command: `systemctl`,
865
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
866
+ stage: `start`,
867
+ unitName: unit.filename
868
+ }));
869
+ }
870
+ const statusArgs = [
576
871
  ...scopeArgs,
577
872
  `show`,
578
873
  unit.filename,
579
874
  `--property=Id,ActiveState,SubState,Result,ExecMainStatus`
580
- ]);
581
- return parseStartResult(unit.filename, status.stdout);
875
+ ];
876
+ let status;
877
+ try {
878
+ status = await this.executor(`systemctl`, statusArgs);
879
+ } catch (cause) {
880
+ return err(new UnitStartError(`Started ${unit.filename} but failed to query its status`, {
881
+ args: statusArgs,
882
+ cause,
883
+ command: `systemctl`,
884
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
885
+ stage: `show-status`,
886
+ unitName: unit.filename
887
+ }));
888
+ }
889
+ return ok(parseStartStatus(unit.filename, status.stdout));
582
890
  }
583
891
  /**
584
892
  * Reads recent output for a managed unit.
@@ -589,10 +897,20 @@ var Systemd = class {
589
897
  */
590
898
  async logs(unit, options) {
591
899
  const fileLogPath = resolveUnitLogPath(unit);
592
- if (fileLogPath !== void 0) return tailLines(await readFile(fileLogPath, `utf8`), options?.lines ?? 50);
900
+ if (fileLogPath !== void 0) try {
901
+ return ok(tailLines(await readFile(fileLogPath, `utf8`), options?.lines ?? 50));
902
+ } catch (cause) {
903
+ return err(new UnitLogsReadError(`Failed to read logs for ${unit.filename} from ${fileLogPath}`, {
904
+ cause,
905
+ reason: isMissingPathError(cause) ? `missing-log-file` : `log-file-read-failed`,
906
+ stage: `read-log-file`,
907
+ unitName: unit.filename,
908
+ unitPath: fileLogPath
909
+ }));
910
+ }
593
911
  const scopeArgs = this.scopeArgs();
594
912
  const lines = options?.lines ?? 50;
595
- const command = [
913
+ const args = [`-lc`, `${[
596
914
  `systemctl`,
597
915
  ...scopeArgs,
598
916
  `status`,
@@ -600,27 +918,100 @@ var Systemd = class {
600
918
  `--no-pager`,
601
919
  `--lines`,
602
920
  String(lines)
603
- ].map(shellQuote).join(` `);
604
- return (await this.executor(`bash`, [`-lc`, `${command} || true`])).stdout;
921
+ ].map(shellQuote).join(` `)} 2>&1 || true`];
922
+ let logs;
923
+ try {
924
+ logs = await this.executor(`bash`, args);
925
+ } catch (cause) {
926
+ return err(new UnitLogsReadError(`Failed to query logs for ${unit.filename} from systemctl status`, {
927
+ args,
928
+ cause,
929
+ command: `bash`,
930
+ reason: `status-command-failed`,
931
+ stage: `status`,
932
+ unitName: unit.filename
933
+ }));
934
+ }
935
+ return ok([logs.stdout, logs.stderr].filter((value) => value.length > 0).join(`\n`));
605
936
  }
606
937
  /** Returns the on-disk unit-file path this instance uses for the given unit. */
607
938
  pathFor(unit) {
608
939
  return join(this.unitDir, unit.filename);
609
940
  }
610
- async prepareUnits(scopeArgs, units) {
941
+ async prepareUnits(scopeArgs, units, operation) {
611
942
  if (this.linkUnits) {
612
943
  const linkPaths = await this.collectLinkPaths(units);
613
- for (const path of linkPaths) await this.executor(`systemctl`, [
614
- ...scopeArgs,
615
- `link`,
616
- path
617
- ]);
944
+ for (const path of linkPaths) {
945
+ const args = [
946
+ ...scopeArgs,
947
+ `link`,
948
+ path
949
+ ];
950
+ try {
951
+ await this.executor(`systemctl`, args);
952
+ } catch (cause) {
953
+ const linkedUnit = units.find((candidate) => this.pathFor(candidate) === path);
954
+ const errorContext = {
955
+ args,
956
+ cause,
957
+ command: `systemctl`,
958
+ stage: `link`,
959
+ ...linkedUnit === void 0 ? {} : { unitName: linkedUnit.filename },
960
+ unitPath: path
961
+ };
962
+ throw operation === `enable` ? new UnitEnableError(`Failed to link ${path} before enable`, errorContext) : new UnitStartError(`Failed to link ${path} before start`, errorContext);
963
+ }
964
+ }
965
+ }
966
+ const args = [...scopeArgs, `daemon-reload`];
967
+ try {
968
+ await this.executor(`systemctl`, args);
969
+ } catch (cause) {
970
+ throw operation === `enable` ? new UnitEnableError(`Failed to reload systemd before enable`, {
971
+ args,
972
+ cause,
973
+ command: `systemctl`,
974
+ stage: `daemon-reload`,
975
+ unitName: units[0]?.filename
976
+ }) : new UnitStartError(`Failed to reload systemd before start`, {
977
+ args,
978
+ cause,
979
+ command: `systemctl`,
980
+ stage: `daemon-reload`,
981
+ unitName: units[0]?.filename
982
+ });
618
983
  }
619
- await this.executor(`systemctl`, [...scopeArgs, `daemon-reload`]);
620
984
  }
621
985
  scopeArgs() {
622
986
  return this.scope === `user` ? [`--user`] : [];
623
987
  }
988
+ async collectStartDiagnostics(scopeArgs, unitName) {
989
+ const diagnostics = {};
990
+ const showCommand = [
991
+ `systemctl`,
992
+ ...scopeArgs,
993
+ `show`,
994
+ unitName,
995
+ `--property=Id,ActiveState,SubState,Result,ExecMainStatus`
996
+ ].map(shellQuote).join(` `);
997
+ const show = await this.tryBestEffortCommand(`bash`, [`-lc`, `${showCommand} 2>&1 || true`]);
998
+ if (show !== void 0 && show.length > 0) {
999
+ diagnostics.showOutput = show;
1000
+ diagnostics.showStatus = parseStartStatus(unitName, show);
1001
+ }
1002
+ const statusCommand = [
1003
+ `systemctl`,
1004
+ ...scopeArgs,
1005
+ `status`,
1006
+ unitName,
1007
+ `--no-pager`,
1008
+ `--lines`,
1009
+ `20`
1010
+ ].map(shellQuote).join(` `);
1011
+ const status = await this.tryBestEffortCommand(`bash`, [`-lc`, `${statusCommand} 2>&1 || true`]);
1012
+ if (status !== void 0 && status.length > 0) diagnostics.statusOutput = status;
1013
+ return diagnostics;
1014
+ }
624
1015
  async collectLinkPaths(units) {
625
1016
  const paths = /* @__PURE__ */ new Set();
626
1017
  for (const unit of units) {
@@ -632,7 +1023,64 @@ var Systemd = class {
632
1023
  }
633
1024
  return [...paths];
634
1025
  }
1026
+ async materializeUnits(units) {
1027
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.materialize()`));
1028
+ try {
1029
+ await writeUnitDirectory(this.unitDir);
1030
+ } catch (cause) {
1031
+ const reason = classifyMaterializationReason(cause);
1032
+ return err(new UnitMaterializationError(`Failed to create unit directory ${this.unitDir}`, {
1033
+ cause,
1034
+ operation: `create-directory`,
1035
+ ...reason === void 0 ? {} : { reason },
1036
+ unitPath: this.unitDir
1037
+ }));
1038
+ }
1039
+ const materialized = [];
1040
+ for (const unit of units) {
1041
+ const path = join(this.unitDir, unit.filename);
1042
+ const rendered = unit.render();
1043
+ if (!rendered.ok) return err(rendered.error);
1044
+ try {
1045
+ await writeFile(path, rendered.value, `utf8`);
1046
+ } catch (cause) {
1047
+ if (cause instanceof InvalidExecDirectiveError) return err(cause);
1048
+ const reason = classifyMaterializationReason(cause);
1049
+ return err(new UnitMaterializationError(`Failed to materialize ${unit.filename} into ${path}`, {
1050
+ cause,
1051
+ operation: `write-file`,
1052
+ ...reason === void 0 ? {} : { reason },
1053
+ unitName: unit.filename,
1054
+ unitPath: path
1055
+ }));
1056
+ }
1057
+ materialized.push({
1058
+ path,
1059
+ unit
1060
+ });
1061
+ }
1062
+ return ok(new SystemdMaterialization(this.unitDir, materialized));
1063
+ }
1064
+ async tryBestEffortCommand(command, args) {
1065
+ try {
1066
+ return (await this.executor(command, args)).stdout;
1067
+ } catch {
1068
+ return;
1069
+ }
1070
+ }
635
1071
  };
1072
+ function ok(value) {
1073
+ return {
1074
+ ok: true,
1075
+ value
1076
+ };
1077
+ }
1078
+ function err(error) {
1079
+ return {
1080
+ ok: false,
1081
+ error
1082
+ };
1083
+ }
636
1084
  let lazyDefaultSystemd;
637
1085
  /**
638
1086
  * Returns the lazily created default `Systemd` instance.
@@ -659,6 +1107,10 @@ function parseFileLogPath(value) {
659
1107
  function tailLines(output, lines) {
660
1108
  return output.split(`\n`).slice(-lines).join(`\n`);
661
1109
  }
1110
+ function isMissingPathError(cause) {
1111
+ if (cause === null || typeof cause !== `object`) return false;
1112
+ return cause[`code`] === `ENOENT`;
1113
+ }
662
1114
  async function writeUnitDirectory(path) {
663
1115
  await mkdir(path, { recursive: true });
664
1116
  }
@@ -670,4 +1122,4 @@ async function writeUnitDirectory(path) {
670
1122
  */
671
1123
  const Internal = internal_exports;
672
1124
  //#endregion
673
- export { Executable, Internal, Systemd, SystemdInstallResult, SystemdService, SystemdTimer, defaultSystemd, defineExecutable, notify };
1125
+ export { Executable, ExecutableInferenceError, Internal, InvalidExecDirectiveError, NoUnitsProvidedError, NotifySendError, Systemd, SystemdMaterialization, SystemdService, SystemdTimer, SystemdTsError, UnitEnableError, UnitLogsReadError, UnitMaterializationError, UnitStartError, defaultSystemd, defineExecutable, notify };