systemd-ts 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,991 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ defaultCommandExecutor,
6
+ defaultUnitDirForScope,
7
+ extractCommandOutput,
8
+ fileExists,
9
+ parseStartStatus,
10
+ type Result,
11
+ } from "./internal.ts";
12
+ import {
13
+ classifyMaterializationReason,
14
+ NoUnitsProvidedError,
15
+ ExecutableInferenceError,
16
+ InvalidExecDirectiveError,
17
+ UnitEnableError,
18
+ UnitLogsReadError,
19
+ UnitMaterializationError,
20
+ UnitStartError,
21
+ } from "./errors.ts";
22
+ import { SystemdService } from "./systemd-service.ts";
23
+ import { Systemctl } from "./systemctl.ts";
24
+ import { SystemdTimer } from "./systemd-timer.ts";
25
+ import type {
26
+ CommandExecutor,
27
+ MaterializedUnit,
28
+ LogsOptions,
29
+ StartStatus,
30
+ SystemdOptions,
31
+ SystemdUnit,
32
+ ValidInstallUnits,
33
+ } from "./types.ts";
34
+
35
+ export interface SystemdTsAttachOptions {
36
+ readonly owner: string;
37
+ readonly enable?: boolean;
38
+ readonly start?: boolean;
39
+ }
40
+
41
+ export interface SystemdTsDetachOptions {
42
+ readonly deleteUnitFiles?: boolean;
43
+ readonly disable?: boolean;
44
+ readonly stop?: boolean;
45
+ }
46
+
47
+ export interface SystemdTsReattachOptions extends SystemdTsAttachOptions {
48
+ readonly disableRemoved?: boolean;
49
+ readonly prune?: boolean;
50
+ readonly restartUpdated?: boolean;
51
+ readonly stopRemoved?: boolean;
52
+ }
53
+
54
+ export interface SystemdTsAttachmentSelector {
55
+ readonly owner: string;
56
+ }
57
+
58
+ export interface SystemdTsAttachmentResult {
59
+ readonly added: readonly string[];
60
+ readonly directory: string;
61
+ readonly enabled: readonly string[];
62
+ readonly manifestPath: string;
63
+ readonly owner: string;
64
+ readonly removed: readonly string[];
65
+ readonly restarted: readonly string[];
66
+ readonly started: readonly string[];
67
+ readonly unchanged: readonly string[];
68
+ readonly updated: readonly string[];
69
+ }
70
+
71
+ interface AttachmentManifest {
72
+ readonly owner: string;
73
+ readonly units: readonly {
74
+ readonly filename: string;
75
+ readonly path: string;
76
+ }[];
77
+ readonly version: 1;
78
+ }
79
+
80
+ /**
81
+ * A successful systemd unit materialization.
82
+ *
83
+ * It records the target directory and the on-disk path associated with each
84
+ * materialized unit.
85
+ */
86
+ export class SystemdMaterialization<
87
+ TUnits extends readonly SystemdUnit[] = readonly SystemdUnit[],
88
+ > {
89
+ /** The directory units were written into. */
90
+ public readonly directory: string;
91
+ /** The materialized units together with their resolved on-disk paths. */
92
+ public readonly materialized: readonly MaterializedUnit<TUnits[number]>[];
93
+
94
+ public constructor(directory: string, materialized: readonly MaterializedUnit<TUnits[number]>[]) {
95
+ this.directory = directory;
96
+ this.materialized = Object.freeze([...materialized]);
97
+ Object.freeze(this);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * A configured interface to a specific systemd environment.
103
+ *
104
+ * `Systemd` combines unit-file materialization with a command execution
105
+ * strategy. It knows which scope it is targeting, where unit files should be
106
+ * written, and how `systemctl` should be invoked.
107
+ *
108
+ * This abstraction is intentionally close to `systemctl` concepts while still
109
+ * accepting full unit definitions instead of loose unit-name strings.
110
+ */
111
+ export class Systemd {
112
+ /** Command executor used for `systemctl` and related subprocess calls. */
113
+ public readonly executor: CommandExecutor;
114
+ /** Whether units should be linked into systemd before manager operations. */
115
+ public readonly linkUnits: boolean;
116
+ /** The target manager scope, either `system` or `user`. */
117
+ public readonly scope: `system` | `user`;
118
+ /** The low-level `systemctl` client for this configured target. */
119
+ public readonly systemctl: Systemctl;
120
+ /** Higher-level `systemd-ts` upkeep helpers for application-owned units. */
121
+ public readonly ts: SystemdTs;
122
+ /** The directory used when materializing unit files. */
123
+ public readonly unitDir: string;
124
+
125
+ /**
126
+ * Creates a configured `Systemd` facade.
127
+ *
128
+ * By default, this targets the system scope and `/etc/systemd/system`. Use
129
+ * `scope: "user"` or an explicit `unitDir` to target a different manager or
130
+ * unit-file location.
131
+ */
132
+ public constructor(options: SystemdOptions = {}) {
133
+ this.scope = options.scope ?? `system`;
134
+ this.unitDir = options.unitDir ?? defaultUnitDirForScope(this.scope);
135
+ this.linkUnits = options.linkUnits ?? false;
136
+ this.executor = options.executor ?? defaultCommandExecutor;
137
+ this.systemctl = new Systemctl({
138
+ executor: this.executor,
139
+ scope: this.scope,
140
+ });
141
+ this.ts = new SystemdTs(this);
142
+ Object.freeze(this);
143
+ }
144
+
145
+ /**
146
+ * Renders and writes one or more units into this instance's `unitDir`.
147
+ *
148
+ * This is intentionally a file-materialization step. It does not enable or
149
+ * start the units on its own.
150
+ */
151
+ public async materialize<const TUnits extends readonly SystemdUnit[]>(
152
+ ...units: TUnits & ValidInstallUnits<TUnits>
153
+ ): Promise<
154
+ Result<
155
+ SystemdMaterialization<TUnits>,
156
+ | ExecutableInferenceError
157
+ | InvalidExecDirectiveError
158
+ | NoUnitsProvidedError
159
+ | UnitMaterializationError
160
+ >
161
+ > {
162
+ return this.materializeUnits(units);
163
+ }
164
+
165
+ /**
166
+ * Enables one or more units via `systemctl enable`.
167
+ *
168
+ * When `linkUnits` is enabled, this first links materialized unit files into the
169
+ * target manager and reloads systemd so the units are visible before the
170
+ * enable operation runs.
171
+ */
172
+ public async enable(
173
+ ...units: readonly SystemdUnit[]
174
+ ): Promise<Result<void, NoUnitsProvidedError | UnitEnableError>> {
175
+ if (units.length === 0) {
176
+ return err(new NoUnitsProvidedError(`Systemd.enable()`));
177
+ }
178
+
179
+ const scopeArgs = this.scopeArgs();
180
+ try {
181
+ await this.prepareUnits(scopeArgs, units, `enable`);
182
+ } catch (cause) {
183
+ if (cause instanceof UnitEnableError) {
184
+ return err(cause);
185
+ }
186
+
187
+ return err(
188
+ new UnitEnableError(`Failed to prepare units for enable`, {
189
+ cause,
190
+ stage: `prepare`,
191
+ unitName: units[0]?.filename,
192
+ }),
193
+ );
194
+ }
195
+
196
+ for (const unit of units) {
197
+ const args = [...scopeArgs, `enable`, unit.filename] as const;
198
+ const enabled = await this.systemctl.enable(unit.filename);
199
+ if (!enabled.ok) {
200
+ const errorOptions = {
201
+ args,
202
+ cause: enabled.error,
203
+ command: `systemctl`,
204
+ stage: `enable`,
205
+ unitName: unit.filename,
206
+ ...(enabled.error.environmentReason === undefined
207
+ ? {}
208
+ : { environmentReason: enabled.error.environmentReason }),
209
+ };
210
+ return err(new UnitEnableError(`Failed to enable ${unit.filename}`, errorOptions));
211
+ }
212
+ }
213
+
214
+ return ok(undefined);
215
+ }
216
+
217
+ /**
218
+ * Starts a unit and returns a parsed `systemctl show` snapshot of its final
219
+ * observed state.
220
+ *
221
+ * For oneshot services, a successful result commonly means `ActiveState` is
222
+ * already back to `inactive` by the time the status snapshot is collected.
223
+ */
224
+ public async start(unit: SystemdUnit): Promise<Result<StartStatus, UnitStartError>> {
225
+ const scopeArgs = this.scopeArgs();
226
+ try {
227
+ await this.prepareUnits(scopeArgs, [unit], `start`);
228
+ } catch (cause) {
229
+ if (cause instanceof UnitStartError) {
230
+ return err(cause);
231
+ }
232
+
233
+ return err(
234
+ new UnitStartError(`Failed to prepare ${unit.filename} for start`, {
235
+ cause,
236
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
237
+ stage: `prepare`,
238
+ unitName: unit.filename,
239
+ }),
240
+ );
241
+ }
242
+
243
+ const startArgs = [...scopeArgs, `start`, unit.filename] as const;
244
+ const started = await this.systemctl.start(unit.filename);
245
+ if (!started.ok) {
246
+ const errorOptions = {
247
+ args: startArgs,
248
+ cause: started.error,
249
+ command: `systemctl`,
250
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
251
+ stage: `start`,
252
+ unitName: unit.filename,
253
+ ...(started.error.environmentReason === undefined
254
+ ? {}
255
+ : { environmentReason: started.error.environmentReason }),
256
+ };
257
+ return err(new UnitStartError(`Failed to start ${unit.filename}`, errorOptions));
258
+ }
259
+
260
+ const status = await this.systemctl.showServiceStatus(unit.filename);
261
+ if (!status.ok) {
262
+ const errorOptions = {
263
+ args: [
264
+ ...scopeArgs,
265
+ `show`,
266
+ unit.filename,
267
+ `--property=Id,ActiveState,SubState,Result,ExecMainStatus`,
268
+ ] as const,
269
+ cause: status.error,
270
+ command: `systemctl`,
271
+ diagnostics: await this.collectStartDiagnostics(scopeArgs, unit.filename),
272
+ stage: `show-status`,
273
+ unitName: unit.filename,
274
+ ...(status.error.environmentReason === undefined
275
+ ? {}
276
+ : { environmentReason: status.error.environmentReason }),
277
+ };
278
+ return err(
279
+ new UnitStartError(`Started ${unit.filename} but failed to query its status`, errorOptions),
280
+ );
281
+ }
282
+ return ok(status.value);
283
+ }
284
+
285
+ /**
286
+ * Reads recent output for a managed unit.
287
+ *
288
+ * If the unit is configured with file-backed `StandardOutput` or
289
+ * `StandardError`, this reads directly from that file. Otherwise it falls back
290
+ * to `systemctl status --lines ...`.
291
+ */
292
+ public async logs(
293
+ unit: SystemdUnit,
294
+ options?: LogsOptions,
295
+ ): Promise<Result<string, UnitLogsReadError>> {
296
+ const fileLogPath = resolveUnitLogPath(unit);
297
+ if (fileLogPath !== undefined) {
298
+ try {
299
+ const output = await readFile(fileLogPath, `utf8`);
300
+ return ok(tailLines(output, options?.lines ?? 50));
301
+ } catch (cause) {
302
+ return err(
303
+ new UnitLogsReadError(`Failed to read logs for ${unit.filename} from ${fileLogPath}`, {
304
+ cause,
305
+ reason: isMissingPathError(cause) ? `missing-log-file` : `log-file-read-failed`,
306
+ stage: `read-log-file`,
307
+ unitName: unit.filename,
308
+ unitPath: fileLogPath,
309
+ }),
310
+ );
311
+ }
312
+ }
313
+
314
+ const scopeArgs = this.scopeArgs();
315
+ const lines = options?.lines ?? 50;
316
+ const logs = await this.systemctl.status(unit.filename, {
317
+ lines,
318
+ });
319
+ if (logs.ok) {
320
+ return ok(joinCommandOutput(logs.value));
321
+ }
322
+
323
+ const errorOutput = extractCommandOutput(logs.error);
324
+ if (errorOutput !== undefined) {
325
+ return ok(joinCommandOutput(errorOutput));
326
+ }
327
+
328
+ const args = [
329
+ ...scopeArgs,
330
+ `status`,
331
+ unit.filename,
332
+ `--no-pager`,
333
+ `--lines`,
334
+ String(lines),
335
+ ] as const;
336
+ return err(
337
+ new UnitLogsReadError(`Failed to query logs for ${unit.filename} from systemctl status`, {
338
+ args,
339
+ cause: logs.error,
340
+ command: `systemctl`,
341
+ reason: `status-command-failed`,
342
+ stage: `status`,
343
+ unitName: unit.filename,
344
+ }),
345
+ );
346
+ }
347
+
348
+ /** Returns the on-disk unit-file path this instance uses for the given unit. */
349
+ public pathFor(unit: SystemdUnit): string {
350
+ return join(this.unitDir, unit.filename);
351
+ }
352
+
353
+ private async prepareUnits(
354
+ scopeArgs: readonly string[],
355
+ units: readonly SystemdUnit[],
356
+ operation: `enable` | `start`,
357
+ ): Promise<void> {
358
+ if (this.linkUnits) {
359
+ const linkPaths = await this.collectLinkPaths(units);
360
+ for (const path of linkPaths) {
361
+ const args = [...scopeArgs, `link`, path] as const;
362
+ const linked = await this.systemctl.link(path);
363
+ if (!linked.ok) {
364
+ const linkedUnit = units.find((candidate) => this.pathFor(candidate) === path);
365
+ const errorContext = {
366
+ args,
367
+ cause: linked.error,
368
+ command: `systemctl`,
369
+ stage: `link`,
370
+ unitPath: path,
371
+ ...(linked.error.environmentReason === undefined
372
+ ? {}
373
+ : { environmentReason: linked.error.environmentReason }),
374
+ ...(linkedUnit === undefined ? {} : { unitName: linkedUnit.filename }),
375
+ };
376
+ throw operation === `enable`
377
+ ? new UnitEnableError(`Failed to link ${path} before enable`, errorContext)
378
+ : new UnitStartError(`Failed to link ${path} before start`, errorContext);
379
+ }
380
+ }
381
+ }
382
+
383
+ const args = [...scopeArgs, `daemon-reload`] as const;
384
+ const reloaded = await this.systemctl.daemonReload();
385
+ if (!reloaded.ok) {
386
+ const errorContext = {
387
+ args,
388
+ cause: reloaded.error,
389
+ command: `systemctl`,
390
+ stage: `daemon-reload`,
391
+ ...(reloaded.error.environmentReason === undefined
392
+ ? {}
393
+ : { environmentReason: reloaded.error.environmentReason }),
394
+ ...(units[0]?.filename === undefined ? {} : { unitName: units[0].filename }),
395
+ };
396
+ throw operation === `enable`
397
+ ? new UnitEnableError(`Failed to reload systemd before enable`, errorContext)
398
+ : new UnitStartError(`Failed to reload systemd before start`, errorContext);
399
+ }
400
+ }
401
+
402
+ private scopeArgs(): readonly string[] {
403
+ return this.scope === `user` ? [`--user`] : [];
404
+ }
405
+
406
+ private async collectStartDiagnostics(
407
+ scopeArgs: readonly string[],
408
+ unitName: string,
409
+ ): Promise<{
410
+ readonly showOutput?: string;
411
+ readonly showStatus?: StartStatus;
412
+ readonly statusOutput?: string;
413
+ }> {
414
+ const diagnostics: {
415
+ showOutput?: string;
416
+ showStatus?: StartStatus;
417
+ statusOutput?: string;
418
+ } = {};
419
+
420
+ const showArgs = [
421
+ ...scopeArgs,
422
+ `show`,
423
+ unitName,
424
+ `--property=Id,ActiveState,SubState,Result,ExecMainStatus`,
425
+ ] as const;
426
+ const show = await this.tryBestEffortCommand(`systemctl`, showArgs);
427
+ if (show !== undefined) {
428
+ const showOutput = joinCommandOutput(show);
429
+ if (showOutput.length > 0) {
430
+ diagnostics.showOutput = showOutput;
431
+ }
432
+ if (show.stdout.length > 0) {
433
+ diagnostics.showStatus = parseStartStatus(unitName, show.stdout);
434
+ }
435
+ }
436
+
437
+ const statusArgs = [...scopeArgs, `status`, unitName, `--no-pager`, `--lines`, `20`] as const;
438
+ const status = await this.tryBestEffortCommand(`systemctl`, statusArgs);
439
+ if (status !== undefined) {
440
+ const statusOutput = joinCommandOutput(status);
441
+ if (statusOutput.length > 0) {
442
+ diagnostics.statusOutput = statusOutput;
443
+ }
444
+ }
445
+
446
+ return diagnostics;
447
+ }
448
+
449
+ private async collectLinkPaths(units: readonly SystemdUnit[]): Promise<readonly string[]> {
450
+ const paths = new Set<string>();
451
+
452
+ for (const unit of units) {
453
+ paths.add(this.pathFor(unit));
454
+
455
+ if (unit instanceof SystemdTimer) {
456
+ const targetPath = join(this.unitDir, unit.targetUnit);
457
+ if (await fileExists(targetPath)) {
458
+ paths.add(targetPath);
459
+ }
460
+ }
461
+ }
462
+
463
+ return [...paths];
464
+ }
465
+
466
+ private async materializeUnits<const TUnits extends readonly SystemdUnit[]>(
467
+ units: TUnits & ValidInstallUnits<TUnits>,
468
+ ): Promise<
469
+ Result<
470
+ SystemdMaterialization<TUnits>,
471
+ | ExecutableInferenceError
472
+ | InvalidExecDirectiveError
473
+ | NoUnitsProvidedError
474
+ | UnitMaterializationError
475
+ >
476
+ > {
477
+ if (units.length === 0) {
478
+ return err(new NoUnitsProvidedError(`Systemd.materialize()`));
479
+ }
480
+
481
+ try {
482
+ await writeUnitDirectory(this.unitDir);
483
+ } catch (cause) {
484
+ const reason = classifyMaterializationReason(cause);
485
+ return err(
486
+ new UnitMaterializationError(`Failed to create unit directory ${this.unitDir}`, {
487
+ cause,
488
+ operation: `create-directory`,
489
+ ...(reason === undefined ? {} : { reason }),
490
+ unitPath: this.unitDir,
491
+ }),
492
+ );
493
+ }
494
+
495
+ const materialized: MaterializedUnit<TUnits[number]>[] = [];
496
+ for (const unit of units) {
497
+ const path = join(this.unitDir, unit.filename);
498
+ const rendered = unit.render();
499
+ if (!rendered.ok) {
500
+ return err(rendered.error);
501
+ }
502
+ try {
503
+ await writeFile(path, rendered.value, `utf8`);
504
+ } catch (cause) {
505
+ if (cause instanceof InvalidExecDirectiveError) {
506
+ return err(cause);
507
+ }
508
+
509
+ const reason = classifyMaterializationReason(cause);
510
+ return err(
511
+ new UnitMaterializationError(`Failed to materialize ${unit.filename} into ${path}`, {
512
+ cause,
513
+ operation: `write-file`,
514
+ ...(reason === undefined ? {} : { reason }),
515
+ unitName: unit.filename,
516
+ unitPath: path,
517
+ }),
518
+ );
519
+ }
520
+ materialized.push({ path, unit });
521
+ }
522
+
523
+ return ok(new SystemdMaterialization<TUnits>(this.unitDir, materialized));
524
+ }
525
+
526
+ private async tryBestEffortCommand(
527
+ command: string,
528
+ args: readonly string[],
529
+ ): Promise<
530
+ | {
531
+ readonly stderr: string;
532
+ readonly stdout: string;
533
+ }
534
+ | undefined
535
+ > {
536
+ try {
537
+ return await this.executor(command, args);
538
+ } catch (cause) {
539
+ const output = extractCommandOutput(cause);
540
+ if (output !== undefined) {
541
+ return output;
542
+ }
543
+ return undefined;
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Opinionated `systemd-ts` upkeep helpers for application-owned units.
550
+ *
551
+ * This layer is intentionally analogous to a subset of `portablectl`'s
552
+ * ownership workflow, but it operates on regular materialized units instead of
553
+ * portable service images.
554
+ */
555
+ export class SystemdTs {
556
+ private readonly systemd: Systemd;
557
+
558
+ public constructor(systemd: Systemd) {
559
+ this.systemd = systemd;
560
+ Object.freeze(this);
561
+ }
562
+
563
+ public async attach(
564
+ units: readonly SystemdUnit[],
565
+ options: SystemdTsAttachOptions,
566
+ ): Promise<
567
+ Result<
568
+ SystemdTsAttachmentResult,
569
+ | ExecutableInferenceError
570
+ | InvalidExecDirectiveError
571
+ | NoUnitsProvidedError
572
+ | UnitEnableError
573
+ | UnitMaterializationError
574
+ | UnitStartError
575
+ >
576
+ > {
577
+ if (units.length === 0) {
578
+ return err(new NoUnitsProvidedError(`Systemd.ts.attach()`));
579
+ }
580
+
581
+ const described = await this.describeUnits(units);
582
+ if (!described.ok) {
583
+ return described;
584
+ }
585
+
586
+ const manifestPath = this.manifestPathFor(options.owner);
587
+ const prior = await this.readManifest(options.owner);
588
+ const diff = await this.diffManifest(prior, described.value);
589
+ const materialized = await this.systemd.materialize(...(units as readonly SystemdUnit[]));
590
+ if (!materialized.ok) {
591
+ return materialized;
592
+ }
593
+
594
+ const enabled: string[] = [];
595
+ if (options.enable ?? false) {
596
+ const result = await this.systemd.enable(...units);
597
+ if (!result.ok) {
598
+ return result;
599
+ }
600
+ enabled.push(...units.map((unit) => unit.filename));
601
+ }
602
+
603
+ const started: string[] = [];
604
+ if (options.start ?? false) {
605
+ for (const unit of units) {
606
+ const result = await this.systemd.start(unit);
607
+ if (!result.ok) {
608
+ return result;
609
+ }
610
+ started.push(unit.filename);
611
+ }
612
+ }
613
+
614
+ await this.writeManifest(options.owner, described.value);
615
+
616
+ return ok({
617
+ added: diff.added,
618
+ directory: this.systemd.unitDir,
619
+ enabled,
620
+ manifestPath,
621
+ owner: options.owner,
622
+ removed: diff.removed,
623
+ restarted: [],
624
+ started,
625
+ unchanged: diff.unchanged,
626
+ updated: diff.updated,
627
+ });
628
+ }
629
+
630
+ public async detach(
631
+ selector: SystemdTsAttachmentSelector,
632
+ options: SystemdTsDetachOptions = {},
633
+ ): Promise<
634
+ Result<
635
+ {
636
+ readonly deleted: readonly string[];
637
+ readonly detached: readonly string[];
638
+ readonly disabled: readonly string[];
639
+ readonly owner: string;
640
+ readonly stopped: readonly string[];
641
+ },
642
+ Error
643
+ >
644
+ > {
645
+ const manifest = await this.readManifest(selector.owner);
646
+ if (manifest === undefined) {
647
+ return ok({
648
+ deleted: [],
649
+ detached: [],
650
+ disabled: [],
651
+ owner: selector.owner,
652
+ stopped: [],
653
+ });
654
+ }
655
+
656
+ const filenames = manifest.units.map((unit) => unit.filename);
657
+ const deleted: string[] = [];
658
+ const disabled: string[] = [];
659
+ const stopped: string[] = [];
660
+
661
+ if (options.stop ?? true) {
662
+ await this.runSystemctl(`stop`, filenames);
663
+ stopped.push(...filenames);
664
+ }
665
+ if (options.disable ?? true) {
666
+ await this.runSystemctl(`disable`, filenames);
667
+ disabled.push(...filenames);
668
+ }
669
+ if (options.deleteUnitFiles ?? true) {
670
+ for (const unit of manifest.units) {
671
+ await this.removeManagedUnitPath(unit.path);
672
+ await rm(unit.path, { force: true });
673
+ deleted.push(unit.filename);
674
+ }
675
+ await this.runSystemctl(`daemon-reload`);
676
+ }
677
+
678
+ await rm(this.manifestPathFor(selector.owner), { force: true });
679
+
680
+ return ok({
681
+ deleted,
682
+ detached: filenames,
683
+ disabled,
684
+ owner: selector.owner,
685
+ stopped,
686
+ });
687
+ }
688
+
689
+ public async reattach(
690
+ units: readonly SystemdUnit[],
691
+ options: SystemdTsReattachOptions,
692
+ ): Promise<
693
+ Result<
694
+ SystemdTsAttachmentResult,
695
+ | ExecutableInferenceError
696
+ | InvalidExecDirectiveError
697
+ | NoUnitsProvidedError
698
+ | UnitEnableError
699
+ | UnitMaterializationError
700
+ | UnitStartError
701
+ | Error
702
+ >
703
+ > {
704
+ if (units.length === 0) {
705
+ return err(new NoUnitsProvidedError(`Systemd.ts.reattach()`));
706
+ }
707
+
708
+ const described = await this.describeUnits(units);
709
+ if (!described.ok) {
710
+ return described;
711
+ }
712
+
713
+ const manifestPath = this.manifestPathFor(options.owner);
714
+ const prior = await this.readManifest(options.owner);
715
+ const diff = await this.diffManifest(prior, described.value);
716
+
717
+ if ((options.stopRemoved ?? true) && diff.removed.length > 0) {
718
+ await this.runSystemctl(`stop`, diff.removed);
719
+ }
720
+ if ((options.disableRemoved ?? true) && diff.removed.length > 0) {
721
+ await this.runSystemctl(`disable`, diff.removed);
722
+ }
723
+ if ((options.prune ?? true) && prior !== undefined) {
724
+ for (const unit of prior.units) {
725
+ if (!diff.removed.includes(unit.filename)) {
726
+ continue;
727
+ }
728
+ await this.removeManagedUnitPath(unit.path);
729
+ await rm(unit.path, { force: true });
730
+ }
731
+ if (diff.removed.length > 0) {
732
+ await this.runSystemctl(`daemon-reload`);
733
+ }
734
+ }
735
+
736
+ const materialized = await this.systemd.materialize(...(units as readonly SystemdUnit[]));
737
+ if (!materialized.ok) {
738
+ return materialized;
739
+ }
740
+
741
+ const enabled: string[] = [];
742
+ if (options.enable ?? false) {
743
+ const result = await this.systemd.enable(...units);
744
+ if (!result.ok) {
745
+ return result;
746
+ }
747
+ enabled.push(...units.map((unit) => unit.filename));
748
+ }
749
+
750
+ const restarted: string[] = [];
751
+ if (options.restartUpdated ?? false) {
752
+ const restartable = units
753
+ .filter((unit) => diff.updated.includes(unit.filename))
754
+ .map((unit) => unit.filename);
755
+ if (restartable.length > 0) {
756
+ await this.runSystemctl(`restart`, restartable);
757
+ restarted.push(...restartable);
758
+ }
759
+ }
760
+
761
+ const started: string[] = [];
762
+ if (options.start ?? false) {
763
+ for (const unit of units) {
764
+ if (restarted.includes(unit.filename)) {
765
+ continue;
766
+ }
767
+ const result = await this.systemd.start(unit);
768
+ if (!result.ok) {
769
+ return result;
770
+ }
771
+ started.push(unit.filename);
772
+ }
773
+ }
774
+
775
+ await this.writeManifest(options.owner, described.value);
776
+
777
+ return ok({
778
+ added: diff.added,
779
+ directory: this.systemd.unitDir,
780
+ enabled,
781
+ manifestPath,
782
+ owner: options.owner,
783
+ removed: diff.removed,
784
+ restarted,
785
+ started,
786
+ unchanged: diff.unchanged,
787
+ updated: diff.updated,
788
+ });
789
+ }
790
+
791
+ private async describeUnits(units: readonly SystemdUnit[]): Promise<
792
+ Result<
793
+ readonly {
794
+ readonly filename: string;
795
+ readonly path: string;
796
+ readonly rendered: string;
797
+ }[],
798
+ ExecutableInferenceError | InvalidExecDirectiveError
799
+ >
800
+ > {
801
+ const described = [];
802
+ for (const unit of units) {
803
+ const rendered = unit.render();
804
+ if (!rendered.ok) {
805
+ return rendered;
806
+ }
807
+ described.push({
808
+ filename: unit.filename,
809
+ path: this.systemd.pathFor(unit),
810
+ rendered: rendered.value,
811
+ });
812
+ }
813
+
814
+ return ok(described);
815
+ }
816
+
817
+ private async diffManifest(
818
+ prior: AttachmentManifest | undefined,
819
+ desired: readonly {
820
+ readonly filename: string;
821
+ readonly path: string;
822
+ readonly rendered: string;
823
+ }[],
824
+ ): Promise<{
825
+ readonly added: readonly string[];
826
+ readonly removed: readonly string[];
827
+ readonly unchanged: readonly string[];
828
+ readonly updated: readonly string[];
829
+ }> {
830
+ const priorUnits = new Map(prior?.units.map((unit) => [unit.filename, unit.path]) ?? []);
831
+ const desiredNames = new Set(desired.map((unit) => unit.filename));
832
+ const added: string[] = [];
833
+ const removed = [...priorUnits.keys()].filter((filename) => !desiredNames.has(filename));
834
+ const unchanged: string[] = [];
835
+ const updated: string[] = [];
836
+
837
+ for (const unit of desired) {
838
+ const priorPath = priorUnits.get(unit.filename);
839
+ if (priorPath === undefined) {
840
+ added.push(unit.filename);
841
+ continue;
842
+ }
843
+
844
+ try {
845
+ const current = await readFile(priorPath, `utf8`);
846
+ if (current === unit.rendered) {
847
+ unchanged.push(unit.filename);
848
+ continue;
849
+ }
850
+ } catch {
851
+ added.push(unit.filename);
852
+ continue;
853
+ }
854
+
855
+ updated.push(unit.filename);
856
+ }
857
+
858
+ return {
859
+ added,
860
+ removed,
861
+ unchanged,
862
+ updated,
863
+ };
864
+ }
865
+
866
+ private async manifestDir(): Promise<string> {
867
+ const directory = join(this.systemd.unitDir, `.systemd-ts`);
868
+ await mkdir(directory, { recursive: true });
869
+ return directory;
870
+ }
871
+
872
+ private manifestPathFor(owner: string): string {
873
+ return join(this.systemd.unitDir, `.systemd-ts`, `${encodeURIComponent(owner)}.json`);
874
+ }
875
+
876
+ private async readManifest(owner: string): Promise<AttachmentManifest | undefined> {
877
+ try {
878
+ const content = await readFile(this.manifestPathFor(owner), `utf8`);
879
+ return JSON.parse(content) as AttachmentManifest;
880
+ } catch {
881
+ return undefined;
882
+ }
883
+ }
884
+
885
+ private async runSystemctl(action: string, units: readonly string[] = []): Promise<void> {
886
+ const args =
887
+ action === `daemon-reload`
888
+ ? [...this.scopeArgs(), action]
889
+ : [...this.scopeArgs(), action, ...units];
890
+ await this.systemd.executor(`systemctl`, args);
891
+ }
892
+
893
+ private async removeManagedUnitPath(path: string): Promise<void> {
894
+ await this.systemd.executor(`rm`, [`-f`, path]);
895
+ }
896
+
897
+ private scopeArgs(): readonly string[] {
898
+ return this.systemd.scope === `user` ? [`--user`] : [];
899
+ }
900
+
901
+ private async writeManifest(
902
+ owner: string,
903
+ units: readonly {
904
+ readonly filename: string;
905
+ readonly path: string;
906
+ readonly rendered: string;
907
+ }[],
908
+ ): Promise<void> {
909
+ await this.manifestDir();
910
+ const manifest: AttachmentManifest = {
911
+ owner,
912
+ units: units.map((unit) => ({
913
+ filename: unit.filename,
914
+ path: unit.path,
915
+ })),
916
+ version: 1,
917
+ };
918
+ await writeFile(this.manifestPathFor(owner), JSON.stringify(manifest, null, 2), `utf8`);
919
+ }
920
+ }
921
+
922
+ function ok<TValue>(value: TValue): Result<TValue, never> {
923
+ return { ok: true, value };
924
+ }
925
+
926
+ function err<TError>(error: TError): Result<never, TError> {
927
+ return { ok: false, error };
928
+ }
929
+
930
+ function joinCommandOutput(output: { readonly stderr: string; readonly stdout: string }): string {
931
+ return [output.stdout, output.stderr].filter((value) => value.length > 0).join(`\n`);
932
+ }
933
+
934
+ let lazyDefaultSystemd: Systemd | undefined;
935
+
936
+ /**
937
+ * Returns the lazily created default `Systemd` instance.
938
+ *
939
+ * The default instance uses the library defaults for scope, unit directory, and
940
+ * command execution.
941
+ */
942
+ export function defaultSystemd(): Systemd {
943
+ lazyDefaultSystemd ??= new Systemd();
944
+ return lazyDefaultSystemd;
945
+ }
946
+
947
+ function resolveUnitLogPath(unit: SystemdUnit): string | undefined {
948
+ if (!(unit instanceof SystemdService)) {
949
+ return undefined;
950
+ }
951
+
952
+ for (const key of [`StandardOutput`, `StandardError`] as const) {
953
+ const value = unit.service[key];
954
+ if (typeof value !== `string`) {
955
+ continue;
956
+ }
957
+
958
+ const path = parseFileLogPath(value);
959
+ if (path !== undefined) {
960
+ return path;
961
+ }
962
+ }
963
+
964
+ return undefined;
965
+ }
966
+
967
+ function parseFileLogPath(value: string): string | undefined {
968
+ for (const prefix of [`append:`, `file:`] as const) {
969
+ if (value.startsWith(prefix)) {
970
+ return value.slice(prefix.length);
971
+ }
972
+ }
973
+
974
+ return undefined;
975
+ }
976
+
977
+ function tailLines(output: string, lines: number): string {
978
+ return output.split(`\n`).slice(-lines).join(`\n`);
979
+ }
980
+
981
+ function isMissingPathError(cause: unknown): boolean {
982
+ if (cause === null || typeof cause !== `object`) {
983
+ return false;
984
+ }
985
+
986
+ return (cause as Record<string, unknown>)[`code`] === `ENOENT`;
987
+ }
988
+
989
+ async function writeUnitDirectory(path: string): Promise<void> {
990
+ await mkdir(path, { recursive: true });
991
+ }