@homeflare/alchemy 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -48
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1433 -305
- package/dist/openbao/index.js.map +34 -13
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +87 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Host.File` — one text file on the host, declared: content, mode, owner, group.
|
|
3
|
+
*
|
|
4
|
+
* - create / update — atomic write (temp file in the same directory, mode and owner set, rename),
|
|
5
|
+
* skipped when the file already matches; then read back and compared.
|
|
6
|
+
* - read — lstat plus the SHA-256 of the bytes.
|
|
7
|
+
* - diff — declared SHA-256 against the stored and on-disk digest, plus mode and owner.
|
|
8
|
+
* - replace — only when `path` changes; create-before-delete.
|
|
9
|
+
* - delete — removes the file.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ NEVER A SECRET — see host-file-form.ts. Secret files stay rendered by openbao-agent.
|
|
12
|
+
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`.
|
|
13
|
+
* ★ IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a
|
|
14
|
+
* daemon's config file and the daemon that reads it, through the same HostRunner.
|
|
15
|
+
*/
|
|
16
|
+
import { Resource } from 'alchemy';
|
|
17
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
18
|
+
import { isResolved } from 'alchemy/Diff';
|
|
19
|
+
import * as Provider from 'alchemy/Provider';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import { lift, resolvedString } from './host-effect.ts';
|
|
22
|
+
import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
|
|
23
|
+
import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
|
|
24
|
+
import { HostRunnerService } from './runner.ts';
|
|
25
|
+
|
|
26
|
+
export type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
|
|
27
|
+
|
|
28
|
+
export interface HostFile extends Resource<'Host.File', HostFileProps, HostFileAttributes> {}
|
|
29
|
+
|
|
30
|
+
export const HostFile = Resource<HostFile>('Host.File');
|
|
31
|
+
|
|
32
|
+
export const HostFileProvider = () =>
|
|
33
|
+
Provider.effect(
|
|
34
|
+
HostFile,
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const runner = yield* HostRunnerService;
|
|
37
|
+
return HostFile.Provider.of({
|
|
38
|
+
list: () => Effect.succeed([]),
|
|
39
|
+
|
|
40
|
+
read: ({ olds, output }) =>
|
|
41
|
+
lift(async () => {
|
|
42
|
+
const found = await readFileAttributes(runner, olds.path);
|
|
43
|
+
if (found === undefined) return undefined;
|
|
44
|
+
return output === undefined ? Unowned(found) : found;
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
diff: ({ news, output }) => {
|
|
48
|
+
if (output === undefined) return Effect.succeed(undefined);
|
|
49
|
+
if (isResolved(news)) return lift(() => diffFile(runner, news, output));
|
|
50
|
+
// ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
|
|
51
|
+
// the engine's default would be an update, which writes the new path and never
|
|
52
|
+
// removes the old one. reconcileFile also defends, for a path that is an Output.
|
|
53
|
+
const path = resolvedString(news, 'path');
|
|
54
|
+
return Effect.succeed(
|
|
55
|
+
path !== undefined && path !== output.path ? { action: 'replace' as const } : undefined,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
reconcile: ({ news, output }) => lift(() => reconcileFile(runner, news, output)),
|
|
60
|
+
|
|
61
|
+
delete: ({ output }) => lift(() => deleteFile(runner, output)),
|
|
62
|
+
});
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lookup parsers against the dscacheutil shape measured on macOS 27.2 and getent's documented
|
|
3
|
+
* one — pure strings, nothing runs.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
|
|
7
|
+
|
|
8
|
+
const DSCACHE_USER = [
|
|
9
|
+
'name: someone',
|
|
10
|
+
'password: ********',
|
|
11
|
+
'uid: 501',
|
|
12
|
+
'gid: 20',
|
|
13
|
+
'dir: /Users/someone',
|
|
14
|
+
'shell: /bin/zsh',
|
|
15
|
+
'gecos: Some One',
|
|
16
|
+
'',
|
|
17
|
+
'name: second',
|
|
18
|
+
'uid: 502',
|
|
19
|
+
'gid: 20',
|
|
20
|
+
'dir: /Users/second',
|
|
21
|
+
'',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
describe('queries', () => {
|
|
25
|
+
test('macOS asks dscacheutil by uid or by name; elsewhere getent', () => {
|
|
26
|
+
expect(userQuery('darwin', '501')).toEqual([
|
|
27
|
+
'/usr/bin/dscacheutil',
|
|
28
|
+
'-q',
|
|
29
|
+
'user',
|
|
30
|
+
'-a',
|
|
31
|
+
'uid',
|
|
32
|
+
'501',
|
|
33
|
+
]);
|
|
34
|
+
expect(userQuery('darwin', 'someone')).toEqual([
|
|
35
|
+
'/usr/bin/dscacheutil',
|
|
36
|
+
'-q',
|
|
37
|
+
'user',
|
|
38
|
+
'-a',
|
|
39
|
+
'name',
|
|
40
|
+
'someone',
|
|
41
|
+
]);
|
|
42
|
+
expect(groupQuery('darwin', 'wheel')).toEqual([
|
|
43
|
+
'/usr/bin/dscacheutil',
|
|
44
|
+
'-q',
|
|
45
|
+
'group',
|
|
46
|
+
'-a',
|
|
47
|
+
'name',
|
|
48
|
+
'wheel',
|
|
49
|
+
]);
|
|
50
|
+
expect(groupQuery('darwin', '0')).toEqual([
|
|
51
|
+
'/usr/bin/dscacheutil',
|
|
52
|
+
'-q',
|
|
53
|
+
'group',
|
|
54
|
+
'-a',
|
|
55
|
+
'gid',
|
|
56
|
+
'0',
|
|
57
|
+
]);
|
|
58
|
+
expect(userQuery('linux', 'someone')).toEqual(['getent', 'passwd', 'someone']);
|
|
59
|
+
expect(groupQuery('linux', 'wheel')).toEqual(['getent', 'group', 'wheel']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('parseUser', () => {
|
|
64
|
+
test('dscacheutil: the first record only, and never the password field', () => {
|
|
65
|
+
const user = parseUser('darwin', DSCACHE_USER);
|
|
66
|
+
expect(user).toEqual({ gid: 20, home: '/Users/someone', uid: 501 });
|
|
67
|
+
expect(JSON.stringify(user)).not.toContain('*');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('dscacheutil: an unknown user prints nothing (measured) and parses to undefined', () => {
|
|
71
|
+
expect(parseUser('darwin', '')).toBeUndefined();
|
|
72
|
+
expect(parseUser('darwin', 'name: partial\nuid: 1\n')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('getent passwd', () => {
|
|
76
|
+
expect(parseUser('linux', 'someone:x:1000:1000:Some One:/home/someone:/bin/bash\n')).toEqual({
|
|
77
|
+
gid: 1000,
|
|
78
|
+
home: '/home/someone',
|
|
79
|
+
uid: 1000,
|
|
80
|
+
});
|
|
81
|
+
expect(parseUser('linux', '')).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('parseGroup', () => {
|
|
86
|
+
test('dscacheutil and getent', () => {
|
|
87
|
+
expect(parseGroup('darwin', 'name: wheel\npassword: *\ngid: 0\nusers: root\n')).toBe(0);
|
|
88
|
+
expect(parseGroup('darwin', '')).toBeUndefined();
|
|
89
|
+
expect(parseGroup('linux', 'staff:x:50:someone\n')).toBe(50);
|
|
90
|
+
expect(parseGroup('linux', '')).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User and group lookups, as argv plus a parser — split out so the parsing is tested against the
|
|
3
|
+
* real output shapes without running anything.
|
|
4
|
+
*
|
|
5
|
+
* ★ macOS FIRST, getent ELSEWHERE. macOS has no `getent`; its directory service answers through
|
|
6
|
+
* `dscacheutil`. Shape MEASURED 2026-09-21 on macOS 27.2 (read-only query):
|
|
7
|
+
*
|
|
8
|
+
* name: someone
|
|
9
|
+
* password: ********
|
|
10
|
+
* uid: 501
|
|
11
|
+
* gid: 20
|
|
12
|
+
* dir: /Users/someone
|
|
13
|
+
* shell: /bin/zsh
|
|
14
|
+
*
|
|
15
|
+
* An unknown uid printed NOTHING and exited 0 — so "no record" is an empty parse, never an
|
|
16
|
+
* exit code. `getent` exits 2 for an unknown key; that also parses as no record.
|
|
17
|
+
* ⛔ NEVER PARSE OR KEEP THE `password:` LINE. It is a placeholder on macOS, but a parser that
|
|
18
|
+
* copies every field would carry a hash from any host where it is not.
|
|
19
|
+
*/
|
|
20
|
+
import type { HostUser } from './runner.ts';
|
|
21
|
+
|
|
22
|
+
const NUMERIC = /^\d+$/;
|
|
23
|
+
|
|
24
|
+
/** argv for looking up a user, by name or numeric id. */
|
|
25
|
+
export const userQuery = (platform: string, nameOrId: string): readonly string[] =>
|
|
26
|
+
platform === 'darwin'
|
|
27
|
+
? [
|
|
28
|
+
'/usr/bin/dscacheutil',
|
|
29
|
+
'-q',
|
|
30
|
+
'user',
|
|
31
|
+
'-a',
|
|
32
|
+
NUMERIC.test(nameOrId) ? 'uid' : 'name',
|
|
33
|
+
nameOrId,
|
|
34
|
+
]
|
|
35
|
+
: ['getent', 'passwd', nameOrId];
|
|
36
|
+
|
|
37
|
+
/** argv for looking up a group, by name or numeric id. */
|
|
38
|
+
export const groupQuery = (platform: string, nameOrId: string): readonly string[] =>
|
|
39
|
+
platform === 'darwin'
|
|
40
|
+
? [
|
|
41
|
+
'/usr/bin/dscacheutil',
|
|
42
|
+
'-q',
|
|
43
|
+
'group',
|
|
44
|
+
'-a',
|
|
45
|
+
NUMERIC.test(nameOrId) ? 'gid' : 'name',
|
|
46
|
+
nameOrId,
|
|
47
|
+
]
|
|
48
|
+
: ['getent', 'group', nameOrId];
|
|
49
|
+
|
|
50
|
+
/** The first `key: value` record in dscacheutil output, minus any password field. */
|
|
51
|
+
const firstRecord = (stdout: string): Map<string, string> => {
|
|
52
|
+
const fields = new Map<string, string>();
|
|
53
|
+
for (const line of stdout.split('\n')) {
|
|
54
|
+
if (line.trim() === '') {
|
|
55
|
+
if (fields.size > 0) break; // ⚠️ records are blank-line separated; keep the first only
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const at = line.indexOf(': ');
|
|
59
|
+
if (at <= 0) continue;
|
|
60
|
+
const key = line.slice(0, at);
|
|
61
|
+
if (key === 'password') continue;
|
|
62
|
+
fields.set(key, line.slice(at + 2).trim());
|
|
63
|
+
}
|
|
64
|
+
return fields;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const toId = (text: string | undefined): number | undefined =>
|
|
68
|
+
text !== undefined && NUMERIC.test(text) ? Number(text) : undefined;
|
|
69
|
+
|
|
70
|
+
export const parseUser = (platform: string, stdout: string): HostUser | undefined => {
|
|
71
|
+
if (platform === 'darwin') {
|
|
72
|
+
const record = firstRecord(stdout);
|
|
73
|
+
const uid = toId(record.get('uid'));
|
|
74
|
+
const gid = toId(record.get('gid'));
|
|
75
|
+
const home = record.get('dir');
|
|
76
|
+
return uid === undefined || gid === undefined || home === undefined
|
|
77
|
+
? undefined
|
|
78
|
+
: { gid, home, uid };
|
|
79
|
+
}
|
|
80
|
+
// getent passwd: name:x:uid:gid:gecos:home:shell
|
|
81
|
+
const fields = (stdout.split('\n')[0] ?? '').split(':');
|
|
82
|
+
const uid = toId(fields[2]);
|
|
83
|
+
const gid = toId(fields[3]);
|
|
84
|
+
const home = fields[5];
|
|
85
|
+
return uid === undefined || gid === undefined || home === undefined || home === ''
|
|
86
|
+
? undefined
|
|
87
|
+
: { gid, home, uid };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const parseGroup = (platform: string, stdout: string): number | undefined => {
|
|
91
|
+
if (platform === 'darwin') return toId(firstRecord(stdout).get('gid'));
|
|
92
|
+
// getent group: name:x:gid:members
|
|
93
|
+
return toId((stdout.split('\n')[0] ?? '').split(':')[2]);
|
|
94
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd providers for Alchemy — declare a Mac host's jobs and their config files.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. The
|
|
5
|
+
* lifecycle files, the launchctl parsers and the fake runner are internals; an `export *` would
|
|
6
|
+
* publish them as API and make the next refactor a breaking change.
|
|
7
|
+
* ⛔ macOS HOSTS ONLY for LaunchdJob. HostFile and the runner work on any POSIX host, but the
|
|
8
|
+
* subpath exists for the Mac: see docs/launchd.md for the cutover from nix-darwin.
|
|
9
|
+
*/
|
|
10
|
+
export type { HostFileAttributes, HostFileProps } from './host-file.ts';
|
|
11
|
+
export { HostFile, HostFileProvider } from './host-file.ts';
|
|
12
|
+
export type {
|
|
13
|
+
CalendarInterval,
|
|
14
|
+
KeepAlive,
|
|
15
|
+
LaunchdDomain,
|
|
16
|
+
LaunchdJobAttributes,
|
|
17
|
+
LaunchdJobProps,
|
|
18
|
+
} from './job.ts';
|
|
19
|
+
export { LaunchdJob, LaunchdJobProvider } from './job.ts';
|
|
20
|
+
export type { LocalRunnerOptions } from './local-runner.ts';
|
|
21
|
+
export { localRunner } from './local-runner.ts';
|
|
22
|
+
export type { PlistDict, PlistValue } from './plist.ts';
|
|
23
|
+
export { PlistError, renderPlist } from './plist.ts';
|
|
24
|
+
export { launchdProviders } from './providers.ts';
|
|
25
|
+
export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
26
|
+
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's pure half: domain parsing, the derived plist path, the props → plist mapping, and
|
|
3
|
+
* every refusal in job-validate.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
type LaunchdJobProps,
|
|
8
|
+
jobPlistDict,
|
|
9
|
+
parseDomain,
|
|
10
|
+
plistPathFor,
|
|
11
|
+
renderJob,
|
|
12
|
+
serviceTarget,
|
|
13
|
+
} from './job-form.ts';
|
|
14
|
+
import { RESERVED_LABEL_PREFIXES, jobProblems } from './job-validate.ts';
|
|
15
|
+
|
|
16
|
+
const base: LaunchdJobProps = {
|
|
17
|
+
domain: 'system',
|
|
18
|
+
label: 'com.example.job',
|
|
19
|
+
programArguments: ['/usr/local/bin/job', '--flag'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('domains and paths', () => {
|
|
23
|
+
test('system and gui/<uid> parse; anything else does not', () => {
|
|
24
|
+
expect(parseDomain('system')).toEqual({ kind: 'system' });
|
|
25
|
+
expect(parseDomain('gui/501')).toEqual({ kind: 'gui', uid: 501 });
|
|
26
|
+
for (const bad of ['gui/', 'gui/abc', 'user/501', 'System', 'gui/501/x']) {
|
|
27
|
+
expect(parseDomain(bad)).toBeUndefined();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the plist path is derived from label and domain', () => {
|
|
32
|
+
expect(plistPathFor('com.example.job', { kind: 'system' }, undefined)).toBe(
|
|
33
|
+
'/Library/LaunchDaemons/com.example.job.plist',
|
|
34
|
+
);
|
|
35
|
+
expect(plistPathFor('com.example.job', { kind: 'gui', uid: 501 }, '/Users/someone')).toBe(
|
|
36
|
+
'/Users/someone/Library/LaunchAgents/com.example.job.plist',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a gui domain with no home refuses rather than guessing `~`', () => {
|
|
41
|
+
expect(() => plistPathFor('x', { kind: 'gui', uid: 9 }, undefined)).toThrow('no home');
|
|
42
|
+
expect(() => plistPathFor('x', { kind: 'gui', uid: 9 }, 'relative')).toThrow('no home');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('service target', () => {
|
|
46
|
+
expect(serviceTarget('com.example.job', 'gui/501')).toBe('gui/501/com.example.job');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('jobPlistDict', () => {
|
|
51
|
+
test('the minimum is Label and ProgramArguments — no invented defaults', () => {
|
|
52
|
+
expect(jobPlistDict(base)).toEqual({
|
|
53
|
+
Label: 'com.example.job',
|
|
54
|
+
ProgramArguments: ['/usr/local/bin/job', '--flag'],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every typed prop maps to its launchd.plist(5) key', () => {
|
|
59
|
+
expect(
|
|
60
|
+
jobPlistDict({
|
|
61
|
+
...base,
|
|
62
|
+
environment: { SSL_CERT_FILE: '/etc/ssl/cert.pem' },
|
|
63
|
+
groupName: 'staff',
|
|
64
|
+
keepAlive: { crashed: true, successfulExit: false },
|
|
65
|
+
runAtLoad: true,
|
|
66
|
+
standardErrorPath: '/var/log/job.err',
|
|
67
|
+
standardOutPath: '/var/log/job.out',
|
|
68
|
+
startCalendarInterval: [{ hour: 3, minute: 30 }, { weekday: 0 }],
|
|
69
|
+
startInterval: 300,
|
|
70
|
+
throttleInterval: 30,
|
|
71
|
+
userName: 'nobody',
|
|
72
|
+
workingDirectory: '/var/empty',
|
|
73
|
+
extraKeys: { ProcessType: 'Background' },
|
|
74
|
+
}),
|
|
75
|
+
).toEqual({
|
|
76
|
+
EnvironmentVariables: { SSL_CERT_FILE: '/etc/ssl/cert.pem' },
|
|
77
|
+
GroupName: 'staff',
|
|
78
|
+
KeepAlive: { Crashed: true, SuccessfulExit: false },
|
|
79
|
+
Label: 'com.example.job',
|
|
80
|
+
ProcessType: 'Background',
|
|
81
|
+
ProgramArguments: ['/usr/local/bin/job', '--flag'],
|
|
82
|
+
RunAtLoad: true,
|
|
83
|
+
StandardErrorPath: '/var/log/job.err',
|
|
84
|
+
StandardOutPath: '/var/log/job.out',
|
|
85
|
+
StartCalendarInterval: [{ Hour: 3, Minute: 30 }, { Weekday: 0 }],
|
|
86
|
+
StartInterval: 300,
|
|
87
|
+
ThrottleInterval: 30,
|
|
88
|
+
UserName: 'nobody',
|
|
89
|
+
WorkingDirectory: '/var/empty',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a single calendar entry stays a dict; keepAlive true stays a boolean; empty env is omitted', () => {
|
|
94
|
+
const dict = jobPlistDict({
|
|
95
|
+
...base,
|
|
96
|
+
environment: {},
|
|
97
|
+
keepAlive: true,
|
|
98
|
+
startCalendarInterval: { minute: 0 },
|
|
99
|
+
});
|
|
100
|
+
expect(dict['StartCalendarInterval']).toEqual({ Minute: 0 });
|
|
101
|
+
expect(dict['KeepAlive']).toBe(true);
|
|
102
|
+
expect('EnvironmentVariables' in dict).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the digest is stable across key order and changes with any value', () => {
|
|
106
|
+
const one = renderJob({ ...base, environment: { A: '1', B: '2' } });
|
|
107
|
+
const two = renderJob({ ...base, environment: { B: '2', A: '1' } });
|
|
108
|
+
expect(one.sha256).toBe(two.sha256);
|
|
109
|
+
expect(one.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
110
|
+
expect(renderJob({ ...base, runAtLoad: true }).sha256).not.toBe(one.sha256);
|
|
111
|
+
expect(new TextDecoder().decode(one.bytes)).toBe(one.text);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('jobProblems', () => {
|
|
116
|
+
test('a well-formed job has none', () => {
|
|
117
|
+
expect(jobProblems(base)).toEqual([]);
|
|
118
|
+
expect(
|
|
119
|
+
jobProblems({ ...base, domain: 'gui/501', startCalendarInterval: { weekday: 7 } }),
|
|
120
|
+
).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test.each([
|
|
124
|
+
[
|
|
125
|
+
'a slash in the label (it becomes a file name)',
|
|
126
|
+
{ label: 'com.example/../evil' },
|
|
127
|
+
'label must be',
|
|
128
|
+
],
|
|
129
|
+
['a leading dot', { label: '.hidden' }, 'label must be'],
|
|
130
|
+
['an unknown domain', { domain: 'user/501' as never }, 'domain must be'],
|
|
131
|
+
['no program', { programArguments: [] }, 'must name a program'],
|
|
132
|
+
['a relative program', { programArguments: ['job'] }, 'absolute path'],
|
|
133
|
+
['a bad env name', { environment: { 'A-B': 'x' } }, 'not a valid variable name'],
|
|
134
|
+
['a zero startInterval', { startInterval: 0 }, 'startInterval'],
|
|
135
|
+
['a fractional throttleInterval', { throttleInterval: 1.5 }, 'throttleInterval'],
|
|
136
|
+
['an hour of 24', { startCalendarInterval: { hour: 24 } }, 'hour must be 0–23'],
|
|
137
|
+
['a day of 0', { startCalendarInterval: [{ day: 0 }] }, 'day must be 1–31'],
|
|
138
|
+
['an empty calendar list', { startCalendarInterval: [] }, 'empty list'],
|
|
139
|
+
['keepAlive with no condition', { keepAlive: {} }, 'names no condition'],
|
|
140
|
+
[
|
|
141
|
+
'a relative log path',
|
|
142
|
+
{ standardOutPath: 'log.txt' },
|
|
143
|
+
'standardOutPath must be an absolute path',
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'userName on an agent',
|
|
147
|
+
{ domain: 'gui/501' as const, userName: 'nobody' },
|
|
148
|
+
'only to the system domain',
|
|
149
|
+
],
|
|
150
|
+
['extraKeys naming a typed key', { extraKeys: { KeepAlive: true } }, 'extraKeys.KeepAlive'],
|
|
151
|
+
['extraKeys naming Program', { extraKeys: { Program: '/bin/x' } }, 'extraKeys.Program'],
|
|
152
|
+
['a secret-looking env var', { environment: { API_TOKEN: 'x' } }, 'unencrypted'],
|
|
153
|
+
])('refuses %s', (_name, patch, message) => {
|
|
154
|
+
expect(jobProblems({ ...base, ...patch } as LaunchdJobProps).join('\n')).toContain(message);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test.each(RESERVED_LABEL_PREFIXES.map((prefix) => [prefix]))(
|
|
158
|
+
'refuses labels under %s',
|
|
159
|
+
(prefix) => {
|
|
160
|
+
expect(jobProblems({ ...base, label: `${prefix}job` }).join('\n')).toContain(
|
|
161
|
+
'another tool owns',
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
test('reports every problem at once, so one plan shows the whole list', () => {
|
|
167
|
+
expect(
|
|
168
|
+
jobProblems({ ...base, label: '/x', programArguments: ['rel'], startInterval: -1 }).length,
|
|
169
|
+
).toBe(3);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's props, attributes and the pure half of the provider: domain parsing, the plist path,
|
|
3
|
+
* and the props → plist mapping whose SHA-256 is the whole diff.
|
|
4
|
+
*
|
|
5
|
+
* ★ KEY NAMES AND MEANINGS FROM launchd.plist(5), checked against the 148 nix-darwin daemons on a
|
|
6
|
+
* macOS 27.2 host on 2026-09-21 (read-only, from /nix/store): Label, ProgramArguments, RunAtLoad,
|
|
7
|
+
* KeepAlive (boolean or {SuccessfulExit}), ThrottleInterval, StandardOutPath/StandardErrorPath,
|
|
8
|
+
* EnvironmentVariables, UserName, StartInterval and StartCalendarInterval are the keys in real
|
|
9
|
+
* use. Everything rarer goes through `extraKeys`.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { type PlistDict, type PlistValue, renderPlist } from './plist.ts';
|
|
13
|
+
|
|
14
|
+
/** `system` (LaunchDaemons, root) or one user's login session, `gui/<uid>` (LaunchAgents). */
|
|
15
|
+
export type LaunchdDomain = 'system' | `gui/${number}`;
|
|
16
|
+
|
|
17
|
+
/** One StartCalendarInterval entry. An omitted field is a wildcard, as in cron. */
|
|
18
|
+
export type CalendarInterval = {
|
|
19
|
+
readonly minute?: number;
|
|
20
|
+
readonly hour?: number;
|
|
21
|
+
readonly day?: number;
|
|
22
|
+
/** 0 and 7 are both Sunday. */
|
|
23
|
+
readonly weekday?: number;
|
|
24
|
+
readonly month?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `true` = always restart; the object form restarts only on the named conditions. */
|
|
28
|
+
export type KeepAlive = boolean | { readonly successfulExit?: boolean; readonly crashed?: boolean };
|
|
29
|
+
|
|
30
|
+
export interface LaunchdJobProps {
|
|
31
|
+
/** Reverse-DNS label. Unique per domain; also the plist's file name. */
|
|
32
|
+
label: string;
|
|
33
|
+
domain: LaunchdDomain;
|
|
34
|
+
/** argv; the first element must be an absolute path. */
|
|
35
|
+
programArguments: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* ⛔ NON-SECRET VALUES ONLY. Props are stored unencrypted in Alchemy state, and these also land in
|
|
38
|
+
* the plist on disk and in `launchctl print`. Pass a secret as a FILE PATH that a secret renderer
|
|
39
|
+
* (openbao-agent) maintains — secret-tripwire.ts refuses the obvious mistakes.
|
|
40
|
+
*/
|
|
41
|
+
environment?: Readonly<Record<string, string>>;
|
|
42
|
+
runAtLoad?: boolean;
|
|
43
|
+
keepAlive?: KeepAlive;
|
|
44
|
+
/** Seconds between starts. */
|
|
45
|
+
startInterval?: number;
|
|
46
|
+
startCalendarInterval?: CalendarInterval | readonly CalendarInterval[];
|
|
47
|
+
/** Minimum seconds between (re)starts; launchd's default is 10. */
|
|
48
|
+
throttleInterval?: number;
|
|
49
|
+
standardOutPath?: string;
|
|
50
|
+
standardErrorPath?: string;
|
|
51
|
+
workingDirectory?: string;
|
|
52
|
+
/** Run as this user. ⚠️ System domain only — launchd ignores it for agents, so it is refused. */
|
|
53
|
+
userName?: string;
|
|
54
|
+
/** Run as this group. ⚠️ System domain only, like `userName`. */
|
|
55
|
+
groupName?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Any other launchd.plist(5) key, verbatim (`ProcessType`, `SoftResourceLimits`, …).
|
|
58
|
+
* ⛔ A key the typed props own is refused, so one job can never say two things.
|
|
59
|
+
*/
|
|
60
|
+
extraKeys?: PlistDict;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LaunchdJobAttributes {
|
|
64
|
+
label: string;
|
|
65
|
+
domain: LaunchdDomain;
|
|
66
|
+
/** `<domain>/<label>` — what `launchctl print` / `bootout` take. */
|
|
67
|
+
serviceTarget: string;
|
|
68
|
+
plistPath: string;
|
|
69
|
+
/** SHA-256 of the plist bytes last bootstrapped (reconcile) or found on disk (read). */
|
|
70
|
+
plistSha256: string;
|
|
71
|
+
/** Bootstrapped into the domain — not necessarily running. */
|
|
72
|
+
loaded: boolean;
|
|
73
|
+
/** launchd's own word: `running`, `not running`, … */
|
|
74
|
+
state?: string;
|
|
75
|
+
pid?: number;
|
|
76
|
+
lastExitCode?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ParsedDomain =
|
|
80
|
+
| { readonly kind: 'system' }
|
|
81
|
+
| { readonly kind: 'gui'; readonly uid: number };
|
|
82
|
+
|
|
83
|
+
export const parseDomain = (domain: string): ParsedDomain | undefined => {
|
|
84
|
+
if (domain === 'system') return { kind: 'system' };
|
|
85
|
+
const match = /^gui\/(\d+)$/.exec(domain);
|
|
86
|
+
return match?.[1] === undefined ? undefined : { kind: 'gui', uid: Number(match[1]) };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const serviceTarget = (label: string, domain: LaunchdDomain): string => `${domain}/${label}`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where launchd expects the job's plist. ★ Derived, never declared: a daemon's plist outside
|
|
93
|
+
* /Library/LaunchDaemons is not bootstrapped at boot, so a free-form path would be a job that
|
|
94
|
+
* silently stops existing after the next restart.
|
|
95
|
+
*/
|
|
96
|
+
export const plistPathFor = (
|
|
97
|
+
label: string,
|
|
98
|
+
domain: ParsedDomain,
|
|
99
|
+
home: string | undefined,
|
|
100
|
+
): string => {
|
|
101
|
+
if (domain.kind === 'system') return `/Library/LaunchDaemons/${label}.plist`;
|
|
102
|
+
// ⛔ No `~` fallback: the provider may run as root, whose `~` is not the agent owner's home.
|
|
103
|
+
if (home === undefined || !home.startsWith('/')) {
|
|
104
|
+
throw new Error(`gui/${String(domain.uid)}: no home directory for that uid on this host`);
|
|
105
|
+
}
|
|
106
|
+
return `${home}/Library/LaunchAgents/${label}.plist`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Plist keys the typed props own — `extraKeys` may not name them. `Program` is refused too. */
|
|
110
|
+
export const OWNED_KEYS: readonly string[] = [
|
|
111
|
+
'EnvironmentVariables',
|
|
112
|
+
'GroupName',
|
|
113
|
+
'KeepAlive',
|
|
114
|
+
'Label',
|
|
115
|
+
'Program',
|
|
116
|
+
'ProgramArguments',
|
|
117
|
+
'RunAtLoad',
|
|
118
|
+
'StandardErrorPath',
|
|
119
|
+
'StandardOutPath',
|
|
120
|
+
'StartCalendarInterval',
|
|
121
|
+
'StartInterval',
|
|
122
|
+
'ThrottleInterval',
|
|
123
|
+
'UserName',
|
|
124
|
+
'WorkingDirectory',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const calendarDict = (entry: CalendarInterval): PlistDict => {
|
|
128
|
+
const dict: Record<string, PlistValue> = {};
|
|
129
|
+
if (entry.minute !== undefined) dict['Minute'] = entry.minute;
|
|
130
|
+
if (entry.hour !== undefined) dict['Hour'] = entry.hour;
|
|
131
|
+
if (entry.day !== undefined) dict['Day'] = entry.day;
|
|
132
|
+
if (entry.weekday !== undefined) dict['Weekday'] = entry.weekday;
|
|
133
|
+
if (entry.month !== undefined) dict['Month'] = entry.month;
|
|
134
|
+
return dict;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const keepAliveValue = (keepAlive: KeepAlive): PlistValue => {
|
|
138
|
+
if (typeof keepAlive === 'boolean') return keepAlive;
|
|
139
|
+
const dict: Record<string, PlistValue> = {};
|
|
140
|
+
if (keepAlive.successfulExit !== undefined) dict['SuccessfulExit'] = keepAlive.successfulExit;
|
|
141
|
+
if (keepAlive.crashed !== undefined) dict['Crashed'] = keepAlive.crashed;
|
|
142
|
+
return dict;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The plist dict for a job. ⚠️ Assumes jobProblems() came back empty. */
|
|
146
|
+
export const jobPlistDict = (props: LaunchdJobProps): PlistDict => {
|
|
147
|
+
const dict: Record<string, PlistValue> = { ...props.extraKeys };
|
|
148
|
+
dict['Label'] = props.label;
|
|
149
|
+
dict['ProgramArguments'] = [...props.programArguments];
|
|
150
|
+
const env = props.environment ?? {};
|
|
151
|
+
if (Object.keys(env).length > 0) dict['EnvironmentVariables'] = { ...env };
|
|
152
|
+
if (props.runAtLoad !== undefined) dict['RunAtLoad'] = props.runAtLoad;
|
|
153
|
+
if (props.keepAlive !== undefined) dict['KeepAlive'] = keepAliveValue(props.keepAlive);
|
|
154
|
+
if (props.startInterval !== undefined) dict['StartInterval'] = props.startInterval;
|
|
155
|
+
const calendar = props.startCalendarInterval;
|
|
156
|
+
if (calendar !== undefined) {
|
|
157
|
+
dict['StartCalendarInterval'] = Array.isArray(calendar)
|
|
158
|
+
? (calendar as readonly CalendarInterval[]).map(calendarDict)
|
|
159
|
+
: calendarDict(calendar as CalendarInterval);
|
|
160
|
+
}
|
|
161
|
+
if (props.throttleInterval !== undefined) dict['ThrottleInterval'] = props.throttleInterval;
|
|
162
|
+
if (props.standardOutPath !== undefined) dict['StandardOutPath'] = props.standardOutPath;
|
|
163
|
+
if (props.standardErrorPath !== undefined) dict['StandardErrorPath'] = props.standardErrorPath;
|
|
164
|
+
if (props.workingDirectory !== undefined) dict['WorkingDirectory'] = props.workingDirectory;
|
|
165
|
+
if (props.userName !== undefined) dict['UserName'] = props.userName;
|
|
166
|
+
if (props.groupName !== undefined) dict['GroupName'] = props.groupName;
|
|
167
|
+
return dict;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const sha256Hex = (bytes: Uint8Array | string): string =>
|
|
171
|
+
createHash('sha256').update(bytes).digest('hex');
|
|
172
|
+
|
|
173
|
+
/** The rendered plist and its digest — the only thing LaunchdJob compares. */
|
|
174
|
+
export const renderJob = (
|
|
175
|
+
props: LaunchdJobProps,
|
|
176
|
+
): { text: string; bytes: Uint8Array; sha256: string } => {
|
|
177
|
+
const text = renderPlist(jobPlistDict(props));
|
|
178
|
+
const bytes = new TextEncoder().encode(text);
|
|
179
|
+
return { bytes, sha256: sha256Hex(bytes), text };
|
|
180
|
+
};
|