@homeflare/alchemy 0.8.0 → 0.10.1

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 (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -1,17 +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 Effect2 from "effect/Effect";
6
+ import * as Effect6 from "effect/Effect";
7
7
 
8
- // src/launchd/host-effect.ts
8
+ // src/ownership/adopt.ts
9
9
  import { AdoptPolicy } from "alchemy/AdoptPolicy";
10
10
  import { AlchemyContext } from "alchemy/AlchemyContext";
11
- import { Stack } from "alchemy/Stack";
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";
12
18
  import * as Effect from "effect/Effect";
13
19
  import * as Option from "effect/Option";
14
- var lift = (step) => Effect.tryPromise({
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";
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);
184
+
185
+ // src/launchd/host-effect.ts
186
+ import * as Effect5 from "effect/Effect";
187
+ var lift = (step) => Effect5.tryPromise({
15
188
  catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
16
189
  try: step
17
190
  });
@@ -21,17 +194,6 @@ var resolvedString = (news, key) => {
21
194
  const value = news[key];
22
195
  return typeof value === "string" ? value : undefined;
23
196
  };
24
- var adoptEnabled = (fqn) => Effect.gen(function* () {
25
- const stack = yield* Effect.serviceOption(Stack);
26
- const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
27
- if (scoped !== undefined)
28
- return scoped;
29
- const policy = yield* Effect.serviceOption(AdoptPolicy);
30
- if (Option.isSome(policy))
31
- return policy.value;
32
- const context = yield* Effect.serviceOption(AlchemyContext);
33
- return Option.isSome(context) ? context.value.adopt : false;
34
- });
35
197
 
36
198
  // src/launchd/secret-tripwire.ts
37
199
  var ALTERNATIVE = "props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) " + "write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).";
@@ -132,7 +294,7 @@ var escapeText = (text, path) => {
132
294
  }
133
295
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\r", "&#13;");
134
296
  };
135
- var isPlainObject = (value) => {
297
+ var isPlainObject2 = (value) => {
136
298
  const proto = Object.getPrototypeOf(value);
137
299
  return proto === Object.prototype || proto === null;
138
300
  };
@@ -167,7 +329,7 @@ var renderValue = (value, path, depth, out) => {
167
329
  out.push(`${pad}</array>`);
168
330
  return;
169
331
  }
170
- if (typeof value === "object" && value !== null && isPlainObject(value)) {
332
+ if (typeof value === "object" && value !== null && isPlainObject2(value)) {
171
333
  renderDict(value, path, depth, out);
172
334
  return;
173
335
  }
@@ -190,7 +352,7 @@ var renderDict = (dict, path, depth, out) => {
190
352
  out.push(`${pad}</dict>`);
191
353
  };
192
354
  var renderPlist = (dict) => {
193
- if (typeof dict !== "object" || dict === null || Array.isArray(dict) || !isPlainObject(dict)) {
355
+ if (typeof dict !== "object" || dict === null || Array.isArray(dict) || !isPlainObject2(dict)) {
194
356
  throw new PlistError("", "the top level of a launchd plist must be a dict");
195
357
  }
196
358
  const out = [];
@@ -346,17 +508,26 @@ var readFileAttributes = async (runner, path) => {
346
508
  uid: stat.uid
347
509
  };
348
510
  };
511
+ var writeOptionsOf = (want) => ({
512
+ mode: want.mode,
513
+ ...want.uid === undefined ? {} : { uid: want.uid },
514
+ ...want.gid === undefined ? {} : { gid: want.gid }
515
+ });
349
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);
350
517
  var diffFile = async (runner, news, output) => {
351
- if (news.path !== output.path)
352
- return { action: "replace" };
353
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" });
354
525
  if (want.sha256 !== output.sha256)
355
- return { action: "update" };
526
+ return writes({ action: "update" });
356
527
  const live = await readFileAttributes(runner, news.path);
357
- return live !== undefined && matches(live, want) ? { action: "noop" } : { action: "update" };
528
+ return live !== undefined && matches(live, want) ? { action: "noop" } : writes({ action: "update" });
358
529
  };
359
- var reconcileFile = async (runner, props, output) => {
530
+ var reconcileFile = async (runner, props, output, adopt = false) => {
360
531
  const want = await desiredFile(runner, props);
361
532
  if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
362
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.");
@@ -368,15 +539,12 @@ var reconcileFile = async (runner, props, output) => {
368
539
  const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
369
540
  const moved = output !== undefined && output.path !== props.path;
370
541
  const prior = moved ? undefined : output;
371
- if (prior === undefined && before !== undefined && !matches(before, want)) {
542
+ const takeOver = adopt && output === undefined;
543
+ if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
372
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.");
373
545
  }
374
546
  if (before === undefined || !matches(before, want)) {
375
- await runner.writeFileAtomic(props.path, want.bytes, {
376
- mode: want.mode,
377
- ...want.uid === undefined ? {} : { uid: want.uid },
378
- ...want.gid === undefined ? {} : { gid: want.gid }
379
- });
547
+ await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
380
548
  }
381
549
  const after = await readFileAttributes(runner, props.path);
382
550
  if (after === undefined || !matches(after, want)) {
@@ -401,34 +569,34 @@ var deleteFile = async (runner, output) => {
401
569
 
402
570
  // src/launchd/host-file.ts
403
571
  var HostFile = Resource("Host.File");
404
- var HostFileProvider = () => Provider.effect(HostFile, Effect2.gen(function* () {
572
+ var HostFileProvider = () => Provider.effect(HostFile, Effect6.gen(function* () {
405
573
  const runner = yield* HostRunnerService;
406
574
  return HostFile.Provider.of({
407
- list: () => Effect2.succeed([]),
575
+ list: () => Effect6.succeed([]),
408
576
  read: ({ olds, output }) => lift(async () => {
409
577
  const found = await readFileAttributes(runner, olds.path);
410
578
  if (found === undefined)
411
579
  return;
412
- return output === undefined ? Unowned(found) : found;
580
+ return output === undefined ? Unowned2(found) : found;
413
581
  }),
414
- diff: ({ news, output }) => {
582
+ diff: ({ instanceId, news, output }) => {
415
583
  if (output === undefined)
416
- return Effect2.succeed(undefined);
584
+ return noteUnfinished(instanceId);
417
585
  if (isResolved(news))
418
586
  return lift(() => diffFile(runner, news, output));
419
587
  const path = resolvedString(news, "path");
420
- return Effect2.succeed(path !== undefined && path !== output.path ? { action: "replace" } : undefined);
588
+ return Effect6.succeed(path !== undefined && path !== output.path ? { action: "replace" } : undefined);
421
589
  },
422
- 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))),
423
591
  delete: ({ output }) => lift(() => deleteFile(runner, output))
424
592
  });
425
593
  }));
426
594
  // src/launchd/job.ts
427
595
  import { Resource as Resource2 } from "alchemy";
428
- import { Unowned as Unowned2 } from "alchemy/AdoptPolicy";
596
+ import { Unowned as Unowned3 } from "alchemy/AdoptPolicy";
429
597
  import { isResolved as isResolved2 } from "alchemy/Diff";
430
598
  import * as Provider2 from "alchemy/Provider";
431
- import * as Effect3 from "effect/Effect";
599
+ import * as Effect7 from "effect/Effect";
432
600
 
433
601
  // src/launchd/job-validate.ts
434
602
  var LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
@@ -623,6 +791,8 @@ var bootoutIfLoaded = async (runner, target) => {
623
791
  };
624
792
 
625
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));
626
796
  var refuse2 = (label, message) => new Error(`Launchd.Job ${label}: ${message}`);
627
797
  var assertValid = (props) => {
628
798
  const found = jobProblems(props);
@@ -679,6 +849,7 @@ var assertReplaceable = async (runner, next, full, old, renderedSha256) => {
679
849
  assertMayWrite(runner, old.label, oldDomain);
680
850
  const { location, status } = await preflight(runner, next);
681
851
  await assertUnclaimed(runner, next.label, location, status, renderedSha256);
852
+ await checkPlistWrite(runner, location);
682
853
  };
683
854
 
684
855
  // src/launchd/job-lifecycle.ts
@@ -714,20 +885,24 @@ var diffJob = async (runner, news, output) => {
714
885
  }
715
886
  assertValid(news);
716
887
  const desired = renderJob(news).sha256;
717
- if (desired !== output.plistSha256)
888
+ const update = async () => {
889
+ await checkPlistWrite(runner, await locate(runner, news));
718
890
  return { action: "update" };
891
+ };
892
+ if (desired !== output.plistSha256)
893
+ return update();
719
894
  const live = await readJob(runner, news);
720
895
  if (live === undefined || !live.loaded || live.plistSha256 !== desired)
721
- return { action: "update" };
896
+ return update();
722
897
  return { action: "noop" };
723
898
  };
724
- var reconcileJob = async (runner, props, output) => {
899
+ var reconcileJob = async (runner, props, output, adopt = false) => {
725
900
  assertValid(props);
726
901
  const { location, status } = await preflight(runner, props);
727
902
  const rendered = renderJob(props);
728
903
  const renamed = output !== undefined && (output.label !== props.label || output.domain !== props.domain);
729
904
  const prior = renamed ? undefined : output;
730
- if (prior === undefined) {
905
+ if (prior === undefined && !(adopt && output === undefined)) {
731
906
  await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
732
907
  }
733
908
  if (renamed)
@@ -736,7 +911,7 @@ var reconcileJob = async (runner, props, output) => {
736
911
  if (status.loaded && before !== undefined && sha256Hex(before) === rendered.sha256 && prior?.plistSha256 === rendered.sha256) {
737
912
  return attributesOf(props, location, rendered.sha256, status);
738
913
  }
739
- 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));
740
915
  await bootoutIfLoaded(runner, location.target);
741
916
  const failed = async (detail) => {
742
917
  const removed = before === undefined && await runner.removeFile(location.plistPath).then(() => true, () => false);
@@ -763,26 +938,26 @@ var deleteJob = async (runner, output) => {
763
938
 
764
939
  // src/launchd/job.ts
765
940
  var LaunchdJob = Resource2("Launchd.Job");
766
- var LaunchdJobProvider = () => Provider2.effect(LaunchdJob, Effect3.gen(function* () {
941
+ var LaunchdJobProvider = () => Provider2.effect(LaunchdJob, Effect7.gen(function* () {
767
942
  const runner = yield* HostRunnerService;
768
943
  return LaunchdJob.Provider.of({
769
- list: () => Effect3.succeed([]),
944
+ list: () => Effect7.succeed([]),
770
945
  read: ({ olds, output }) => lift(async () => {
771
946
  const found = await readJob(runner, olds);
772
947
  if (found === undefined)
773
948
  return;
774
- return output === undefined ? Unowned2(found) : found;
949
+ return output === undefined ? Unowned3(found) : found;
775
950
  }),
776
- diff: ({ news, output }) => {
951
+ diff: ({ instanceId, news, output }) => {
777
952
  if (output === undefined)
778
- return Effect3.succeed(undefined);
953
+ return noteUnfinished(instanceId);
779
954
  if (isResolved2(news))
780
955
  return lift(() => diffJob(runner, news, output));
781
956
  const label = resolvedString(news, "label");
782
957
  const domain = resolvedString(news, "domain");
783
- 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);
784
959
  },
785
- 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))),
786
961
  delete: ({ output }) => lift(() => deleteJob(runner, output))
787
962
  });
788
963
  }));
@@ -854,7 +1029,7 @@ var parseGroup = (platform, stdout) => {
854
1029
 
855
1030
  // src/launchd/local-runner.ts
856
1031
  var errnoCode = (cause) => typeof cause === "object" && cause !== null && ("code" in cause) ? String(cause.code) : undefined;
857
- var absent = (cause) => errnoCode(cause) === "ENOENT" || errnoCode(cause) === "ENOTDIR";
1032
+ var absent2 = (cause) => errnoCode(cause) === "ENOENT" || errnoCode(cause) === "ENOTDIR";
858
1033
  var run = (argv, timeoutMs) => new Promise((resolve, reject) => {
859
1034
  const [program, ...args] = argv;
860
1035
  if (program === undefined) {
@@ -933,7 +1108,7 @@ var localRunner = (options = {}) => {
933
1108
  try {
934
1109
  return new Uint8Array(await readFile(path));
935
1110
  } catch (cause) {
936
- if (absent(cause))
1111
+ if (absent2(cause))
937
1112
  return;
938
1113
  throw cause;
939
1114
  }
@@ -942,7 +1117,7 @@ var localRunner = (options = {}) => {
942
1117
  try {
943
1118
  await unlink(path);
944
1119
  } catch (cause) {
945
- if (!absent(cause))
1120
+ if (!absent2(cause))
946
1121
  throw cause;
947
1122
  }
948
1123
  },
@@ -958,7 +1133,7 @@ var localRunner = (options = {}) => {
958
1133
  uid: stats.uid
959
1134
  };
960
1135
  } catch (cause) {
961
- if (absent(cause))
1136
+ if (absent2(cause))
962
1137
  return;
963
1138
  throw cause;
964
1139
  }
@@ -1084,13 +1259,46 @@ var routeExec = (argv, operatorUid) => {
1084
1259
  };
1085
1260
  };
1086
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
+
1087
1291
  // src/launchd/sudo-guard.ts
1088
1292
  var refuse3 = (path, message) => new SudoRefusedError(`sudoRunner ${path}: ${message}`);
1089
1293
  var betweenDirs = (prefix, path) => {
1090
1294
  const parts = path.slice(prefix.length + 1).split("/").slice(0, -1);
1091
1295
  return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join("/")}`);
1092
1296
  };
1093
- var prefixProblem = (stat) => {
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) => {
1094
1302
  if (stat === undefined)
1095
1303
  return "is missing";
1096
1304
  if (stat.kind !== "directory")
@@ -1103,17 +1311,30 @@ var prefixProblem = (stat) => {
1103
1311
  return;
1104
1312
  };
1105
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
+ }
1106
1321
  const dirs = betweenDirs(prefix, path);
1107
1322
  const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
1108
- const bad = prefixProblem(prefixStat);
1323
+ const bad = rootOnlyProblem(prefixStat);
1109
1324
  if (bad !== undefined) {
1110
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.");
1111
1326
  }
1112
1327
  for (const [index, stat] of stats.entries()) {
1328
+ const dir = String(dirs[index]);
1113
1329
  if (stat?.kind !== "directory") {
1114
- throw refuse3(path, `${String(dirs[index])} is ${stat === undefined ? "missing" : `a ${stat.kind}`}; root ` + "writes only through real directories under a declared prefix.");
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.");
1115
1335
  }
1116
1336
  }
1337
+ await assertNoAclGrants(base, path, [...above, prefix, ...dirs]);
1117
1338
  const target = await base.stat(path);
1118
1339
  if (target === undefined && expect === "file-or-absent")
1119
1340
  return "absent";
@@ -1121,6 +1342,26 @@ var assertPlainPath = async (base, prefix, path, expect) => {
1121
1342
  return "file";
1122
1343
  throw refuse3(path, `is ${target === undefined ? "missing" : `a ${target.kind}`}, not a file`);
1123
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
+ };
1124
1365
  var canRead = (file, uid, groups) => {
1125
1366
  if (uid === 0)
1126
1367
  return true;
@@ -1149,6 +1390,27 @@ var operatorGroups = async (base) => {
1149
1390
  return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
1150
1391
  };
1151
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
+
1152
1414
  // src/launchd/sudo-stage.ts
1153
1415
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
1154
1416
  import { tmpdir } from "node:os";
@@ -1167,14 +1429,6 @@ var stageFile = async (bytes) => {
1167
1429
  };
1168
1430
 
1169
1431
  // src/launchd/sudo-runner.ts
1170
- var PASSWORD = /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
1171
- 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;
1172
- var undeclared = (path, prefixes) => (cause) => {
1173
- const code = errnoCode(cause);
1174
- if (code !== "EACCES" && code !== "EPERM")
1175
- throw cause;
1176
- 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 });
1177
- };
1178
1432
  var makeSudoRunner = (prefixList, deps) => {
1179
1433
  const prefixes = checkPrefixes(prefixList);
1180
1434
  const { base } = deps;
@@ -1189,16 +1443,26 @@ var makeSudoRunner = (prefixList, deps) => {
1189
1443
  await before?.();
1190
1444
  deps.log(`homeflare/launchd sudo -n ${shown}`);
1191
1445
  const result = await base.exec([SUDO, "-n", "--", ...argv]);
1192
- if (result.exitCode !== 1)
1193
- return result;
1194
- if (PASSWORD.test(result.stderr)) {
1195
- throw 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.");
1196
- }
1197
- if (NOT_ALLOWED.test(result.stderr)) {
1198
- throw new SudoRefusedError(`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`);
1199
- }
1446
+ const refused = sudoRefusal(shown, result);
1447
+ if (refused !== undefined)
1448
+ throw refused;
1200
1449
  return result;
1201
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
+ };
1202
1466
  const mustSucceed = async (argv, staged) => {
1203
1467
  const result = await elevate(argv, staged === undefined ? {} : { staged });
1204
1468
  if (result.exitCode !== 0) {
@@ -1207,6 +1471,9 @@ var makeSudoRunner = (prefixList, deps) => {
1207
1471
  }
1208
1472
  };
1209
1473
  return {
1474
+ checkWrite: async (path, options) => {
1475
+ await vetWrite(path, options);
1476
+ },
1210
1477
  effectiveUid: () => base.effectiveUid(),
1211
1478
  exec: async (argv) => {
1212
1479
  const route = routeExec(argv, base.effectiveUid());
@@ -1240,19 +1507,11 @@ var makeSudoRunner = (prefixList, deps) => {
1240
1507
  sleep: (ms) => base.sleep(ms),
1241
1508
  stat: (path) => base.stat(path),
1242
1509
  writeFileAtomic: async (path, bytes, options) => {
1243
- const prefix = prefixOf(path, prefixes);
1244
- const operator = base.effectiveUid();
1245
- if (prefix === undefined) {
1246
- if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
1247
- 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.");
1248
- }
1510
+ const vetted = await vetWrite(path, options);
1511
+ if (vetted === undefined) {
1249
1512
  return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
1250
1513
  }
1251
- const mode = octalMode(options.mode);
1252
- if (mode === undefined)
1253
- throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
1254
- await assertPlainPath(base, prefix, path, "file-or-absent");
1255
- await assertReadableBack(base, path, options, await deps.groups());
1514
+ const { mode } = vetted;
1256
1515
  const staged = await deps.stage(bytes);
1257
1516
  try {
1258
1517
  await mustSucceed([
@@ -1299,5 +1558,5 @@ export {
1299
1558
  sudoRunner
1300
1559
  };
1301
1560
 
1302
- //# debugId=750ACEF7A88B083E64756E2164756E21
1561
+ //# debugId=59F08ED5EC139A6064756E2164756E21
1303
1562
  //# sourceMappingURL=index.js.map