@homeflare/alchemy 0.7.0 → 0.10.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 (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -1,13 +1,190 @@
1
1
  // src/launchd/host-file.ts
2
2
  import { Resource } from "alchemy";
3
- import { Unowned } from "alchemy/AdoptPolicy";
3
+ import { Unowned as Unowned2 } from "alchemy/AdoptPolicy";
4
4
  import { isResolved } from "alchemy/Diff";
5
5
  import * as Provider from "alchemy/Provider";
6
+ import * as Effect6 from "effect/Effect";
7
+
8
+ // src/ownership/adopt.ts
9
+ import { AdoptPolicy } from "alchemy/AdoptPolicy";
10
+ import { AlchemyContext } from "alchemy/AlchemyContext";
11
+ import { Stack as Stack3 } from "alchemy/Stack";
12
+ import * as Effect4 from "effect/Effect";
13
+ import * as Option4 from "effect/Option";
14
+
15
+ // src/ownership/resume.ts
16
+ import { Unowned } from "alchemy/AdoptPolicy";
17
+ import { Artifacts } from "alchemy/Artifacts";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Option from "effect/Option";
20
+ var PROVEN = "homeflare/ownership/resumes";
21
+ var UNFINISHED = "homeflare/ownership/unfinished";
22
+ var note = (key, instanceId) => Effect.gen(function* () {
23
+ const bag = yield* Effect.serviceOption(Artifacts);
24
+ if (Option.isSome(bag))
25
+ yield* bag.value.set(key, instanceId);
26
+ });
27
+ var noted = (key, instanceId) => Effect.gen(function* () {
28
+ const bag = yield* Effect.serviceOption(Artifacts);
29
+ if (Option.isNone(bag))
30
+ return false;
31
+ return (yield* bag.value.get(key)) === instanceId;
32
+ });
33
+ var noteUnfinished = (instanceId) => Effect.as(note(UNFINISHED, instanceId), undefined);
34
+ var noteResume = (instanceId, recovered) => Effect.gen(function* () {
35
+ yield* noteUnfinished(instanceId);
36
+ const unproven = () => Effect.succeed(undefined);
37
+ const found = yield* recovered.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
38
+ if (found === undefined || found === null || Unowned.is(found))
39
+ return;
40
+ yield* note(PROVEN, instanceId);
41
+ });
42
+ var resumes = (instanceId) => noted(PROVEN, instanceId);
43
+ var unfinished = (instanceId) => noted(UNFINISHED, instanceId);
44
+ var provingResumes = (service) => {
45
+ const { diff, read } = service;
46
+ if (diff === undefined || read === undefined)
47
+ return service;
48
+ const proving = (input) => Effect.gen(function* () {
49
+ if (input.output === undefined) {
50
+ yield* noteResume(input.instanceId, read({ ...input, output: undefined }));
51
+ }
52
+ return yield* diff(input);
53
+ });
54
+ return { ...service, diff: proving };
55
+ };
56
+
57
+ // src/ownership/rows.ts
58
+ import { Stack as Stack2 } from "alchemy/Stack";
59
+ import { State, isActionState } from "alchemy/State/State";
60
+ import * as Effect3 from "effect/Effect";
61
+ import * as Option3 from "effect/Option";
62
+
63
+ // src/ownership/whole.ts
64
+ import { Stack } from "alchemy/Stack";
6
65
  import * as Effect2 from "effect/Effect";
66
+ import * as Option2 from "effect/Option";
67
+ var absent = (value) => value === undefined || value === null;
68
+ var isPlainObject = (value) => {
69
+ if (typeof value !== "object" || value === null || Array.isArray(value))
70
+ return false;
71
+ const proto = Object.getPrototypeOf(value);
72
+ return proto === Object.prototype || proto === null;
73
+ };
74
+ var carries = (declared, recorded) => {
75
+ if (absent(declared))
76
+ return true;
77
+ if (absent(recorded))
78
+ return false;
79
+ if (Array.isArray(declared)) {
80
+ if (!Array.isArray(recorded))
81
+ return false;
82
+ return declared.every((each, index) => carries(each, recorded[index]));
83
+ }
84
+ if (isPlainObject(declared)) {
85
+ if (!isPlainObject(recorded))
86
+ return false;
87
+ return Object.entries(declared).every(([key, each]) => carries(each, recorded[key]));
88
+ }
89
+ return true;
90
+ };
91
+ var wholeDeclaration = (fqn, props) => Effect2.map(Effect2.serviceOption(Stack), (stack) => {
92
+ const declared = Option2.isSome(stack) ? stack.value.resources[fqn] : undefined;
93
+ return declared !== undefined && carries(declared.Props, props);
94
+ });
95
+
96
+ // src/ownership/rows.ts
97
+ var storeOf = Effect3.gen(function* () {
98
+ const stack = yield* Effect3.serviceOption(Stack2);
99
+ const state = yield* Effect3.serviceOption(State);
100
+ if (Option3.isNone(stack) || Option3.isNone(state))
101
+ return;
102
+ const store = yield* state.value;
103
+ const where = { stack: stack.value.name, stage: stack.value.stage };
104
+ return { resources: stack.value.resources, store, where };
105
+ });
106
+ var rowAt = (store, where, fqn) => store.get({ ...where, fqn }).pipe(Effect3.orElseSucceed(() => {
107
+ return;
108
+ }));
109
+ var generationOf = (row, instanceId) => {
110
+ let at = row;
111
+ while (typeof at === "object" && at !== null && !isActionState(at)) {
112
+ const generation = at;
113
+ if (generation.instanceId === instanceId)
114
+ return generation;
115
+ at = generation.old;
116
+ }
117
+ return;
118
+ };
119
+ var recordedGeneration = (fqn, instanceId) => Effect3.gen(function* () {
120
+ const found = yield* storeOf;
121
+ if (found === undefined)
122
+ return;
123
+ for (const at of [fqn, ...found.resources[fqn]?.FormerFqns ?? []]) {
124
+ const generation = generationOf(yield* rowAt(found.store, found.where, at), instanceId);
125
+ if (generation !== undefined)
126
+ return generation;
127
+ }
128
+ return;
129
+ });
130
+ var recordedInstance = (fqn, instanceId) => Effect3.gen(function* () {
131
+ const generation = yield* recordedGeneration(fqn, instanceId);
132
+ if (generation === undefined)
133
+ return "absent";
134
+ return (yield* wholeDeclaration(fqn, generation.props)) ? "whole" : "partial";
135
+ });
136
+ var isCreate = (fqn, instanceId) => Effect3.map(recordedGeneration(fqn, instanceId), (generation) => generation?.status === "creating");
137
+ var forgetRefusedCreate = (fqn, instanceId) => Effect3.gen(function* () {
138
+ const found = yield* storeOf;
139
+ if (found === undefined)
140
+ return;
141
+ const row = yield* rowAt(found.store, found.where, fqn);
142
+ const mine = row;
143
+ if (mine?.status !== "creating" || mine.instanceId !== instanceId || mine.attr !== undefined) {
144
+ return;
145
+ }
146
+ yield* found.store.delete({ ...found.where, fqn }).pipe(Effect3.ignore);
147
+ });
148
+
149
+ // src/ownership/adopt.ts
150
+ var adoptEnabled = (fqn) => Effect4.gen(function* () {
151
+ const stack = yield* Effect4.serviceOption(Stack3);
152
+ const scoped = Option4.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
153
+ if (scoped !== undefined)
154
+ return scoped;
155
+ const policy = yield* Effect4.serviceOption(AdoptPolicy);
156
+ if (Option4.isSome(policy))
157
+ return policy.value;
158
+ const context = yield* Effect4.serviceOption(AlchemyContext);
159
+ return Option4.isSome(context) ? context.value.adopt : false;
160
+ });
161
+ var adoptable = (owner) => Effect4.gen(function* () {
162
+ if (yield* isCreate(owner.fqn, owner.instanceId))
163
+ return true;
164
+ return yield* unfinished(owner.instanceId);
165
+ });
166
+ var adoptsAtApply = (owner) => Effect4.gen(function* () {
167
+ if (!(yield* adoptEnabled(owner.fqn)))
168
+ return false;
169
+ return yield* adoptable(owner);
170
+ });
171
+ var refuseTakeover = (owner, what) => Effect4.gen(function* () {
172
+ if (owner.output !== undefined)
173
+ return;
174
+ if (yield* resumes(owner.instanceId))
175
+ return;
176
+ const may = yield* adoptable(owner);
177
+ if (may && (yield* adoptEnabled(owner.fqn)))
178
+ return;
179
+ yield* forgetRefusedCreate(owner.fqn, owner.instanceId);
180
+ const remedy = may ? "Deploy with --adopt (or wrap the resource in adopt(true)) to take it over, or remove it first." : "It is the new identity of a replace, which --adopt does not cover: remove it first.";
181
+ return yield* Effect4.die(new Error(`${what}: already exists, and this stack holds no state for it — the plan could not ask ` + "first (a prop was still an Output, a replace moved onto it, or it appeared after the " + `plan). Nothing was written. ${remedy}`));
182
+ });
183
+ var claimFor = (owner) => (what) => refuseTakeover(owner, what);
7
184
 
8
185
  // src/launchd/host-effect.ts
9
- import * as Effect from "effect/Effect";
10
- var lift = (step) => Effect.tryPromise({
186
+ import * as Effect5 from "effect/Effect";
187
+ var lift = (step) => Effect5.tryPromise({
11
188
  catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
12
189
  try: step
13
190
  });
@@ -69,9 +246,8 @@ var identityProblems = (name, value) => {
69
246
  }
70
247
  return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value) ? [] : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
71
248
  };
72
- var fileProblems = (props) => {
249
+ var pathProblems = (path) => {
73
250
  const found = [];
74
- const { path } = props;
75
251
  if (!path.startsWith("/"))
76
252
  found.push("path must be absolute");
77
253
  if (path.endsWith("/") || path.split("/").slice(1).some((part) => part === "" || part === "." || part === "..")) {
@@ -79,6 +255,10 @@ var fileProblems = (props) => {
79
255
  }
80
256
  if (path.includes("\x00"))
81
257
  found.push("path contains NUL");
258
+ return found;
259
+ };
260
+ var fileProblems = (props) => {
261
+ const found = pathProblems(props.path);
82
262
  const mode = props.mode ?? DEFAULT_MODE;
83
263
  if (!Number.isSafeInteger(mode) || mode < 0 || mode > 4095)
84
264
  found.push("mode must be 0–0o7777");
@@ -114,7 +294,7 @@ var escapeText = (text, path) => {
114
294
  }
115
295
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\r", "&#13;");
116
296
  };
117
- var isPlainObject = (value) => {
297
+ var isPlainObject2 = (value) => {
118
298
  const proto = Object.getPrototypeOf(value);
119
299
  return proto === Object.prototype || proto === null;
120
300
  };
@@ -149,7 +329,7 @@ var renderValue = (value, path, depth, out) => {
149
329
  out.push(`${pad}</array>`);
150
330
  return;
151
331
  }
152
- if (typeof value === "object" && value !== null && isPlainObject(value)) {
332
+ if (typeof value === "object" && value !== null && isPlainObject2(value)) {
153
333
  renderDict(value, path, depth, out);
154
334
  return;
155
335
  }
@@ -172,7 +352,7 @@ var renderDict = (dict, path, depth, out) => {
172
352
  out.push(`${pad}</dict>`);
173
353
  };
174
354
  var renderPlist = (dict) => {
175
- if (typeof dict !== "object" || dict === null || Array.isArray(dict) || !isPlainObject(dict)) {
355
+ if (typeof dict !== "object" || dict === null || Array.isArray(dict) || !isPlainObject2(dict)) {
176
356
  throw new PlistError("", "the top level of a launchd plist must be a dict");
177
357
  }
178
358
  const out = [];
@@ -328,20 +508,29 @@ var readFileAttributes = async (runner, path) => {
328
508
  uid: stat.uid
329
509
  };
330
510
  };
511
+ var writeOptionsOf = (want) => ({
512
+ mode: want.mode,
513
+ ...want.uid === undefined ? {} : { uid: want.uid },
514
+ ...want.gid === undefined ? {} : { gid: want.gid }
515
+ });
331
516
  var matches = (live, want) => live.sha256 === want.sha256 && live.mode === want.mode && (want.uid === undefined || live.uid === want.uid) && (want.gid === undefined || live.gid === want.gid);
332
517
  var diffFile = async (runner, news, output) => {
333
- if (news.path !== output.path)
334
- return { action: "replace" };
335
518
  const want = await desiredFile(runner, news);
519
+ const writes = async (diff) => {
520
+ await runner.checkWrite?.(news.path, writeOptionsOf(want));
521
+ return diff;
522
+ };
523
+ if (news.path !== output.path)
524
+ return writes({ action: "replace" });
336
525
  if (want.sha256 !== output.sha256)
337
- return { action: "update" };
526
+ return writes({ action: "update" });
338
527
  const live = await readFileAttributes(runner, news.path);
339
- return live !== undefined && matches(live, want) ? { action: "noop" } : { action: "update" };
528
+ return live !== undefined && matches(live, want) ? { action: "noop" } : writes({ action: "update" });
340
529
  };
341
- var reconcileFile = async (runner, props, output) => {
530
+ var reconcileFile = async (runner, props, output, adopt = false) => {
342
531
  const want = await desiredFile(runner, props);
343
532
  if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
344
- throw refuse(props.path, `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (privileged: true). This provider never calls sudo.");
533
+ throw refuse(props.path, `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (sudoRunner() with this path under a prefix). This " + "provider never calls sudo itself.");
345
534
  }
346
535
  const stat = await runner.stat(props.path);
347
536
  if (stat !== undefined && stat.kind !== "file") {
@@ -350,15 +539,12 @@ var reconcileFile = async (runner, props, output) => {
350
539
  const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
351
540
  const moved = output !== undefined && output.path !== props.path;
352
541
  const prior = moved ? undefined : output;
353
- if (prior === undefined && before !== undefined && !matches(before, want)) {
542
+ const takeOver = adopt && output === undefined;
543
+ if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
354
544
  throw refuse(props.path, "already exists and is not this resource. Remove it, or declare it as a new resource and " + "deploy with --adopt.");
355
545
  }
356
546
  if (before === undefined || !matches(before, want)) {
357
- await runner.writeFileAtomic(props.path, want.bytes, {
358
- mode: want.mode,
359
- ...want.uid === undefined ? {} : { uid: want.uid },
360
- ...want.gid === undefined ? {} : { gid: want.gid }
361
- });
547
+ await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
362
548
  }
363
549
  const after = await readFileAttributes(runner, props.path);
364
550
  if (after === undefined || !matches(after, want)) {
@@ -383,34 +569,34 @@ var deleteFile = async (runner, output) => {
383
569
 
384
570
  // src/launchd/host-file.ts
385
571
  var HostFile = Resource("Host.File");
386
- var HostFileProvider = () => Provider.effect(HostFile, Effect2.gen(function* () {
572
+ var HostFileProvider = () => Provider.effect(HostFile, Effect6.gen(function* () {
387
573
  const runner = yield* HostRunnerService;
388
574
  return HostFile.Provider.of({
389
- list: () => Effect2.succeed([]),
575
+ list: () => Effect6.succeed([]),
390
576
  read: ({ olds, output }) => lift(async () => {
391
577
  const found = await readFileAttributes(runner, olds.path);
392
578
  if (found === undefined)
393
579
  return;
394
- return output === undefined ? Unowned(found) : found;
580
+ return output === undefined ? Unowned2(found) : found;
395
581
  }),
396
- diff: ({ news, output }) => {
582
+ diff: ({ instanceId, news, output }) => {
397
583
  if (output === undefined)
398
- return Effect2.succeed(undefined);
584
+ return noteUnfinished(instanceId);
399
585
  if (isResolved(news))
400
586
  return lift(() => diffFile(runner, news, output));
401
587
  const path = resolvedString(news, "path");
402
- return Effect2.succeed(path !== undefined && path !== output.path ? { action: "replace" } : undefined);
588
+ return Effect6.succeed(path !== undefined && path !== output.path ? { action: "replace" } : undefined);
403
589
  },
404
- reconcile: ({ news, output }) => lift(() => reconcileFile(runner, news, output)),
590
+ reconcile: ({ fqn, instanceId, news, output }) => Effect6.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) => lift(() => reconcileFile(runner, news, output, adopt))),
405
591
  delete: ({ output }) => lift(() => deleteFile(runner, output))
406
592
  });
407
593
  }));
408
594
  // src/launchd/job.ts
409
595
  import { Resource as Resource2 } from "alchemy";
410
- import { Unowned as Unowned2 } from "alchemy/AdoptPolicy";
596
+ import { Unowned as Unowned3 } from "alchemy/AdoptPolicy";
411
597
  import { isResolved as isResolved2 } from "alchemy/Diff";
412
598
  import * as Provider2 from "alchemy/Provider";
413
- import * as Effect3 from "effect/Effect";
599
+ import * as Effect7 from "effect/Effect";
414
600
 
415
601
  // src/launchd/job-validate.ts
416
602
  var LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
@@ -605,6 +791,8 @@ var bootoutIfLoaded = async (runner, target) => {
605
791
  };
606
792
 
607
793
  // src/launchd/job-preflight.ts
794
+ var plistWriteOptions = (domain) => domain.kind === "system" ? { gid: 0, mode: 420, uid: 0 } : { mode: 420, uid: domain.uid };
795
+ var checkPlistWrite = async (runner, location) => runner.checkWrite?.(location.plistPath, plistWriteOptions(location.domain));
608
796
  var refuse2 = (label, message) => new Error(`Launchd.Job ${label}: ${message}`);
609
797
  var assertValid = (props) => {
610
798
  const found = jobProblems(props);
@@ -626,7 +814,7 @@ var assertMayWrite = (runner, label, domain) => {
626
814
  if (canActAsRoot(runner))
627
815
  return;
628
816
  if (domain.kind === "system") {
629
- throw refuse2(label, "the system domain needs root. Run the deploy as root, or provide a HostRunner that is " + "deliberately privileged (privileged: true). This provider never calls sudo.");
817
+ throw refuse2(label, "the system domain needs root. Run the deploy as root, or pass a deliberately privileged " + "HostRunner: sudoRunner() elevates exactly the calls this needs. This provider never " + "calls sudo itself.");
630
818
  }
631
819
  if (domain.uid !== runner.effectiveUid()) {
632
820
  throw refuse2(label, `gui/${String(domain.uid)} belongs to another user; only root may write it. ` + "Deploy as that user, as root, or through a privileged HostRunner.");
@@ -661,6 +849,7 @@ var assertReplaceable = async (runner, next, full, old, renderedSha256) => {
661
849
  assertMayWrite(runner, old.label, oldDomain);
662
850
  const { location, status } = await preflight(runner, next);
663
851
  await assertUnclaimed(runner, next.label, location, status, renderedSha256);
852
+ await checkPlistWrite(runner, location);
664
853
  };
665
854
 
666
855
  // src/launchd/job-lifecycle.ts
@@ -696,20 +885,24 @@ var diffJob = async (runner, news, output) => {
696
885
  }
697
886
  assertValid(news);
698
887
  const desired = renderJob(news).sha256;
699
- if (desired !== output.plistSha256)
888
+ const update = async () => {
889
+ await checkPlistWrite(runner, await locate(runner, news));
700
890
  return { action: "update" };
891
+ };
892
+ if (desired !== output.plistSha256)
893
+ return update();
701
894
  const live = await readJob(runner, news);
702
895
  if (live === undefined || !live.loaded || live.plistSha256 !== desired)
703
- return { action: "update" };
896
+ return update();
704
897
  return { action: "noop" };
705
898
  };
706
- var reconcileJob = async (runner, props, output) => {
899
+ var reconcileJob = async (runner, props, output, adopt = false) => {
707
900
  assertValid(props);
708
901
  const { location, status } = await preflight(runner, props);
709
902
  const rendered = renderJob(props);
710
903
  const renamed = output !== undefined && (output.label !== props.label || output.domain !== props.domain);
711
904
  const prior = renamed ? undefined : output;
712
- if (prior === undefined) {
905
+ if (prior === undefined && !(adopt && output === undefined)) {
713
906
  await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
714
907
  }
715
908
  if (renamed)
@@ -718,7 +911,7 @@ var reconcileJob = async (runner, props, output) => {
718
911
  if (status.loaded && before !== undefined && sha256Hex(before) === rendered.sha256 && prior?.plistSha256 === rendered.sha256) {
719
912
  return attributesOf(props, location, rendered.sha256, status);
720
913
  }
721
- await runner.writeFileAtomic(location.plistPath, rendered.bytes, location.domain.kind === "system" ? { gid: 0, mode: 420, uid: 0 } : { mode: 420, uid: location.domain.uid });
914
+ await runner.writeFileAtomic(location.plistPath, rendered.bytes, plistWriteOptions(location.domain));
722
915
  await bootoutIfLoaded(runner, location.target);
723
916
  const failed = async (detail) => {
724
917
  const removed = before === undefined && await runner.removeFile(location.plistPath).then(() => true, () => false);
@@ -745,26 +938,26 @@ var deleteJob = async (runner, output) => {
745
938
 
746
939
  // src/launchd/job.ts
747
940
  var LaunchdJob = Resource2("Launchd.Job");
748
- var LaunchdJobProvider = () => Provider2.effect(LaunchdJob, Effect3.gen(function* () {
941
+ var LaunchdJobProvider = () => Provider2.effect(LaunchdJob, Effect7.gen(function* () {
749
942
  const runner = yield* HostRunnerService;
750
943
  return LaunchdJob.Provider.of({
751
- list: () => Effect3.succeed([]),
944
+ list: () => Effect7.succeed([]),
752
945
  read: ({ olds, output }) => lift(async () => {
753
946
  const found = await readJob(runner, olds);
754
947
  if (found === undefined)
755
948
  return;
756
- return output === undefined ? Unowned2(found) : found;
949
+ return output === undefined ? Unowned3(found) : found;
757
950
  }),
758
- diff: ({ news, output }) => {
951
+ diff: ({ instanceId, news, output }) => {
759
952
  if (output === undefined)
760
- return Effect3.succeed(undefined);
953
+ return noteUnfinished(instanceId);
761
954
  if (isResolved2(news))
762
955
  return lift(() => diffJob(runner, news, output));
763
956
  const label = resolvedString(news, "label");
764
957
  const domain = resolvedString(news, "domain");
765
- return label !== undefined && domain !== undefined && (label !== output.label || domain !== output.domain) ? lift(() => replaceDiff(runner, { domain, label }, undefined, output)) : Effect3.succeed(undefined);
958
+ return label !== undefined && domain !== undefined && (label !== output.label || domain !== output.domain) ? lift(() => replaceDiff(runner, { domain, label }, undefined, output)) : Effect7.succeed(undefined);
766
959
  },
767
- reconcile: ({ news, output }) => lift(() => reconcileJob(runner, news, output)),
960
+ reconcile: ({ fqn, instanceId, news, output }) => Effect7.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) => lift(() => reconcileJob(runner, news, output, adopt))),
768
961
  delete: ({ output }) => lift(() => deleteJob(runner, output))
769
962
  });
770
963
  }));
@@ -835,8 +1028,8 @@ var parseGroup = (platform, stdout) => {
835
1028
  };
836
1029
 
837
1030
  // src/launchd/local-runner.ts
838
- var code = (cause) => typeof cause === "object" && cause !== null && ("code" in cause) ? String(cause.code) : undefined;
839
- var absent = (cause) => code(cause) === "ENOENT" || code(cause) === "ENOTDIR";
1031
+ var errnoCode = (cause) => typeof cause === "object" && cause !== null && ("code" in cause) ? String(cause.code) : undefined;
1032
+ var absent2 = (cause) => errnoCode(cause) === "ENOENT" || errnoCode(cause) === "ENOTDIR";
840
1033
  var run = (argv, timeoutMs) => new Promise((resolve, reject) => {
841
1034
  const [program, ...args] = argv;
842
1035
  if (program === undefined) {
@@ -915,7 +1108,7 @@ var localRunner = (options = {}) => {
915
1108
  try {
916
1109
  return new Uint8Array(await readFile(path));
917
1110
  } catch (cause) {
918
- if (absent(cause))
1111
+ if (absent2(cause))
919
1112
  return;
920
1113
  throw cause;
921
1114
  }
@@ -924,7 +1117,7 @@ var localRunner = (options = {}) => {
924
1117
  try {
925
1118
  await unlink(path);
926
1119
  } catch (cause) {
927
- if (!absent(cause))
1120
+ if (!absent2(cause))
928
1121
  throw cause;
929
1122
  }
930
1123
  },
@@ -940,7 +1133,7 @@ var localRunner = (options = {}) => {
940
1133
  uid: stats.uid
941
1134
  };
942
1135
  } catch (cause) {
943
- if (absent(cause))
1136
+ if (absent2(cause))
944
1137
  return;
945
1138
  throw cause;
946
1139
  }
@@ -951,6 +1144,404 @@ var localRunner = (options = {}) => {
951
1144
  // src/launchd/providers.ts
952
1145
  import * as Layer2 from "effect/Layer";
953
1146
  var launchdProviders = (runner = localRunner()) => Layer2.mergeAll(LaunchdJobProvider(), HostFileProvider()).pipe(Layer2.provide(hostRunnerLayer(runner)));
1147
+ // src/launchd/sudo-allowlist.ts
1148
+ class SudoRefusedError extends Error {
1149
+ constructor(message) {
1150
+ super(message);
1151
+ this.name = "SudoRefusedError";
1152
+ }
1153
+ }
1154
+ var SUDO = "/usr/bin/sudo";
1155
+ var INSTALL = "/usr/bin/install";
1156
+ var RM = "/bin/rm";
1157
+ var WRITES = new Set(["bootstrap", "bootout", "kickstart"]);
1158
+ var octalMode = (mode) => Number.isSafeInteger(mode) && mode >= 0 && mode <= 4095 ? mode.toString(8).padStart(4, "0") : undefined;
1159
+ var checkPrefixes = (prefixes) => {
1160
+ if (prefixes.length === 0) {
1161
+ throw new Error("sudoRunner: no prefixes. Name the directories root may write, e.g. /Library/LaunchDaemons.");
1162
+ }
1163
+ for (const prefix of prefixes) {
1164
+ const found = prefix === "/" ? ['"/" would allow every path'] : pathProblems(prefix);
1165
+ if (found.length > 0) {
1166
+ throw new Error(`sudoRunner: prefix ${JSON.stringify(prefix)}: ${found.join("; ")}`);
1167
+ }
1168
+ }
1169
+ return [...prefixes];
1170
+ };
1171
+ var prefixOf = (path, prefixes) => pathProblems(path).length > 0 ? undefined : prefixes.find((prefix) => path.startsWith(`${prefix}/`));
1172
+ var labelOk = (label) => LABEL.test(label) && !RESERVED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix));
1173
+ var systemLabel = (target) => {
1174
+ const label = target?.startsWith("system/") === true ? target.slice("system/".length) : undefined;
1175
+ return label !== undefined && labelOk(label) ? label : undefined;
1176
+ };
1177
+ var daemonPlist = (label, prefixes) => {
1178
+ const path = plistPathFor(label, { kind: "system" }, undefined);
1179
+ return prefixOf(path, prefixes) === undefined ? undefined : path;
1180
+ };
1181
+ var NO_DAEMON_PREFIX = "needs /Library/LaunchDaemons among the prefixes, where the job’s plist is written and removed";
1182
+ var launchctlProblem = (args, prefixes) => {
1183
+ const [sub, ...rest] = args;
1184
+ if (sub === "bootstrap") {
1185
+ const given = rest[1] ?? "";
1186
+ const name = given.slice(given.lastIndexOf("/") + 1);
1187
+ const label = name.endsWith(".plist") ? name.slice(0, -".plist".length) : "";
1188
+ const shaped = rest.length === 2 && rest[0] === "system" && labelOk(label);
1189
+ if (!shaped)
1190
+ return "bootstrap takes exactly `system /Library/LaunchDaemons/<label>.plist`";
1191
+ const plist = daemonPlist(label, prefixes);
1192
+ if (plist === undefined)
1193
+ return `bootstrap ${NO_DAEMON_PREFIX}`;
1194
+ return given === plist ? undefined : `bootstrap loads only ${plist}, never another path`;
1195
+ }
1196
+ if (sub === "bootout") {
1197
+ const label = rest.length === 1 ? systemLabel(rest[0]) : undefined;
1198
+ if (label === undefined)
1199
+ return "bootout takes exactly `system/<label>`, never a bare domain";
1200
+ return daemonPlist(label, prefixes) === undefined ? `bootout ${NO_DAEMON_PREFIX}` : undefined;
1201
+ }
1202
+ if (sub === "kickstart") {
1203
+ const flags = rest.slice(0, -1);
1204
+ const flagsOk = flags.every((flag) => flag === "-k" || flag === "-p") && new Set(flags).size === flags.length;
1205
+ return flagsOk && systemLabel(rest[rest.length - 1]) !== undefined ? undefined : "kickstart takes only -k / -p, then `system/<label>`";
1206
+ }
1207
+ return `launchctl ${String(sub)} is never run as root`;
1208
+ };
1209
+ var ID = /^\d+$/;
1210
+ var idsOk = (ids) => {
1211
+ const [flag, id, second, secondId] = ids;
1212
+ if (ids.length === 0)
1213
+ return true;
1214
+ if (ids.length === 2)
1215
+ return (flag === "-o" || flag === "-g") && ID.test(id ?? "");
1216
+ return ids.length === 4 && flag === "-o" && ID.test(id ?? "") && second === "-g" && ID.test(secondId ?? "");
1217
+ };
1218
+ var privilegedProblem = (argv, context) => {
1219
+ const [program, ...args] = argv;
1220
+ const under = (path) => path !== undefined && prefixOf(path, context.prefixes) !== undefined;
1221
+ if (program === LAUNCHCTL)
1222
+ return launchctlProblem(args, context.prefixes);
1223
+ if (program === INSTALL) {
1224
+ const [safe, m, mode, ...rest] = args;
1225
+ if (safe !== "-S" || m !== "-m" || mode === undefined || !/^[0-7]{4}$/.test(mode)) {
1226
+ return "install takes exactly `-S -m <4 octal digits> [-o <uid>] [-g <gid>] <staged> <dest>`";
1227
+ }
1228
+ if (rest.length < 2 || !idsOk(rest.slice(0, -2)))
1229
+ return "install takes only -o <uid> -g <gid>";
1230
+ const [source, dest] = rest.slice(-2);
1231
+ if (source === undefined || source !== context.staged) {
1232
+ return "install copies only the file this runner staged";
1233
+ }
1234
+ return under(dest) ? undefined : "install destination is not under a declared prefix";
1235
+ }
1236
+ if (program === RM) {
1237
+ const [force, end, path, ...extra] = args;
1238
+ return force === "-f" && end === "--" && extra.length === 0 && under(path) ? undefined : "rm takes exactly `-f -- <path under a declared prefix>`";
1239
+ }
1240
+ return `${String(program)} is not on the sudo allowlist`;
1241
+ };
1242
+ var routeExec = (argv, operatorUid) => {
1243
+ const [program, sub] = argv;
1244
+ if (program === "sudo" || program?.endsWith("/sudo") === true) {
1245
+ return {
1246
+ refuse: "sudoRunner never passes a sudo argv through; it elevates only its allowlist"
1247
+ };
1248
+ }
1249
+ if (program !== LAUNCHCTL || sub === undefined || !WRITES.has(sub))
1250
+ return { as: "operator" };
1251
+ const target = sub === "kickstart" ? argv[argv.length - 1] : argv[2];
1252
+ if (target === "system" || target?.startsWith("system/") === true)
1253
+ return { as: "root" };
1254
+ const owner = /^(?:gui|user)\/(\d+)(?:\/|$)/.exec(target ?? "")?.[1];
1255
+ if (owner === undefined || Number(owner) === operatorUid)
1256
+ return { as: "operator" };
1257
+ return {
1258
+ refuse: `launchctl ${sub} ${String(target)}: that domain belongs to uid ${owner}, and sudoRunner ` + "elevates only the system domain. Deploy as that user, or as root."
1259
+ };
1260
+ };
1261
+
1262
+ // src/launchd/sudo-acl.ts
1263
+ var LS = "/bin/ls";
1264
+ var WRITE_RIGHTS = new Set([
1265
+ "add_file",
1266
+ "add_subdirectory",
1267
+ "append",
1268
+ "chown",
1269
+ "delete",
1270
+ "delete_child",
1271
+ "write",
1272
+ "writesecurity"
1273
+ ]);
1274
+ var ENTRY = /^\s*\d+:\s+\S+\s+(?:inherited\s+)?allow\s+(\S+)/;
1275
+ var aclGrants = (listing) => listing.split(`
1276
+ `).filter((line) => {
1277
+ const rights = ENTRY.exec(line)?.[1];
1278
+ return rights !== undefined && rights.split(",").some((right) => WRITE_RIGHTS.has(right));
1279
+ }).map((line) => line.trim());
1280
+ var assertNoAclGrants = async (base, path, chain) => {
1281
+ const listed = await base.exec([LS, "-lden", "--", ...chain]);
1282
+ if (listed.exitCode !== 0) {
1283
+ throw new SudoRefusedError(`sudoRunner ${path}: could not read the ACLs of the directories from / down to it ` + `(${LS} -lden exit ${String(listed.exitCode)}). Nothing ran as root.`);
1284
+ }
1285
+ const grants = aclGrants(listed.stdout);
1286
+ if (grants.length === 0)
1287
+ return;
1288
+ throw new SudoRefusedError(`sudoRunner ${path}: a directory from / down to it carries an ACL that lets someone other ` + `than root change what it holds (${grants.join("; ")}). Remove the entry (chmod -a), or ` + "declare a prefix without one. Nothing ran as root.");
1289
+ };
1290
+
1291
+ // src/launchd/sudo-guard.ts
1292
+ var refuse3 = (path, message) => new SudoRefusedError(`sudoRunner ${path}: ${message}`);
1293
+ var betweenDirs = (prefix, path) => {
1294
+ const parts = path.slice(prefix.length + 1).split("/").slice(0, -1);
1295
+ return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join("/")}`);
1296
+ };
1297
+ var aboveDirs = (prefix) => {
1298
+ const parts = prefix.split("/").slice(1, -1);
1299
+ return ["/", ...parts.map((_, index) => `/${parts.slice(0, index + 1).join("/")}`)];
1300
+ };
1301
+ var rootOnlyProblem = (stat) => {
1302
+ if (stat === undefined)
1303
+ return "is missing";
1304
+ if (stat.kind !== "directory")
1305
+ return `is a ${stat.kind}, not a directory`;
1306
+ if (stat.uid !== 0)
1307
+ return `is owned by uid ${String(stat.uid)}, not root`;
1308
+ if ((stat.mode & 18) !== 0) {
1309
+ return `is writable by group or other (mode ${stat.mode.toString(8).padStart(4, "0")})`;
1310
+ }
1311
+ return;
1312
+ };
1313
+ var assertPlainPath = async (base, prefix, path, expect) => {
1314
+ const above = aboveDirs(prefix);
1315
+ for (const [index, stat] of (await Promise.all(above.map((dir) => base.stat(dir)))).entries()) {
1316
+ const up = stat?.kind === "symlink" && stat.uid === 0 ? undefined : rootOnlyProblem(stat);
1317
+ if (up !== undefined) {
1318
+ throw refuse3(path, `${String(above[index])}, above the declared prefix ${prefix}, ${up}. Whoever may change it ` + "may swap the prefix itself; every directory from / down must be root-only. Nothing ran as root.");
1319
+ }
1320
+ }
1321
+ const dirs = betweenDirs(prefix, path);
1322
+ const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
1323
+ const bad = rootOnlyProblem(prefixStat);
1324
+ if (bad !== undefined) {
1325
+ throw refuse3(path, `the declared prefix ${prefix} ${bad}. Declare only a real directory that root owns and ` + "only root may write. Nothing ran as root.");
1326
+ }
1327
+ for (const [index, stat] of stats.entries()) {
1328
+ const dir = String(dirs[index]);
1329
+ if (stat?.kind !== "directory") {
1330
+ throw refuse3(path, `${dir} is ${stat === undefined ? "missing" : `a ${stat.kind}`}; root ` + "writes only through real directories under a declared prefix.");
1331
+ }
1332
+ const below = rootOnlyProblem(stat);
1333
+ if (below !== undefined) {
1334
+ throw refuse3(path, `${dir} ${below}. Every directory from the prefix ${prefix} down to the file must be one ` + "that root owns and only root may write. Nothing ran as root.");
1335
+ }
1336
+ }
1337
+ await assertNoAclGrants(base, path, [...above, prefix, ...dirs]);
1338
+ const target = await base.stat(path);
1339
+ if (target === undefined && expect === "file-or-absent")
1340
+ return "absent";
1341
+ if (target?.kind === "file")
1342
+ return "file";
1343
+ throw refuse3(path, `is ${target === undefined ? "missing" : `a ${target.kind}`}, not a file`);
1344
+ };
1345
+ var modeProblem = (options) => {
1346
+ if (options.uid !== undefined && options.uid !== 0)
1347
+ return;
1348
+ const found = [];
1349
+ if ((options.mode & 3072) !== 0)
1350
+ found.push("setuid/setgid");
1351
+ if ((options.mode & 18) !== 0)
1352
+ found.push("writable by group or other");
1353
+ if (found.length === 0)
1354
+ return;
1355
+ return `mode ${options.mode.toString(8).padStart(4, "0")} is ${found.join(" and ")} on a root-owned file`;
1356
+ };
1357
+ var assertInstallable = async (base, prefix, path, options, groups) => {
1358
+ const mode = modeProblem(options);
1359
+ if (mode !== undefined) {
1360
+ throw refuse3(path, `${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` + "real owner. Nothing ran as root.");
1361
+ }
1362
+ await assertPlainPath(base, prefix, path, "file-or-absent");
1363
+ await assertReadableBack(base, path, options, groups);
1364
+ };
1365
+ var canRead = (file, uid, groups) => {
1366
+ if (uid === 0)
1367
+ return true;
1368
+ if (file.uid === uid)
1369
+ return (file.mode & 256) !== 0;
1370
+ const groupRead = (file.mode & 32) !== 0;
1371
+ const otherRead = (file.mode & 4) !== 0;
1372
+ if (groupRead === otherRead)
1373
+ return groupRead;
1374
+ if (groups === undefined)
1375
+ return;
1376
+ return groups.includes(file.gid) ? groupRead : otherRead;
1377
+ };
1378
+ var assertReadableBack = async (base, path, options, groups) => {
1379
+ const operator = base.effectiveUid();
1380
+ const parent = path.slice(0, path.lastIndexOf("/")) || "/";
1381
+ const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
1382
+ const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
1383
+ if (canRead(file, operator, groups) !== false)
1384
+ return;
1385
+ throw refuse3(path, `owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` + `from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` + "mode that user can read, or deploy as root. Nothing was written.");
1386
+ };
1387
+ var operatorGroups = async (base) => {
1388
+ const result = await base.exec(["/usr/bin/id", "-G"]);
1389
+ const ids = result.stdout.trim().split(/\s+/);
1390
+ return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
1391
+ };
1392
+
1393
+ // src/launchd/sudo-said.ts
1394
+ var PASSWORD = /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
1395
+ var NOT_ALLOWED = /^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
1396
+ var undeclared = (path, prefixes) => (cause) => {
1397
+ const code = errnoCode(cause);
1398
+ if (code !== "EACCES" && code !== "EPERM")
1399
+ throw cause;
1400
+ throw new Error(`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` + `(${prefixes.join(", ")}). If root owns its directory, declare that directory as a prefix.`, { cause });
1401
+ };
1402
+ var sudoRefusal = (shown, result) => {
1403
+ if (result.exitCode !== 1)
1404
+ return;
1405
+ if (PASSWORD.test(result.stderr)) {
1406
+ return new SudoRefusedError(`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` + "in the deploying terminal just before the deploy, or grant exactly these commands " + "NOPASSWD (docs/launchd-sudo.md). The command did not run.");
1407
+ }
1408
+ if (NOT_ALLOWED.test(result.stderr)) {
1409
+ return new SudoRefusedError(`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`);
1410
+ }
1411
+ return;
1412
+ };
1413
+
1414
+ // src/launchd/sudo-stage.ts
1415
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
1416
+ import { tmpdir } from "node:os";
1417
+ import { join as join2 } from "node:path";
1418
+ var stageFile = async (bytes) => {
1419
+ const dir = await mkdtemp(join2(tmpdir(), "hf-sudo-"));
1420
+ const dispose = () => rm(dir, { force: true, recursive: true });
1421
+ try {
1422
+ const path = join2(dir, "staged");
1423
+ await writeFile(path, bytes, { flag: "wx", mode: 384 });
1424
+ return { dispose, path };
1425
+ } catch (cause) {
1426
+ await dispose();
1427
+ throw cause;
1428
+ }
1429
+ };
1430
+
1431
+ // src/launchd/sudo-runner.ts
1432
+ var makeSudoRunner = (prefixList, deps) => {
1433
+ const prefixes = checkPrefixes(prefixList);
1434
+ const { base } = deps;
1435
+ const elevate = async (argv, { before, staged } = {}) => {
1436
+ const shown = JSON.stringify(argv);
1437
+ const problem = privilegedProblem(argv, {
1438
+ prefixes,
1439
+ ...staged === undefined ? {} : { staged }
1440
+ });
1441
+ if (problem !== undefined)
1442
+ throw new SudoRefusedError(`sudoRunner refused ${shown}: ${problem}`);
1443
+ await before?.();
1444
+ deps.log(`homeflare/launchd sudo -n ${shown}`);
1445
+ const result = await base.exec([SUDO, "-n", "--", ...argv]);
1446
+ const refused = sudoRefusal(shown, result);
1447
+ if (refused !== undefined)
1448
+ throw refused;
1449
+ return result;
1450
+ };
1451
+ const vetWrite = async (path, options) => {
1452
+ const prefix = prefixOf(path, prefixes);
1453
+ const operator = base.effectiveUid();
1454
+ if (prefix === undefined) {
1455
+ if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
1456
+ throw new SudoRefusedError(`sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` + `declared prefix (${prefixes.join(", ")}). Declare its directory, or deploy as root. ` + "Nothing was written.");
1457
+ }
1458
+ return;
1459
+ }
1460
+ const mode = octalMode(options.mode);
1461
+ if (mode === undefined)
1462
+ throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
1463
+ await assertInstallable(base, prefix, path, options, await deps.groups());
1464
+ return { mode, prefix };
1465
+ };
1466
+ const mustSucceed = async (argv, staged) => {
1467
+ const result = await elevate(argv, staged === undefined ? {} : { staged });
1468
+ if (result.exitCode !== 0) {
1469
+ const stderr = result.stderr.trim().slice(0, 300);
1470
+ throw new Error(`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${stderr}`);
1471
+ }
1472
+ };
1473
+ return {
1474
+ checkWrite: async (path, options) => {
1475
+ await vetWrite(path, options);
1476
+ },
1477
+ effectiveUid: () => base.effectiveUid(),
1478
+ exec: async (argv) => {
1479
+ const route = routeExec(argv, base.effectiveUid());
1480
+ if ("refuse" in route)
1481
+ throw new SudoRefusedError(`sudoRunner: ${route.refuse}`);
1482
+ if (route.as === "operator")
1483
+ return base.exec(argv);
1484
+ const plist = argv[1] === "bootstrap" ? argv[3] : undefined;
1485
+ const prefix = plist === undefined ? undefined : prefixOf(plist, prefixes);
1486
+ return elevate(argv, {
1487
+ before: async () => {
1488
+ if (plist !== undefined && prefix !== undefined) {
1489
+ await assertPlainPath(base, prefix, plist, "file");
1490
+ }
1491
+ }
1492
+ });
1493
+ },
1494
+ lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
1495
+ lookupUser: (nameOrId) => base.lookupUser(nameOrId),
1496
+ privileged: true,
1497
+ readFile: (path) => base.readFile(path),
1498
+ removeFile: async (path) => {
1499
+ const prefix = prefixOf(path, prefixes);
1500
+ if (prefix === undefined)
1501
+ return base.removeFile(path).catch(undeclared(path, prefixes));
1502
+ if (await base.stat(path) === undefined)
1503
+ return;
1504
+ await assertPlainPath(base, prefix, path, "file");
1505
+ await mustSucceed([RM, "-f", "--", path]);
1506
+ },
1507
+ sleep: (ms) => base.sleep(ms),
1508
+ stat: (path) => base.stat(path),
1509
+ writeFileAtomic: async (path, bytes, options) => {
1510
+ const vetted = await vetWrite(path, options);
1511
+ if (vetted === undefined) {
1512
+ return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
1513
+ }
1514
+ const { mode } = vetted;
1515
+ const staged = await deps.stage(bytes);
1516
+ try {
1517
+ await mustSucceed([
1518
+ INSTALL,
1519
+ "-S",
1520
+ "-m",
1521
+ mode,
1522
+ ...options.uid === undefined ? [] : ["-o", String(options.uid)],
1523
+ ...options.gid === undefined ? [] : ["-g", String(options.gid)],
1524
+ staged.path,
1525
+ path
1526
+ ], staged.path);
1527
+ } finally {
1528
+ await staged.dispose();
1529
+ }
1530
+ }
1531
+ };
1532
+ };
1533
+ var sudoRunner = (options) => {
1534
+ const base = localRunner(options);
1535
+ return makeSudoRunner(options.prefixes, {
1536
+ base,
1537
+ groups: () => operatorGroups(base),
1538
+ log: options.log ?? ((line) => {
1539
+ process.stderr.write(`${line}
1540
+ `);
1541
+ }),
1542
+ stage: stageFile
1543
+ });
1544
+ };
954
1545
  export {
955
1546
  HostFile,
956
1547
  HostFileProvider,
@@ -958,12 +1549,14 @@ export {
958
1549
  LaunchdJob,
959
1550
  LaunchdJobProvider,
960
1551
  PlistError,
1552
+ SudoRefusedError,
961
1553
  canActAsRoot,
962
1554
  hostRunnerLayer,
963
1555
  launchdProviders,
964
1556
  localRunner,
965
- renderPlist
1557
+ renderPlist,
1558
+ sudoRunner
966
1559
  };
967
1560
 
968
- //# debugId=86750D103F13F7F764756E2164756E21
1561
+ //# debugId=59F08ED5EC139A6064756E2164756E21
969
1562
  //# sourceMappingURL=index.js.map