@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.
Files changed (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * LaunchdJob's identity edges against the fake host: renames (a delete-first replace), jobs this
3
+ * resource does not own, a gui domain with no login session, and what a failure leaves behind.
4
+ *
5
+ * ★ Split from job-lifecycle.test.ts, which covers the ordinary create / update / delete path.
6
+ * Every test here is a way to end up with NOTHING running, or TWO copies running.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { fakeRunner } from './fake-runner.ts';
10
+ import type { LaunchdJobProps } from './job-form.ts';
11
+ import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
12
+
13
+ const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
14
+ const RENAMED = '/Library/LaunchDaemons/com.example.renamed.plist';
15
+ const AGENT = '/Users/someone/Library/LaunchAgents/com.example.job.plist';
16
+
17
+ const job: LaunchdJobProps = {
18
+ domain: 'system',
19
+ label: 'com.example.job',
20
+ programArguments: ['/usr/local/bin/job'],
21
+ };
22
+ const renamed: LaunchdJobProps = { ...job, label: 'com.example.renamed' };
23
+
24
+ const host = (euid = 0) =>
25
+ fakeRunner({
26
+ dirs: { '/Library/LaunchDaemons': 0, '/Users/someone/Library/LaunchAgents': 501 },
27
+ euid,
28
+ users: { someone: { gid: 20, home: '/Users/someone', uid: 501 } },
29
+ });
30
+
31
+ const verbs = (calls: string[][]) =>
32
+ calls
33
+ .map((call) => (call[0] === 'write' ? 'write' : call[1]))
34
+ .filter((verb) => verb !== 'print' && verb !== 'print-disabled');
35
+
36
+ describe('a rename is refused at PLAN time for anything its reconcile would refuse', () => {
37
+ // ★ The engine deletes the old job before reconciling the new one (deleteFirst), so each of
38
+ // these, caught only at reconcile, would leave nothing running.
39
+ test('an invalid or reserved new label', async () => {
40
+ const fake = host();
41
+ const output = await reconcileJob(fake.runner, job, undefined);
42
+ const bad = { ...job, label: 'org.nixos.job' };
43
+ await expect(diffJob(fake.runner, bad, output)).rejects.toThrow('another tool owns');
44
+ await expect(replaceDiff(fake.runner, bad, undefined, output)).rejects.toThrow('another tool');
45
+ });
46
+
47
+ test('a new domain the deployer may not write', async () => {
48
+ const fake = host(501);
49
+ const agent = { ...job, domain: 'gui/501' } as const;
50
+ const output = await reconcileJob(fake.runner, agent, undefined);
51
+ await expect(diffJob(fake.runner, job, output)).rejects.toThrow('needs root');
52
+ expect(fake.loaded.has('gui/501/com.example.job')).toBe(true);
53
+ });
54
+
55
+ test('a disabled new label', async () => {
56
+ const fake = host();
57
+ const output = await reconcileJob(fake.runner, job, undefined);
58
+ fake.disabled.add('system/com.example.renamed');
59
+ await expect(diffJob(fake.runner, renamed, output)).rejects.toThrow('launchctl enable');
60
+ });
61
+
62
+ test('a new label someone else already loaded', async () => {
63
+ const fake = host();
64
+ const output = await reconcileJob(fake.runner, job, undefined);
65
+ fake.loaded.set('system/com.example.renamed', 77);
66
+ await expect(diffJob(fake.runner, renamed, output)).rejects.toThrow('not this resource');
67
+ expect(fake.loaded.get('system/com.example.renamed')).toBe(77);
68
+ });
69
+
70
+ test('a clean rename plans a delete-first replace', async () => {
71
+ const fake = host();
72
+ const output = await reconcileJob(fake.runner, job, undefined);
73
+ expect(await diffJob(fake.runner, renamed, output)).toEqual({
74
+ action: 'replace',
75
+ deleteFirst: true,
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('a rename that reaches reconcile as an update', () => {
81
+ test('boots out and removes the old job BEFORE bootstrapping the new one', async () => {
82
+ const fake = host();
83
+ const output = await reconcileJob(fake.runner, job, undefined);
84
+ fake.calls.length = 0;
85
+ const next = await reconcileJob(fake.runner, renamed, output);
86
+ expect(verbs(fake.calls)).toEqual(['bootout', 'write', 'bootstrap']);
87
+ expect(fake.files.has(DAEMON)).toBe(false);
88
+ expect(fake.loaded.has('system/com.example.job')).toBe(false);
89
+ expect(next).toMatchObject({ loaded: true, plistPath: RENAMED });
90
+ });
91
+
92
+ test('never boots out a job it does not own under the new label', async () => {
93
+ const fake = host();
94
+ const output = await reconcileJob(fake.runner, job, undefined);
95
+ fake.loaded.set('system/com.example.renamed', 77);
96
+ await expect(reconcileJob(fake.runner, renamed, output)).rejects.toThrow('not this resource');
97
+ expect(fake.loaded.has('system/com.example.job')).toBe(true);
98
+ expect(fake.loaded.get('system/com.example.renamed')).toBe(77);
99
+ });
100
+ });
101
+
102
+ describe('a job still running its OLD config is not mistaken for converged', () => {
103
+ test('write landed, bootout failed: the next deploy restarts it', async () => {
104
+ const fake = host();
105
+ const v1 = await reconcileJob(fake.runner, job, undefined);
106
+ fake.state.bootoutKeepsJob = true;
107
+ const v2 = { ...job, runAtLoad: true };
108
+ await expect(reconcileJob(fake.runner, v2, v1)).rejects.toThrow('bootout');
109
+ // The v2 plist is on disk and the v1 job is loaded: both look converged. Only state knows.
110
+ fake.state.bootoutKeepsJob = false;
111
+ fake.calls.length = 0;
112
+ const after = await reconcileJob(fake.runner, v2, v1);
113
+ expect(verbs(fake.calls)).toEqual(['write', 'bootout', 'bootstrap']);
114
+ expect(after.pid).not.toBe(v1.pid);
115
+ });
116
+ });
117
+
118
+ describe('a failed first bootstrap leaves nothing behind', () => {
119
+ test('the plist it wrote is removed, so the next plan does not find it Unowned', async () => {
120
+ const fake = host();
121
+ fake.state.bootstrapFailure = { exitCode: 5, stderr: 'Bootstrap failed: 5', stdout: '' };
122
+ await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow('removed again');
123
+ expect(fake.files.has(DAEMON)).toBe(false);
124
+ expect(await readJob(fake.runner, job)).toBeUndefined();
125
+ });
126
+ });
127
+
128
+ describe('a gui domain with no login session', () => {
129
+ const agent = { ...job, domain: 'gui/501' } as const;
130
+
131
+ test('reconcile refuses before writing anything', async () => {
132
+ const fake = host(501);
133
+ fake.state.loggedOut.add(501);
134
+ await expect(reconcileJob(fake.runner, agent, undefined)).rejects.toThrow('no login session');
135
+ expect(fake.files.has(AGENT)).toBe(false);
136
+ });
137
+
138
+ test('delete still removes the plist, so launchd cannot load it at the next login', async () => {
139
+ const fake = host(501);
140
+ const output = await reconcileJob(fake.runner, agent, undefined);
141
+ fake.loaded.clear();
142
+ fake.state.loggedOut.add(501);
143
+ await deleteJob(fake.runner, output);
144
+ expect(fake.files.has(AGENT)).toBe(false);
145
+ expect(await readJob(fake.runner, agent)).toBeUndefined();
146
+ });
147
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * LaunchdJob's lifecycle against the fake host: no real launchctl, no real writes.
3
+ *
4
+ * ★ The assertions are on the SEQUENCE of host calls, because the ordering is the design — write
5
+ * before bootout before bootstrap, and nothing at all when the host is already converged.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { fakeRunner } from './fake-runner.ts';
9
+ import type { LaunchdJobProps } from './job-form.ts';
10
+ import { deleteJob, diffJob, readJob, reconcileJob } from './job-lifecycle.ts';
11
+
12
+ const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
13
+ const AGENT = '/Users/someone/Library/LaunchAgents/com.example.job.plist';
14
+
15
+ const job: LaunchdJobProps = {
16
+ domain: 'system',
17
+ keepAlive: true,
18
+ label: 'com.example.job',
19
+ programArguments: ['/usr/local/bin/job'],
20
+ };
21
+
22
+ const host = (euid = 0, privileged = false) =>
23
+ fakeRunner({
24
+ dirs: { '/Library/LaunchDaemons': 0, '/Users/someone/Library/LaunchAgents': 501 },
25
+ euid,
26
+ privileged,
27
+ users: { someone: { gid: 20, home: '/Users/someone', uid: 501 } },
28
+ });
29
+
30
+ /** `bootstrap`, `write`, … — the verbs, in order, skipping the read-only ones. */
31
+ const writes = (calls: string[][]) =>
32
+ calls
33
+ .map((call) => (call[0] === 'write' ? 'write' : call[1]))
34
+ .filter((verb) => verb !== 'print' && verb !== 'print-disabled');
35
+
36
+ describe('create', () => {
37
+ test('writes the plist root:wheel 0644, then bootstraps it', async () => {
38
+ const fake = host();
39
+ const attrs = await reconcileJob(fake.runner, job, undefined);
40
+ expect(writes(fake.calls)).toEqual(['write', 'bootstrap']);
41
+ expect(fake.files.get(DAEMON)).toMatchObject({ gid: 0, mode: 0o644, uid: 0 });
42
+ expect(attrs).toMatchObject({
43
+ loaded: true,
44
+ plistPath: DAEMON,
45
+ serviceTarget: 'system/com.example.job',
46
+ state: 'running',
47
+ });
48
+ expect(attrs.plistSha256).toMatch(/^[0-9a-f]{64}$/);
49
+ });
50
+
51
+ test("an agent is written as its user into that user's LaunchAgents", async () => {
52
+ const fake = host(501);
53
+ const attrs = await reconcileJob(fake.runner, { ...job, domain: 'gui/501' }, undefined);
54
+ expect(attrs.plistPath).toBe(AGENT);
55
+ expect(fake.files.get(AGENT)).toMatchObject({ mode: 0o644, uid: 501 });
56
+ expect(fake.loaded.has('gui/501/com.example.job')).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('diff', () => {
61
+ test('noop when nothing changed, on disk or in launchd', async () => {
62
+ const fake = host();
63
+ const output = await reconcileJob(fake.runner, job, undefined);
64
+ expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'noop' });
65
+ });
66
+
67
+ test('update when a prop changes the rendered plist', async () => {
68
+ const fake = host();
69
+ const output = await reconcileJob(fake.runner, job, undefined);
70
+ expect(await diffJob(fake.runner, { ...job, runAtLoad: true }, output)).toEqual({
71
+ action: 'update',
72
+ });
73
+ });
74
+
75
+ test('update when the file was hand-edited, or the job was unloaded behind our back', async () => {
76
+ const fake = host();
77
+ const output = await reconcileJob(fake.runner, job, undefined);
78
+ const entry = fake.files.get(DAEMON);
79
+ if (entry === undefined) throw new Error('no plist');
80
+ fake.files.set(DAEMON, { ...entry, bytes: new TextEncoder().encode('hand edit') });
81
+ expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'update' });
82
+ await reconcileJob(fake.runner, job, output);
83
+ fake.loaded.clear();
84
+ expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'update' });
85
+ });
86
+
87
+ test('replace, deleting first, only when label or domain changes', async () => {
88
+ const fake = host();
89
+ const output = await reconcileJob(fake.runner, job, undefined);
90
+ const replace = { action: 'replace', deleteFirst: true } as const;
91
+ expect(await diffJob(fake.runner, { ...job, label: 'com.example.renamed' }, output)).toEqual(
92
+ replace,
93
+ );
94
+ expect(await diffJob(fake.runner, { ...job, domain: 'gui/501' }, output)).toEqual(replace);
95
+ });
96
+
97
+ test('a bad declaration fails the plan, not the apply', async () => {
98
+ const fake = host();
99
+ const output = await reconcileJob(fake.runner, job, undefined);
100
+ await expect(diffJob(fake.runner, { ...job, startInterval: 0 }, output)).rejects.toThrow(
101
+ 'startInterval',
102
+ );
103
+ });
104
+ });
105
+
106
+ describe('update', () => {
107
+ test('write, then bootout, then bootstrap — a restart with a new pid', async () => {
108
+ const fake = host();
109
+ const first = await reconcileJob(fake.runner, job, undefined);
110
+ fake.calls.length = 0;
111
+ fake.state.lingerPrints = 2;
112
+ const second = await reconcileJob(fake.runner, { ...job, runAtLoad: true }, first);
113
+ expect(writes(fake.calls)).toEqual(['write', 'bootout', 'bootstrap']);
114
+ expect(second.pid).not.toBe(first.pid);
115
+ expect(second.plistSha256).not.toBe(first.plistSha256);
116
+ });
117
+
118
+ test('a converged job is not restarted', async () => {
119
+ const fake = host();
120
+ const output = await reconcileJob(fake.runner, job, undefined);
121
+ fake.calls.length = 0;
122
+ const again = await reconcileJob(fake.runner, job, output);
123
+ expect(writes(fake.calls)).toEqual([]);
124
+ expect(again.pid).toBe(output.pid);
125
+ });
126
+
127
+ test('a failed bootstrap leaves state behind, so the next diff retries', async () => {
128
+ const fake = host();
129
+ const output = await reconcileJob(fake.runner, job, undefined);
130
+ fake.state.bootstrapFailure = {
131
+ exitCode: 5,
132
+ stderr: 'Bootstrap failed: 5: Input/output error',
133
+ stdout: '',
134
+ };
135
+ const changed = { ...job, runAtLoad: true };
136
+ await expect(reconcileJob(fake.runner, changed, output)).rejects.toThrow('NOT RUNNING');
137
+ expect(fake.loaded.size).toBe(0);
138
+ expect(await diffJob(fake.runner, changed, output)).toEqual({ action: 'update' });
139
+ });
140
+ });
141
+
142
+ describe('refusals', () => {
143
+ test('the system domain as a non-root user: refused before anything is written', async () => {
144
+ const fake = host(501);
145
+ await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow('never calls sudo');
146
+ expect(writes(fake.calls)).toEqual([]);
147
+ expect(fake.calls).toEqual([]);
148
+ });
149
+
150
+ test('a runner that declares itself privileged may write the system domain', async () => {
151
+ const fake = host(501, true);
152
+ expect((await reconcileJob(fake.runner, job, undefined)).loaded).toBe(true);
153
+ });
154
+
155
+ test("another user's gui domain needs root", async () => {
156
+ const fake = fakeRunner({
157
+ euid: 501,
158
+ users: { other: { gid: 20, home: '/Users/other', uid: 502 } },
159
+ });
160
+ await expect(
161
+ reconcileJob(fake.runner, { ...job, domain: 'gui/502' }, undefined),
162
+ ).rejects.toThrow('another user');
163
+ });
164
+
165
+ test('a gui uid with no user on the host', async () => {
166
+ const fake = host();
167
+ await expect(
168
+ reconcileJob(fake.runner, { ...job, domain: 'gui/777' }, undefined),
169
+ ).rejects.toThrow('no home');
170
+ });
171
+
172
+ test('a disabled label is left disabled, with the command to change that', async () => {
173
+ const fake = host();
174
+ fake.disabled.add('system/com.example.job');
175
+ await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow(
176
+ 'launchctl enable system/com.example.job',
177
+ );
178
+ expect(fake.files.has(DAEMON)).toBe(false);
179
+ });
180
+
181
+ test('a reserved label never reaches the host', async () => {
182
+ const fake = host();
183
+ await expect(
184
+ reconcileJob(fake.runner, { ...job, label: 'org.nixos.job' }, undefined),
185
+ ).rejects.toThrow('another tool owns');
186
+ expect(fake.calls).toEqual([]);
187
+ });
188
+ });
189
+
190
+ describe('read', () => {
191
+ test('nothing on disk and nothing loaded is undefined', async () => {
192
+ expect(await readJob(host().runner, job)).toBeUndefined();
193
+ });
194
+
195
+ test('a plist on disk that is not loaded reads as present, not loaded', async () => {
196
+ const fake = host();
197
+ await reconcileJob(fake.runner, job, undefined);
198
+ fake.loaded.clear();
199
+ expect(await readJob(fake.runner, job)).toMatchObject({ loaded: false, plistPath: DAEMON });
200
+ });
201
+ });
202
+
203
+ describe('delete', () => {
204
+ test('boots out, removes the plist, and is idempotent', async () => {
205
+ const fake = host();
206
+ const output = await reconcileJob(fake.runner, job, undefined);
207
+ fake.calls.length = 0;
208
+ await deleteJob(fake.runner, output);
209
+ expect(writes(fake.calls)).toEqual(['bootout']);
210
+ expect(fake.files.has(DAEMON)).toBe(false);
211
+ expect(fake.loaded.size).toBe(0);
212
+ await deleteJob(fake.runner, output);
213
+ });
214
+
215
+ test('refused for a non-root user in the system domain', async () => {
216
+ const root = host();
217
+ const output = await reconcileJob(root.runner, job, undefined);
218
+ await expect(deleteJob(host(501).runner, output)).rejects.toThrow('needs root');
219
+ });
220
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * LaunchdJob's read / diff / reconcile / delete as plain async functions over a HostRunner — split
3
+ * from job.ts so the whole lifecycle runs against fake-runner.ts in tests, with no real launchd.
4
+ * The checks made before any write live in job-preflight.ts.
5
+ *
6
+ * ★ THE ORDER IS WRITE, BOOTOUT, BOOTSTRAP — not bootout first. launchd reads the plist only at
7
+ * bootstrap, so writing the new file while the old job runs changes nothing yet, and a write that
8
+ * fails (disk full, EACCES) leaves the old job running instead of leaving nothing running.
9
+ * ⚠️ A FAILED BOOTSTRAP LEAVES THE JOB DOWN. On an UPDATE the new plist stays on disk, the state
10
+ * keeps the previous digest, and the next deploy retries. On a CREATE the plist this deploy wrote
11
+ * is removed again: left behind, the next plan's recovery `read` would find a plist with no
12
+ * state and report it `Unowned`, and every later deploy would demand `--adopt` for our own file.
13
+ * There is no rollback to an older plist: one that launchd refuses is a declaration to fix, and
14
+ * re-bootstrapping the old one could fail for the same host-side reason.
15
+ */
16
+ import type { Diff } from 'alchemy/Diff';
17
+ import {
18
+ type LaunchdJobAttributes,
19
+ type LaunchdJobProps,
20
+ parseDomain,
21
+ renderJob,
22
+ sha256Hex,
23
+ } from './job-form.ts';
24
+ import {
25
+ type Identity,
26
+ type Location,
27
+ assertMayWrite,
28
+ assertReplaceable,
29
+ assertUnclaimed,
30
+ assertValid,
31
+ locate,
32
+ preflight,
33
+ refuse,
34
+ } from './job-preflight.ts';
35
+ import { type ServiceStatus, bootoutIfLoaded, bootstrap, printService } from './launchctl.ts';
36
+ import type { HostRunner } from './runner.ts';
37
+
38
+ const attributesOf = (
39
+ props: Identity,
40
+ location: Location,
41
+ plistSha256: string,
42
+ status: ServiceStatus,
43
+ ): LaunchdJobAttributes => ({
44
+ domain: props.domain,
45
+ label: props.label,
46
+ loaded: status.loaded,
47
+ plistPath: location.plistPath,
48
+ plistSha256,
49
+ serviceTarget: location.target,
50
+ ...(status.state === undefined ? {} : { state: status.state }),
51
+ ...(status.pid === undefined ? {} : { pid: status.pid }),
52
+ ...(status.lastExitCode === undefined ? {} : { lastExitCode: status.lastExitCode }),
53
+ });
54
+
55
+ /** What is on the host now: the plist's digest and launchd's view. `undefined` when neither exists. */
56
+ export const readJob = async (
57
+ runner: HostRunner,
58
+ props: Identity,
59
+ ): Promise<LaunchdJobAttributes | undefined> => {
60
+ const location = await locate(runner, props);
61
+ const bytes = await runner.readFile(location.plistPath);
62
+ const status = await printService(runner, location.target);
63
+ if (bytes === undefined && !status.loaded) return undefined;
64
+ return attributesOf(props, location, bytes === undefined ? '' : sha256Hex(bytes), status);
65
+ };
66
+
67
+ /**
68
+ * ★ `deleteFirst`: labels are unique per domain, and the old and new job would otherwise run the
69
+ * same program side by side — same port, same files — until the old one is deleted. A brief
70
+ * outage beats two copies. ⛔ Which is why everything the new job's reconcile would refuse is
71
+ * refused HERE, at plan time (job-preflight.ts): after the delete it would be too late.
72
+ * `full` is the whole declaration when resolved; job.ts passes only the identity otherwise.
73
+ */
74
+ export const replaceDiff = async (
75
+ runner: HostRunner,
76
+ next: Identity,
77
+ full: LaunchdJobProps | undefined,
78
+ output: LaunchdJobAttributes,
79
+ ): Promise<Diff> => {
80
+ // ★ Validate before rendering, so a bad declaration shows its whole list, not a render error.
81
+ if (full !== undefined) assertValid(full);
82
+ const rendered = full === undefined ? undefined : renderJob(full).sha256;
83
+ await assertReplaceable(runner, next, full, output, rendered);
84
+ return { action: 'replace', deleteFirst: true };
85
+ };
86
+
87
+ export const diffJob = async (
88
+ runner: HostRunner,
89
+ news: LaunchdJobProps,
90
+ output: LaunchdJobAttributes,
91
+ ): Promise<Diff> => {
92
+ if (news.label !== output.label || news.domain !== output.domain) {
93
+ return replaceDiff(runner, news, news, output);
94
+ }
95
+ assertValid(news);
96
+ const desired = renderJob(news).sha256;
97
+ // ⛔ BOTH digests: the stored one catches a bootstrap that failed after the write landed, the live
98
+ // one catches a hand edit. Either alone would report `noop` over a job that is not what we say.
99
+ if (desired !== output.plistSha256) return { action: 'update' };
100
+ const live = await readJob(runner, news);
101
+ if (live === undefined || !live.loaded || live.plistSha256 !== desired)
102
+ return { action: 'update' };
103
+ return { action: 'noop' };
104
+ };
105
+
106
+ export const reconcileJob = async (
107
+ runner: HostRunner,
108
+ props: LaunchdJobProps,
109
+ output: LaunchdJobAttributes | undefined,
110
+ ): Promise<LaunchdJobAttributes> => {
111
+ assertValid(props);
112
+ const { location, status } = await preflight(runner, props);
113
+ const rendered = renderJob(props);
114
+ /**
115
+ * ⚠️ AN `output` UNDER ANOTHER LABEL OR DOMAIN means the engine planned an UPDATE across a rename —
116
+ * which it does whenever diff could not see the rename (a label that was itself an unresolved
117
+ * Output at plan time; job.ts). Bootstrapping the new label alone would orphan the old job,
118
+ * still running, with nothing left in state to ever remove it. So do what the replace would
119
+ * have done: every check above first, then delete the old job, then create the new one.
120
+ */
121
+ const renamed =
122
+ output !== undefined && (output.label !== props.label || output.domain !== props.domain);
123
+ const prior = renamed ? undefined : output;
124
+ // ⛔ No prior state for THIS identity: whatever is there already belongs to someone else.
125
+ if (prior === undefined) {
126
+ await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
127
+ }
128
+ if (renamed) await deleteJob(runner, output);
129
+ const before = await runner.readFile(location.plistPath);
130
+ // ★ Already converged (a retried deploy, or an adopted job that matches): do not restart it.
131
+ // ⛔ The STORED digest is part of the test: a write that landed before a failed bootout leaves
132
+ // the new plist on disk and the OLD job loaded — both look right, and only state knows better.
133
+ if (
134
+ status.loaded &&
135
+ before !== undefined &&
136
+ sha256Hex(before) === rendered.sha256 &&
137
+ prior?.plistSha256 === rendered.sha256
138
+ ) {
139
+ return attributesOf(props, location, rendered.sha256, status);
140
+ }
141
+ /**
142
+ * ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
143
+ * ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
144
+ * belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
145
+ * bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
146
+ */
147
+ await runner.writeFileAtomic(
148
+ location.plistPath,
149
+ rendered.bytes,
150
+ location.domain.kind === 'system'
151
+ ? { gid: 0, mode: 0o644, uid: 0 }
152
+ : { mode: 0o644, uid: location.domain.uid },
153
+ );
154
+ await bootoutIfLoaded(runner, location.target);
155
+ const failed = async (detail: string): Promise<never> => {
156
+ // ⚠️ A failed removal must not replace the bootstrap error, nor let the message claim it worked.
157
+ const removed =
158
+ before === undefined &&
159
+ (await runner.removeFile(location.plistPath).then(
160
+ () => true,
161
+ () => false,
162
+ ));
163
+ throw refuse(
164
+ props.label,
165
+ removed
166
+ ? `bootstrap failed, so the job is NOT RUNNING; the plist this deploy wrote was removed ` +
167
+ `again, so the next deploy starts clean. ${detail}`
168
+ : `bootstrap failed, so the job is NOT RUNNING; the new plist is at ${location.plistPath} ` +
169
+ `and the next deploy retries. ${detail}`,
170
+ );
171
+ };
172
+ try {
173
+ await bootstrap(runner, props.domain, location.plistPath);
174
+ } catch (cause) {
175
+ return failed(cause instanceof Error ? cause.message : String(cause));
176
+ }
177
+ const after = await printService(runner, location.target);
178
+ if (!after.loaded) return failed('bootstrap exited 0 but launchctl print cannot find the job');
179
+ return attributesOf(props, location, rendered.sha256, after);
180
+ };
181
+
182
+ /** Boot out, then remove the plist. Idempotent: an absent job and an absent file are success. */
183
+ export const deleteJob = async (
184
+ runner: HostRunner,
185
+ output: LaunchdJobAttributes,
186
+ ): Promise<void> => {
187
+ const domain = parseDomain(output.domain);
188
+ if (domain === undefined) throw refuse(output.label, `unknown domain ${output.domain}`);
189
+ assertMayWrite(runner, output.label, domain);
190
+ // ★ A gui domain with no login session reads as "not loaded" (launchctl.ts), so the plist is
191
+ // still removed — which is what stops launchd loading it again at that user's next login.
192
+ await bootoutIfLoaded(runner, output.serviceTarget);
193
+ await runner.removeFile(output.plistPath);
194
+ };