@homeflare/alchemy 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +2 -0
  2. package/dist/caddy/admin-address.d.ts +27 -0
  3. package/dist/caddy/admin-address.d.ts.map +1 -0
  4. package/dist/caddy/admin-calls.d.ts +25 -28
  5. package/dist/caddy/admin-calls.d.ts.map +1 -1
  6. package/dist/caddy/admin.d.ts +28 -38
  7. package/dist/caddy/admin.d.ts.map +1 -1
  8. package/dist/caddy/caddy-http-client.d.ts +25 -0
  9. package/dist/caddy/caddy-http-client.d.ts.map +1 -0
  10. package/dist/caddy/config-errors.d.ts +24 -0
  11. package/dist/caddy/config-errors.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +20 -49
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  14. package/dist/caddy/config-reconcile.d.ts +45 -0
  15. package/dist/caddy/config-reconcile.d.ts.map +1 -0
  16. package/dist/caddy/config.d.ts +4 -3
  17. package/dist/caddy/config.d.ts.map +1 -1
  18. package/dist/caddy/fake-caddy.d.ts +10 -2
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  20. package/dist/caddy/index.d.ts +3 -3
  21. package/dist/caddy/index.d.ts.map +1 -1
  22. package/dist/caddy/index.js +487 -444
  23. package/dist/caddy/index.js.map +16 -12
  24. package/dist/caddy/local-admin.d.ts +20 -34
  25. package/dist/caddy/local-admin.d.ts.map +1 -1
  26. package/dist/caddy/providers.d.ts +19 -3
  27. package/dist/caddy/providers.d.ts.map +1 -1
  28. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  29. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  30. package/dist/forgejo/branch-protection.d.ts +4 -4
  31. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  32. package/dist/forgejo/org-label.d.ts +4 -4
  33. package/dist/forgejo/org-team.d.ts +4 -4
  34. package/dist/forgejo/repo-webhook.d.ts +4 -4
  35. package/dist/forgejo/repository.d.ts +4 -4
  36. package/dist/forgejo/team-member.d.ts +4 -4
  37. package/dist/github/declare-repo-baseline.d.ts +1 -1
  38. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  39. package/dist/github/index.d.ts +8 -4
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +83 -24
  42. package/dist/github/index.js.map +14 -12
  43. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  44. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  45. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  46. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  47. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  48. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  49. package/dist/github/repo-baseline-data.d.ts +10 -1
  50. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  51. package/dist/github/repo-policy.d.ts +1 -1
  52. package/dist/github/repo-policy.d.ts.map +1 -1
  53. package/dist/github/repository-ruleset-errors.d.ts +3 -2
  54. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  55. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-guards.d.ts +3 -2
  57. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  59. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  60. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  61. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  62. package/dist/github/repository-ruleset-props.d.ts +7 -4
  63. package/dist/github/repository-ruleset-props.d.ts.map +1 -1
  64. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  65. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  66. package/dist/github/repository-ruleset.d.ts +26 -4
  67. package/dist/github/repository-ruleset.d.ts.map +1 -1
  68. package/dist/litellm/fake-litellm.d.ts +27 -16
  69. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  70. package/dist/litellm/fake-stack.d.ts +9 -4
  71. package/dist/litellm/fake-stack.d.ts.map +1 -1
  72. package/dist/litellm/index.d.ts +7 -5
  73. package/dist/litellm/index.d.ts.map +1 -1
  74. package/dist/litellm/index.js +53 -123
  75. package/dist/litellm/index.js.map +8 -8
  76. package/dist/litellm/operations.d.ts +60 -0
  77. package/dist/litellm/operations.d.ts.map +1 -0
  78. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  79. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  80. package/dist/litellm/pass-through-errors.d.ts +37 -0
  81. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  82. package/dist/litellm/pass-through-form.d.ts +11 -4
  83. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  84. package/dist/litellm/providers.d.ts +7 -5
  85. package/dist/litellm/providers.d.ts.map +1 -1
  86. package/dist/netbox/prefix.d.ts +4 -4
  87. package/dist/paperless/custom-field.d.ts +21 -21
  88. package/dist/paperless/custom-field.d.ts.map +1 -1
  89. package/dist/paperless/document-type.d.ts +34 -4
  90. package/dist/paperless/document-type.d.ts.map +1 -1
  91. package/dist/paperless/fake-paperless.d.ts +36 -19
  92. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  93. package/dist/paperless/index.d.ts +7 -2
  94. package/dist/paperless/index.d.ts.map +1 -1
  95. package/dist/paperless/index.js +164 -228
  96. package/dist/paperless/index.js.map +10 -13
  97. package/dist/paperless/matching-locate.d.ts +42 -20
  98. package/dist/paperless/matching-locate.d.ts.map +1 -1
  99. package/dist/paperless/matching-types.d.ts +47 -18
  100. package/dist/paperless/matching-types.d.ts.map +1 -1
  101. package/dist/paperless/matching.d.ts +30 -27
  102. package/dist/paperless/matching.d.ts.map +1 -1
  103. package/dist/paperless/providers.d.ts +13 -7
  104. package/dist/paperless/providers.d.ts.map +1 -1
  105. package/dist/paperless/storage-path.d.ts +34 -4
  106. package/dist/paperless/storage-path.d.ts.map +1 -1
  107. package/dist/paperless/tag.d.ts +43 -8
  108. package/dist/paperless/tag.d.ts.map +1 -1
  109. package/dist/proxmox/acl.d.ts +30 -23
  110. package/dist/proxmox/acl.d.ts.map +1 -1
  111. package/dist/proxmox/distilled-pve.d.ts +89 -0
  112. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  113. package/dist/proxmox/index.js +663 -609
  114. package/dist/proxmox/index.js.map +12 -11
  115. package/dist/proxmox/members.d.ts +29 -10
  116. package/dist/proxmox/members.d.ts.map +1 -1
  117. package/docs/caddy-admin.md +1 -1
  118. package/docs/caddy.md +3 -2
  119. package/docs/credentials.md +16 -0
  120. package/docs/litellm.md +21 -13
  121. package/docs/paperless.md +12 -0
  122. package/docs/peers.md +5 -0
  123. package/docs/repo-policy.md +16 -1
  124. package/docs/repository-ruleset.md +53 -13
  125. package/docs/upstream-conformance.md +126 -10
  126. package/package.json +5 -2
  127. package/src/caddy/admin-address.ts +71 -0
  128. package/src/caddy/admin-calls.ts +48 -100
  129. package/src/caddy/admin.ts +37 -41
  130. package/src/caddy/adopt-scope.test.ts +1 -1
  131. package/src/caddy/caddy-http-client.ts +229 -0
  132. package/src/caddy/config-adopt-provider.test.ts +139 -0
  133. package/src/caddy/config-adopt.test.ts +60 -126
  134. package/src/caddy/config-errors.ts +47 -0
  135. package/src/caddy/config-lifecycle.test.ts +55 -42
  136. package/src/caddy/config-lifecycle.ts +72 -162
  137. package/src/caddy/config-reconcile.ts +138 -0
  138. package/src/caddy/config.ts +36 -42
  139. package/src/caddy/fake-caddy.ts +20 -2
  140. package/src/caddy/index.ts +3 -8
  141. package/src/caddy/local-admin.test.ts +35 -21
  142. package/src/caddy/local-admin.ts +60 -167
  143. package/src/caddy/providers.test.ts +14 -6
  144. package/src/caddy/providers.ts +20 -4
  145. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  146. package/src/github/declare-repo-baseline.ts +40 -7
  147. package/src/github/index.ts +8 -4
  148. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  149. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  150. package/src/github/repo-auto-merge-gate.ts +57 -0
  151. package/src/github/repo-baseline-data.test.ts +13 -0
  152. package/src/github/repo-baseline-data.ts +11 -2
  153. package/src/github/repo-policy-apply-order.test.ts +72 -0
  154. package/src/github/repo-policy-stack.test.ts +59 -0
  155. package/src/github/repo-policy.ts +31 -3
  156. package/src/github/repository-ruleset-errors.ts +6 -4
  157. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  158. package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
  159. package/src/github/repository-ruleset-form.ts +7 -3
  160. package/src/github/repository-ruleset-guards.ts +3 -2
  161. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  162. package/src/github/repository-ruleset-octokit.ts +121 -30
  163. package/src/github/repository-ruleset-probe.ts +2 -1
  164. package/src/github/repository-ruleset-props.ts +7 -4
  165. package/src/github/repository-ruleset-providers.ts +46 -0
  166. package/src/github/repository-ruleset-wire.test.ts +38 -27
  167. package/src/github/repository-ruleset.ts +27 -3
  168. package/src/litellm/fake-litellm.ts +83 -78
  169. package/src/litellm/fake-stack.ts +13 -5
  170. package/src/litellm/index.ts +7 -21
  171. package/src/litellm/operations.ts +149 -0
  172. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  173. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  174. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  175. package/src/litellm/pass-through-endpoint.ts +21 -31
  176. package/src/litellm/pass-through-errors.ts +37 -0
  177. package/src/litellm/pass-through-form.ts +20 -10
  178. package/src/litellm/providers.ts +8 -7
  179. package/src/paperless/custom-field.test.ts +49 -40
  180. package/src/paperless/custom-field.ts +57 -33
  181. package/src/paperless/document-type.test.ts +48 -0
  182. package/src/paperless/document-type.ts +51 -22
  183. package/src/paperless/fake-paperless.ts +63 -72
  184. package/src/paperless/index.ts +7 -13
  185. package/src/paperless/matching-locate.test.ts +102 -0
  186. package/src/paperless/matching-locate.ts +80 -86
  187. package/src/paperless/matching-types.ts +49 -16
  188. package/src/paperless/matching.ts +77 -90
  189. package/src/paperless/providers.ts +14 -10
  190. package/src/paperless/storage-path.test.ts +56 -0
  191. package/src/paperless/storage-path.ts +53 -24
  192. package/src/paperless/tag-identity.test.ts +127 -0
  193. package/src/paperless/tag.test.ts +100 -0
  194. package/src/paperless/tag.ts +62 -32
  195. package/src/proxmox/acl.test.ts +145 -0
  196. package/src/proxmox/acl.ts +128 -150
  197. package/src/proxmox/distilled-pve.test.ts +231 -0
  198. package/src/proxmox/distilled-pve.ts +147 -0
  199. package/src/proxmox/members-timeout.test.ts +161 -0
  200. package/src/proxmox/members.test.ts +9 -0
  201. package/src/proxmox/members.ts +36 -2
  202. package/dist/litellm/client.d.ts +0 -76
  203. package/dist/litellm/client.d.ts.map +0 -1
  204. package/dist/litellm/credentials.d.ts +0 -48
  205. package/dist/litellm/credentials.d.ts.map +0 -1
  206. package/dist/litellm/generated/pass-through.d.ts +0 -71
  207. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  208. package/dist/paperless/client.d.ts +0 -37
  209. package/dist/paperless/client.d.ts.map +0 -1
  210. package/dist/paperless/credentials.d.ts +0 -40
  211. package/dist/paperless/credentials.d.ts.map +0 -1
  212. package/dist/paperless/errors.d.ts +0 -49
  213. package/dist/paperless/errors.d.ts.map +0 -1
  214. package/src/litellm/client.ts +0 -195
  215. package/src/litellm/credentials.ts +0 -73
  216. package/src/litellm/generated/pass-through.ts +0 -73
  217. package/src/paperless/client.ts +0 -88
  218. package/src/paperless/credentials.ts +0 -69
  219. package/src/paperless/errors.ts +0 -62
  220. package/src/paperless/matching-identity.test.ts +0 -194
  221. package/src/paperless/matching.test.ts +0 -128
@@ -1,12 +1,13 @@
1
1
  /**
2
- * CaddyConfig's lifecycle against the fake admin API (fake-caddy.ts): apply, a refused config
3
- * rolled back, drift, and the refusals that must happen before anything is sent.
2
+ * CaddyConfig's read / diff / reconcile against the fake admin API (fake-caddy.ts) — a real HTTP
3
+ * server, so this drives the real distilled wire protocol: apply, a refused config rolled back,
4
+ * drift, and the refusals that must happen before anything is sent.
4
5
  */
5
6
  import { afterEach, describe, expect, test } from 'bun:test';
6
- import type { CaddyAdmin } from './admin.ts';
7
- import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
7
+ import { type FakeCaddy, fakeDefaultCaddy, runCaddy } from './fake-caddy.ts';
8
8
  import { configDigest } from './digest.ts';
9
- import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
9
+ import { readLive } from './config-lifecycle.ts';
10
+ import { diffConfig, reconcileConfig } from './config-reconcile.ts';
10
11
 
11
12
  const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
12
13
  const OTHER = 'www.example.com respond <hello> & bye';
@@ -32,7 +33,7 @@ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/
32
33
  describe('apply', () => {
33
34
  test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
34
35
  const { admin, caddy } = setup();
35
- const applied = await reconcileConfig(admin, { caddyfile: OTHER }, OWNED);
36
+ const applied = await runCaddy(reconcileConfig({ caddyfile: OTHER }, OWNED), admin);
36
37
  expect(applied.loaded).toBe(true);
37
38
  // ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
38
39
  expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
@@ -44,8 +45,8 @@ describe('apply', () => {
44
45
 
45
46
  test('a Caddy already running this config is not reloaded', async () => {
46
47
  const { admin, caddy } = setup();
47
- await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
48
- const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
48
+ await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
49
+ const again = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
49
50
  expect(again.loaded).toBe(false);
50
51
  expect(loads(caddy)).toHaveLength(1);
51
52
  });
@@ -53,7 +54,10 @@ describe('apply', () => {
53
54
  test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
54
55
  const { admin, caddy } = setup();
55
56
  const path = '/usr/local/etc/Caddyfile';
56
- const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path }, OWNED);
57
+ const applied = await runCaddy(
58
+ reconcileConfig({ caddyfile: SITE, sourceFile: path }, OWNED),
59
+ admin,
60
+ );
57
61
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
58
62
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
59
63
  expect(applied.attributes.sourceFile).toBe(path);
@@ -61,7 +65,7 @@ describe('apply', () => {
61
65
 
62
66
  test('adapter warnings are returned, not refused', async () => {
63
67
  const { admin } = setup();
64
- const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` }, OWNED);
68
+ const applied = await runCaddy(reconcileConfig({ caddyfile: `${SITE}\t` }, OWNED), admin);
65
69
  expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
66
70
  });
67
71
  });
@@ -69,43 +73,39 @@ describe('apply', () => {
69
73
  describe('a config Caddy refuses', () => {
70
74
  test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
71
75
  const { admin, caddy } = setup();
72
- await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
76
+ await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
73
77
  const before = caddy.running;
74
78
  await expect(
75
- reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED),
79
+ runCaddy(reconcileConfig({ caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED), admin),
76
80
  ).rejects.toThrow(/address already in use.*kept the previous config/);
77
81
  expect(caddy.running).toEqual(before);
78
82
  });
79
83
 
80
84
  test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
81
85
  const { admin, caddy } = setup();
82
- await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
86
+ await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
83
87
  await expect(
84
- reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED),
85
- ).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
88
+ runCaddy(reconcileConfig({ caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED), admin),
89
+ ).rejects.toThrow(/address already in use.*LoadRefused.*200.*kept the previous/);
86
90
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
87
91
  });
88
92
 
89
93
  test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
90
94
  const { admin, caddy } = setup();
91
- await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
95
+ await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
92
96
  // ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
93
- const swallowing: CaddyAdmin = {
94
- ...admin,
95
- request: (call) =>
96
- call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
97
- };
98
- await expect(reconcileConfig(swallowing, { caddyfile: OTHER }, OWNED)).rejects.toThrow(
99
- /after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
100
- );
97
+ // fake-caddy.ts's SWALLOW_LOAD keyword answers `/load` 200 without applying anything.
98
+ await expect(
99
+ runCaddy(reconcileConfig({ caddyfile: `${OTHER}\nSWALLOW_LOAD` }, OWNED), admin),
100
+ ).rejects.toThrow(/after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/);
101
101
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
102
102
  });
103
103
 
104
104
  test('a Caddyfile that does not adapt is refused before any load', async () => {
105
105
  const { admin, caddy } = setup();
106
- await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' }, OWNED)).rejects.toThrow(
107
- /\/adapt → 400: Caddyfile:1 - Error during parsing/,
108
- );
106
+ await expect(
107
+ runCaddy(reconcileConfig({ caddyfile: 'SYNTAX_ERROR' }, OWNED), admin),
108
+ ).rejects.toThrow(/POST \/adapt failed.*Caddyfile:1 - Error during parsing/);
109
109
  expect(loads(caddy)).toHaveLength(0);
110
110
  });
111
111
  });
@@ -116,7 +116,7 @@ describe('refused before anything is sent', () => {
116
116
  ['an empty Caddyfile', ' \n'],
117
117
  ])('%s', async (_, caddyfile) => {
118
118
  const { admin, caddy } = setup();
119
- await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(
119
+ await expect(runCaddy(reconcileConfig({ caddyfile }, OWNED), admin)).rejects.toThrow(
120
120
  /Caddy\.Config at http/,
121
121
  );
122
122
  expect(caddy.seen).toHaveLength(0);
@@ -127,14 +127,16 @@ describe('refused before anything is sent', () => {
127
127
  ['only global options', 'admin localhost:2019'],
128
128
  ])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
129
129
  const { admin, caddy } = setup();
130
- await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(/adapts to no apps/);
130
+ await expect(runCaddy(reconcileConfig({ caddyfile }, OWNED), admin)).rejects.toThrow(
131
+ /adapts to no apps/,
132
+ );
131
133
  expect(loads(caddy)).toHaveLength(0);
132
134
  });
133
135
 
134
136
  test('a Caddyfile that would turn the admin API off is never loaded', async () => {
135
137
  const { admin, caddy } = setup();
136
138
  await expect(
137
- reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` }, OWNED),
139
+ runCaddy(reconcileConfig({ caddyfile: `admin off\n${SITE}` }, OWNED), admin),
138
140
  ).rejects.toThrow(/admin off/);
139
141
  expect(loads(caddy)).toHaveLength(0);
140
142
  });
@@ -143,53 +145,64 @@ describe('refused before anything is sent', () => {
143
145
  describe('drift', () => {
144
146
  const applied = async () => {
145
147
  const { admin, caddy } = setup();
146
- const { attributes } = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
148
+ const { attributes } = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
147
149
  return { admin, attributes, caddy };
148
150
  };
149
151
 
150
152
  test('converged: declared, live and stored agree → noop', async () => {
151
153
  const { admin, attributes } = await applied();
152
- expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'noop' });
154
+ expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
155
+ action: 'noop',
156
+ });
153
157
  });
154
158
 
155
159
  test('a changed declaration → update', async () => {
156
160
  const { admin, attributes } = await applied();
157
- expect(await diffConfig(admin, { caddyfile: OTHER }, attributes)).toEqual({ action: 'update' });
161
+ expect(await runCaddy(diffConfig({ caddyfile: OTHER }, attributes), admin)).toEqual({
162
+ action: 'update',
163
+ });
158
164
  });
159
165
 
160
166
  test('a hand edit through the API → update, and the apply puts the declaration back', async () => {
161
167
  const { admin, attributes, caddy } = await applied();
162
168
  caddy.running = { apps: { http: { servers: {} } } };
163
- expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
164
- const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
169
+ expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
170
+ action: 'update',
171
+ });
172
+ const again = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
165
173
  expect(again.attributes.configSha256).toBe(attributes.configSha256);
166
174
  });
167
175
 
168
176
  test('a restart with a different file → update', async () => {
169
177
  const { admin, attributes, caddy } = await applied();
170
178
  caddy.running = JSON.parse(JSON.stringify(caddy.adapt(OTHER))) as unknown;
171
- expect((await readLive(admin)).configSha256).not.toBe(attributes.configSha256);
172
- expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
179
+ expect((await runCaddy(readLive(), admin)).configSha256).not.toBe(attributes.configSha256);
180
+ expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
181
+ action: 'update',
182
+ });
173
183
  });
174
184
 
175
185
  test('state that lags the live config → update, so the stored digest is refreshed', async () => {
176
186
  const { admin, attributes } = await applied();
177
187
  const stale = { ...attributes, configSha256: configDigest({ apps: { before: true } }) };
178
- expect(await diffConfig(admin, { caddyfile: SITE }, stale)).toEqual({ action: 'update' });
188
+ expect(await runCaddy(diffConfig({ caddyfile: SITE }, stale), admin)).toEqual({
189
+ action: 'update',
190
+ });
179
191
  });
180
192
 
181
193
  test('the transport now reaches Caddy at another endpoint → update (load there)', async () => {
182
194
  const { admin, attributes } = await applied();
183
195
  const moved = { ...attributes, endpoint: 'unix:///run/caddy/admin.sock' };
184
- expect(await diffConfig(admin, { caddyfile: SITE }, moved)).toEqual({ action: 'update' });
196
+ expect(await runCaddy(diffConfig({ caddyfile: SITE }, moved), admin)).toEqual({
197
+ action: 'update',
198
+ });
185
199
  });
186
200
 
187
201
  test('a new sourceFile → update, so state records the file this config mirrors', async () => {
188
202
  const { admin, attributes } = await applied();
189
- const diff = await diffConfig(
203
+ const diff = await runCaddy(
204
+ diffConfig({ caddyfile: SITE, sourceFile: '/etc/Caddyfile' }, attributes),
190
205
  admin,
191
- { caddyfile: SITE, sourceFile: '/etc/Caddyfile' },
192
- attributes,
193
206
  );
194
207
  expect(diff).toEqual({ action: 'update' });
195
208
  });
@@ -1,26 +1,21 @@
1
1
  /**
2
- * CaddyConfig's read / diff / reconcile as plain async functions over a CaddyAdmin, so the
3
- * lifecycle runs against a fake admin server in tests.
2
+ * CaddyConfig's validate / read / probe as Effects over `@distilled.cloud/caddy`'s typed `admin`
3
+ * operations (admin-calls.ts), so the lifecycle runs against a fake admin server in tests — and,
4
+ * since fake-caddy.ts is a real HTTP server, against the real distilled wire protocol too.
5
+ * config-reconcile.ts covers `diff`/`reconcile`, the other half of the same lifecycle.
4
6
  *
5
7
  * The comparison is always between DIGESTS OF ADAPTED JSON (digest.ts): what `POST /adapt` makes
6
- * of the declared Caddyfile, what `GET /config/` reports, and what was stored after the last
7
- * apply. Three digests, because they answer different questions:
8
- * · declared ≠ stored — the declaration changed.
9
- * · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
10
- * (or an `autosave.json` holding someone else's change, under `--resume`).
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.
8
+ * of the declared Caddyfile and what `GET /config/` reports — `claimable` below asks whether a
9
+ * running config is provably the declared one, which both the adoption probe and the apply need.
14
10
  */
15
- import type { Diff } from 'alchemy/Diff';
16
- import { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';
17
- import {
18
- CaddyAdminError,
19
- adaptCaddyfile,
20
- loadCaddyfile,
21
- readRunningConfig,
22
- } from './admin-calls.ts';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Result from 'effect/Result';
13
+ import type * as Caddy from '@distilled.cloud/caddy';
14
+ import { CaddyAdminService } from './admin.ts';
15
+ import { adaptCaddyfile, readRunningConfig } from './admin-calls.ts';
23
16
  import { adminProblems } from './admin-guard.ts';
17
+ import { isUnreachable } from './caddy-http-client.ts';
18
+ import { refuse, wrapOperation } from './config-errors.ts';
24
19
  import {
25
20
  type CaddyConfigAttributes,
26
21
  type CaddyConfigProps,
@@ -28,11 +23,6 @@ import {
28
23
  } from './config-form.ts';
29
24
  import { configDigest } from './digest.ts';
30
25
 
31
- const refuse = (admin: CaddyAdmin, message: string): Error =>
32
- new Error(`Caddy.Config at ${admin.endpoint}: ${message}`);
33
-
34
- const short = (digest: string): string => digest.slice(0, 12);
35
-
36
26
  type Desired = { readonly digest: string; readonly warnings: readonly string[] };
37
27
 
38
28
  /**
@@ -41,7 +31,7 @@ type Desired = { readonly digest: string; readonly warnings: readonly string[] }
41
31
  * 2026-09-21 on a throwaway Caddy 2.11.4 — and loading it stops every server: the same outage,
42
32
  * from the same templating bug, that the empty check exists to prevent.
43
33
  */
44
- const servesNothing = (config: unknown): boolean => {
34
+ export const servesNothing = (config: unknown): boolean => {
45
35
  const apps =
46
36
  typeof config === 'object' && config !== null ? (config as { apps?: unknown }).apps : undefined;
47
37
  return typeof apps !== 'object' || apps === null || Object.keys(apps).length === 0;
@@ -53,37 +43,49 @@ const servesNothing = (config: unknown): boolean => {
53
43
  * Caddyfile that does not adapt, carries a literal secret, or would strand the admin API fails the
54
44
  * PLAN, before any resource (a HostFile holding the same text included) is applied.
55
45
  */
56
- export const desiredConfig = async (
57
- admin: CaddyAdmin,
46
+ export const desiredConfig = (
58
47
  props: CaddyConfigProps,
59
- ): Promise<Desired> => {
60
- const found = configProblems(props);
61
- if (found.length > 0) throw refuse(admin, found.join('; '));
62
- const adapted = await adaptCaddyfile(admin, props.caddyfile);
63
- if (servesNothing(adapted.config)) {
64
- throw refuse(admin, 'the Caddyfile adapts to no apps — loading it would stop every site');
65
- }
66
- const guard = adminProblems(adapted.config, admin.listener);
67
- if (guard.length > 0) throw refuse(admin, guard.join('; '));
68
- return { digest: configDigest(adapted.config), warnings: adapted.warnings };
69
- };
48
+ ): Effect.Effect<Desired, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
49
+ Effect.gen(function* () {
50
+ const { endpoint, listener } = yield* CaddyAdminService;
51
+ const found = configProblems(props);
52
+ if (found.length > 0) return yield* Effect.fail(refuse(endpoint, found.join('; ')));
53
+ const adapted = yield* adaptCaddyfile(props.caddyfile).pipe(
54
+ wrapOperation(endpoint, 'POST /adapt'),
55
+ );
56
+ if (servesNothing(adapted.config)) {
57
+ return yield* Effect.fail(
58
+ refuse(endpoint, 'the Caddyfile adapts to no apps — loading it would stop every site'),
59
+ );
60
+ }
61
+ const guard = adminProblems(adapted.config, listener);
62
+ if (guard.length > 0) return yield* Effect.fail(refuse(endpoint, guard.join('; ')));
63
+ return { digest: configDigest(adapted.config), warnings: adapted.warnings };
64
+ });
70
65
 
71
- const attributesOf = (
72
- admin: CaddyAdmin,
66
+ export const attributesOf = (
67
+ endpoint: string,
73
68
  running: unknown,
74
69
  sourceFile?: string,
75
70
  ): CaddyConfigAttributes => ({
76
71
  configSha256: configDigest(running),
77
- endpoint: admin.endpoint,
72
+ endpoint,
78
73
  ...(sourceFile === undefined ? {} : { sourceFile }),
79
74
  });
80
75
 
81
76
  /** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
82
- export const readLive = async (
83
- admin: CaddyAdmin,
77
+ export const readLive = (
84
78
  sourceFile?: string,
85
- ): Promise<CaddyConfigAttributes> =>
86
- attributesOf(admin, await readRunningConfig(admin), sourceFile);
79
+ ): Effect.Effect<
80
+ CaddyConfigAttributes,
81
+ Error | Caddy.CaddyOpError,
82
+ CaddyAdminService | Caddy.CaddyOpContext
83
+ > =>
84
+ Effect.gen(function* () {
85
+ const { endpoint } = yield* CaddyAdminService;
86
+ const running = yield* readRunningConfig().pipe(wrapOperation(endpoint, 'GET /config/'));
87
+ return attributesOf(endpoint, running, sourceFile);
88
+ });
87
89
 
88
90
  /**
89
91
  * ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
@@ -112,128 +114,36 @@ export type Probe = {
112
114
  * adapt then would fail every later plan, the one carrying the fix included. What cannot be
113
115
  * compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
114
116
  */
115
- export const probeLive = async (
116
- admin: CaddyAdmin,
117
+ export const probeLive = (
117
118
  caddyfile: string | undefined,
118
119
  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;
120
+ ): Effect.Effect<Probe | undefined, Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
121
+ Effect.gen(function* () {
122
+ const { endpoint } = yield* CaddyAdminService;
123
+ // ⚠️ NOT wrapOperation here: an unreachable Caddy must still raise the raw, narrowable
124
+ // HttpClientError (config.ts's plan-time escape hatch), and every OTHER failure reading the
125
+ // running config mid-probe is "not proven ours" below, never a thrown refusal.
126
+ const running = yield* readRunningConfig();
127
+ if (servesNothing(running)) return undefined;
128
+ const attributes = attributesOf(endpoint, running, sourceFile);
129
+ if (caddyfile === undefined) {
130
+ return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
131
+ }
132
+ // ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours" —
133
+ // re-failed with the raw, still-narrowable error, never swallowed into `unchecked`. Every
134
+ // OTHER refusal desiredConfig raises mid-probe (a bad admin block, a Caddyfile that will not
135
+ // adapt) is "not proven ours", with the reason — never a thrown refusal over the DECLARATION
136
+ // (the ⛔ above). `Effect.result`, per S20, rather than a `catchAll` this Effect version
137
+ // does not export.
138
+ const desired = yield* Effect.result(desiredConfig({ caddyfile }));
139
+ if (Result.isSuccess(desired)) {
140
+ return { attributes, ours: claimable(running, desired.success.digest) };
141
+ }
142
+ if (isUnreachable(desired.failure)) return yield* Effect.fail(desired.failure);
143
+ const cause = desired.failure;
132
144
  return {
133
145
  attributes,
134
146
  ours: false,
135
147
  unchecked: cause instanceof Error ? cause.message : String(cause),
136
148
  };
137
- }
138
- };
139
-
140
- /**
141
- * ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
142
- * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
143
- * the ⚠️ on `endpoint` in the diff.)
144
- */
145
- export const diffConfig = async (
146
- admin: CaddyAdmin,
147
- news: CaddyConfigProps,
148
- output: CaddyConfigAttributes,
149
- ): Promise<Diff> => {
150
- const want = await desiredConfig(admin, news);
151
- const live = await readLive(admin);
152
- const converged =
153
- want.digest === live.configSha256 &&
154
- live.configSha256 === output.configSha256 &&
155
- news.sourceFile === output.sourceFile &&
156
- // ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
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.
159
- admin.endpoint === output.endpoint;
160
- return converged ? { action: 'noop' } : { action: 'update' };
161
- };
162
-
163
- export type Applied = {
164
- readonly attributes: CaddyConfigAttributes;
165
- /** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
166
- readonly warnings: readonly string[];
167
- /** Whether a `POST /load` was sent (false when Caddy already ran this config). */
168
- readonly loaded: boolean;
169
- };
170
-
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
- */
189
- export const reconcileConfig = async (
190
- admin: CaddyAdmin,
191
- props: CaddyConfigProps,
192
- authority: Authority,
193
- ): Promise<Applied> => {
194
- const want = await desiredConfig(admin, props);
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
- }
207
- let warnings = want.warnings;
208
- const loaded = before !== want.digest;
209
- if (loaded) {
210
- try {
211
- warnings = await loadCaddyfile(admin, props.caddyfile, props.sourceFile);
212
- } catch (cause) {
213
- if (!(cause instanceof CaddyAdminError)) throw cause;
214
- // ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
215
- // changeConfig); confirm it rather than promise it, because the deploy log is where the
216
- // person decides whether sites are down.
217
- const after = await readLive(admin).catch(() => undefined);
218
- const state =
219
- after === undefined
220
- ? 'and the running config could not be read back'
221
- : after.configSha256 === before
222
- ? 'Caddy kept the previous config (still serving)'
223
- : `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
224
- throw refuse(admin, `Caddy refused the Caddyfile — ${cause.message}; ${state}`);
225
- }
226
- }
227
- // ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
228
- // did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
229
- // already wrong.
230
- const attributes = await readLive(admin, props.sourceFile);
231
- if (attributes.configSha256 !== want.digest) {
232
- throw refuse(
233
- admin,
234
- `after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
235
- `${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was not applied`,
236
- );
237
- }
238
- return { attributes, loaded, warnings };
239
- };
149
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * CaddyConfig's diff / reconcile — the apply half of the lifecycle config-lifecycle.ts's
3
+ * `desiredConfig`/`readLive`/`probeLive` compute the inputs for. Split out at the 250-line file
4
+ * cap; the two files together are one lifecycle, read config-lifecycle.ts's module doc first.
5
+ */
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Result from 'effect/Result';
8
+ import type * as Diff from 'alchemy/Diff';
9
+ import type * as Caddy from '@distilled.cloud/caddy';
10
+ import { CaddyAdminService } from './admin.ts';
11
+ import { loadCaddyfile, readRunningConfig } from './admin-calls.ts';
12
+ import { isUnreachable } from './caddy-http-client.ts';
13
+ import { messageOf, refuse, short, wrapOperation } from './config-errors.ts';
14
+ import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
15
+ import { claimable, desiredConfig, readLive } from './config-lifecycle.ts';
16
+ import { configDigest } from './digest.ts';
17
+
18
+ /**
19
+ * ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
20
+ * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
21
+ * the ⚠️ on `endpoint` in the diff.)
22
+ */
23
+ export const diffConfig = (
24
+ news: CaddyConfigProps,
25
+ output: CaddyConfigAttributes,
26
+ ): Effect.Effect<Diff.Diff, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
27
+ Effect.gen(function* () {
28
+ const { endpoint } = yield* CaddyAdminService;
29
+ const want = yield* desiredConfig(news);
30
+ const live = yield* readLive();
31
+ const converged =
32
+ want.digest === live.configSha256 &&
33
+ live.configSha256 === output.configSha256 &&
34
+ news.sourceFile === output.sourceFile &&
35
+ // ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
36
+ // old one keeps what it has — the same as a delete, which never unloads (see config.ts).
37
+ // Planned as an update, but the state does not vouch for that Caddy: see Authority.
38
+ endpoint === output.endpoint;
39
+ return converged ? { action: 'noop' } : { action: 'update' };
40
+ });
41
+
42
+ export type Applied = {
43
+ readonly attributes: CaddyConfigAttributes;
44
+ /** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
45
+ readonly warnings: readonly string[];
46
+ /** Whether a `POST /load` was sent (false when Caddy already ran this config). */
47
+ readonly loaded: boolean;
48
+ };
49
+
50
+ /**
51
+ * What this apply may load over — config.ts decides from the state and the adopt setting:
52
+ * · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
53
+ * correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
54
+ * resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
55
+ * · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
56
+ * Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
57
+ */
58
+ export type Authority = { readonly takeOver: boolean; readonly stored?: string };
59
+
60
+ /**
61
+ * Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
62
+ * ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
63
+ * probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
64
+ * caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
65
+ * plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
66
+ * endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
67
+ */
68
+ export const reconcileConfig = (
69
+ props: CaddyConfigProps,
70
+ authority: Authority,
71
+ ): Effect.Effect<Applied, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
72
+ Effect.gen(function* () {
73
+ const { endpoint } = yield* CaddyAdminService;
74
+ const want = yield* desiredConfig(props);
75
+ const running = yield* readRunningConfig().pipe(wrapOperation(endpoint, 'GET /config/'));
76
+ const before = configDigest(running);
77
+ const known = before === authority.stored || claimable(running, want.digest);
78
+ if (!authority.takeOver && !known) {
79
+ return yield* Effect.fail(
80
+ refuse(
81
+ endpoint,
82
+ `Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile ` +
83
+ `adapts to ${short(want.digest)} — loading it would replace every site that config ` +
84
+ 'serves. Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy ' +
85
+ 'over, or point caddyProviders() at the Caddy you meant.',
86
+ ),
87
+ );
88
+ }
89
+ let warnings = want.warnings;
90
+ const loaded = before !== want.digest;
91
+ if (loaded) {
92
+ const outcome = yield* Effect.result(loadCaddyfile(props.caddyfile, props.sourceFile));
93
+ if (Result.isFailure(outcome)) {
94
+ const error = outcome.failure;
95
+ // ⚠️ Caddy going unreachable MID-load is not a refusal — nothing to "say what's running
96
+ // now" about, and re-sending it is unsafe (admin-calls.ts's module doc). Propagated raw,
97
+ // still narrowable, matching every other unreachable path in this file.
98
+ if (isUnreachable(error)) return yield* Effect.fail(error);
99
+ // ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
100
+ // changeConfig); confirm it rather than promise it, because the deploy log is where the
101
+ // person decides whether sites are down.
102
+ const afterRead = yield* Effect.result(readLive());
103
+ const after = Result.isSuccess(afterRead) ? afterRead.success : undefined;
104
+ const state =
105
+ after === undefined
106
+ ? 'and the running config could not be read back'
107
+ : after.configSha256 === before
108
+ ? 'Caddy kept the previous config (still serving)'
109
+ : `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
110
+ // ⚠️ SAY SO WHEN THE STATUS LIED, or a bare "refused" reads like a normal 4xx/5xx.
111
+ // `LoadRefused` IS the 200-embedded-error trap (admin-calls.ts's module doc): Caddy
112
+ // answered 200, and the failure was only in the body.
113
+ const reason =
114
+ error._tag === 'LoadRefused'
115
+ ? `${error.message} (LoadRefused: a 200 whose body carried the error, not a failure status)`
116
+ : messageOf(error);
117
+ return yield* Effect.fail(
118
+ refuse(endpoint, `Caddy refused the Caddyfile — ${reason}; ${state}`),
119
+ );
120
+ }
121
+ warnings = outcome.success;
122
+ }
123
+ // ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
124
+ // did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
125
+ // already wrong.
126
+ const attributes = yield* readLive(props.sourceFile);
127
+ if (attributes.configSha256 !== want.digest) {
128
+ return yield* Effect.fail(
129
+ refuse(
130
+ endpoint,
131
+ `after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
132
+ `${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was ` +
133
+ 'not applied',
134
+ ),
135
+ );
136
+ }
137
+ return { attributes, loaded, warnings };
138
+ });