@homeflare/alchemy 0.5.0 → 0.7.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 (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Everything LaunchdJob refuses before it touches the host. Run at plan time (diff) and again at
3
+ * reconcile, so a bad declaration fails the plan instead of half-applying.
4
+ *
5
+ * ★ REFUSE WHAT launchd WOULD SILENTLY IGNORE OR MISREAD. launchd loads a plist with an out-of-range
6
+ * calendar field or a UserName on an agent without complaint and then simply never does what the
7
+ * declaration says; a plan that fails is cheaper than a job that quietly does nothing.
8
+ */
9
+ import {
10
+ type CalendarInterval,
11
+ type LaunchdJobProps,
12
+ OWNED_KEYS,
13
+ parseDomain,
14
+ } from './job-form.ts';
15
+ import { jobSecretProblems } from './secret-tripwire.ts';
16
+
17
+ /** ★ Labels become file names: no `/`, no leading dot, nothing a path could re-interpret. */
18
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
19
+
20
+ /**
21
+ * ⛔ LABELS OTHER TOOLS OWN ARE NEVER DECLARED HERE. nix-darwin (`org.nixos.*`) rewrites its plists
22
+ * on every activation and unloads any it no longer lists; macOS owns `com.apple.*`; `brew
23
+ * services` owns `homebrew.mxcl.*`. Declaring one of those makes two tools fight over one file.
24
+ * To move a job off such a tool, declare a NEW label and cut over (docs/launchd.md).
25
+ */
26
+ export const RESERVED_LABEL_PREFIXES: readonly string[] = [
27
+ 'org.nixos.',
28
+ 'com.apple.',
29
+ 'homebrew.mxcl.',
30
+ ];
31
+
32
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+
34
+ const RANGES: ReadonlyArray<readonly [keyof CalendarInterval, number, number]> = [
35
+ ['minute', 0, 59],
36
+ ['hour', 0, 23],
37
+ ['day', 1, 31],
38
+ ['weekday', 0, 7],
39
+ ['month', 1, 12],
40
+ ];
41
+
42
+ const isInt = (value: number, min: number, max = Number.MAX_SAFE_INTEGER) =>
43
+ Number.isSafeInteger(value) && value >= min && value <= max;
44
+
45
+ const calendarProblems = (entries: readonly CalendarInterval[]): string[] => {
46
+ if (entries.length === 0) return ['startCalendarInterval is an empty list; omit it instead'];
47
+ const found: string[] = [];
48
+ for (const [index, entry] of entries.entries()) {
49
+ for (const [field, min, max] of RANGES) {
50
+ const value = entry[field];
51
+ if (value !== undefined && !isInt(value, min, max)) {
52
+ found.push(
53
+ `startCalendarInterval[${String(index)}].${field} must be ${String(min)}–${String(max)}`,
54
+ );
55
+ }
56
+ }
57
+ }
58
+ return found;
59
+ };
60
+
61
+ const absolute = (name: string, value: string | undefined): string[] =>
62
+ value === undefined || value.startsWith('/') ? [] : [`${name} must be an absolute path`];
63
+
64
+ /**
65
+ * The label and domain alone — the job's identity. ★ Split out because a rename is a delete-first
66
+ * replace, and it must be refusable at plan time even while other props are still unresolved
67
+ * Outputs (see job.ts diff): otherwise the old job is deleted and only then is the new label
68
+ * found to be invalid.
69
+ */
70
+ export const identityProblems = (label: string, domainText: string): string[] => {
71
+ const found: string[] = [];
72
+ if (!LABEL.test(label)) {
73
+ found.push('label must be 1–200 of [A-Za-z0-9._-], starting with a letter or digit');
74
+ }
75
+ const reserved = RESERVED_LABEL_PREFIXES.find((prefix) => label.startsWith(prefix));
76
+ if (reserved !== undefined) {
77
+ found.push(`label ${label} is under ${reserved}, which another tool owns; declare a new label`);
78
+ }
79
+ if (parseDomain(domainText) === undefined)
80
+ found.push(`domain must be 'system' or 'gui/<uid>', not ${domainText}`);
81
+ return found;
82
+ };
83
+
84
+ export const jobProblems = (props: LaunchdJobProps): string[] => {
85
+ const found = identityProblems(props.label, props.domain);
86
+ const domain = parseDomain(props.domain);
87
+
88
+ const [program] = props.programArguments;
89
+ // ★ Absolute, because launchd's PATH for a job is not the deploying shell's PATH.
90
+ if (program === undefined) found.push('programArguments must name a program');
91
+ else if (!program.startsWith('/')) found.push('programArguments[0] must be an absolute path');
92
+
93
+ for (const name of Object.keys(props.environment ?? {})) {
94
+ if (!ENV_NAME.test(name))
95
+ found.push(`environment name ${JSON.stringify(name)} is not a valid variable name`);
96
+ }
97
+ found.push(...jobSecretProblems(props.environment, props.programArguments, props.extraKeys));
98
+
99
+ if (props.startInterval !== undefined && !isInt(props.startInterval, 1)) {
100
+ found.push('startInterval must be a positive whole number of seconds');
101
+ }
102
+ if (props.throttleInterval !== undefined && !isInt(props.throttleInterval, 0)) {
103
+ found.push('throttleInterval must be a non-negative whole number of seconds');
104
+ }
105
+ const calendar = props.startCalendarInterval;
106
+ if (calendar !== undefined) {
107
+ found.push(
108
+ ...calendarProblems(Array.isArray(calendar) ? calendar : [calendar as CalendarInterval]),
109
+ );
110
+ }
111
+ const keepAlive = props.keepAlive;
112
+ if (
113
+ typeof keepAlive === 'object' &&
114
+ keepAlive.successfulExit === undefined &&
115
+ keepAlive.crashed === undefined
116
+ ) {
117
+ found.push('keepAlive {} names no condition; use true, false, or a condition');
118
+ }
119
+
120
+ found.push(...absolute('standardOutPath', props.standardOutPath));
121
+ found.push(...absolute('standardErrorPath', props.standardErrorPath));
122
+ found.push(...absolute('workingDirectory', props.workingDirectory));
123
+
124
+ if (domain?.kind === 'gui' && (props.userName !== undefined || props.groupName !== undefined)) {
125
+ found.push(
126
+ 'userName/groupName apply only to the system domain; launchd ignores them for agents',
127
+ );
128
+ }
129
+ for (const key of Object.keys(props.extraKeys ?? {})) {
130
+ if (OWNED_KEYS.includes(key))
131
+ found.push(`extraKeys.${key} is owned by a typed prop; use that instead`);
132
+ }
133
+ return found;
134
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `Launchd.Job` — one launchd job (a LaunchDaemon in `system`, a LaunchAgent in `gui/<uid>`),
3
+ * declared rather than hand-loaded.
4
+ *
5
+ * ★ launchd HAS NO SDK. The provider renders the plist (plist.ts), writes it where launchd looks
6
+ * (job-form.ts plistPathFor), and drives `launchctl` (launchctl.ts) — all through the HostRunner
7
+ * the stack provides (runner.ts), so none of it is reachable except through that seam.
8
+ *
9
+ * Lifecycle (job-lifecycle.ts has the reasoning for each step):
10
+ * - create / update — write the plist atomically, boot out the old job if loaded, bootstrap.
11
+ * An update is therefore a RESTART; a converged job is left alone.
12
+ * - read — `launchctl print` (loaded? pid? last exit?) plus the plist's SHA-256.
13
+ * - diff — the rendered plist's SHA-256 against the stored AND the on-disk digest.
14
+ * - replace — only when `label` or `domain` changes, and DELETE FIRST (labels are unique); so
15
+ * everything the new job would be refused for is refused at plan time (job-preflight.ts).
16
+ * - delete — boot out, remove the plist.
17
+ *
18
+ * ⛔ ENVIRONMENT IS NON-SECRET ONLY — see the ⛔ on LaunchdJobProps.environment.
19
+ * ⛔ NO SILENT SUDO — see assertMayWrite in job-preflight.ts.
20
+ * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`. A label already loaded, or a plist already on disk, reads
21
+ * as `Unowned`, so Alchemy refuses to take it over unless asked. Labels under `org.nixos.`,
22
+ * `com.apple.` and `homebrew.mxcl.` are refused outright (job-validate.ts) — declare a new label
23
+ * and cut over instead (docs/launchd.md).
24
+ */
25
+ import { Resource } from 'alchemy';
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import { isResolved } from 'alchemy/Diff';
28
+ import * as Provider from 'alchemy/Provider';
29
+ import * as Effect from 'effect/Effect';
30
+ import { lift, resolvedString } from './host-effect.ts';
31
+ import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
32
+ import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
33
+ import { HostRunnerService } from './runner.ts';
34
+
35
+ export type {
36
+ CalendarInterval,
37
+ KeepAlive,
38
+ LaunchdDomain,
39
+ LaunchdJobAttributes,
40
+ LaunchdJobProps,
41
+ } from './job-form.ts';
42
+
43
+ export interface LaunchdJob extends Resource<
44
+ 'Launchd.Job',
45
+ LaunchdJobProps,
46
+ LaunchdJobAttributes
47
+ > {}
48
+
49
+ export const LaunchdJob = Resource<LaunchdJob>('Launchd.Job');
50
+
51
+ export const LaunchdJobProvider = () =>
52
+ Provider.effect(
53
+ LaunchdJob,
54
+ Effect.gen(function* () {
55
+ const runner = yield* HostRunnerService;
56
+ return LaunchdJob.Provider.of({
57
+ /**
58
+ * ⛔ launchd's job list is not a list of things this stack owns — it is every Apple, Nix
59
+ * and hand-loaded job on the host. Returning them would invite a nuke to boot them out.
60
+ */
61
+ list: () => Effect.succeed([]),
62
+
63
+ read: ({ olds, output }) =>
64
+ lift(async () => {
65
+ const found = await readJob(runner, olds);
66
+ if (found === undefined) return undefined;
67
+ return output === undefined ? Unowned(found) : found;
68
+ }),
69
+
70
+ diff: ({ news, output }) => {
71
+ if (output === undefined) return Effect.succeed(undefined);
72
+ if (isResolved(news)) return lift(() => diffJob(runner, news, output));
73
+ /**
74
+ * ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED. Returning undefined
75
+ * hands the call to the engine's default — `update` for any prop change (Plan.ts,
76
+ * havePropsChanged) — and an update under a new label bootstraps the new job and
77
+ * orphans the old one, still running. `config.path` in programArguments (the pattern
78
+ * docs/launchd.md recommends) is unresolved exactly when its HostFile changes in the
79
+ * same deploy. reconcileJob also defends against this, for a label that is itself an
80
+ * Output; seeing it here is what gets it planned as the replace it is.
81
+ */
82
+ const label = resolvedString(news, 'label');
83
+ const domain = resolvedString(news, 'domain') as LaunchdJobProps['domain'] | undefined;
84
+ return label !== undefined &&
85
+ domain !== undefined &&
86
+ (label !== output.label || domain !== output.domain)
87
+ ? lift(() => replaceDiff(runner, { domain, label }, undefined, output))
88
+ : Effect.succeed(undefined);
89
+ },
90
+
91
+ reconcile: ({ news, output }) => lift(() => reconcileJob(runner, news, output)),
92
+
93
+ delete: ({ output }) => lift(() => deleteJob(runner, output)),
94
+ });
95
+ }),
96
+ );
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The launchctl parsers against the shapes measured on macOS 27.2 (launchctl.ts header), and the
3
+ * bootout sequence against the fake launchd.
4
+ *
5
+ * ★ The fixture keeps the two traps from the real output: a multi-line shell argument that prints
6
+ * raw (with a `}` in column 0), and nested blocks that repeat `state = ` two tabs deep.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { fakeRunner } from './fake-runner.ts';
10
+ import {
11
+ LAUNCHCTL,
12
+ bootoutIfLoaded,
13
+ parseDisabled,
14
+ parsePrint,
15
+ printFields,
16
+ printService,
17
+ } from './launchctl.ts';
18
+
19
+ const RUNNING = [
20
+ 'system/com.example.job = {',
21
+ '\tactive count = 1',
22
+ '\tpath = /Library/LaunchDaemons/com.example.job.plist',
23
+ '\ttype = LaunchDaemon',
24
+ '\tstate = running',
25
+ '',
26
+ '\tprogram = /bin/sh',
27
+ '\targuments = {',
28
+ '\t\t/bin/sh',
29
+ '\t\t-c',
30
+ '\t\tprog=/opt/example/bin/job',
31
+ '[ -x "$prog" ] || {',
32
+ ' pid = 99',
33
+ '}',
34
+ 'exec "$prog"',
35
+ '',
36
+ '\t}',
37
+ '',
38
+ '\tstdout path = /var/log/example.log',
39
+ '\tresource coalition = {',
40
+ '\t\tID = 777',
41
+ '\t\ttype = resource',
42
+ '\t\tstate = active',
43
+ '\t}',
44
+ '',
45
+ '\tdomain = system',
46
+ '\truns = 1',
47
+ '\tpid = 1219',
48
+ '\tlast exit code = (never exited)',
49
+ '\tspawn type = daemon (3)',
50
+ '\tjob state = running',
51
+ '}',
52
+ ].join('\n');
53
+
54
+ describe('printFields / parsePrint', () => {
55
+ test('reads the job block, not the nested blocks or the raw argument text', () => {
56
+ const fields = printFields(RUNNING);
57
+ expect(fields.get('state')).toBe('running');
58
+ expect(fields.get('pid')).toBe('1219');
59
+ expect(fields.get('type')).toBe('LaunchDaemon');
60
+ expect(fields.get('path')).toBe('/Library/LaunchDaemons/com.example.job.plist');
61
+ expect(fields.has('ID')).toBe(false);
62
+ });
63
+
64
+ test('a running job', () => {
65
+ expect(parsePrint(RUNNING)).toEqual({ loaded: true, pid: 1219, state: 'running' });
66
+ });
67
+
68
+ test('a stopped job with an exit code, and the newer "78: EX_CONFIG" form', () => {
69
+ const stopped = RUNNING.replace('\tstate = running', '\tstate = not running')
70
+ .replace('\tpid = 1219\n', '')
71
+ .replace('(never exited)', '78: EX_CONFIG');
72
+ expect(parsePrint(stopped)).toEqual({ lastExitCode: 78, loaded: true, state: 'not running' });
73
+ expect(parsePrint(stopped.replace('78: EX_CONFIG', '0')).lastExitCode).toBe(0);
74
+ });
75
+
76
+ test('empty output parses to loaded with no details (the caller decides loaded from the exit code)', () => {
77
+ expect(parsePrint('')).toEqual({ loaded: true });
78
+ });
79
+ });
80
+
81
+ describe('parseDisabled', () => {
82
+ test('the measured `=> enabled|disabled` form, and the older `=> true|false`', () => {
83
+ const out = [
84
+ '\tdisabled services = {',
85
+ '\t\t"com.example.on" => enabled',
86
+ '\t\t"com.example.off" => disabled',
87
+ '\t\t"com.example.legacy-off" => true',
88
+ '\t\t"com.example.legacy-on" => false',
89
+ '\t}',
90
+ ].join('\n');
91
+ expect([...parseDisabled(out)].sort()).toEqual(['com.example.legacy-off', 'com.example.off']);
92
+ });
93
+ });
94
+
95
+ describe('printService', () => {
96
+ test('exit 113 is "not loaded", not an error', async () => {
97
+ const fake = fakeRunner();
98
+ expect(await printService(fake.runner, 'system/com.example.none')).toEqual({ loaded: false });
99
+ });
100
+
101
+ test('exit 112 "Could not find domain" (a gui uid with no login session) is "not loaded"', async () => {
102
+ const fake = fakeRunner();
103
+ fake.state.loggedOut.add(502);
104
+ expect(await printService(fake.runner, 'gui/502/com.example.x')).toEqual({
105
+ domainMissing: true,
106
+ loaded: false,
107
+ });
108
+ });
109
+
110
+ test('exit 112 without that text stays an error', async () => {
111
+ const runner = {
112
+ ...fakeRunner().runner,
113
+ exec: async () => ({ exitCode: 112, stderr: 'Bad request.', stdout: '' }),
114
+ };
115
+ await expect(printService(runner, 'system/x')).rejects.toThrow('-> 112');
116
+ });
117
+
118
+ test('any other non-zero exit is an error carrying stderr, never stdout', async () => {
119
+ const runner = {
120
+ ...fakeRunner().runner,
121
+ exec: async () => ({ exitCode: 1, stderr: 'Bad request.', stdout: 'SECRET_ENV=leak' }),
122
+ };
123
+ const error = await printService(runner, 'system/x').then(
124
+ () => new Error('expected a rejection'),
125
+ (cause: unknown) => cause as Error,
126
+ );
127
+ expect(error.message).toContain('Bad request.');
128
+ expect(error.message).not.toContain('leak');
129
+ });
130
+ });
131
+
132
+ describe('bootoutIfLoaded', () => {
133
+ test('does nothing when the job is not loaded (bootout of an absent job exits non-zero)', async () => {
134
+ const fake = fakeRunner();
135
+ await bootoutIfLoaded(fake.runner, 'system/com.example.none');
136
+ expect(fake.calls.map((call) => call[1])).toEqual(['print']);
137
+ });
138
+
139
+ test('boots out, then polls until launchd lets go', async () => {
140
+ const fake = fakeRunner();
141
+ fake.loaded.set('system/com.example.job', 1);
142
+ fake.state.lingerPrints = 3;
143
+ await bootoutIfLoaded(fake.runner, 'system/com.example.job');
144
+ expect(fake.loaded.has('system/com.example.job')).toBe(false);
145
+ const subs = fake.calls.map((call) => call[1]);
146
+ expect(subs.slice(0, 2)).toEqual(['print', 'bootout']);
147
+ expect(subs.filter((sub) => sub === 'print').length).toBeGreaterThan(3);
148
+ expect(fake.calls.every((call) => call[0] === LAUNCHCTL)).toBe(true);
149
+ });
150
+
151
+ test('a non-zero bootout for a job that is still exiting (EINPROGRESS) waits, not fails', async () => {
152
+ const fake = fakeRunner();
153
+ fake.loaded.set('system/com.example.slow', 1);
154
+ fake.state.lingerPrints = 3;
155
+ fake.state.bootoutFailure = {
156
+ exitCode: 36,
157
+ stderr: 'Boot-out failed: 36: Operation now in progress',
158
+ stdout: '',
159
+ };
160
+ await bootoutIfLoaded(fake.runner, 'system/com.example.slow');
161
+ expect(fake.loaded.has('system/com.example.slow')).toBe(false);
162
+ });
163
+
164
+ test('a non-zero bootout for a job that never leaves fails with bootout’s own error', async () => {
165
+ const fake = fakeRunner();
166
+ fake.loaded.set('system/com.example.stuck', 1);
167
+ fake.state.bootoutKeepsJob = true;
168
+ await expect(bootoutIfLoaded(fake.runner, 'system/com.example.stuck')).rejects.toThrow(
169
+ /bootout system\/com\.example\.stuck -> 5: .*still loaded after waiting/,
170
+ );
171
+ });
172
+
173
+ test('gives up with a clear error when the job never goes away', async () => {
174
+ const fake = fakeRunner();
175
+ fake.loaded.set('system/com.example.stuck', 1);
176
+ fake.state.lingerPrints = 1_000;
177
+ await expect(bootoutIfLoaded(fake.runner, 'system/com.example.stuck')).rejects.toThrow(
178
+ 'still loaded',
179
+ );
180
+ });
181
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The four `launchctl` subcommands LaunchdJob uses — print, print-disabled, bootstrap, bootout —
3
+ * over a HostRunner, plus the parsers for the two read-only ones.
4
+ *
5
+ * ★ MEASURED 2026-09-21 ON macOS 27.2 (read-only `print` / `print-disabled` only; nothing was
6
+ * bootstrapped or booted out to write this):
7
+ * - `print system/<unknown>` exits **113** with "Could not find service … in domain for system"
8
+ * on stderr. 113 is therefore "not loaded", not an error.
9
+ * - `print` of a loaded job is a `<target> = {` block. Its own fields sit one tab deep
10
+ * (`\tstate = running`, `\tpid = 1219`, `\tlast exit code = 0` or `(never exited)`); nested
11
+ * blocks (`resource coalition`, `semaphores`) repeat names like `state` two tabs deep.
12
+ * - `print-disabled <domain>` lists `"label" => enabled|disabled`.
13
+ * - reading needs no root: a uid-501 shell printed system-domain jobs.
14
+ * - `print gui/<uid>/<label>` for a uid with NO LOGIN SESSION exits **112** with "Could not find
15
+ * domain for user gui: <uid>" (and so does `print-disabled gui/<uid>`); a malformed target is
16
+ * 64, a missing service in a live gui domain is 113. So 112 + that text is "the domain does
17
+ * not exist", which means the job cannot be loaded — not an error for a read or a delete.
18
+ * ⚠️ REASONED, NOT MEASURED: bootstrap/bootout exit codes. nix-darwin's own activation script on the
19
+ * same host notes "`bootout` on an already-absent job is a no-op that returns non-zero", which is
20
+ * why bootout here is always preceded by a print and followed by a poll, never trusted alone.
21
+ * ⛔ NEVER PUT `print` STDOUT IN AN ERROR. It contains the job's whole environment.
22
+ */
23
+ import type { HostRunner } from './runner.ts';
24
+
25
+ export const LAUNCHCTL = '/bin/launchctl';
26
+ /** `launchctl print` of an unknown service — measured, see above. */
27
+ export const NOT_FOUND = 113;
28
+ /** `launchctl print` into a gui domain with no login session — measured, see above. */
29
+ export const NO_DOMAIN = 112;
30
+ const NO_DOMAIN_TEXT = 'Could not find domain';
31
+
32
+ export class LaunchctlError extends Error {
33
+ constructor(command: string, exitCode: number, stderr: string) {
34
+ super(`launchctl ${command} -> ${String(exitCode)}: ${stderr.trim().slice(0, 300)}`);
35
+ this.name = 'LaunchctlError';
36
+ }
37
+ }
38
+
39
+ export type ServiceStatus = {
40
+ readonly loaded: boolean;
41
+ /** The domain itself is absent (a gui/<uid> with no login session). Implies `loaded: false`. */
42
+ readonly domainMissing?: true;
43
+ readonly state?: string;
44
+ readonly pid?: number;
45
+ readonly lastExitCode?: number;
46
+ };
47
+
48
+ /**
49
+ * The job block's own `key = value` lines, ignoring nested blocks.
50
+ * ⚠️ A multi-line ProgramArguments entry is printed raw inside `arguments = {`, so a close is only
51
+ * accepted when its indentation matches the open. An argument that itself contains a line of
52
+ * exactly one tab and `}` would still desynchronise this; no real job has one.
53
+ */
54
+ export const printFields = (stdout: string): Map<string, string> => {
55
+ const fields = new Map<string, string>();
56
+ const closers: string[] = [];
57
+ for (const line of stdout.split('\n')) {
58
+ if (closers.length === 0) {
59
+ if (fields.size === 0 && line.endsWith(' = {')) closers.push('}');
60
+ continue;
61
+ }
62
+ if (line === closers[closers.length - 1]) {
63
+ closers.pop();
64
+ continue;
65
+ }
66
+ const opened = /^(\t+)\S.* = \{$/.exec(line);
67
+ if (opened?.[1] !== undefined && opened[1].length === closers.length) {
68
+ closers.push(`${opened[1]}}`);
69
+ continue;
70
+ }
71
+ const field = closers.length === 1 ? /^\t([a-z][a-z ]*[a-z]) = (.*)$/.exec(line) : null;
72
+ if (field?.[1] !== undefined && field[2] !== undefined && !fields.has(field[1])) {
73
+ fields.set(field[1], field[2]);
74
+ }
75
+ }
76
+ return fields;
77
+ };
78
+
79
+ const leadingInt = (text: string | undefined): number | undefined => {
80
+ const match = text === undefined ? null : /^(-?\d+)/.exec(text);
81
+ return match?.[1] === undefined ? undefined : Number(match[1]);
82
+ };
83
+
84
+ export const parsePrint = (stdout: string): ServiceStatus => {
85
+ const fields = printFields(stdout);
86
+ const state = fields.get('state');
87
+ const pid = leadingInt(fields.get('pid'));
88
+ // "(never exited)" parses to undefined; "78: EX_CONFIG" to 78.
89
+ const lastExitCode = leadingInt(fields.get('last exit code'));
90
+ return {
91
+ loaded: true,
92
+ ...(state === undefined ? {} : { state }),
93
+ ...(pid === undefined ? {} : { pid }),
94
+ ...(lastExitCode === undefined ? {} : { lastExitCode }),
95
+ };
96
+ };
97
+
98
+ /** Labels `print-disabled` reports as disabled. Older releases printed `=> true` for disabled. */
99
+ export const parseDisabled = (stdout: string): Set<string> => {
100
+ const disabled = new Set<string>();
101
+ for (const match of stdout.matchAll(/^\s*"([^"]+)" => (\w+)\s*$/gm)) {
102
+ if (match[1] !== undefined && (match[2] === 'disabled' || match[2] === 'true')) {
103
+ disabled.add(match[1]);
104
+ }
105
+ }
106
+ return disabled;
107
+ };
108
+
109
+ export const printService = async (runner: HostRunner, target: string): Promise<ServiceStatus> => {
110
+ const result = await runner.exec([LAUNCHCTL, 'print', target]);
111
+ if (result.exitCode === NOT_FOUND) return { loaded: false };
112
+ // ⚠️ Without this, deleting an agent while its user is logged out fails on the read, and the
113
+ // plist stays in LaunchAgents — where launchd loads it again at that user's next login.
114
+ if (result.exitCode === NO_DOMAIN && result.stderr.includes(NO_DOMAIN_TEXT)) {
115
+ return { domainMissing: true, loaded: false };
116
+ }
117
+ if (result.exitCode !== 0)
118
+ throw new LaunchctlError(`print ${target}`, result.exitCode, result.stderr);
119
+ return parsePrint(result.stdout);
120
+ };
121
+
122
+ export const isDisabled = async (
123
+ runner: HostRunner,
124
+ domain: string,
125
+ label: string,
126
+ ): Promise<boolean> => {
127
+ const result = await runner.exec([LAUNCHCTL, 'print-disabled', domain]);
128
+ if (result.exitCode !== 0) {
129
+ throw new LaunchctlError(`print-disabled ${domain}`, result.exitCode, result.stderr);
130
+ }
131
+ return parseDisabled(result.stdout).has(label);
132
+ };
133
+
134
+ export const bootstrap = async (
135
+ runner: HostRunner,
136
+ domain: string,
137
+ plistPath: string,
138
+ ): Promise<void> => {
139
+ const result = await runner.exec([LAUNCHCTL, 'bootstrap', domain, plistPath]);
140
+ if (result.exitCode !== 0) {
141
+ throw new LaunchctlError(`bootstrap ${domain} ${plistPath}`, result.exitCode, result.stderr);
142
+ }
143
+ };
144
+
145
+ /**
146
+ * Poll `print` until the service is gone. ⚠️ bootout can return before launchd lets go of it.
147
+ * ★ 30 s, because a job gets SIGTERM and then its ExitTimeOut before SIGKILL: launchd.plist(5)
148
+ * documents a 20 s default (a macOS 27.2 `print` showed `exit timeout = 5` for a job that sets
149
+ * none). Giving up sooner would fail a deploy over a job that was, correctly, still exiting.
150
+ */
151
+ export const waitUnloaded = async (
152
+ runner: HostRunner,
153
+ target: string,
154
+ attempts = 120,
155
+ intervalMs = 250,
156
+ ): Promise<void> => {
157
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
158
+ if (!(await printService(runner, target)).loaded) return;
159
+ await runner.sleep(intervalMs);
160
+ }
161
+ throw new Error(
162
+ `${target} is still loaded ${String((attempts * intervalMs) / 1000)}s after bootout`,
163
+ );
164
+ };
165
+
166
+ /** Boot a service out if it is loaded, and wait until launchd has let go. Idempotent. */
167
+ export const bootoutIfLoaded = async (runner: HostRunner, target: string): Promise<void> => {
168
+ if (!(await printService(runner, target)).loaded) return;
169
+ const result = await runner.exec([LAUNCHCTL, 'bootout', target]);
170
+ /**
171
+ * ⚠️ A non-zero bootout is only an error if the service is STILL there after the wait (see
172
+ * header) — so wait FIRST, then judge. A job slower to exit than launchctl's own wait is widely
173
+ * reported to fail bootout with `36: Operation now in progress` (EINPROGRESS) while it is
174
+ * still going away; REASONED from those reports, not measured here. Judging on an immediate
175
+ * print would fail the deploy with the new plist written and the old job already exiting —
176
+ * the job ends up down either way, and the error would blame the wrong step.
177
+ */
178
+ try {
179
+ await waitUnloaded(runner, target);
180
+ } catch (cause) {
181
+ if (result.exitCode === 0) throw cause;
182
+ throw new LaunchctlError(
183
+ `bootout ${target}`,
184
+ result.exitCode,
185
+ `${result.stderr.trim()} (and still loaded after waiting)`,
186
+ );
187
+ }
188
+ };