systemd-ts 0.3.0 → 0.3.2

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,348 @@
1
+ import type { StartStatus } from "./types.ts";
2
+
3
+ export type SystemdTsErrorCode =
4
+ | `SYSTEMD_TS_INVALID_EXEC_DIRECTIVE`
5
+ | `SYSTEMD_TS_NO_UNITS_PROVIDED`
6
+ | `SYSTEMD_TS_EXECUTABLE_INFERENCE`
7
+ | `SYSTEMD_TS_UNIT_MATERIALIZATION`
8
+ | `SYSTEMD_TS_UNIT_ENABLE`
9
+ | `SYSTEMD_TS_UNIT_START`
10
+ | `SYSTEMD_TS_UNIT_LOGS`
11
+ | `SYSTEMD_TS_SYSTEMCTL`
12
+ | `SYSTEMD_TS_NOTIFY_SEND`;
13
+
14
+ export interface SystemdTsErrorOptions {
15
+ readonly cause?: unknown;
16
+ }
17
+
18
+ export interface SystemdCommandErrorOptions extends SystemdTsErrorOptions {
19
+ readonly args?: readonly string[];
20
+ readonly command?: string;
21
+ readonly environmentReason?: SystemdCommandEnvironmentReason;
22
+ readonly stage?: string;
23
+ readonly unitName?: string;
24
+ readonly unitPath?: string;
25
+ }
26
+
27
+ export interface UnitStartDiagnostics {
28
+ readonly showOutput?: string;
29
+ readonly showStatus?: StartStatus;
30
+ readonly statusOutput?: string;
31
+ }
32
+
33
+ export interface UnitStartErrorOptions extends SystemdCommandErrorOptions {
34
+ readonly diagnostics?: UnitStartDiagnostics;
35
+ }
36
+
37
+ export interface UnitMaterializationErrorOptions extends SystemdTsErrorOptions {
38
+ readonly reason?: UnitMaterializationErrorReason;
39
+ readonly operation?: `create-directory` | `write-file`;
40
+ readonly unitName?: string;
41
+ readonly unitPath?: string;
42
+ }
43
+
44
+ export type UnitMaterializationErrorReason =
45
+ | `file-system-failed`
46
+ | `invalid-unit-directory`
47
+ | `permission-denied`;
48
+
49
+ export interface UnitLogsReadErrorOptions extends SystemdCommandErrorOptions {
50
+ readonly reason?: UnitLogsReadErrorReason;
51
+ readonly unitPath?: string;
52
+ }
53
+
54
+ export type UnitLogsReadErrorReason =
55
+ | `missing-log-file`
56
+ | `log-file-read-failed`
57
+ | `status-command-failed`;
58
+
59
+ export interface NotifySendErrorOptions extends SystemdCommandErrorOptions {
60
+ readonly reason?: NotifySendErrorReason;
61
+ }
62
+
63
+ export type NotifySendErrorReason = `executor-failed` | `systemd-notify-failed`;
64
+
65
+ export interface SystemctlCommandErrorOptions extends SystemdCommandErrorOptions {
66
+ readonly operation?: string;
67
+ readonly reason?: SystemctlCommandErrorReason;
68
+ }
69
+
70
+ export type SystemctlCommandErrorReason = `executor-failed` | `invalid-json`;
71
+
72
+ export type SystemdCommandEnvironmentReason =
73
+ | `manager-unavailable`
74
+ | `missing-command`
75
+ | `permission-denied`;
76
+
77
+ export class SystemdTsError extends Error {
78
+ public readonly code: SystemdTsErrorCode;
79
+
80
+ public constructor(
81
+ code: SystemdTsErrorCode,
82
+ message: string,
83
+ options: SystemdTsErrorOptions = {},
84
+ ) {
85
+ super(message, { cause: options.cause });
86
+ this.code = code;
87
+ this.name = new.target.name;
88
+ }
89
+ }
90
+
91
+ export class InvalidExecDirectiveError extends SystemdTsError {
92
+ public readonly directive: string;
93
+
94
+ public constructor(directive: string, options: SystemdTsErrorOptions = {}) {
95
+ super(
96
+ `SYSTEMD_TS_INVALID_EXEC_DIRECTIVE`,
97
+ `${directive} must use an absolute executable path for systemd`,
98
+ options,
99
+ );
100
+ this.directive = directive;
101
+ }
102
+ }
103
+
104
+ export class NoUnitsProvidedError extends SystemdTsError {
105
+ public readonly operation: string;
106
+
107
+ public constructor(operation: string, options: SystemdTsErrorOptions = {}) {
108
+ super(
109
+ `SYSTEMD_TS_NO_UNITS_PROVIDED`,
110
+ `${operation} requires at least one service or timer`,
111
+ options,
112
+ );
113
+ this.operation = operation;
114
+ }
115
+ }
116
+
117
+ export class ExecutableInferenceError extends SystemdTsError {
118
+ public constructor(options: SystemdTsErrorOptions = {}) {
119
+ super(
120
+ `SYSTEMD_TS_EXECUTABLE_INFERENCE`,
121
+ `Could not infer the calling module path for defineExecutable(); pass { modulePath } explicitly`,
122
+ options,
123
+ );
124
+ }
125
+ }
126
+
127
+ export class UnitMaterializationError extends SystemdTsError {
128
+ public readonly operation: `create-directory` | `write-file` | undefined;
129
+ public readonly reason: UnitMaterializationErrorReason | undefined;
130
+ public readonly unitName: string | undefined;
131
+ public readonly unitPath: string | undefined;
132
+
133
+ public constructor(message: string, options: UnitMaterializationErrorOptions = {}) {
134
+ super(`SYSTEMD_TS_UNIT_MATERIALIZATION`, message, options);
135
+ this.operation = options.operation;
136
+ this.reason = options.reason;
137
+ this.unitName = options.unitName;
138
+ this.unitPath = options.unitPath;
139
+ }
140
+ }
141
+
142
+ export class UnitEnableError extends SystemdTsError {
143
+ public readonly args: readonly string[] | undefined;
144
+ public readonly command: string | undefined;
145
+ public readonly environmentReason: SystemdCommandEnvironmentReason | undefined;
146
+ public readonly exitCode: number | undefined;
147
+ public readonly stage: string | undefined;
148
+ public readonly stderr: string | undefined;
149
+ public readonly stdout: string | undefined;
150
+ public readonly unitName: string | undefined;
151
+ public readonly unitPath: string | undefined;
152
+
153
+ public constructor(message: string, options: SystemdCommandErrorOptions = {}) {
154
+ super(`SYSTEMD_TS_UNIT_ENABLE`, message, options);
155
+ const details = extractCommandErrorDetails(options.cause);
156
+ this.args = options.args;
157
+ this.command = options.command;
158
+ this.environmentReason =
159
+ options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
160
+ this.exitCode = details.exitCode;
161
+ this.stage = options.stage;
162
+ this.stderr = details.stderr;
163
+ this.stdout = details.stdout;
164
+ this.unitName = options.unitName;
165
+ this.unitPath = options.unitPath;
166
+ }
167
+ }
168
+
169
+ export class UnitStartError extends SystemdTsError {
170
+ public readonly args: readonly string[] | undefined;
171
+ public readonly command: string | undefined;
172
+ public readonly diagnostics: UnitStartDiagnostics | undefined;
173
+ public readonly environmentReason: SystemdCommandEnvironmentReason | undefined;
174
+ public readonly exitCode: number | undefined;
175
+ public readonly stage: string | undefined;
176
+ public readonly stderr: string | undefined;
177
+ public readonly stdout: string | undefined;
178
+ public readonly unitName: string | undefined;
179
+ public readonly unitPath: string | undefined;
180
+
181
+ public constructor(message: string, options: UnitStartErrorOptions = {}) {
182
+ super(`SYSTEMD_TS_UNIT_START`, message, options);
183
+ const details = extractCommandErrorDetails(options.cause);
184
+ this.args = options.args;
185
+ this.command = options.command;
186
+ this.diagnostics = options.diagnostics;
187
+ this.environmentReason =
188
+ options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
189
+ this.exitCode = details.exitCode;
190
+ this.stage = options.stage;
191
+ this.stderr = details.stderr;
192
+ this.stdout = details.stdout;
193
+ this.unitName = options.unitName;
194
+ this.unitPath = options.unitPath;
195
+ }
196
+ }
197
+
198
+ export class UnitLogsReadError extends SystemdTsError {
199
+ public readonly args: readonly string[] | undefined;
200
+ public readonly command: string | undefined;
201
+ public readonly environmentReason: SystemdCommandEnvironmentReason | undefined;
202
+ public readonly exitCode: number | undefined;
203
+ public readonly reason: UnitLogsReadErrorReason | undefined;
204
+ public readonly stage: string | undefined;
205
+ public readonly stderr: string | undefined;
206
+ public readonly stdout: string | undefined;
207
+ public readonly unitName: string | undefined;
208
+ public readonly unitPath: string | undefined;
209
+
210
+ public constructor(message: string, options: UnitLogsReadErrorOptions = {}) {
211
+ super(`SYSTEMD_TS_UNIT_LOGS`, message, options);
212
+ const details = extractCommandErrorDetails(options.cause);
213
+ this.args = options.args;
214
+ this.command = options.command;
215
+ this.environmentReason =
216
+ options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
217
+ this.exitCode = details.exitCode;
218
+ this.reason = options.reason;
219
+ this.stage = options.stage;
220
+ this.stderr = details.stderr;
221
+ this.stdout = details.stdout;
222
+ this.unitName = options.unitName;
223
+ this.unitPath = options.unitPath;
224
+ }
225
+ }
226
+
227
+ export class NotifySendError extends SystemdTsError {
228
+ public readonly args: readonly string[] | undefined;
229
+ public readonly command: string | undefined;
230
+ public readonly environmentReason: SystemdCommandEnvironmentReason | undefined;
231
+ public readonly exitCode: number | undefined;
232
+ public readonly reason: NotifySendErrorReason | undefined;
233
+ public readonly stage: string | undefined;
234
+ public readonly stderr: string | undefined;
235
+ public readonly stdout: string | undefined;
236
+
237
+ public constructor(message: string, options: NotifySendErrorOptions = {}) {
238
+ super(`SYSTEMD_TS_NOTIFY_SEND`, message, options);
239
+ const details = extractCommandErrorDetails(options.cause);
240
+ this.args = options.args;
241
+ this.command = options.command;
242
+ this.environmentReason =
243
+ options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
244
+ this.exitCode = details.exitCode;
245
+ this.reason = options.reason;
246
+ this.stage = options.stage;
247
+ this.stderr = details.stderr;
248
+ this.stdout = details.stdout;
249
+ }
250
+ }
251
+
252
+ export class SystemctlCommandError extends SystemdTsError {
253
+ public readonly args: readonly string[] | undefined;
254
+ public readonly command: string | undefined;
255
+ public readonly environmentReason: SystemdCommandEnvironmentReason | undefined;
256
+ public readonly exitCode: number | undefined;
257
+ public readonly operation: string | undefined;
258
+ public readonly reason: SystemctlCommandErrorReason | undefined;
259
+ public readonly stage: string | undefined;
260
+ public readonly stderr: string | undefined;
261
+ public readonly stdout: string | undefined;
262
+
263
+ public constructor(message: string, options: SystemctlCommandErrorOptions = {}) {
264
+ super(`SYSTEMD_TS_SYSTEMCTL`, message, options);
265
+ const details = extractCommandErrorDetails(options.cause);
266
+ this.args = options.args;
267
+ this.command = options.command;
268
+ this.environmentReason =
269
+ options.environmentReason ?? classifyCommandEnvironmentReason(options.cause);
270
+ this.exitCode = details.exitCode;
271
+ this.operation = options.operation;
272
+ this.reason = options.reason;
273
+ this.stage = options.stage;
274
+ this.stderr = details.stderr;
275
+ this.stdout = details.stdout;
276
+ }
277
+ }
278
+
279
+ export function classifyCommandEnvironmentReason(
280
+ cause: unknown,
281
+ ): SystemdCommandEnvironmentReason | undefined {
282
+ if (cause === null || typeof cause !== `object`) {
283
+ return undefined;
284
+ }
285
+
286
+ const record = cause as Record<string, unknown>;
287
+ if (record[`code`] === `ENOENT`) {
288
+ return `missing-command`;
289
+ }
290
+
291
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM`) {
292
+ return `permission-denied`;
293
+ }
294
+
295
+ const combinedOutput = [record[`stderr`], record[`stdout`]]
296
+ .filter((value): value is string => typeof value === `string` && value.length > 0)
297
+ .join(`\n`);
298
+
299
+ if (
300
+ combinedOutput.includes(`Failed to connect to bus`) ||
301
+ combinedOutput.includes(`System has not been booted with systemd`) ||
302
+ combinedOutput.includes(`Failed to get D-Bus connection`)
303
+ ) {
304
+ return `manager-unavailable`;
305
+ }
306
+
307
+ return undefined;
308
+ }
309
+
310
+ export function classifyMaterializationReason(
311
+ cause: unknown,
312
+ ): UnitMaterializationErrorReason | undefined {
313
+ if (cause === null || typeof cause !== `object`) {
314
+ return undefined;
315
+ }
316
+
317
+ const record = cause as Record<string, unknown>;
318
+ if (record[`code`] === `EACCES` || record[`code`] === `EPERM` || record[`code`] === `EROFS`) {
319
+ return `permission-denied`;
320
+ }
321
+
322
+ if (record[`code`] === `EEXIST` || record[`code`] === `ENOTDIR` || record[`code`] === `EISDIR`) {
323
+ return `invalid-unit-directory`;
324
+ }
325
+
326
+ return `file-system-failed`;
327
+ }
328
+
329
+ function extractCommandErrorDetails(cause: unknown): {
330
+ readonly exitCode: number | undefined;
331
+ readonly stderr: string | undefined;
332
+ readonly stdout: string | undefined;
333
+ } {
334
+ if (cause === null || typeof cause !== `object`) {
335
+ return {
336
+ exitCode: undefined,
337
+ stderr: undefined,
338
+ stdout: undefined,
339
+ };
340
+ }
341
+
342
+ const record = cause as Record<string, unknown>;
343
+ return {
344
+ exitCode: typeof record[`code`] === `number` ? record[`code`] : undefined,
345
+ stderr: typeof record[`stderr`] === `string` ? record[`stderr`] : undefined,
346
+ stdout: typeof record[`stdout`] === `string` ? record[`stdout`] : undefined,
347
+ };
348
+ }
@@ -0,0 +1,175 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { ExecutableInferenceError } from "./errors.ts";
5
+ import type { Result } from "./internal.ts";
6
+ import type { ExecutableOptions } from "./types.ts";
7
+
8
+ const currentModulePath = fileURLToPath(import.meta.url);
9
+
10
+ /**
11
+ * An immutable description of a runnable module entrypoint that can be used in
12
+ * executable-valued systemd unit directives.
13
+ *
14
+ * `Executable` captures three pieces of information:
15
+ * - the absolute runtime binary that should launch the module
16
+ * - the absolute path to the module itself
17
+ * - any additional arguments that should be passed after the module path
18
+ *
19
+ * Most consumers will create one via {@link defineExecutable} and then pass it
20
+ * into a {@link SystemdService} directive such as `ExecStart`, `ExecStop`, or
21
+ * `ExecReload`.
22
+ */
23
+ export class Executable {
24
+ /** Additional arguments passed after the module path. */
25
+ public readonly args: readonly string[];
26
+ /** Inference issue captured when no explicit module path was provided. */
27
+ public readonly inferenceError: ExecutableInferenceError | undefined;
28
+ /** Absolute path to the module that should be executed. */
29
+ public readonly modulePath: string;
30
+ /** Absolute path to the runtime binary that should launch the module. */
31
+ public readonly runtimeEntrypoint: string;
32
+
33
+ /**
34
+ * Creates an executable description.
35
+ *
36
+ * When `modulePath` is omitted, the calling module is inferred from the
37
+ * current stack so `defineExecutable()` can be used inline from the module
38
+ * that should become runnable.
39
+ *
40
+ * When `runtimeEntrypoint` is omitted, the current process executable is
41
+ * used. This works well for the common case where the same runtime that is
42
+ * evaluating your module should also be used by systemd.
43
+ */
44
+ public constructor(options: ExecutableOptions = {}) {
45
+ this.runtimeEntrypoint = options.runtimeEntrypoint ?? process.execPath;
46
+ const inferredModulePath = options.modulePath ?? tryInferCallerModulePath();
47
+ this.inferenceError =
48
+ inferredModulePath === undefined && options.modulePath === undefined
49
+ ? new ExecutableInferenceError()
50
+ : undefined;
51
+ this.modulePath = inferredModulePath ?? ``;
52
+ this.args = Object.freeze([...(options.args ?? [])]);
53
+ Object.freeze(this);
54
+ }
55
+
56
+ /**
57
+ * Returns the executable as raw command parts.
58
+ *
59
+ * The first element is always the runtime entrypoint, followed by the module
60
+ * path and any configured arguments.
61
+ */
62
+ public toCommandParts(): Result<readonly [string, ...string[]], ExecutableInferenceError> {
63
+ if (this.inferenceError !== undefined) {
64
+ return {
65
+ ok: false,
66
+ error: this.inferenceError,
67
+ };
68
+ }
69
+
70
+ return {
71
+ ok: true,
72
+ value: [this.runtimeEntrypoint, this.modulePath, ...this.args],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Renders the executable as a shell-quoted command string suitable for
78
+ * executable-valued systemd directives such as `ExecStart=`.
79
+ */
80
+ public toExecStart(): Result<string, ExecutableInferenceError> {
81
+ const commandParts = this.toCommandParts();
82
+ if (!commandParts.ok) {
83
+ return commandParts;
84
+ }
85
+
86
+ return {
87
+ ok: true,
88
+ value: commandParts.value.map(shellQuote).join(` `),
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Defines a module as a runnable executable and returns its immutable
95
+ * `Executable` description.
96
+ *
97
+ * This helper is designed for the pattern:
98
+ *
99
+ * ```ts
100
+ * export default defineExecutable(async () => {
101
+ * // do work here
102
+ * });
103
+ * ```
104
+ *
105
+ * When the defining module is executed as the main entrypoint, `fn` is invoked.
106
+ * When the module is merely imported, `fn` is not run and only the executable
107
+ * description is returned.
108
+ *
109
+ * Pass `options.runtimeEntrypoint` to override the default runtime binary when
110
+ * the current process is not the exact runtime you want systemd to use.
111
+ */
112
+ export function defineExecutable(
113
+ fn: () => void | Promise<void>,
114
+ options: ExecutableOptions = {},
115
+ ): Executable {
116
+ const executable = new Executable(options);
117
+
118
+ if (executable.inferenceError === undefined && isMainModule(executable.modulePath)) {
119
+ void Promise.resolve(fn()).catch((error: unknown) => {
120
+ process.exitCode = 1;
121
+ throw error;
122
+ });
123
+ }
124
+
125
+ return executable;
126
+ }
127
+
128
+ function tryInferCallerModulePath(): string | undefined {
129
+ const stack = new Error().stack ?? ``;
130
+ for (const line of stack.split(`\n`).slice(1)) {
131
+ const candidate = extractStackPath(line);
132
+ if (candidate === undefined || candidate === currentModulePath) {
133
+ continue;
134
+ }
135
+
136
+ return candidate;
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
142
+ function extractStackPath(line: string): string | undefined {
143
+ const fileUrlMatch = line.match(/(file:\/\/\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
144
+ if (fileUrlMatch !== null) {
145
+ return fileURLToPath(fileUrlMatch[1]);
146
+ }
147
+
148
+ const pathMatch = line.match(/(\/[^)\s:]+(?:\.[cm]?[jt]s)?)/u);
149
+ if (pathMatch !== null) {
150
+ return pathMatch[1];
151
+ }
152
+
153
+ return undefined;
154
+ }
155
+
156
+ function isMainModule(modulePath: string): boolean {
157
+ const mainArg = process.argv[1];
158
+ if (mainArg === undefined) {
159
+ return false;
160
+ }
161
+
162
+ return normalizeFilePath(mainArg) === normalizeFilePath(modulePath);
163
+ }
164
+
165
+ function shellQuote(value: string): string {
166
+ return `'${value.replaceAll(`'`, `'\\''`)}'`;
167
+ }
168
+
169
+ function normalizeFilePath(path: string): string {
170
+ try {
171
+ return realpathSync(path);
172
+ } catch {
173
+ return path;
174
+ }
175
+ }
@@ -0,0 +1,76 @@
1
+ export {
2
+ ExecutableInferenceError,
3
+ InvalidExecDirectiveError,
4
+ NoUnitsProvidedError,
5
+ NotifySendError,
6
+ SystemctlCommandError,
7
+ SystemdTsError,
8
+ UnitEnableError,
9
+ UnitLogsReadError,
10
+ UnitMaterializationError,
11
+ UnitStartError,
12
+ } from "./errors.ts";
13
+ export { Executable, defineExecutable } from "./executable.ts";
14
+ export { notify } from "./notify.ts";
15
+ export { SystemdService } from "./systemd-service.ts";
16
+ export { SystemdTimer } from "./systemd-timer.ts";
17
+ export { Systemctl } from "./systemctl.ts";
18
+ export { Systemd, SystemdMaterialization, SystemdTs, defaultSystemd } from "./systemd.ts";
19
+ export type {
20
+ SystemdTsAttachmentSelector,
21
+ SystemdTsAttachOptions,
22
+ SystemdTsAttachmentResult,
23
+ SystemdTsDetachOptions,
24
+ SystemdTsReattachOptions,
25
+ } from "./systemd.ts";
26
+ export type { Result } from "./internal.ts";
27
+ export type {
28
+ AnySystemdService,
29
+ AnySystemdTimer,
30
+ CommandExecutionOptions,
31
+ CommandExecutor,
32
+ CommandOutput,
33
+ ExecutableOptions,
34
+ MaterializedUnit,
35
+ LogsOptions,
36
+ NotifyOptions,
37
+ ServiceBaseName,
38
+ ServiceFilename,
39
+ StartStatus,
40
+ SystemdOptions,
41
+ SystemdServiceOptions,
42
+ SystemctlActiveState,
43
+ SystemctlEnablementState,
44
+ SystemctlIsEnabledOptions,
45
+ SystemctlIsSystemRunningOptions,
46
+ SystemctlListTimersOptions,
47
+ SystemctlOptions,
48
+ SystemctlServiceStatus,
49
+ SystemctlSystemRunningState,
50
+ SystemctlStateQueryOptions,
51
+ SystemctlShowOptions,
52
+ SystemctlStatusOptions,
53
+ SystemctlTimerListEntry,
54
+ SystemdTimerOptions,
55
+ SystemdUnit,
56
+ TimerBaseName,
57
+ TimerFilename,
58
+ TimerTargetServiceName,
59
+ TimerTargetUnit,
60
+ UnitValue,
61
+ ValidInstallUnits,
62
+ } from "./types.ts";
63
+ export type {
64
+ NotifySendErrorReason,
65
+ SystemctlCommandErrorReason,
66
+ SystemdCommandEnvironmentReason,
67
+ UnitLogsReadErrorReason,
68
+ UnitMaterializationErrorReason,
69
+ } from "./errors.ts";
70
+
71
+ import * as I from "./internal.ts";
72
+ /**
73
+ * Internal helpers are exposed for advanced use, but they are not a stable public API.
74
+ * Expect breaking changes here outside the package's normal compatibility guarantees.
75
+ */
76
+ export const Internal: typeof I = I;