@homeflare/alchemy 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Ownership with no state — decision 2026-09-21: CaddyConfig never adopts silently. The adoption
3
+ * probe (`read` with no output) and the apply of a create the engine never probed must agree on
4
+ * what is claimable: the declared config itself, or a Caddy serving nothing. Anything else is
5
+ * `Unowned` at plan time and refused at apply, unless the deploy runs with `--adopt` — and state
6
+ * lends authority only over the Caddy it was applied to.
7
+ */
8
+ import { afterEach, describe, expect, test } from 'bun:test';
9
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
10
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import { type CaddyAdmin, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
14
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
15
+ import { probeLive, reconcileConfig } from './config-lifecycle.ts';
16
+ import { configDigest } from './digest.ts';
17
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
18
+
19
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
20
+ /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
21
+ const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
22
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
23
+
24
+ let fake: FakeCaddy | undefined;
25
+ afterEach(() => {
26
+ fake?.stop();
27
+ fake = undefined;
28
+ });
29
+
30
+ const setup = (running?: unknown) => {
31
+ const made = fakeDefaultCaddy(running);
32
+ fake = made.caddy;
33
+ return made;
34
+ };
35
+
36
+ /** A Caddy already running the declared config — as one started from caddyWithFile's file is. */
37
+ const runningDeclared = () => {
38
+ const made = setup();
39
+ made.caddy.running = made.caddy.adapt(SITE);
40
+ return made;
41
+ };
42
+
43
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
44
+
45
+ describe('the probe: read with no state', () => {
46
+ test('a Caddy already running the declared config is ours — adopting it changes nothing', async () => {
47
+ const { admin, caddy } = runningDeclared();
48
+ const probe = await probeLive(admin, SITE);
49
+ expect(probe).toMatchObject({ attributes: { configSha256: configDigest(caddy.adapt(SITE)) } });
50
+ expect(probe?.ours).toBe(true);
51
+ expect(loads(caddy)).toHaveLength(0);
52
+ });
53
+
54
+ test('a Caddy running any other config is not ours', async () => {
55
+ const { admin } = setup(FOREIGN);
56
+ const probe = await probeLive(admin, SITE, '/usr/local/etc/Caddyfile');
57
+ expect(probe?.ours).toBe(false);
58
+ expect(probe?.unchecked).toBeUndefined();
59
+ expect(probe?.attributes).toEqual({
60
+ configSha256: configDigest(FOREIGN),
61
+ endpoint: fake?.address ?? '',
62
+ sourceFile: '/usr/local/etc/Caddyfile',
63
+ });
64
+ });
65
+
66
+ test.each([
67
+ ['no config at all', null],
68
+ ['only an admin block', { admin: { listen: 'localhost:2019' } }],
69
+ ])('a Caddy serving nothing (%s) reads as absent: a create', async (_, running) => {
70
+ const { admin } = setup(running);
71
+ expect(await probeLive(admin, SITE)).toBeUndefined();
72
+ });
73
+
74
+ test.each([
75
+ ['does not adapt', 'SYNTAX_ERROR', /Error during parsing/],
76
+ ['is not plain text (a stripped Output)', undefined, /not plain text/],
77
+ ])('a declaration that %s is not ours — and never fails the read', async (_, text, reason) => {
78
+ // ⛔ The engine replays this read with an interrupted create's props: a throw here would fail
79
+ // every later plan, the one carrying the fix included.
80
+ const { admin } = setup(FOREIGN);
81
+ const probe = await probeLive(admin, text);
82
+ expect(probe?.ours).toBe(false);
83
+ expect(probe?.unchecked).toMatch(reason);
84
+ });
85
+
86
+ test('Caddy vanishing mid-probe stays "no Caddy", never "not ours"', async () => {
87
+ const { admin } = setup(FOREIGN);
88
+ const flaky: CaddyAdmin = {
89
+ ...admin,
90
+ request: (call) =>
91
+ call.path === '/adapt'
92
+ ? Promise.reject(new CaddyUnreachableError('gone'))
93
+ : admin.request(call),
94
+ };
95
+ await expect(probeLive(flaky, SITE)).rejects.toThrow('gone');
96
+ });
97
+ });
98
+
99
+ /** A create the engine never probed, or state from another endpoint (config.ts decides which). */
100
+ describe('the apply without authority over the running config', () => {
101
+ test('without authority, a config the stack cannot claim is refused before any load', async () => {
102
+ const { admin, caddy } = setup(FOREIGN);
103
+ await expect(reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false })).rejects.toThrow(
104
+ /which this stack did not load.*Deploy with --adopt/,
105
+ );
106
+ expect(loads(caddy)).toHaveLength(0);
107
+ expect(caddy.running).toEqual(FOREIGN);
108
+ });
109
+
110
+ test('the declared config already running is taken as it is — no load', async () => {
111
+ const { admin, caddy } = runningDeclared();
112
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
113
+ expect(applied.loaded).toBe(false);
114
+ expect(loads(caddy)).toHaveLength(0);
115
+ });
116
+
117
+ test('a Caddy serving nothing is loaded — there is nothing to take over', async () => {
118
+ const { admin, caddy } = setup();
119
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
120
+ expect(applied.loaded).toBe(true);
121
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
122
+ });
123
+
124
+ test('the config the state last stored is known — the same Caddy at a new endpoint', async () => {
125
+ const { admin } = setup(FOREIGN);
126
+ const authority = { stored: configDigest(FOREIGN), takeOver: false };
127
+ expect((await reconcileConfig(admin, { caddyfile: SITE }, authority)).loaded).toBe(true);
128
+ });
129
+
130
+ test('with authority, the foreign config is replaced', async () => {
131
+ const { admin, caddy } = setup(FOREIGN);
132
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: true });
133
+ expect(applied.loaded).toBe(true);
134
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
135
+ });
136
+ });
137
+
138
+ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
139
+
140
+ /** The provider over `admin`, run under the services a deploy provides (the CLI's `--adopt`). */
141
+ const withProvider = <A>(
142
+ admin: CaddyAdmin,
143
+ deploy: Deploy,
144
+ use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
145
+ ) => {
146
+ let program = Effect.gen(function* () {
147
+ return yield* use(yield* CaddyConfig.Provider);
148
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)))));
149
+ if (deploy.adopt !== undefined) {
150
+ program = program.pipe(Effect.provideService(AdoptPolicy, deploy.adopt));
151
+ }
152
+ if (deploy.contextAdopt !== undefined) {
153
+ const context = { adopt: deploy.contextAdopt, dev: false, dotAlchemy: '.alchemy' };
154
+ program = program.pipe(Effect.provideService(AlchemyContext, context));
155
+ }
156
+ return Effect.runPromise(program);
157
+ };
158
+
159
+ const read = (admin: CaddyAdmin, caddyfile: unknown, output?: object) =>
160
+ withProvider(admin, {}, (provider) => {
161
+ if (provider.read === undefined) throw new Error('provider has no read handler');
162
+ return provider.read({ ...ids, olds: { caddyfile } as never, output: output as never });
163
+ });
164
+
165
+ /** Apply, then check that exactly the expected loads reached Caddy — or none, and why. */
166
+ const expectApply = async (applying: Promise<unknown>, caddy: FakeCaddy, loaded: boolean) => {
167
+ if (loaded) await applying;
168
+ else await expect(applying).rejects.toThrow(/Deploy with --adopt/);
169
+ expect(loads(caddy)).toHaveLength(loaded ? 1 : 0);
170
+ };
171
+
172
+ const reconcile = (admin: CaddyAdmin, deploy: Deploy, output?: object) =>
173
+ withProvider(admin, deploy, (provider) =>
174
+ provider.reconcile({
175
+ ...ids,
176
+ bindings: [] as never,
177
+ news: { caddyfile: SITE },
178
+ olds: undefined,
179
+ output: output as never,
180
+ session: undefined as never,
181
+ }),
182
+ );
183
+
184
+ describe('CaddyConfigProvider', () => {
185
+ test('read: Unowned with no state, plain with state, plain when it is the declared config', async () => {
186
+ const { admin, caddy } = setup(FOREIGN);
187
+ const probe = await read(admin, SITE);
188
+ expect(Unowned.is(probe)).toBe(true);
189
+ expect(probe).toMatchObject({ configSha256: configDigest(FOREIGN) });
190
+ const owned = await read(admin, SITE, { configSha256: 'stored', endpoint: caddy.address });
191
+ expect(owned).toBeDefined();
192
+ expect(Unowned.is(owned)).toBe(false);
193
+ caddy.running = caddy.adapt(SITE);
194
+ const same = await read(admin, SITE);
195
+ expect(same).toBeDefined();
196
+ expect(Unowned.is(same)).toBe(false);
197
+ });
198
+
199
+ test('read with no state: nothing served is a create; an unreadable declaration is Unowned', async () => {
200
+ expect(await read(setup().admin, SITE)).toBeUndefined();
201
+ fake?.stop();
202
+ const { admin } = setup(FOREIGN);
203
+ expect(Unowned.is(await read(admin, 'SYNTAX_ERROR'))).toBe(true);
204
+ expect(Unowned.is(await read(admin, {}))).toBe(true);
205
+ });
206
+
207
+ test.each([
208
+ ['no adopt setting at all', {}, false],
209
+ ['AdoptPolicy off', { adopt: false }, false],
210
+ ['--adopt (AdoptPolicy)', { adopt: true }, true],
211
+ ['--adopt reaching only AlchemyContext', { contextAdopt: true }, true],
212
+ ['AdoptPolicy off over AlchemyContext on', { adopt: false, contextAdopt: true }, false],
213
+ ] as const)('reconcile with no state, %s', async (_, deploy, loaded) => {
214
+ const { admin, caddy } = setup(FOREIGN);
215
+ await expectApply(reconcile(admin, deploy), caddy, loaded);
216
+ });
217
+
218
+ test('reconcile with state for this Caddy loads over anything, without --adopt', async () => {
219
+ // ★ An update correcting drift (a hand edit since the stored digest), or an adopted create.
220
+ const { admin, caddy } = setup(FOREIGN);
221
+ const output = { configSha256: 'before-a-hand-edit', endpoint: caddy.address };
222
+ await expectApply(reconcile(admin, {}, output), caddy, true);
223
+ });
224
+
225
+ test.each([
226
+ ['a config it never stored, without --adopt', 'stored-elsewhere', {}, false],
227
+ ['a config it never stored, with --adopt', 'stored-elsewhere', { adopt: true }, true],
228
+ ['the config it last stored (the same Caddy, renamed)', configDigest(FOREIGN), {}, true],
229
+ ] as const)('reconcile with state from another endpoint: %s', async (_, sha, deploy, loaded) => {
230
+ // ⛔ State vouches for the Caddy it was applied to — not for whatever the transport now reaches.
231
+ const { admin, caddy } = setup(FOREIGN);
232
+ const output = { configSha256: sha, endpoint: 'unix:///run/elsewhere.sock' };
233
+ await expectApply(reconcile(admin, deploy, output), caddy, loaded);
234
+ });
235
+ });
@@ -4,10 +4,9 @@
4
4
  */
5
5
  import { afterEach, describe, expect, test } from 'bun:test';
6
6
  import type { CaddyAdmin } from './admin.ts';
7
- import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
7
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
8
8
  import { configDigest } from './digest.ts';
9
9
  import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
10
- import { localCaddyAdmin } from './local-admin.ts';
11
10
 
12
11
  const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
13
12
  const OTHER = 'www.example.com respond <hello> & bye';
@@ -18,27 +17,22 @@ afterEach(() => {
18
17
  fake = undefined;
19
18
  });
20
19
 
21
- /**
22
- * ★ Addressed as a Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), so a Caddyfile with
23
- * no `admin` line — the common case — passes the guard. A Caddy reached on any other port needs
24
- * `admin <address>` declared, or the first load would move it (admin-guard.ts).
25
- */
20
+ /** A Caddy on its default :2019 (fake-caddy.ts fakeDefaultCaddy), stopped after each test. */
26
21
  const setup = (running?: unknown) => {
27
- fake = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
28
- const admin = localCaddyAdmin({
29
- address: fake.address,
30
- hostHeader: '127.0.0.1:2019',
31
- retries: 0,
32
- });
33
- return { admin, caddy: fake };
22
+ const made = fakeDefaultCaddy(running);
23
+ fake = made.caddy;
24
+ return made;
34
25
  };
35
26
 
27
+ /** ★ These are the resource's own applies (it has state); config-adopt.test.ts covers a create. */
28
+ const OWNED = { takeOver: true } as const;
29
+
36
30
  const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
37
31
 
38
32
  describe('apply', () => {
39
33
  test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
40
34
  const { admin, caddy } = setup();
41
- const applied = await reconcileConfig(admin, { caddyfile: OTHER });
35
+ const applied = await reconcileConfig(admin, { caddyfile: OTHER }, OWNED);
42
36
  expect(applied.loaded).toBe(true);
43
37
  // ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
44
38
  expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
@@ -50,8 +44,8 @@ describe('apply', () => {
50
44
 
51
45
  test('a Caddy already running this config is not reloaded', async () => {
52
46
  const { admin, caddy } = setup();
53
- await reconcileConfig(admin, { caddyfile: SITE });
54
- const again = await reconcileConfig(admin, { caddyfile: SITE });
47
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
48
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
55
49
  expect(again.loaded).toBe(false);
56
50
  expect(loads(caddy)).toHaveLength(1);
57
51
  });
@@ -59,7 +53,7 @@ describe('apply', () => {
59
53
  test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
60
54
  const { admin, caddy } = setup();
61
55
  const path = '/usr/local/etc/Caddyfile';
62
- const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path });
56
+ const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path }, OWNED);
63
57
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
64
58
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
65
59
  expect(applied.attributes.sourceFile).toBe(path);
@@ -67,7 +61,7 @@ describe('apply', () => {
67
61
 
68
62
  test('adapter warnings are returned, not refused', async () => {
69
63
  const { admin } = setup();
70
- const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` });
64
+ const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` }, OWNED);
71
65
  expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
72
66
  });
73
67
  });
@@ -75,33 +69,33 @@ describe('apply', () => {
75
69
  describe('a config Caddy refuses', () => {
76
70
  test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
77
71
  const { admin, caddy } = setup();
78
- await reconcileConfig(admin, { caddyfile: SITE });
72
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
79
73
  const before = caddy.running;
80
- await expect(reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` })).rejects.toThrow(
81
- /address already in use.*kept the previous config/,
82
- );
74
+ await expect(
75
+ reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED),
76
+ ).rejects.toThrow(/address already in use.*kept the previous config/);
83
77
  expect(caddy.running).toEqual(before);
84
78
  });
85
79
 
86
80
  test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
87
81
  const { admin, caddy } = setup();
88
- await reconcileConfig(admin, { caddyfile: SITE });
82
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
89
83
  await expect(
90
- reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }),
84
+ reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED),
91
85
  ).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
92
86
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
93
87
  });
94
88
 
95
89
  test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
96
90
  const { admin, caddy } = setup();
97
- await reconcileConfig(admin, { caddyfile: SITE });
91
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
98
92
  // ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
99
93
  const swallowing: CaddyAdmin = {
100
94
  ...admin,
101
95
  request: (call) =>
102
96
  call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
103
97
  };
104
- await expect(reconcileConfig(swallowing, { caddyfile: OTHER })).rejects.toThrow(
98
+ await expect(reconcileConfig(swallowing, { caddyfile: OTHER }, OWNED)).rejects.toThrow(
105
99
  /after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
106
100
  );
107
101
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
@@ -109,7 +103,7 @@ describe('a config Caddy refuses', () => {
109
103
 
110
104
  test('a Caddyfile that does not adapt is refused before any load', async () => {
111
105
  const { admin, caddy } = setup();
112
- await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' })).rejects.toThrow(
106
+ await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' }, OWNED)).rejects.toThrow(
113
107
  /\/adapt → 400: Caddyfile:1 - Error during parsing/,
114
108
  );
115
109
  expect(loads(caddy)).toHaveLength(0);
@@ -122,7 +116,9 @@ describe('refused before anything is sent', () => {
122
116
  ['an empty Caddyfile', ' \n'],
123
117
  ])('%s', async (_, caddyfile) => {
124
118
  const { admin, caddy } = setup();
125
- await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/Caddy\.Config at http/);
119
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(
120
+ /Caddy\.Config at http/,
121
+ );
126
122
  expect(caddy.seen).toHaveLength(0);
127
123
  });
128
124
 
@@ -131,15 +127,15 @@ describe('refused before anything is sent', () => {
131
127
  ['only global options', 'admin localhost:2019'],
132
128
  ])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
133
129
  const { admin, caddy } = setup();
134
- await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/adapts to no apps/);
130
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(/adapts to no apps/);
135
131
  expect(loads(caddy)).toHaveLength(0);
136
132
  });
137
133
 
138
134
  test('a Caddyfile that would turn the admin API off is never loaded', async () => {
139
135
  const { admin, caddy } = setup();
140
- await expect(reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` })).rejects.toThrow(
141
- /admin off/,
142
- );
136
+ await expect(
137
+ reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` }, OWNED),
138
+ ).rejects.toThrow(/admin off/);
143
139
  expect(loads(caddy)).toHaveLength(0);
144
140
  });
145
141
  });
@@ -147,7 +143,7 @@ describe('refused before anything is sent', () => {
147
143
  describe('drift', () => {
148
144
  const applied = async () => {
149
145
  const { admin, caddy } = setup();
150
- const { attributes } = await reconcileConfig(admin, { caddyfile: SITE });
146
+ const { attributes } = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
151
147
  return { admin, attributes, caddy };
152
148
  };
153
149
 
@@ -165,7 +161,7 @@ describe('drift', () => {
165
161
  const { admin, attributes, caddy } = await applied();
166
162
  caddy.running = { apps: { http: { servers: {} } } };
167
163
  expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
168
- const again = await reconcileConfig(admin, { caddyfile: SITE });
164
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
169
165
  expect(again.attributes.configSha256).toBe(attributes.configSha256);
170
166
  });
171
167
 
@@ -198,21 +194,3 @@ describe('drift', () => {
198
194
  expect(diff).toEqual({ action: 'update' });
199
195
  });
200
196
  });
201
-
202
- describe('adopt', () => {
203
- test('a running Caddy reads as its live config; the first apply replaces it', async () => {
204
- const { admin, caddy } = setup({
205
- apps: { http: { servers: { legacy: { listen: [':80'] } } } },
206
- });
207
- const adopted = await readLive(admin);
208
- expect(adopted.configSha256).toBe(configDigest(caddy.running));
209
- expect(await diffConfig(admin, { caddyfile: SITE }, adopted)).toEqual({ action: 'update' });
210
- await reconcileConfig(admin, { caddyfile: SITE });
211
- expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
212
- });
213
-
214
- test('an empty Caddy (config null) is adoptable too', async () => {
215
- const { admin } = setup();
216
- expect((await readLive(admin)).configSha256).toBe(configDigest(null));
217
- });
218
- });
@@ -9,9 +9,11 @@
9
9
  * · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
10
10
  * (or an `autosave.json` holding someone else's change, under `--resume`).
11
11
  * · declared = live — nothing to load, whatever the state says.
12
+ * With NO state there is no stored digest, and the question is whose config is running: see
13
+ * `claimable` below, which both the adoption probe and the apply ask.
12
14
  */
13
15
  import type { Diff } from 'alchemy/Diff';
14
- import type { CaddyAdmin } from './admin.ts';
16
+ import { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';
15
17
  import {
16
18
  CaddyAdminError,
17
19
  adaptCaddyfile,
@@ -66,16 +68,75 @@ export const desiredConfig = async (
66
68
  return { digest: configDigest(adapted.config), warnings: adapted.warnings };
67
69
  };
68
70
 
69
- /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
70
- export const readLive = async (
71
+ const attributesOf = (
71
72
  admin: CaddyAdmin,
73
+ running: unknown,
72
74
  sourceFile?: string,
73
- ): Promise<CaddyConfigAttributes> => ({
74
- configSha256: configDigest(await readRunningConfig(admin)),
75
+ ): CaddyConfigAttributes => ({
76
+ configSha256: configDigest(running),
75
77
  endpoint: admin.endpoint,
76
78
  ...(sourceFile === undefined ? {} : { sourceFile }),
77
79
  });
78
80
 
81
+ /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
82
+ export const readLive = async (
83
+ admin: CaddyAdmin,
84
+ sourceFile?: string,
85
+ ): Promise<CaddyConfigAttributes> =>
86
+ attributesOf(admin, await readRunningConfig(admin), sourceFile);
87
+
88
+ /**
89
+ * ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
90
+ * 2026-09-21: CaddyConfig must not adopt silently — the house rule HostFile and LaunchdJob keep).
91
+ * Anything else was put there by a person, another tool or another stack, and loading over it
92
+ * replaces every site it serves. ★ A config that serves nothing (`null`, or no apps) is claimable
93
+ * too: there is nothing to take over, as an empty R2 lock rule set reads as no lock.
94
+ */
95
+ export const claimable = (running: unknown, declaredDigest: string): boolean =>
96
+ servesNothing(running) || configDigest(running) === declaredDigest;
97
+
98
+ export type Probe = {
99
+ readonly attributes: CaddyConfigAttributes;
100
+ /** The live config IS the declared one: adopting it changes nothing. */
101
+ readonly ours: boolean;
102
+ /** Why the declaration could not be compared, when it could not — the Caddy is then not ours. */
103
+ readonly unchecked?: string;
104
+ };
105
+
106
+ /**
107
+ * The adoption probe — `read` with no state: `undefined` when Caddy serves nothing (plan a create),
108
+ * else the live attributes and whether they are provably ours (the provider brands the rest
109
+ * `Unowned`, and the plan refuses them without `--adopt`).
110
+ * ⛔ IT NEVER THROWS OVER THE DECLARATION. The engine also runs this read to recover an interrupted
111
+ * create, with THAT deploy's props (Plan.ts, `status: "creating"`) — so a Caddyfile that failed to
112
+ * adapt then would fail every later plan, the one carrying the fix included. What cannot be
113
+ * compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
114
+ */
115
+ export const probeLive = async (
116
+ admin: CaddyAdmin,
117
+ caddyfile: string | undefined,
118
+ sourceFile?: string,
119
+ ): Promise<Probe | undefined> => {
120
+ const running = await readRunningConfig(admin);
121
+ if (servesNothing(running)) return undefined;
122
+ const attributes = attributesOf(admin, running, sourceFile);
123
+ if (caddyfile === undefined) {
124
+ return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
125
+ }
126
+ try {
127
+ const want = await desiredConfig(admin, { caddyfile });
128
+ return { attributes, ours: claimable(running, want.digest) };
129
+ } catch (cause) {
130
+ // ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours".
131
+ if (cause instanceof CaddyUnreachableError) throw cause;
132
+ return {
133
+ attributes,
134
+ ours: false,
135
+ unchecked: cause instanceof Error ? cause.message : String(cause),
136
+ };
137
+ }
138
+ };
139
+
79
140
  /**
80
141
  * ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
81
142
  * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
@@ -94,6 +155,7 @@ export const diffConfig = async (
94
155
  news.sourceFile === output.sourceFile &&
95
156
  // ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
96
157
  // old one keeps what it has — the same as a delete, which never unloads (see config.ts).
158
+ // Planned as an update, but the state does not vouch for that Caddy: see Authority.
97
159
  admin.endpoint === output.endpoint;
98
160
  return converged ? { action: 'noop' } : { action: 'update' };
99
161
  };
@@ -106,13 +168,42 @@ export type Applied = {
106
168
  readonly loaded: boolean;
107
169
  };
108
170
 
109
- /** Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches. */
171
+ /**
172
+ * What this apply may load over — config.ts decides from the state and the adopt setting:
173
+ * · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
174
+ * correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
175
+ * resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
176
+ * · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
177
+ * Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
178
+ */
179
+ export type Authority = { readonly takeOver: boolean; readonly stored?: string };
180
+
181
+ /**
182
+ * Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
183
+ * ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
184
+ * probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
185
+ * caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
186
+ * plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
187
+ * endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
188
+ */
110
189
  export const reconcileConfig = async (
111
190
  admin: CaddyAdmin,
112
191
  props: CaddyConfigProps,
192
+ authority: Authority,
113
193
  ): Promise<Applied> => {
114
194
  const want = await desiredConfig(admin, props);
115
- const before = configDigest(await readRunningConfig(admin));
195
+ const running = await readRunningConfig(admin);
196
+ const before = configDigest(running);
197
+ const known = before === authority.stored || claimable(running, want.digest);
198
+ if (!authority.takeOver && !known) {
199
+ throw refuse(
200
+ admin,
201
+ `Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +
202
+ `to ${short(want.digest)} — loading it would replace every site that config serves. ` +
203
+ 'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +
204
+ 'point caddyProviders() at the Caddy you meant.',
205
+ );
206
+ }
116
207
  let warnings = want.warnings;
117
208
  const loaded = before !== want.digest;
118
209
  if (loaded) {