systemd-ts 0.1.1 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +297 -25
  2. package/dist/index.d.mts +461 -117
  3. package/dist/index.mjs +1179 -242
  4. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -2,9 +2,201 @@ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { execFile } from "node:child_process";
5
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { access, mkdir, readFile, rm, 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
+ var SystemctlCommandError = class extends SystemdTsError {
148
+ args;
149
+ command;
150
+ environmentReason;
151
+ exitCode;
152
+ operation;
153
+ reason;
154
+ stage;
155
+ stderr;
156
+ stdout;
157
+ constructor(message, options = {}) {
158
+ super(`SYSTEMD_TS_SYSTEMCTL`, message, options);
159
+ const details = extractCommandErrorDetails(options.cause);
160
+ this.args = options.args;
161
+ this.command = options.command;
162
+ this.environmentReason = options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
163
+ this.exitCode = details.exitCode;
164
+ this.operation = options.operation;
165
+ this.reason = options.reason;
166
+ this.stage = options.stage;
167
+ this.stderr = details.stderr;
168
+ this.stdout = details.stdout;
169
+ }
170
+ };
171
+ function classifyCommandEnvironmentReason(cause) {
172
+ if (cause === null || typeof cause !== `object`) return;
173
+ const record = cause;
174
+ if (record[`code`] === `ENOENT`) return `missing-command`;
175
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM`) return `permission-denied`;
176
+ const combinedOutput = [record[`stderr`], record[`stdout`]].filter((value) => typeof value === `string` && value.length > 0).join(`\n`);
177
+ 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`;
178
+ }
179
+ function classifyMaterializationReason(cause) {
180
+ if (cause === null || typeof cause !== `object`) return;
181
+ const record = cause;
182
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM` || record[`code`] === `EROFS`) return `permission-denied`;
183
+ if (record[`code`] === `EEXIST` || record[`code`] === `ENOTDIR` || record[`code`] === `EISDIR`) return `invalid-unit-directory`;
184
+ return `file-system-failed`;
185
+ }
186
+ function extractCommandErrorDetails(cause) {
187
+ if (cause === null || typeof cause !== `object`) return {
188
+ exitCode: void 0,
189
+ stderr: void 0,
190
+ stdout: void 0
191
+ };
192
+ const record = cause;
193
+ return {
194
+ exitCode: typeof record[`code`] === `number` ? record[`code`] : void 0,
195
+ stderr: typeof record[`stderr`] === `string` ? record[`stderr`] : void 0,
196
+ stdout: typeof record[`stdout`] === `string` ? record[`stdout`] : void 0
197
+ };
198
+ }
199
+ //#endregion
8
200
  //#region src/main/executable.ts
9
201
  const currentModulePath = fileURLToPath(import.meta.url);
10
202
  /**
@@ -23,6 +215,8 @@ const currentModulePath = fileURLToPath(import.meta.url);
23
215
  var Executable = class {
24
216
  /** Additional arguments passed after the module path. */
25
217
  args;
218
+ /** Inference issue captured when no explicit module path was provided. */
219
+ inferenceError;
26
220
  /** Absolute path to the module that should be executed. */
27
221
  modulePath;
28
222
  /** Absolute path to the runtime binary that should launch the module. */
@@ -40,7 +234,9 @@ var Executable = class {
40
234
  */
41
235
  constructor(options = {}) {
42
236
  this.runtimeEntrypoint = options.runtimeEntrypoint ?? process.execPath;
43
- this.modulePath = options.modulePath ?? inferCallerModulePath();
237
+ const inferredModulePath = options.modulePath ?? tryInferCallerModulePath();
238
+ this.inferenceError = inferredModulePath === void 0 && options.modulePath === void 0 ? new ExecutableInferenceError() : void 0;
239
+ this.modulePath = inferredModulePath ?? ``;
44
240
  this.args = Object.freeze([...options.args ?? []]);
45
241
  Object.freeze(this);
46
242
  }
@@ -51,18 +247,30 @@ var Executable = class {
51
247
  * path and any configured arguments.
52
248
  */
53
249
  toCommandParts() {
54
- return [
55
- this.runtimeEntrypoint,
56
- this.modulePath,
57
- ...this.args
58
- ];
250
+ if (this.inferenceError !== void 0) return {
251
+ ok: false,
252
+ error: this.inferenceError
253
+ };
254
+ return {
255
+ ok: true,
256
+ value: [
257
+ this.runtimeEntrypoint,
258
+ this.modulePath,
259
+ ...this.args
260
+ ]
261
+ };
59
262
  }
60
263
  /**
61
264
  * Renders the executable as a shell-quoted command string suitable for
62
265
  * executable-valued systemd directives such as `ExecStart=`.
63
266
  */
64
267
  toExecStart() {
65
- return this.toCommandParts().map(shellQuote$1).join(` `);
268
+ const commandParts = this.toCommandParts();
269
+ if (!commandParts.ok) return commandParts;
270
+ return {
271
+ ok: true,
272
+ value: commandParts.value.map(shellQuote).join(` `)
273
+ };
66
274
  }
67
275
  };
68
276
  /**
@@ -86,20 +294,19 @@ var Executable = class {
86
294
  */
87
295
  function defineExecutable(fn, options = {}) {
88
296
  const executable = new Executable(options);
89
- if (isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
297
+ if (executable.inferenceError === void 0 && isMainModule(executable.modulePath)) Promise.resolve(fn()).catch((error) => {
90
298
  process.exitCode = 1;
91
299
  throw error;
92
300
  });
93
301
  return executable;
94
302
  }
95
- function inferCallerModulePath() {
303
+ function tryInferCallerModulePath() {
96
304
  const stack = (/* @__PURE__ */ new Error()).stack ?? ``;
97
305
  for (const line of stack.split(`\n`).slice(1)) {
98
306
  const candidate = extractStackPath(line);
99
307
  if (candidate === void 0 || candidate === currentModulePath) continue;
100
308
  return candidate;
101
309
  }
102
- throw new Error(`Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`);
103
310
  }
104
311
  function extractStackPath(line) {
105
312
  const fileUrlMatch = line.match(/(file:\/\/\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
@@ -112,7 +319,7 @@ function isMainModule(modulePath) {
112
319
  if (mainArg === void 0) return false;
113
320
  return normalizeFilePath(mainArg) === normalizeFilePath(modulePath);
114
321
  }
115
- function shellQuote$1(value) {
322
+ function shellQuote(value) {
116
323
  return `'${value.replaceAll(`'`, `'\\''`)}'`;
117
324
  }
118
325
  function normalizeFilePath(path) {
@@ -123,135 +330,19 @@ function normalizeFilePath(path) {
123
330
  }
124
331
  }
125
332
  //#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
333
  //#region src/main/internal.ts
242
334
  var internal_exports = /* @__PURE__ */ __exportAll({
243
- assertInstallableTogether: () => assertInstallableTogether,
244
335
  cloneUnitSection: () => cloneUnitSection,
245
336
  defaultCommandExecutor: () => defaultCommandExecutor,
246
337
  defaultUnitDirForScope: () => defaultUnitDirForScope,
338
+ extractCommandOutput: () => extractCommandOutput,
247
339
  fileExists: () => fileExists,
248
340
  freezeUnitOptions: () => freezeUnitOptions,
249
341
  normalizeUnitName: () => normalizeUnitName,
250
- parseStartResult: () => parseStartResult,
342
+ parseStartStatus: () => parseStartStatus,
251
343
  renderUnitFile: () => renderUnitFile,
252
344
  resolveTimerTargetUnit: () => resolveTimerTargetUnit,
253
345
  sendNotify: () => sendNotify,
254
- shellQuote: () => shellQuote,
255
346
  validateServiceSection: () => validateServiceSection
256
347
  });
257
348
  const EXEC_DIRECTIVE_KEYS = [
@@ -295,17 +386,16 @@ function cloneUnitSection(section) {
295
386
  return Object.freeze(Object.fromEntries(entries));
296
387
  }
297
388
  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}`);
389
+ for (const key of EXEC_DIRECTIVE_KEYS) {
390
+ const validation = assertAbsoluteExecValue(key, service[key]);
391
+ if (!validation.ok) return validation;
306
392
  }
393
+ return {
394
+ ok: true,
395
+ value: void 0
396
+ };
307
397
  }
308
- function parseStartResult(unit, output) {
398
+ function parseStartStatus(unit, output) {
309
399
  const properties = Object.fromEntries(output.split(`\n`).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
310
400
  const separatorIndex = line.indexOf(`=`);
311
401
  if (separatorIndex === -1) return [line, ``];
@@ -320,27 +410,47 @@ function parseStartResult(unit, output) {
320
410
  };
321
411
  }
322
412
  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(`'`, `'\\''`)}'`;
413
+ try {
414
+ const renderedSections = sections.flatMap(([sectionName, section]) => {
415
+ if (section === void 0) return [];
416
+ const lines = [];
417
+ for (const [key, value] of Object.entries(section)) {
418
+ if (value === void 0) continue;
419
+ if (isUnitValueList(value)) {
420
+ for (const entry of value) {
421
+ const rendered = stringifyUnitValue(entry);
422
+ if (!rendered.ok) throw rendered.error;
423
+ lines.push(`${key}=${rendered.value}`);
424
+ }
425
+ continue;
426
+ }
427
+ const rendered = stringifyUnitValue(value);
428
+ if (!rendered.ok) throw rendered.error;
429
+ lines.push(`${key}=${rendered.value}`);
430
+ }
431
+ if (lines.length === 0) return [];
432
+ return [
433
+ `[${sectionName}]`,
434
+ ...lines,
435
+ ``
436
+ ];
437
+ }).join(`\n`);
438
+ return {
439
+ ok: true,
440
+ value: renderedSections.endsWith(`\n`) ? renderedSections : `${renderedSections}\n`
441
+ };
442
+ } catch (error) {
443
+ return {
444
+ ok: false,
445
+ error: error instanceof ExecutableInferenceError ? error : new ExecutableInferenceError({ cause: error })
446
+ };
447
+ }
341
448
  }
342
- async function defaultCommandExecutor(command, args) {
343
- const result = await execFileAsync(command, [...args]);
449
+ async function defaultCommandExecutor(command, args, options = {}) {
450
+ const result = await execFileAsync(command, [...args], options.env === void 0 ? {} : { env: {
451
+ ...process.env,
452
+ ...options.env
453
+ } });
344
454
  return {
345
455
  stderr: result.stderr,
346
456
  stdout: result.stdout
@@ -355,18 +465,48 @@ async function fileExists(path) {
355
465
  }
356
466
  }
357
467
  async function sendNotify(state, options) {
358
- const args = [state];
468
+ const args = [`--no-block`, state];
359
469
  if (options.pid !== void 0) args.push(`MAINPID=${options.pid}`);
360
470
  if (options.status !== void 0) args.push(`STATUS=${options.status}`);
361
471
  if (options.executor !== void 0) {
362
- const command = buildNotifyShellCommand(args, options.socketPath);
363
- await options.executor(`bash`, [`-lc`, command]);
472
+ try {
473
+ await options.executor(`systemd-notify`, args, options.socketPath === void 0 ? {} : { env: { NOTIFY_SOCKET: options.socketPath } });
474
+ } catch (cause) {
475
+ throw new NotifySendError(`Failed to send systemd notification through the configured executor`, {
476
+ args,
477
+ cause,
478
+ command: `systemd-notify`,
479
+ reason: `executor-failed`,
480
+ stage: `executor`
481
+ });
482
+ }
364
483
  return;
365
484
  }
366
- await execFileAsync(`systemd-notify`, args, { env: {
367
- ...process.env,
368
- ...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
369
- } });
485
+ try {
486
+ await execFileAsync(`systemd-notify`, args, { env: {
487
+ ...process.env,
488
+ ...options.socketPath === void 0 ? {} : { NOTIFY_SOCKET: options.socketPath }
489
+ } });
490
+ } catch (cause) {
491
+ throw new NotifySendError(`Failed to send systemd notification with systemd-notify`, {
492
+ args,
493
+ cause,
494
+ command: `systemd-notify`,
495
+ reason: `systemd-notify-failed`,
496
+ stage: `systemd-notify`
497
+ });
498
+ }
499
+ }
500
+ function extractCommandOutput(cause) {
501
+ if (cause === null || typeof cause !== `object`) return;
502
+ const record = cause;
503
+ const stdout = typeof record[`stdout`] === `string` ? record[`stdout`] : void 0;
504
+ const stderr = typeof record[`stderr`] === `string` ? record[`stderr`] : void 0;
505
+ if (stdout === void 0 && stderr === void 0) return;
506
+ return {
507
+ stderr: stderr ?? ``,
508
+ stdout: stdout ?? ``
509
+ };
370
510
  }
371
511
  function hasServiceSection(options) {
372
512
  return `service` in options;
@@ -376,19 +516,41 @@ function hasTimerSection(options) {
376
516
  }
377
517
  function assertAbsoluteExecValue(key, value) {
378
518
  if (isUnitValueList(value)) {
379
- for (const entry of value) assertAbsoluteExecEntry(key, entry);
380
- return;
519
+ for (const entry of value) {
520
+ const validation = assertAbsoluteExecEntry(key, entry);
521
+ if (!validation.ok) return validation;
522
+ }
523
+ return {
524
+ ok: true,
525
+ value: void 0
526
+ };
381
527
  }
382
- assertAbsoluteExecEntry(key, value);
528
+ return assertAbsoluteExecEntry(key, value);
383
529
  }
384
530
  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`);
531
+ if (typeof value === `string` && value.length > 0 && !isAbsoluteExecCommand(value)) return {
532
+ ok: false,
533
+ error: new InvalidExecDirectiveError(key)
534
+ };
535
+ if (value instanceof Executable && !value.runtimeEntrypoint.startsWith(`/`)) return {
536
+ ok: false,
537
+ error: new InvalidExecDirectiveError(key)
538
+ };
539
+ return {
540
+ ok: true,
541
+ value: void 0
542
+ };
387
543
  }
388
544
  function stringifyUnitValue(value) {
389
545
  if (value instanceof Executable) return value.toExecStart();
390
- if (typeof value === `boolean`) return value ? `true` : `false`;
391
- return String(value);
546
+ if (typeof value === `boolean`) return {
547
+ ok: true,
548
+ value: value ? `true` : `false`
549
+ };
550
+ return {
551
+ ok: true,
552
+ value: String(value)
553
+ };
392
554
  }
393
555
  function isUnitValueList(value) {
394
556
  return Array.isArray(value);
@@ -418,9 +580,6 @@ function isAbsoluteExecCommand(value) {
418
580
  }
419
581
  return value[index] === `/`;
420
582
  }
421
- function buildNotifyShellCommand(args, socketPath) {
422
- return `${socketPath === void 0 ? `` : `NOTIFY_SOCKET=${shellQuote(socketPath)} `}${[`systemd-notify`, ...args].map(shellQuote).join(` `)}`;
423
- }
424
583
  //#endregion
425
584
  //#region src/main/notify.ts
426
585
  /**
@@ -444,7 +603,21 @@ const notify = {
444
603
  * sent as an additional `STATUS=...` field.
445
604
  */
446
605
  async ready(options = {}) {
447
- await sendNotify(`READY=1`, options);
606
+ try {
607
+ await sendNotify(`READY=1`, options);
608
+ return {
609
+ ok: true,
610
+ value: void 0
611
+ };
612
+ } catch (error) {
613
+ return {
614
+ ok: false,
615
+ error: error instanceof NotifySendError ? error : new NotifySendError(`Failed to send READY=1 notification`, {
616
+ cause: error,
617
+ stage: `ready`
618
+ })
619
+ };
620
+ }
448
621
  },
449
622
  /**
450
623
  * Sends `WATCHDOG=1` to systemd.
@@ -454,35 +627,375 @@ const notify = {
454
627
  * `MAINPID=...`, and `options.status` is forwarded as `STATUS=...`.
455
628
  */
456
629
  async watchdog(options = {}) {
457
- await sendNotify(`WATCHDOG=1`, options);
630
+ try {
631
+ await sendNotify(`WATCHDOG=1`, options);
632
+ return {
633
+ ok: true,
634
+ value: void 0
635
+ };
636
+ } catch (error) {
637
+ return {
638
+ ok: false,
639
+ error: error instanceof NotifySendError ? error : new NotifySendError(`Failed to send WATCHDOG=1 notification`, {
640
+ cause: error,
641
+ stage: `watchdog`
642
+ })
643
+ };
644
+ }
645
+ }
646
+ };
647
+ //#endregion
648
+ //#region src/main/systemd-service.ts
649
+ /**
650
+ * An immutable definition of a `.service` unit.
651
+ *
652
+ * `SystemdService` is a pure value object: it captures the intended unit name
653
+ * and section contents, but it does not write files or talk to `systemd`
654
+ * directly. Operational actions such as installation or startup belong on
655
+ * {@link Systemd}.
656
+ *
657
+ * Source:
658
+ * - https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
659
+ */
660
+ var SystemdService = class {
661
+ /** Optional `[Install]` section for enable-time relationships. */
662
+ install;
663
+ /** The normalized base unit name, without the `.service` suffix. */
664
+ name;
665
+ /** The fully frozen original options used to construct this service. */
666
+ options;
667
+ /** The `[Service]` section payload. */
668
+ service;
669
+ /** Optional `[Unit]` section metadata and dependency configuration. */
670
+ unit;
671
+ /**
672
+ * Creates an immutable service definition.
673
+ *
674
+ * The constructor preserves literal types where possible and rejects unknown
675
+ * directive names within the provided sections, while still allowing custom
676
+ * `X-...` extension directives.
677
+ */
678
+ constructor(options) {
679
+ this.options = freezeUnitOptions(options);
680
+ this.name = normalizeUnitName(options.name, `.service`);
681
+ this.unit = cloneUnitSection(options.unit);
682
+ this.service = cloneUnitSection(options.service) ?? {};
683
+ this.install = cloneUnitSection(options.install);
684
+ Object.freeze(this);
685
+ }
686
+ /** The canonical unit filename, including the `.service` suffix. */
687
+ get filename() {
688
+ return `${this.name}.service`;
689
+ }
690
+ /**
691
+ * Renders the service as a complete unit file.
692
+ *
693
+ * Rendering also validates executable-valued directives such as `ExecStart`
694
+ * and `ExecStop`, ensuring they use absolute runtime entrypoints as required
695
+ * by systemd.
696
+ */
697
+ render() {
698
+ const validation = validateServiceSection(this.service);
699
+ if (!validation.ok) return validation;
700
+ return renderUnitFile([
701
+ [`Unit`, this.unit],
702
+ [`Service`, this.service],
703
+ [`Install`, this.install]
704
+ ]);
458
705
  }
459
706
  };
460
707
  //#endregion
708
+ //#region src/main/systemd-timer.ts
709
+ /**
710
+ * An immutable definition of a `.timer` unit.
711
+ *
712
+ * Like {@link SystemdService}, this is a pure value object. It models the timer
713
+ * configuration and its attachment target, but does not write files or interact
714
+ * with the service manager directly.
715
+ *
716
+ * Source:
717
+ * - https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
718
+ */
719
+ var SystemdTimer = class {
720
+ /** Optional `[Install]` section for enable-time relationships. */
721
+ install;
722
+ /** The normalized base unit name, without the `.timer` suffix. */
723
+ name;
724
+ /** The fully frozen original options used to construct this timer. */
725
+ options;
726
+ /** The attached service basename inferred from `targetUnit`. */
727
+ targetServiceName;
728
+ /** The unit name this timer activates, explicit or implicit. */
729
+ targetUnit;
730
+ /** The `[Timer]` section payload. */
731
+ timer;
732
+ /** Optional `[Unit]` section metadata and dependency configuration. */
733
+ unit;
734
+ /**
735
+ * Creates an immutable timer definition.
736
+ *
737
+ * If no explicit `timer.Unit` is provided, the target defaults to the service
738
+ * with the same basename, matching systemd's native timer behavior.
739
+ */
740
+ constructor(options) {
741
+ this.options = freezeUnitOptions(options);
742
+ this.name = normalizeUnitName(options.name, `.timer`);
743
+ this.unit = cloneUnitSection(options.unit);
744
+ this.timer = cloneUnitSection(options.timer) ?? {};
745
+ this.install = cloneUnitSection(options.install);
746
+ this.targetUnit = resolveTimerTargetUnit(options);
747
+ this.targetServiceName = normalizeUnitName(this.targetUnit, `.service`);
748
+ Object.freeze(this);
749
+ }
750
+ /** The canonical unit filename, including the `.timer` suffix. */
751
+ get filename() {
752
+ return `${this.name}.timer`;
753
+ }
754
+ /** Renders the timer as a complete unit file. */
755
+ render() {
756
+ return renderUnitFile([
757
+ [`Unit`, this.unit],
758
+ [`Timer`, this.timer],
759
+ [`Install`, this.install]
760
+ ]);
761
+ }
762
+ };
763
+ //#endregion
764
+ //#region src/main/systemctl.ts
765
+ /**
766
+ * A TypeScript-first client for invoking `systemctl` with structured argument
767
+ * vectors instead of shell-composed command strings.
768
+ *
769
+ * This lower-level primitive is useful both on its own and as the subprocess
770
+ * transport underneath higher-level abstractions such as {@link Systemd}.
771
+ */
772
+ var Systemctl = class {
773
+ /** Command executor used for `systemctl` subprocesses. */
774
+ executor;
775
+ /** The target manager scope, either `system` or `user`. */
776
+ scope;
777
+ constructor(options = {}) {
778
+ this.scope = options.scope ?? `system`;
779
+ this.executor = options.executor ?? defaultCommandExecutor;
780
+ Object.freeze(this);
781
+ }
782
+ /** Reloads the targeted systemd manager. */
783
+ async daemonReload() {
784
+ return this.run(`daemon-reload`);
785
+ }
786
+ /** Enables a unit by filename. */
787
+ async enable(unit) {
788
+ return this.run(`enable`, unit);
789
+ }
790
+ /** Disables a unit by filename. */
791
+ async disable(unit) {
792
+ return this.run(`disable`, unit);
793
+ }
794
+ /** Links a unit file path into the targeted manager. */
795
+ async link(path) {
796
+ return this.run(`link`, path);
797
+ }
798
+ /**
799
+ * Checks the install-time enablement state of a unit file.
800
+ *
801
+ * Source:
802
+ * - systemd v260.1, `systemctl(1)`
803
+ */
804
+ async isEnabled(unit, options = {}) {
805
+ const output = await this.run(`is-enabled`, ...options.full ? [`--full`] : [], ...options.quiet ? [`--quiet`] : [], unit);
806
+ if (!output.ok) return output;
807
+ return ok$1(firstNonEmptyOutputLine(output.value));
808
+ }
809
+ /**
810
+ * Checks the runtime active state of a unit.
811
+ *
812
+ * Unless `quiet` is set, this returns the state string printed by
813
+ * `systemctl is-active`.
814
+ */
815
+ async isActive(unit, options = {}) {
816
+ const output = await this.run(`is-active`, ...options.quiet ? [`--quiet`] : [], unit);
817
+ if (!output.ok) return output;
818
+ return ok$1(firstNonEmptyOutputLine(output.value));
819
+ }
820
+ /**
821
+ * Checks whether a unit is in the failed state.
822
+ *
823
+ * Unless `quiet` is set, this returns the state string printed by
824
+ * `systemctl is-failed`.
825
+ */
826
+ async isFailed(unit, options = {}) {
827
+ const output = await this.run(`is-failed`, ...options.quiet ? [`--quiet`] : [], unit);
828
+ if (!output.ok) return output;
829
+ return ok$1(firstNonEmptyOutputLine(output.value));
830
+ }
831
+ /**
832
+ * Queries the overall manager state reported by `systemctl is-system-running`.
833
+ *
834
+ * Source:
835
+ * - systemd v260.1, `systemctl(1)`
836
+ */
837
+ async isSystemRunning(options = {}) {
838
+ const output = await this.run(`is-system-running`, ...options.wait ? [`--wait`] : []);
839
+ if (!output.ok) return output;
840
+ return ok$1(firstNonEmptyOutputLine(output.value));
841
+ }
842
+ /**
843
+ * Queries `systemctl show` for a unit.
844
+ *
845
+ * When `properties` are provided, only those properties are requested.
846
+ */
847
+ async show(unit, options = {}) {
848
+ const propertyFlag = options.properties === void 0 || options.properties.length === 0 ? [] : [`--property=${options.properties.join(`,`)}`];
849
+ return this.run(`show`, unit, ...propertyFlag);
850
+ }
851
+ /**
852
+ * Queries `systemctl show` and parses the result into a key-value object.
853
+ *
854
+ * Properties are returned exactly as printed by `systemctl show`.
855
+ */
856
+ async showProperties(unit, options = {}) {
857
+ const output = await this.show(unit, options);
858
+ if (!output.ok) return output;
859
+ return ok$1(parseSystemctlShowOutput(output.value.stdout));
860
+ }
861
+ /**
862
+ * Queries the standard status-related `show` properties for a unit and
863
+ * returns a parsed snapshot.
864
+ */
865
+ async showStatus(unit) {
866
+ return this.showServiceStatus(unit);
867
+ }
868
+ /**
869
+ * Queries the standard service-status `show` properties for a service unit
870
+ * and returns a parsed snapshot.
871
+ */
872
+ async showServiceStatus(unit) {
873
+ const output = await this.show(unit, { properties: [
874
+ `Id`,
875
+ `ActiveState`,
876
+ `SubState`,
877
+ `Result`,
878
+ `ExecMainStatus`
879
+ ] });
880
+ if (!output.ok) return output;
881
+ return ok$1(parseStartStatus(unit, output.value.stdout));
882
+ }
883
+ /**
884
+ * Queries `systemctl list-timers` and returns the observed JSON payload
885
+ * shape directly, with raw integer timestamps left intact.
886
+ */
887
+ async listTimers(options = {}) {
888
+ const args = [
889
+ `list-timers`,
890
+ ...options.all ? [`--all`] : [],
891
+ ...options.noPager ?? true ? [`--no-pager`] : [],
892
+ `--output=json`,
893
+ ...options.patterns ?? []
894
+ ];
895
+ const output = await this.run(...args);
896
+ if (!output.ok) return output;
897
+ try {
898
+ return ok$1(JSON.parse(output.value.stdout));
899
+ } catch (cause) {
900
+ return err$1(new SystemctlCommandError(`Failed to parse JSON output from systemctl list-timers`, {
901
+ args: [...this.scopeArgs(), ...args],
902
+ cause,
903
+ command: `systemctl`,
904
+ operation: `list-timers`,
905
+ reason: `invalid-json`
906
+ }));
907
+ }
908
+ }
909
+ /** Starts a unit by filename. */
910
+ async start(unit) {
911
+ return this.run(`start`, unit);
912
+ }
913
+ /** Stops a unit by filename. */
914
+ async stop(unit) {
915
+ return this.run(`stop`, unit);
916
+ }
917
+ /** Restarts a unit by filename. */
918
+ async restart(unit) {
919
+ return this.run(`restart`, unit);
920
+ }
921
+ /**
922
+ * Resets the failed state for matching units, or for all units when no
923
+ * patterns are provided.
924
+ */
925
+ async resetFailed(patterns = []) {
926
+ return this.run(`reset-failed`, ...patterns);
927
+ }
928
+ /**
929
+ * Queries `systemctl status` for a unit.
930
+ *
931
+ * This is primarily a human-oriented command and is best paired with
932
+ * `show()` when structured state is needed.
933
+ */
934
+ async status(unit, options = {}) {
935
+ const args = [`status`, unit];
936
+ if (options.noPager ?? true) args.push(`--no-pager`);
937
+ if (options.lines !== void 0) args.push(`--lines`, String(options.lines));
938
+ return this.run(...args);
939
+ }
940
+ /** Executes a raw `systemctl` invocation with scope-aware arguments. */
941
+ async run(...args) {
942
+ const scopedArgs = [...this.scopeArgs(), ...args];
943
+ try {
944
+ return ok$1(await this.executor(`systemctl`, scopedArgs));
945
+ } catch (cause) {
946
+ return err$1(new SystemctlCommandError(`Failed to run systemctl ${args[0] ?? `command`}`, {
947
+ args: scopedArgs,
948
+ cause,
949
+ command: `systemctl`,
950
+ operation: args[0],
951
+ reason: `executor-failed`
952
+ }));
953
+ }
954
+ }
955
+ scopeArgs() {
956
+ return this.scope === `user` ? [`--user`] : [];
957
+ }
958
+ };
959
+ function ok$1(value) {
960
+ return {
961
+ ok: true,
962
+ value
963
+ };
964
+ }
965
+ function err$1(error) {
966
+ return {
967
+ ok: false,
968
+ error
969
+ };
970
+ }
971
+ function parseSystemctlShowOutput(output) {
972
+ return Object.freeze(Object.fromEntries(output.split(`\n`).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
973
+ const separatorIndex = line.indexOf(`=`);
974
+ if (separatorIndex === -1) return [line, ``];
975
+ return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
976
+ })));
977
+ }
978
+ function firstNonEmptyOutputLine(output) {
979
+ return [output.stdout, output.stderr].flatMap((value) => value.split(`\n`)).map((value) => value.trim()).find((value) => value.length > 0) ?? ``;
980
+ }
981
+ //#endregion
461
982
  //#region src/main/systemd.ts
462
983
  /**
463
- * The result of installing one or more units with {@link Systemd.install}.
984
+ * A successful systemd unit materialization.
464
985
  *
465
- * It records the installation directory and the on-disk path associated with
466
- * each installed unit.
986
+ * It records the target directory and the on-disk path associated with each
987
+ * materialized unit.
467
988
  */
468
- var SystemdInstallResult = class {
989
+ var SystemdMaterialization = class {
469
990
  /** The directory units were written into. */
470
991
  directory;
471
- /** The installed units together with their resolved on-disk paths. */
472
- installed;
473
- pathByFilename;
474
- constructor(directory, installed) {
992
+ /** The materialized units together with their resolved on-disk paths. */
993
+ materialized;
994
+ constructor(directory, materialized) {
475
995
  this.directory = directory;
476
- this.installed = Object.freeze([...installed]);
477
- this.pathByFilename = new Map(installed.map((entry) => [entry.unit.filename, entry.path]));
996
+ this.materialized = Object.freeze([...materialized]);
478
997
  Object.freeze(this);
479
998
  }
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
999
  };
487
1000
  /**
488
1001
  * A configured interface to a specific systemd environment.
@@ -501,6 +1014,10 @@ var Systemd = class {
501
1014
  linkUnits;
502
1015
  /** The target manager scope, either `system` or `user`. */
503
1016
  scope;
1017
+ /** The low-level `systemctl` client for this configured target. */
1018
+ systemctl;
1019
+ /** Higher-level `systemd-ts` upkeep helpers for application-owned units. */
1020
+ ts;
504
1021
  /** The directory used when materializing unit files. */
505
1022
  unitDir;
506
1023
  /**
@@ -515,47 +1032,62 @@ var Systemd = class {
515
1032
  this.unitDir = options.unitDir ?? defaultUnitDirForScope(this.scope);
516
1033
  this.linkUnits = options.linkUnits ?? false;
517
1034
  this.executor = options.executor ?? defaultCommandExecutor;
1035
+ this.systemctl = new Systemctl({
1036
+ executor: this.executor,
1037
+ scope: this.scope
1038
+ });
1039
+ this.ts = new SystemdTs(this);
518
1040
  Object.freeze(this);
519
1041
  }
520
1042
  /**
521
1043
  * Renders and writes one or more units into this instance's `unitDir`.
522
1044
  *
523
1045
  * 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.
1046
+ * start the units on its own.
527
1047
  */
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);
1048
+ async materialize(...units) {
1049
+ return this.materializeUnits(units);
542
1050
  }
543
1051
  /**
544
1052
  * Enables one or more units via `systemctl enable`.
545
1053
  *
546
- * When `linkUnits` is enabled, this first links installed unit files into the
1054
+ * When `linkUnits` is enabled, this first links materialized unit files into the
547
1055
  * target manager and reloads systemd so the units are visible before the
548
1056
  * enable operation runs.
549
1057
  */
550
1058
  async enable(...units) {
551
- if (units.length === 0) throw new Error(`Systemd.enable() requires at least one service or timer`);
1059
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.enable()`));
552
1060
  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
- ]);
1061
+ try {
1062
+ await this.prepareUnits(scopeArgs, units, `enable`);
1063
+ } catch (cause) {
1064
+ if (cause instanceof UnitEnableError) return err(cause);
1065
+ return err(new UnitEnableError(`Failed to prepare units for enable`, {
1066
+ cause,
1067
+ stage: `prepare`,
1068
+ unitName: units[0]?.filename
1069
+ }));
1070
+ }
1071
+ for (const unit of units) {
1072
+ const args = [
1073
+ ...scopeArgs,
1074
+ `enable`,
1075
+ unit.filename
1076
+ ];
1077
+ const enabled = await this.systemctl.enable(unit.filename);
1078
+ if (!enabled.ok) {
1079
+ const errorOptions = {
1080
+ args,
1081
+ cause: enabled.error,
1082
+ command: `systemctl`,
1083
+ stage: `enable`,
1084
+ unitName: unit.filename,
1085
+ ...enabled.error.environmentReason === void 0 ? {} : { environmentReason: enabled.error.environmentReason }
1086
+ };
1087
+ return err(new UnitEnableError(`Failed to enable ${unit.filename}`, errorOptions));
1088
+ }
1089
+ }
1090
+ return ok(void 0);
559
1091
  }
560
1092
  /**
561
1093
  * Starts a unit and returns a parsed `systemctl show` snapshot of its final
@@ -566,19 +1098,54 @@ var Systemd = class {
566
1098
  */
567
1099
  async start(unit) {
568
1100
  const scopeArgs = this.scopeArgs();
569
- await this.prepareUnits(scopeArgs, [unit]);
570
- await this.executor(`systemctl`, [
1101
+ try {
1102
+ await this.prepareUnits(scopeArgs, [unit], `start`);
1103
+ } catch (cause) {
1104
+ if (cause instanceof UnitStartError) return err(cause);
1105
+ return err(new UnitStartError(`Failed to prepare ${unit.filename} for start`, {
1106
+ cause,
1107
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
1108
+ stage: `prepare`,
1109
+ unitName: unit.filename
1110
+ }));
1111
+ }
1112
+ const startArgs = [
571
1113
  ...scopeArgs,
572
1114
  `start`,
573
1115
  unit.filename
574
- ]);
575
- const status = await this.executor(`systemctl`, [
576
- ...scopeArgs,
577
- `show`,
578
- unit.filename,
579
- `--property=Id,ActiveState,SubState,Result,ExecMainStatus`
580
- ]);
581
- return parseStartResult(unit.filename, status.stdout);
1116
+ ];
1117
+ const started = await this.systemctl.start(unit.filename);
1118
+ if (!started.ok) {
1119
+ const errorOptions = {
1120
+ args: startArgs,
1121
+ cause: started.error,
1122
+ command: `systemctl`,
1123
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
1124
+ stage: `start`,
1125
+ unitName: unit.filename,
1126
+ ...started.error.environmentReason === void 0 ? {} : { environmentReason: started.error.environmentReason }
1127
+ };
1128
+ return err(new UnitStartError(`Failed to start ${unit.filename}`, errorOptions));
1129
+ }
1130
+ const status = await this.systemctl.showServiceStatus(unit.filename);
1131
+ if (!status.ok) {
1132
+ const errorOptions = {
1133
+ args: [
1134
+ ...scopeArgs,
1135
+ `show`,
1136
+ unit.filename,
1137
+ `--property=Id,ActiveState,SubState,Result,ExecMainStatus`
1138
+ ],
1139
+ cause: status.error,
1140
+ command: `systemctl`,
1141
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
1142
+ stage: `show-status`,
1143
+ unitName: unit.filename,
1144
+ ...status.error.environmentReason === void 0 ? {} : { environmentReason: status.error.environmentReason }
1145
+ };
1146
+ return err(new UnitStartError(`Started ${unit.filename} but failed to query its status`, errorOptions));
1147
+ }
1148
+ return ok(status.value);
582
1149
  }
583
1150
  /**
584
1151
  * Reads recent output for a managed unit.
@@ -589,38 +1156,115 @@ var Systemd = class {
589
1156
  */
590
1157
  async logs(unit, options) {
591
1158
  const fileLogPath = resolveUnitLogPath(unit);
592
- if (fileLogPath !== void 0) return tailLines(await readFile(fileLogPath, `utf8`), options?.lines ?? 50);
1159
+ if (fileLogPath !== void 0) try {
1160
+ return ok(tailLines(await readFile(fileLogPath, `utf8`), options?.lines ?? 50));
1161
+ } catch (cause) {
1162
+ return err(new UnitLogsReadError(`Failed to read logs for ${unit.filename} from ${fileLogPath}`, {
1163
+ cause,
1164
+ reason: isMissingPathError(cause) ? `missing-log-file` : `log-file-read-failed`,
1165
+ stage: `read-log-file`,
1166
+ unitName: unit.filename,
1167
+ unitPath: fileLogPath
1168
+ }));
1169
+ }
593
1170
  const scopeArgs = this.scopeArgs();
594
1171
  const lines = options?.lines ?? 50;
595
- const command = [
596
- `systemctl`,
1172
+ const logs = await this.systemctl.status(unit.filename, { lines });
1173
+ if (logs.ok) return ok(joinCommandOutput(logs.value));
1174
+ const errorOutput = extractCommandOutput(logs.error);
1175
+ if (errorOutput !== void 0) return ok(joinCommandOutput(errorOutput));
1176
+ const args = [
597
1177
  ...scopeArgs,
598
1178
  `status`,
599
1179
  unit.filename,
600
1180
  `--no-pager`,
601
1181
  `--lines`,
602
1182
  String(lines)
603
- ].map(shellQuote).join(` `);
604
- return (await this.executor(`bash`, [`-lc`, `${command} || true`])).stdout;
1183
+ ];
1184
+ return err(new UnitLogsReadError(`Failed to query logs for ${unit.filename} from systemctl status`, {
1185
+ args,
1186
+ cause: logs.error,
1187
+ command: `systemctl`,
1188
+ reason: `status-command-failed`,
1189
+ stage: `status`,
1190
+ unitName: unit.filename
1191
+ }));
605
1192
  }
606
1193
  /** Returns the on-disk unit-file path this instance uses for the given unit. */
607
1194
  pathFor(unit) {
608
1195
  return join(this.unitDir, unit.filename);
609
1196
  }
610
- async prepareUnits(scopeArgs, units) {
1197
+ async prepareUnits(scopeArgs, units, operation) {
611
1198
  if (this.linkUnits) {
612
1199
  const linkPaths = await this.collectLinkPaths(units);
613
- for (const path of linkPaths) await this.executor(`systemctl`, [
614
- ...scopeArgs,
615
- `link`,
616
- path
617
- ]);
1200
+ for (const path of linkPaths) {
1201
+ const args = [
1202
+ ...scopeArgs,
1203
+ `link`,
1204
+ path
1205
+ ];
1206
+ const linked = await this.systemctl.link(path);
1207
+ if (!linked.ok) {
1208
+ const linkedUnit = units.find((candidate) => this.pathFor(candidate) === path);
1209
+ const errorContext = {
1210
+ args,
1211
+ cause: linked.error,
1212
+ command: `systemctl`,
1213
+ stage: `link`,
1214
+ unitPath: path,
1215
+ ...linked.error.environmentReason === void 0 ? {} : { environmentReason: linked.error.environmentReason },
1216
+ ...linkedUnit === void 0 ? {} : { unitName: linkedUnit.filename }
1217
+ };
1218
+ throw operation === `enable` ? new UnitEnableError(`Failed to link ${path} before enable`, errorContext) : new UnitStartError(`Failed to link ${path} before start`, errorContext);
1219
+ }
1220
+ }
1221
+ }
1222
+ const args = [...scopeArgs, `daemon-reload`];
1223
+ const reloaded = await this.systemctl.daemonReload();
1224
+ if (!reloaded.ok) {
1225
+ const errorContext = {
1226
+ args,
1227
+ cause: reloaded.error,
1228
+ command: `systemctl`,
1229
+ stage: `daemon-reload`,
1230
+ ...reloaded.error.environmentReason === void 0 ? {} : { environmentReason: reloaded.error.environmentReason },
1231
+ ...units[0]?.filename === void 0 ? {} : { unitName: units[0].filename }
1232
+ };
1233
+ throw operation === `enable` ? new UnitEnableError(`Failed to reload systemd before enable`, errorContext) : new UnitStartError(`Failed to reload systemd before start`, errorContext);
618
1234
  }
619
- await this.executor(`systemctl`, [...scopeArgs, `daemon-reload`]);
620
1235
  }
621
1236
  scopeArgs() {
622
1237
  return this.scope === `user` ? [`--user`] : [];
623
1238
  }
1239
+ async collectStartDiagnostics(scopeArgs, unitName) {
1240
+ const diagnostics = {};
1241
+ const showArgs = [
1242
+ ...scopeArgs,
1243
+ `show`,
1244
+ unitName,
1245
+ `--property=Id,ActiveState,SubState,Result,ExecMainStatus`
1246
+ ];
1247
+ const show = await this.tryBestEffortCommand(`systemctl`, showArgs);
1248
+ if (show !== void 0) {
1249
+ const showOutput = joinCommandOutput(show);
1250
+ if (showOutput.length > 0) diagnostics.showOutput = showOutput;
1251
+ if (show.stdout.length > 0) diagnostics.showStatus = parseStartStatus(unitName, show.stdout);
1252
+ }
1253
+ const statusArgs = [
1254
+ ...scopeArgs,
1255
+ `status`,
1256
+ unitName,
1257
+ `--no-pager`,
1258
+ `--lines`,
1259
+ `20`
1260
+ ];
1261
+ const status = await this.tryBestEffortCommand(`systemctl`, statusArgs);
1262
+ if (status !== void 0) {
1263
+ const statusOutput = joinCommandOutput(status);
1264
+ if (statusOutput.length > 0) diagnostics.statusOutput = statusOutput;
1265
+ }
1266
+ return diagnostics;
1267
+ }
624
1268
  async collectLinkPaths(units) {
625
1269
  const paths = /* @__PURE__ */ new Set();
626
1270
  for (const unit of units) {
@@ -632,7 +1276,296 @@ var Systemd = class {
632
1276
  }
633
1277
  return [...paths];
634
1278
  }
1279
+ async materializeUnits(units) {
1280
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.materialize()`));
1281
+ try {
1282
+ await writeUnitDirectory(this.unitDir);
1283
+ } catch (cause) {
1284
+ const reason = classifyMaterializationReason(cause);
1285
+ return err(new UnitMaterializationError(`Failed to create unit directory ${this.unitDir}`, {
1286
+ cause,
1287
+ operation: `create-directory`,
1288
+ ...reason === void 0 ? {} : { reason },
1289
+ unitPath: this.unitDir
1290
+ }));
1291
+ }
1292
+ const materialized = [];
1293
+ for (const unit of units) {
1294
+ const path = join(this.unitDir, unit.filename);
1295
+ const rendered = unit.render();
1296
+ if (!rendered.ok) return err(rendered.error);
1297
+ try {
1298
+ await writeFile(path, rendered.value, `utf8`);
1299
+ } catch (cause) {
1300
+ if (cause instanceof InvalidExecDirectiveError) return err(cause);
1301
+ const reason = classifyMaterializationReason(cause);
1302
+ return err(new UnitMaterializationError(`Failed to materialize ${unit.filename} into ${path}`, {
1303
+ cause,
1304
+ operation: `write-file`,
1305
+ ...reason === void 0 ? {} : { reason },
1306
+ unitName: unit.filename,
1307
+ unitPath: path
1308
+ }));
1309
+ }
1310
+ materialized.push({
1311
+ path,
1312
+ unit
1313
+ });
1314
+ }
1315
+ return ok(new SystemdMaterialization(this.unitDir, materialized));
1316
+ }
1317
+ async tryBestEffortCommand(command, args) {
1318
+ try {
1319
+ return await this.executor(command, args);
1320
+ } catch (cause) {
1321
+ const output = extractCommandOutput(cause);
1322
+ if (output !== void 0) return output;
1323
+ return;
1324
+ }
1325
+ }
1326
+ };
1327
+ /**
1328
+ * Opinionated `systemd-ts` upkeep helpers for application-owned units.
1329
+ *
1330
+ * This layer is intentionally analogous to a subset of `portablectl`'s
1331
+ * ownership workflow, but it operates on regular materialized units instead of
1332
+ * portable service images.
1333
+ */
1334
+ var SystemdTs = class {
1335
+ systemd;
1336
+ constructor(systemd) {
1337
+ this.systemd = systemd;
1338
+ Object.freeze(this);
1339
+ }
1340
+ async attach(units, options) {
1341
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.ts.attach()`));
1342
+ const described = await this.describeUnits(units);
1343
+ if (!described.ok) return described;
1344
+ const manifestPath = this.manifestPathFor(options.owner);
1345
+ const prior = await this.readManifest(options.owner);
1346
+ const diff = await this.diffManifest(prior, described.value);
1347
+ const materialized = await this.systemd.materialize(...units);
1348
+ if (!materialized.ok) return materialized;
1349
+ const enabled = [];
1350
+ if (options.enable ?? false) {
1351
+ const result = await this.systemd.enable(...units);
1352
+ if (!result.ok) return result;
1353
+ enabled.push(...units.map((unit) => unit.filename));
1354
+ }
1355
+ const started = [];
1356
+ if (options.start ?? false) for (const unit of units) {
1357
+ const result = await this.systemd.start(unit);
1358
+ if (!result.ok) return result;
1359
+ started.push(unit.filename);
1360
+ }
1361
+ await this.writeManifest(options.owner, described.value);
1362
+ return ok({
1363
+ added: diff.added,
1364
+ directory: this.systemd.unitDir,
1365
+ enabled,
1366
+ manifestPath,
1367
+ owner: options.owner,
1368
+ removed: diff.removed,
1369
+ restarted: [],
1370
+ started,
1371
+ unchanged: diff.unchanged,
1372
+ updated: diff.updated
1373
+ });
1374
+ }
1375
+ async detach(selector, options = {}) {
1376
+ const manifest = await this.readManifest(selector.owner);
1377
+ if (manifest === void 0) return ok({
1378
+ deleted: [],
1379
+ detached: [],
1380
+ disabled: [],
1381
+ owner: selector.owner,
1382
+ stopped: []
1383
+ });
1384
+ const filenames = manifest.units.map((unit) => unit.filename);
1385
+ const deleted = [];
1386
+ const disabled = [];
1387
+ const stopped = [];
1388
+ if (options.stop ?? true) {
1389
+ await this.runSystemctl(`stop`, filenames);
1390
+ stopped.push(...filenames);
1391
+ }
1392
+ if (options.disable ?? true) {
1393
+ await this.runSystemctl(`disable`, filenames);
1394
+ disabled.push(...filenames);
1395
+ }
1396
+ if (options.deleteUnitFiles ?? true) {
1397
+ for (const unit of manifest.units) {
1398
+ await this.removeManagedUnitPath(unit.path);
1399
+ await rm(unit.path, { force: true });
1400
+ deleted.push(unit.filename);
1401
+ }
1402
+ await this.runSystemctl(`daemon-reload`);
1403
+ }
1404
+ await rm(this.manifestPathFor(selector.owner), { force: true });
1405
+ return ok({
1406
+ deleted,
1407
+ detached: filenames,
1408
+ disabled,
1409
+ owner: selector.owner,
1410
+ stopped
1411
+ });
1412
+ }
1413
+ async reattach(units, options) {
1414
+ if (units.length === 0) return err(new NoUnitsProvidedError(`Systemd.ts.reattach()`));
1415
+ const described = await this.describeUnits(units);
1416
+ if (!described.ok) return described;
1417
+ const manifestPath = this.manifestPathFor(options.owner);
1418
+ const prior = await this.readManifest(options.owner);
1419
+ const diff = await this.diffManifest(prior, described.value);
1420
+ if ((options.stopRemoved ?? true) && diff.removed.length > 0) await this.runSystemctl(`stop`, diff.removed);
1421
+ if ((options.disableRemoved ?? true) && diff.removed.length > 0) await this.runSystemctl(`disable`, diff.removed);
1422
+ if ((options.prune ?? true) && prior !== void 0) {
1423
+ for (const unit of prior.units) {
1424
+ if (!diff.removed.includes(unit.filename)) continue;
1425
+ await this.removeManagedUnitPath(unit.path);
1426
+ await rm(unit.path, { force: true });
1427
+ }
1428
+ if (diff.removed.length > 0) await this.runSystemctl(`daemon-reload`);
1429
+ }
1430
+ const materialized = await this.systemd.materialize(...units);
1431
+ if (!materialized.ok) return materialized;
1432
+ const enabled = [];
1433
+ if (options.enable ?? false) {
1434
+ const result = await this.systemd.enable(...units);
1435
+ if (!result.ok) return result;
1436
+ enabled.push(...units.map((unit) => unit.filename));
1437
+ }
1438
+ const restarted = [];
1439
+ if (options.restartUpdated ?? false) {
1440
+ const restartable = units.filter((unit) => diff.updated.includes(unit.filename)).map((unit) => unit.filename);
1441
+ if (restartable.length > 0) {
1442
+ await this.runSystemctl(`restart`, restartable);
1443
+ restarted.push(...restartable);
1444
+ }
1445
+ }
1446
+ const started = [];
1447
+ if (options.start ?? false) for (const unit of units) {
1448
+ if (restarted.includes(unit.filename)) continue;
1449
+ const result = await this.systemd.start(unit);
1450
+ if (!result.ok) return result;
1451
+ started.push(unit.filename);
1452
+ }
1453
+ await this.writeManifest(options.owner, described.value);
1454
+ return ok({
1455
+ added: diff.added,
1456
+ directory: this.systemd.unitDir,
1457
+ enabled,
1458
+ manifestPath,
1459
+ owner: options.owner,
1460
+ removed: diff.removed,
1461
+ restarted,
1462
+ started,
1463
+ unchanged: diff.unchanged,
1464
+ updated: diff.updated
1465
+ });
1466
+ }
1467
+ async describeUnits(units) {
1468
+ const described = [];
1469
+ for (const unit of units) {
1470
+ const rendered = unit.render();
1471
+ if (!rendered.ok) return rendered;
1472
+ described.push({
1473
+ filename: unit.filename,
1474
+ path: this.systemd.pathFor(unit),
1475
+ rendered: rendered.value
1476
+ });
1477
+ }
1478
+ return ok(described);
1479
+ }
1480
+ async diffManifest(prior, desired) {
1481
+ const priorUnits = new Map(prior?.units.map((unit) => [unit.filename, unit.path]) ?? []);
1482
+ const desiredNames = new Set(desired.map((unit) => unit.filename));
1483
+ const added = [];
1484
+ const removed = [...priorUnits.keys()].filter((filename) => !desiredNames.has(filename));
1485
+ const unchanged = [];
1486
+ const updated = [];
1487
+ for (const unit of desired) {
1488
+ const priorPath = priorUnits.get(unit.filename);
1489
+ if (priorPath === void 0) {
1490
+ added.push(unit.filename);
1491
+ continue;
1492
+ }
1493
+ try {
1494
+ if (await readFile(priorPath, `utf8`) === unit.rendered) {
1495
+ unchanged.push(unit.filename);
1496
+ continue;
1497
+ }
1498
+ } catch {
1499
+ added.push(unit.filename);
1500
+ continue;
1501
+ }
1502
+ updated.push(unit.filename);
1503
+ }
1504
+ return {
1505
+ added,
1506
+ removed,
1507
+ unchanged,
1508
+ updated
1509
+ };
1510
+ }
1511
+ async manifestDir() {
1512
+ const directory = join(this.systemd.unitDir, `.systemd-ts`);
1513
+ await mkdir(directory, { recursive: true });
1514
+ return directory;
1515
+ }
1516
+ manifestPathFor(owner) {
1517
+ return join(this.systemd.unitDir, `.systemd-ts`, `${encodeURIComponent(owner)}.json`);
1518
+ }
1519
+ async readManifest(owner) {
1520
+ try {
1521
+ const content = await readFile(this.manifestPathFor(owner), `utf8`);
1522
+ return JSON.parse(content);
1523
+ } catch {
1524
+ return;
1525
+ }
1526
+ }
1527
+ async runSystemctl(action, units = []) {
1528
+ const args = action === `daemon-reload` ? [...this.scopeArgs(), action] : [
1529
+ ...this.scopeArgs(),
1530
+ action,
1531
+ ...units
1532
+ ];
1533
+ await this.systemd.executor(`systemctl`, args);
1534
+ }
1535
+ async removeManagedUnitPath(path) {
1536
+ await this.systemd.executor(`rm`, [`-f`, path]);
1537
+ }
1538
+ scopeArgs() {
1539
+ return this.systemd.scope === `user` ? [`--user`] : [];
1540
+ }
1541
+ async writeManifest(owner, units) {
1542
+ await this.manifestDir();
1543
+ const manifest = {
1544
+ owner,
1545
+ units: units.map((unit) => ({
1546
+ filename: unit.filename,
1547
+ path: unit.path
1548
+ })),
1549
+ version: 1
1550
+ };
1551
+ await writeFile(this.manifestPathFor(owner), JSON.stringify(manifest, null, 2), `utf8`);
1552
+ }
635
1553
  };
1554
+ function ok(value) {
1555
+ return {
1556
+ ok: true,
1557
+ value
1558
+ };
1559
+ }
1560
+ function err(error) {
1561
+ return {
1562
+ ok: false,
1563
+ error
1564
+ };
1565
+ }
1566
+ function joinCommandOutput(output) {
1567
+ return [output.stdout, output.stderr].filter((value) => value.length > 0).join(`\n`);
1568
+ }
636
1569
  let lazyDefaultSystemd;
637
1570
  /**
638
1571
  * Returns the lazily created default `Systemd` instance.
@@ -659,6 +1592,10 @@ function parseFileLogPath(value) {
659
1592
  function tailLines(output, lines) {
660
1593
  return output.split(`\n`).slice(-lines).join(`\n`);
661
1594
  }
1595
+ function isMissingPathError(cause) {
1596
+ if (cause === null || typeof cause !== `object`) return false;
1597
+ return cause[`code`] === `ENOENT`;
1598
+ }
662
1599
  async function writeUnitDirectory(path) {
663
1600
  await mkdir(path, { recursive: true });
664
1601
  }
@@ -670,4 +1607,4 @@ async function writeUnitDirectory(path) {
670
1607
  */
671
1608
  const Internal = internal_exports;
672
1609
  //#endregion
673
- export { Executable, Internal, Systemd, SystemdInstallResult, SystemdService, SystemdTimer, defaultSystemd, defineExecutable, notify };
1610
+ export { Executable, ExecutableInferenceError, Internal, InvalidExecDirectiveError, NoUnitsProvidedError, NotifySendError, Systemctl, SystemctlCommandError, Systemd, SystemdMaterialization, SystemdService, SystemdTimer, SystemdTs, SystemdTsError, UnitEnableError, UnitLogsReadError, UnitMaterializationError, UnitStartError, defaultSystemd, defineExecutable, notify };