@homeflare/alchemy 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `fetchMeshNodeToken` — a Mesh node's enrolment token, fetched on demand for a one-off Bun
3
+ * `mesh-enroll` step. ⛔ Not a Resource, and never an attribute: see mesh-node-form.ts for why
4
+ * the token must stay out of Alchemy state.
5
+ *
6
+ * ⛔ WHERE THE VALUE MAY GO. Straight into a root-owned `0600` file on the node's host, which the
7
+ * enrolment reads (`warp-cli --accept-tos connector new <token>`, per the Mesh get-started page,
8
+ * read 2026-09-21). Nowhere else: not Alchemy state, not a secrets store (a runtime credential
9
+ * belongs to the host that runs it), not a log line, not a CI variable. It is returned
10
+ * `Redacted`, so an accidental `console.log` or `Effect.log` prints `<redacted>`; call
11
+ * `Redacted.value` only at the write.
12
+ * ⚠️ `warp-cli` takes the token as an ARGUMENT, so it is visible in the host's process list for
13
+ * the life of that command. Run the enrolment as root on the node itself, never over a shell
14
+ * whose history is kept.
15
+ * ⚠️ THE ENDPOINT NEEDS A WRITE PERMISSION. Its API reference (read 2026-09-21) accepts
16
+ * "Cloudflare One Connectors Write", "Cloudflare One Connector: cloudflared Write" or
17
+ * "Cloudflare Tunnel Write"; the Mesh get-started guide also names "Cloudflare One Connector:
18
+ * WARP Write". Connectors Write is in both lists. Mint a short-lived token for this step alone;
19
+ * a read-only plan token gets a 403 here.
20
+ *
21
+ * ★ Requires distilled `Credentials` and an `HttpClient`, not Alchemy's CloudflareEnvironment: a
22
+ * Bun script has no Alchemy profile, so the account id is an argument. Provide
23
+ * `Credentials.fromApiToken({ apiToken })` (from `@distilled.cloud/cloudflare/Credentials`) and
24
+ * `FetchHttpClient.layer`. ⛔ Not `Credentials.fromEnv()`: it falls back to the Global API Key
25
+ * (`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set — and `refuseUnsafe` below
26
+ * refuses that key rather than trusting the caller to have read this.
27
+ */
28
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
29
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
30
+ import * as Effect from 'effect/Effect';
31
+ import * as Redacted from 'effect/Redacted';
32
+ import { MeshNodeError, findNodeByName } from './mesh-node-api.ts';
33
+
34
+ /** Which node: by id (the `MeshNode` attribute) or by its exact name. */
35
+ export type MeshNodeRef = { readonly accountId: string } & (
36
+ | { readonly id: string; readonly name?: undefined }
37
+ | { readonly name: string; readonly id?: undefined }
38
+ );
39
+
40
+ /**
41
+ * ⛔ REFUSED BEFORE ANY REQUEST IS SENT:
42
+ * - `DISTILLED_DEBUG_HTTP` set. distilled's Cloudflare protocol then `console.error`s the first
43
+ * 400 characters of EVERY response body (protocol.ts, measured in 1.0.0-rc.12) — the token
44
+ * response is `{"success":true,…,"result":"<token>"}`, so the whole token lands on stderr and in
45
+ * whatever captures it. The check reads `process.env` because that is what distilled reads.
46
+ * - The Global API Key (`apiKey` credentials). Account-wide and long-lived: the one credential a
47
+ * one-off enrolment must never use.
48
+ * - An EMPTY API token. A denied secrets-store grant renders empty, and Cloudflare then answers
49
+ * like a bad permission — which would send the operator to widen a grant that was fine
50
+ * (client.ts says the same for `CLOUDFLARE_API_TOKEN`).
51
+ */
52
+ const refuseUnsafe = Effect.gen(function* () {
53
+ if ((globalThis.process?.env?.['DISTILLED_DEBUG_HTTP'] ?? '') !== '') {
54
+ return yield* Effect.fail(
55
+ new MeshNodeError({
56
+ message:
57
+ 'Refusing to fetch a Mesh node token while DISTILLED_DEBUG_HTTP is set: the SDK would print the token to stderr. Unset it and run again.',
58
+ }),
59
+ );
60
+ }
61
+ const credentials = yield* yield* Credentials;
62
+ if (credentials.type === 'apiKey') {
63
+ return yield* Effect.fail(
64
+ new MeshNodeError({
65
+ message:
66
+ 'Refusing the Global API Key for a Mesh node token. Mint a short-lived API token with "Cloudflare One Connectors Write" and pass it with fromApiToken.',
67
+ }),
68
+ );
69
+ }
70
+ if (credentials.type === 'apiToken' && Redacted.value(credentials.apiToken).length === 0) {
71
+ return yield* Effect.fail(
72
+ new MeshNodeError({
73
+ message:
74
+ 'The Cloudflare API token is EMPTY. That is a missing or denied secrets grant, not a Cloudflare permission: re-mint the token before changing any grant.',
75
+ }),
76
+ );
77
+ }
78
+ });
79
+
80
+ const resolveId = (ref: MeshNodeRef) => {
81
+ if (ref.id !== undefined) return Effect.succeed(ref.id);
82
+ const name = ref.name;
83
+ return Effect.flatMap(findNodeByName(ref.accountId, name), (node) =>
84
+ node === undefined
85
+ ? Effect.fail(new MeshNodeError({ message: `No live Mesh node named "${name}".` }))
86
+ : Effect.succeed(node.id),
87
+ );
88
+ };
89
+
90
+ /**
91
+ * ⛔ An EMPTY token fails closed: `warp-cli connector new ""` would not enrol, and a script that
92
+ * wrote an empty file would report success over a host that never joined.
93
+ * ⛔ The failures made here name the node and account and quote no response body, because the
94
+ * success body IS the token. Other SDK errors pass through; they come only from non-2xx
95
+ * responses, which carry an error envelope, not a token.
96
+ */
97
+ export const fetchMeshNodeToken = (ref: MeshNodeRef) =>
98
+ Effect.gen(function* () {
99
+ yield* refuseUnsafe;
100
+ const nodeId = yield* resolveId(ref);
101
+ const token = yield* zeroTrust
102
+ .getTunnelWarpConnectorToken({ accountId: ref.accountId, tunnelId: nodeId })
103
+ .pipe(
104
+ Effect.catchTag('TunnelNotFound', () =>
105
+ Effect.fail(
106
+ new MeshNodeError({
107
+ message: `Mesh node ${nodeId} does not exist in account ${ref.accountId}.`,
108
+ }),
109
+ ),
110
+ ),
111
+ Effect.catchTag('Forbidden', () =>
112
+ Effect.fail(
113
+ new MeshNodeError({
114
+ message: `Reading Mesh node ${nodeId}'s token was refused (403). The endpoint needs a Write permission such as "Cloudflare One Connectors Write", on a token scoped to account ${ref.accountId}.`,
115
+ }),
116
+ ),
117
+ ),
118
+ );
119
+ if (typeof token !== 'string' || token.length === 0) {
120
+ return yield* Effect.fail(
121
+ new MeshNodeError({ message: `Mesh node ${nodeId} returned an empty token.` }),
122
+ );
123
+ }
124
+ return Redacted.make(token);
125
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * MeshNodeProvider against a fake Cloudflare (fake-mesh.ts), its handlers called the way the
3
+ * engine calls them.
4
+ *
5
+ * ⛔ THE ASSERTION THAT MATTERS MOST: no lifecycle handler ever requests `/token`, and nothing
6
+ * the provider returns contains the node token — even though the fake's create response carries
7
+ * one, as Cloudflare's HA page says the real one does.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { Unowned } from 'alchemy/AdoptPolicy';
11
+ import type { StackServices } from 'alchemy/Stack';
12
+ import * as Effect from 'effect/Effect';
13
+ import type * as Layer from 'effect/Layer';
14
+ import { FAKE_ACCOUNT, FAKE_API_TOKEN, fakeMesh } from './fake-mesh.ts';
15
+ import { diff, extra, ids, read, reconcile, run, stored, tokenReads } from './mesh-node-harness.ts';
16
+ import { providers } from './providers.ts';
17
+
18
+ // ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. Adding MeshNode
19
+ // first leaked Alchemy's auth errors into the error channel; `bun run types` now catches that.
20
+ type Built = Layer.Success<ReturnType<typeof providers>>;
21
+ const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
22
+ void _stackShaped;
23
+
24
+ describe('MeshNodeProvider', () => {
25
+ test('create sends ha, and the attributes carry no token', async () => {
26
+ const fake = fakeMesh();
27
+ const out = await run(fake, (p) => reconcile(p, { name: 'door-a', ha: true }));
28
+ const post = fake.seen.find((s) => s.method === 'POST');
29
+ expect(post?.body).toEqual({ name: 'door-a', ha: true });
30
+ expect(out).toMatchObject({
31
+ name: 'door-a',
32
+ ha: true,
33
+ accountId: FAKE_ACCOUNT,
34
+ status: 'inactive',
35
+ });
36
+ expect(Object.keys(out).sort()).toEqual(['accountId', 'ha', 'id', 'name', 'status']);
37
+ expect(JSON.stringify(out)).not.toContain('fake-node-token');
38
+ expect(tokenReads(fake)).toBe(0);
39
+ expect(new Set(fake.auth)).toEqual(new Set([`Bearer ${FAKE_API_TOKEN}`]));
40
+ });
41
+
42
+ test('read with state refreshes by id; without state it adopts by exact name, Unowned', async () => {
43
+ const fake = fakeMesh();
44
+ fake.seed({ name: 'door-a-old' });
45
+ fake.seed({ name: 'door-a-older' });
46
+ const node = fake.seed({ name: 'door-a', status: 'healthy' });
47
+ const props = { name: 'door-a', ha: false };
48
+ const [owned, probe] = await run(fake, (p) =>
49
+ Effect.gen(function* () {
50
+ const output = stored(FAKE_ACCOUNT, { id: node.id });
51
+ return [yield* read(p, props, output), yield* read(p, props)] as const;
52
+ }),
53
+ );
54
+ expect(Unowned.is(owned)).toBe(false);
55
+ expect(owned).toMatchObject({ id: node.id, status: 'healthy' });
56
+ expect(Unowned.is(probe)).toBe(true);
57
+ expect(probe).toMatchObject({ id: node.id, name: 'door-a', ha: false });
58
+ expect(tokenReads(fake)).toBe(0);
59
+ });
60
+
61
+ test('a prefix-only match or a deleted node is not adopted', async () => {
62
+ const fake = fakeMesh();
63
+ fake.seed({ name: 'door-a-2' });
64
+ fake.seed({ name: 'door-a', deleted_at: '2026-09-20T00:00:00Z' });
65
+ const probe = await run(fake, (p) => read(p, { name: 'door-a', ha: false }));
66
+ expect(probe).toBeUndefined();
67
+ });
68
+
69
+ test('a rename is a PATCH on the same id', async () => {
70
+ const fake = fakeMesh();
71
+ const [first, second] = await run(fake, (p) =>
72
+ Effect.gen(function* () {
73
+ const first = yield* reconcile(p, { name: 'door-a', ha: false });
74
+ return [first, yield* reconcile(p, { name: 'door-b', ha: false }, first)] as const;
75
+ }),
76
+ );
77
+ expect(second.id).toBe(first.id);
78
+ expect(second.name).toBe('door-b');
79
+ expect(fake.seen.filter((s) => s.method === 'POST')).toHaveLength(1);
80
+ expect(fake.seen.find((s) => s.method === 'PATCH')?.body).toEqual({ name: 'door-b' });
81
+ });
82
+
83
+ test('an unchanged node costs no write', async () => {
84
+ const fake = fakeMesh();
85
+ await run(fake, (p) =>
86
+ Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
87
+ reconcile(p, { name: 'door-a', ha: false }, out),
88
+ ),
89
+ );
90
+ expect(fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method)).toEqual(['POST']);
91
+ });
92
+
93
+ test('reconcile refuses an ha change that reached it as an update', async () => {
94
+ const fake = fakeMesh();
95
+ const failure = await run(fake, (p) =>
96
+ Effect.flip(
97
+ Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
98
+ reconcile(p, { name: 'door-a', ha: true }, out),
99
+ ),
100
+ ),
101
+ );
102
+ expect(String(failure)).toContain('create-only');
103
+ });
104
+
105
+ test('create never converges on a node it did not create (a retained old generation)', async () => {
106
+ const fake = fakeMesh();
107
+ fake.seed({ name: 'door-a', ha: false });
108
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, { name: 'door-a', ha: true })));
109
+ expect(String(failure)).toContain('already exists');
110
+ expect(fake.seen.some((s) => s.method === 'POST')).toBe(false);
111
+ });
112
+
113
+ test('a node deleted out of band is created again', async () => {
114
+ const fake = fakeMesh();
115
+ const [first, second] = await run(fake, (p) =>
116
+ Effect.gen(function* () {
117
+ const first = yield* reconcile(p, { name: 'door-a', ha: false });
118
+ const node = fake.nodes.get(first.id);
119
+ if (node !== undefined) node.deleted_at = '2026-09-21T02:00:00Z';
120
+ return [first, yield* reconcile(p, { name: 'door-a', ha: false }, first)] as const;
121
+ }),
122
+ );
123
+ expect(second.id).not.toBe(first.id);
124
+ expect(fake.live()).toHaveLength(1);
125
+ });
126
+
127
+ test('delete removes the node, and deleting it again succeeds', async () => {
128
+ const fake = fakeMesh();
129
+ await run(fake, (p) =>
130
+ Effect.gen(function* () {
131
+ const output = yield* reconcile(p, { name: 'door-a', ha: false });
132
+ const olds = { name: 'door-a', ha: false };
133
+ yield* p.delete({ ...ids, ...extra, olds, output });
134
+ yield* p.delete({ ...ids, ...extra, olds, output });
135
+ }),
136
+ );
137
+ expect(fake.live()).toHaveLength(0);
138
+ expect(fake.seen.filter((s) => s.method === 'DELETE')).toHaveLength(2);
139
+ });
140
+
141
+ test('diff reads the account from the provider environment', async () => {
142
+ const fake = fakeMesh();
143
+ const props = { name: 'door-a', ha: false };
144
+ const plan = (p: Parameters<typeof diff>[0]) => diff(p, props, props, stored(FAKE_ACCOUNT));
145
+ expect(await run(fake, plan)).toBeUndefined();
146
+ expect(await run(fake, plan, '00000000000000000000000000000002')).toEqual({
147
+ action: 'replace',
148
+ });
149
+ });
150
+
151
+ test('an invalid declaration fails the PLAN, before a delete-first replace tears anything down', async () => {
152
+ const fake = fakeMesh();
153
+ const failure = await run(fake, (p) =>
154
+ Effect.flip(
155
+ diff(p, { name: 'door-a ', ha: true }, { name: 'door-a', ha: false }, stored(FAKE_ACCOUNT)),
156
+ ),
157
+ );
158
+ expect(String(failure)).toContain('whitespace');
159
+ expect(fake.seen).toHaveLength(0);
160
+ });
161
+
162
+ test('list is empty and nuke skips it: Alchemy WarpConnector already enumerates these', async () => {
163
+ const fake = fakeMesh();
164
+ fake.seed({ name: 'door-a' });
165
+ const [listed, nuke] = await run(fake, (p) =>
166
+ Effect.map(p.list(), (rows) => [rows, p.nuke] as const),
167
+ );
168
+ expect(listed).toEqual([]);
169
+ expect(nuke).toEqual({ skip: true });
170
+ expect(fake.seen).toHaveLength(0);
171
+ });
172
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `Cloudflare.MeshNode` — a Cloudflare Mesh node (a `warp_connector` object), declared without
3
+ * its token ever touching Alchemy state. Usage and the enrolment step: docs/mesh-node.md.
4
+ *
5
+ * 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.WarpConnector`. A Mesh node that fronts a
6
+ * private service needs a node record in the graph, so the Gateway rules and DNS records that
7
+ * name it deploy together. Alchemy beta.79's WarpConnector would put the node token — the
8
+ * credential that lets any Linux host join the account's Mesh as that node — into plaintext
9
+ * state on every read, and it cannot create an HA node. See mesh-node-form.ts.
10
+ *
11
+ * ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `MeshNode` below, and what it does to an
12
+ * `ha` replace in mesh-node-lifecycle.ts.
13
+ */
14
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
15
+ import * as Cloudflare from 'alchemy/Cloudflare';
16
+ import { isResolved } from 'alchemy/Diff';
17
+ import * as Provider from 'alchemy/Provider';
18
+ import { Resource, type ResourceClass } from 'alchemy/Resource';
19
+ import * as Context from 'effect/Context';
20
+ import * as Effect from 'effect/Effect';
21
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import {
23
+ type MeshNodeAttributes,
24
+ type MeshNodeProps,
25
+ diffMeshNode,
26
+ validateMeshNode,
27
+ } from './mesh-node-form.ts';
28
+ import { deleteMeshNode, readMeshNode, reconcileMeshNode } from './mesh-node-lifecycle.ts';
29
+ import type { Providers } from './providers.ts';
30
+
31
+ export interface MeshNode extends Resource<
32
+ 'Cloudflare.MeshNode',
33
+ MeshNodeProps,
34
+ MeshNodeAttributes,
35
+ never,
36
+ Providers
37
+ > {}
38
+
39
+ /**
40
+ * ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
41
+ * (the `Bao.*` families, `R2BucketLock`, the Proxmox storage families; the reasoning is written
42
+ * once in proxmox/resource.ts). Deleting a node cuts every enrolled replica off the Mesh, and
43
+ * nothing brings it back: a new node has a new id, a new token and a new Mesh IP per replica, so
44
+ * each host must be re-enrolled by hand and every Gateway rule or allow-list that pins the old
45
+ * Mesh IP breaks. So dropping the declaration, or `alchemy destroy`, leaves the node live.
46
+ * ⚠️ IT ALSO SKIPS THE OLD NODE'S DELETE IN A REPLACE (Apply.ts, `deleteOldGenerations` and the
47
+ * GC's `retainOldGeneration`, beta.79). A same-name `ha` change therefore REFUSES, deleting
48
+ * nothing, until that deploy opts in with `.pipe(RemovalPolicy.destroy())`; a create-first
49
+ * replace leaves the old node live and unmanaged. Both measured in mesh-node-policy.test.ts.
50
+ * ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
51
+ * the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
52
+ */
53
+ export const MeshNode: ResourceClass<MeshNode> = Resource<MeshNode>('Cloudflare.MeshNode', {
54
+ defaultRemovalPolicy: 'retain',
55
+ });
56
+
57
+ /** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
58
+ export type MeshNodeServices =
59
+ | Cloudflare.CloudflareEnvironment
60
+ | Credentials
61
+ | HttpClient.HttpClient;
62
+
63
+ /** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
64
+ const accountId = Effect.gen(function* () {
65
+ const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
66
+ return resolved.accountId;
67
+ });
68
+
69
+ /**
70
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD. `Provider.succeed` (what Alchemy's
71
+ * own providers use) leaves each handler to find CloudflareEnvironment and Credentials in the
72
+ * engine's context at call time — which works for `Cloudflare.providers()` only because it
73
+ * `provideMerge`s them into the stack. Capturing them here keeps this provider correct whether
74
+ * or not the stack also merges `Cloudflare.providers()`.
75
+ */
76
+ export const MeshNodeProvider = () =>
77
+ Provider.effect(
78
+ MeshNode,
79
+ Effect.gen(function* () {
80
+ const services = Context.make(
81
+ Cloudflare.CloudflareEnvironment,
82
+ yield* Cloudflare.CloudflareEnvironment,
83
+ ).pipe(
84
+ Context.add(Credentials, yield* Credentials),
85
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
86
+ );
87
+ const run = <A, E>(effect: Effect.Effect<A, E, MeshNodeServices>) =>
88
+ Effect.provideContext(effect, services);
89
+
90
+ return MeshNode.Provider.of({
91
+ stables: ['id', 'accountId', 'ha'],
92
+ /**
93
+ * ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.WarpConnector` already lists
94
+ * every `warp_connector` in the account for `alchemy unsafe nuke`; a second listing here
95
+ * would make nuke delete each node twice. `read` does not depend on this.
96
+ */
97
+ list: () => Effect.succeed([]),
98
+ nuke: { skip: true },
99
+ /**
100
+ * ⛔ VALIDATE AT PLAN TIME, NOT ONLY IN `reconcile`. An `ha` replace is delete-first: the
101
+ * engine deletes the old node BEFORE the new generation's reconcile runs, so a
102
+ * declaration refused there would leave no node at all. launchd's job-preflight.ts
103
+ * refuses at plan time for the same reason.
104
+ */
105
+ diff: ({ news, output }) =>
106
+ run(
107
+ Effect.gen(function* () {
108
+ const invalid = isResolved(news) ? validateMeshNode(news) : undefined;
109
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
110
+ return diffMeshNode(news, output, yield* accountId);
111
+ }),
112
+ ),
113
+ read: ({ olds, output }) =>
114
+ run(Effect.flatMap(accountId, (account) => readMeshNode(account, olds, output))),
115
+ reconcile: ({ news, output }) =>
116
+ run(Effect.flatMap(accountId, (account) => reconcileMeshNode(account, news, output))),
117
+ delete: ({ output }) => run(deleteMeshNode(output)),
118
+ });
119
+ }),
120
+ );
@@ -8,16 +8,34 @@
8
8
  * both a `Cloudflare.R2.Bucket` and this lock merges the two:
9
9
  * `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
10
10
  * a consuming stack names for one talking to more than one account.
11
+ *
12
+ * ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
13
+ * `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
14
+ * and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
15
+ * source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
16
+ * `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
17
+ * ⚠️ That second layer is built again here even when the stack also merges
18
+ * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
19
+ * nothing to the stack's context.
11
20
  */
21
+ import * as Cloudflare from 'alchemy/Cloudflare';
12
22
  import * as Provider from 'alchemy/Provider';
13
23
  import * as Layer from 'effect/Layer';
14
24
  import { CloudflareApiLive } from './client.ts';
25
+ import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
15
26
  import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
16
27
 
17
28
  export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
18
29
 
19
30
  export const providers = () =>
20
- Layer.effect(Providers, Provider.collection([R2BucketLock])).pipe(
21
- Layer.provide(R2BucketLockProvider()),
31
+ Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
32
+ Layer.provide(
33
+ Layer.mergeAll(
34
+ R2BucketLockProvider(),
35
+ // ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
36
+ // fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
37
+ MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
38
+ ),
39
+ ),
22
40
  Layer.provideMerge(CloudflareApiLive),
23
41
  );
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * import { BaoMount } from '@homeflare/alchemy/openbao';
10
10
  * import { TalosCluster } from '@homeflare/alchemy/talos';
11
11
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
12
+ * import { LaunchdJob } from '@homeflare/alchemy/launchd';
13
+ * import { CaddyConfig } from '@homeflare/alchemy/caddy';
12
14
  *
13
15
  * ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
14
16
  * is a runbook step a human runs once — and a plan can never show a missing runbook
@@ -0,0 +1,176 @@
1
+ /**
2
+ * An in-memory host for the tests beside it: a filesystem in a Map and a scripted launchd.
3
+ *
4
+ * ⛔ TEST-ONLY. No provider imports this file, it is not on the barrel, and it never touches the
5
+ * real filesystem or runs a real program — which is the only way the lifecycle tests can prove
6
+ * "bootout before bootstrap" without writing /Library or loading a job.
7
+ * ★ IT MODELS THE BEHAVIOURS THE PROVIDER DEPENDS ON, as launchctl.ts documents them: `print` of an
8
+ * unknown target exits 113; bootstrap of a loaded or disabled label fails; bootout of an absent
9
+ * job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
10
+ * session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
11
+ * uid, need root.
12
+ */
13
+ import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
14
+ import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
15
+
16
+ type Entry = { bytes: Uint8Array; mode: number; uid: number; gid: number; kind: FileStat['kind'] };
17
+
18
+ export type FakeOptions = {
19
+ euid?: number;
20
+ privileged?: boolean;
21
+ /** Directories that exist, and their owner uid. */
22
+ dirs?: Record<string, number>;
23
+ users?: Record<string, HostUser>;
24
+ groups?: Record<string, number>;
25
+ };
26
+
27
+ const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
28
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
29
+ const errno = (code: string, path: string) =>
30
+ Object.assign(new Error(`${code}: ${path}`), { code });
31
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
32
+
33
+ /** The shape `launchctl print` produced on macOS 27.2 (see launchctl.ts), trimmed. */
34
+ const printBlock = (target: string, pid: number | undefined, lastExit: string) =>
35
+ [
36
+ `${target} = {`,
37
+ '\tactive count = 1',
38
+ `\tstate = ${pid === undefined ? 'not running' : 'running'}`,
39
+ '\targuments = {',
40
+ '\t\t/bin/sh',
41
+ '}',
42
+ '\t}',
43
+ '\tresource coalition = {',
44
+ '\t\tstate = active',
45
+ '\t}',
46
+ ...(pid === undefined ? [] : [`\tpid = ${String(pid)}`]),
47
+ `\tlast exit code = ${lastExit}`,
48
+ '}',
49
+ ].join('\n');
50
+
51
+ export const fakeRunner = (options: FakeOptions = {}) => {
52
+ const euid = options.euid ?? 501;
53
+ const privileged = options.privileged ?? false;
54
+ const root = privileged || euid === 0;
55
+ const dirs = new Map(Object.entries(options.dirs ?? {}));
56
+ const files = new Map<string, Entry>();
57
+ const calls: string[][] = [];
58
+ /** target → pid (undefined = loaded, not running). */
59
+ const loaded = new Map<string, number | undefined>();
60
+ const disabled = new Set<string>();
61
+ const state = {
62
+ lingerPrints: 0,
63
+ nextPid: 4000,
64
+ bootstrapFailure: undefined as ExecResult | undefined,
65
+ /** Returned by bootout in place of success. The job still goes, unless `bootoutKeepsJob`. */
66
+ bootoutFailure: undefined as ExecResult | undefined,
67
+ bootoutKeepsJob: false,
68
+ /** gui uids with no login session: every launchctl call into `gui/<uid>` exits 112. */
69
+ loggedOut: new Set<number>(),
70
+ };
71
+ /** Booted-out targets launchd has not let go of yet → prints left before they vanish. */
72
+ const lingering = new Map<string, number>();
73
+
74
+ const labelIn = (path: string) =>
75
+ /<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(
76
+ new TextDecoder().decode(files.get(path)?.bytes),
77
+ )?.[1];
78
+
79
+ const launchctl = (args: readonly string[]): ExecResult => {
80
+ const [sub, first, second] = args;
81
+ const gui = /^gui\/(\d+)(?:\/|$)/.exec(first ?? '')?.[1];
82
+ if (gui !== undefined && state.loggedOut.has(Number(gui))) {
83
+ // The measured shape (launchctl.ts): 112, "Bad request." then the domain line.
84
+ return fail(NO_DOMAIN, `Bad request.\nCould not find domain for user gui: ${gui}`);
85
+ }
86
+ if (sub === 'print' && first !== undefined) {
87
+ const left = lingering.get(first);
88
+ if (left !== undefined) {
89
+ if (left <= 0) {
90
+ lingering.delete(first);
91
+ loaded.delete(first);
92
+ } else lingering.set(first, left - 1);
93
+ }
94
+ if (!loaded.has(first)) return fail(NOT_FOUND, `Could not find service "${first}"`);
95
+ return ok(printBlock(first, loaded.get(first), '(never exited)'));
96
+ }
97
+ if (sub === 'print-disabled' && first !== undefined) {
98
+ const lines = [...disabled]
99
+ .filter((target) => target.startsWith(`${first}/`))
100
+ .map((target) => `\t\t"${target.slice(first.length + 1)}" => disabled`);
101
+ return ok(['\tdisabled services = {', ...lines, '\t}'].join('\n'));
102
+ }
103
+ if (sub === 'bootstrap' && first !== undefined && second !== undefined) {
104
+ if (state.bootstrapFailure !== undefined) return state.bootstrapFailure;
105
+ const label = labelIn(second);
106
+ if (label === undefined) return fail(5, 'Bootstrap failed: 5: Input/output error');
107
+ const target = `${first}/${label}`;
108
+ if (loaded.has(target) || disabled.has(target))
109
+ return fail(5, 'Bootstrap failed: 5: Input/output error');
110
+ state.nextPid += 1;
111
+ loaded.set(target, state.nextPid);
112
+ return ok();
113
+ }
114
+ if (sub === 'bootout' && first !== undefined) {
115
+ if (!loaded.has(first) || lingering.has(first))
116
+ return fail(3, 'Boot-out failed: 3: No such process');
117
+ if (state.bootoutKeepsJob) return state.bootoutFailure ?? fail(5, 'Boot-out failed: 5');
118
+ // ⚠️ Model launchd letting go late: the job stays visible for `lingerPrints` more prints.
119
+ if (state.lingerPrints === 0) loaded.delete(first);
120
+ else lingering.set(first, state.lingerPrints);
121
+ return state.bootoutFailure ?? ok();
122
+ }
123
+ return fail(64, `fake launchctl: unsupported ${args.join(' ')}`);
124
+ };
125
+
126
+ const runner: HostRunner = {
127
+ effectiveUid: () => euid,
128
+ exec: async (argv) => {
129
+ calls.push([...argv]);
130
+ if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
131
+ return fail(127, `fake: no program ${String(argv[0])}`);
132
+ },
133
+ lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
134
+ lookupUser: async (nameOrId) =>
135
+ options.users?.[nameOrId] ??
136
+ Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
137
+ privileged,
138
+ readFile: async (path) => files.get(path)?.bytes,
139
+ removeFile: async (path) => {
140
+ if (files.has(path) && (dirs.get(parent(path)) ?? 0) !== euid && !root)
141
+ throw errno('EACCES', path);
142
+ files.delete(path);
143
+ },
144
+ // ★ No real waiting: a lingering job drains one `print` per poll instead.
145
+ sleep: async () => undefined,
146
+ stat: async (path) => {
147
+ const entry = files.get(path);
148
+ if (entry !== undefined)
149
+ return {
150
+ gid: entry.gid,
151
+ kind: entry.kind,
152
+ mode: entry.mode,
153
+ size: entry.bytes.length,
154
+ uid: entry.uid,
155
+ };
156
+ return dirs.has(path)
157
+ ? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
158
+ : undefined;
159
+ },
160
+ writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
161
+ calls.push(['write', path]);
162
+ const owner = dirs.get(parent(path));
163
+ if (owner === undefined) throw errno('ENOENT', parent(path));
164
+ if (owner !== euid && !root) throw errno('EACCES', path);
165
+ if (write.uid !== undefined && write.uid !== euid && !root) throw errno('EPERM', path);
166
+ files.set(path, {
167
+ bytes,
168
+ gid: write.gid ?? 20,
169
+ kind: 'file',
170
+ mode: write.mode,
171
+ uid: write.uid ?? euid,
172
+ });
173
+ },
174
+ };
175
+ return { calls, disabled, dirs, files, loaded, runner, state };
176
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
3
+ * `news` both providers' diffs share.
4
+ *
5
+ * ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
6
+ * runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
7
+ * ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
8
+ * thrown is wrapped, so the failure channel is always an Error with a readable message.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+
12
+ export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
13
+ Effect.tryPromise({
14
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
15
+ try: step,
16
+ });
17
+
18
+ /**
19
+ * A string prop of a diff's `news`, when that one prop is already resolved; `undefined` while it
20
+ * is still an Output (a function-typed proxy), an Effect or a Config.
21
+ * ★ For the identity props a diff must see even when the rest of `news` is unresolved — a plain
22
+ * string is resolved by definition, so `typeof` is the whole test.
23
+ */
24
+ export const resolvedString = (news: unknown, key: string): string | undefined => {
25
+ if (typeof news !== 'object' || news === null) return undefined;
26
+ const value = (news as Record<string, unknown>)[key];
27
+ return typeof value === 'string' ? value : undefined;
28
+ };