@homeflare/alchemy 0.27.4 → 0.29.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 (217) 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 +9 -5
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +161 -25
  42. package/dist/github/index.js.map +15 -13
  43. package/dist/github/repo-baseline-data.d.ts +10 -1
  44. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  45. package/dist/github/repo-policy.d.ts +1 -1
  46. package/dist/github/repo-policy.d.ts.map +1 -1
  47. package/dist/github/repository-ruleset-errors.d.ts +37 -3
  48. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  49. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  50. package/dist/github/repository-ruleset-guards.d.ts +15 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  53. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  54. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  55. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  57. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-props.d.ts +91 -0
  59. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  61. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  62. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  63. package/dist/github/repository-ruleset.d.ts +28 -34
  64. package/dist/github/repository-ruleset.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts +27 -16
  66. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  67. package/dist/litellm/fake-stack.d.ts +9 -4
  68. package/dist/litellm/fake-stack.d.ts.map +1 -1
  69. package/dist/litellm/index.d.ts +7 -5
  70. package/dist/litellm/index.d.ts.map +1 -1
  71. package/dist/litellm/index.js +53 -123
  72. package/dist/litellm/index.js.map +8 -8
  73. package/dist/litellm/operations.d.ts +60 -0
  74. package/dist/litellm/operations.d.ts.map +1 -0
  75. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  76. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  77. package/dist/litellm/pass-through-errors.d.ts +37 -0
  78. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  79. package/dist/litellm/pass-through-form.d.ts +11 -4
  80. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  81. package/dist/litellm/providers.d.ts +7 -5
  82. package/dist/litellm/providers.d.ts.map +1 -1
  83. package/dist/netbox/prefix.d.ts +4 -4
  84. package/dist/paperless/custom-field.d.ts +21 -21
  85. package/dist/paperless/custom-field.d.ts.map +1 -1
  86. package/dist/paperless/document-type.d.ts +34 -4
  87. package/dist/paperless/document-type.d.ts.map +1 -1
  88. package/dist/paperless/fake-paperless.d.ts +36 -19
  89. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  90. package/dist/paperless/index.d.ts +7 -2
  91. package/dist/paperless/index.d.ts.map +1 -1
  92. package/dist/paperless/index.js +164 -228
  93. package/dist/paperless/index.js.map +10 -13
  94. package/dist/paperless/matching-locate.d.ts +42 -20
  95. package/dist/paperless/matching-locate.d.ts.map +1 -1
  96. package/dist/paperless/matching-types.d.ts +47 -18
  97. package/dist/paperless/matching-types.d.ts.map +1 -1
  98. package/dist/paperless/matching.d.ts +30 -27
  99. package/dist/paperless/matching.d.ts.map +1 -1
  100. package/dist/paperless/providers.d.ts +13 -7
  101. package/dist/paperless/providers.d.ts.map +1 -1
  102. package/dist/paperless/storage-path.d.ts +34 -4
  103. package/dist/paperless/storage-path.d.ts.map +1 -1
  104. package/dist/paperless/tag.d.ts +43 -8
  105. package/dist/paperless/tag.d.ts.map +1 -1
  106. package/dist/proxmox/acl.d.ts +30 -23
  107. package/dist/proxmox/acl.d.ts.map +1 -1
  108. package/dist/proxmox/distilled-pve.d.ts +89 -0
  109. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  110. package/dist/proxmox/index.js +663 -609
  111. package/dist/proxmox/index.js.map +12 -11
  112. package/dist/proxmox/members.d.ts +29 -10
  113. package/dist/proxmox/members.d.ts.map +1 -1
  114. package/docs/caddy-admin.md +1 -1
  115. package/docs/caddy.md +3 -2
  116. package/docs/credentials.md +16 -0
  117. package/docs/litellm.md +21 -13
  118. package/docs/paperless.md +12 -0
  119. package/docs/peers.md +5 -0
  120. package/docs/repo-policy.md +16 -1
  121. package/docs/repository-ruleset.md +53 -13
  122. package/docs/upstream-conformance.md +126 -10
  123. package/package.json +9 -2
  124. package/src/caddy/admin-address.ts +71 -0
  125. package/src/caddy/admin-calls.ts +48 -100
  126. package/src/caddy/admin.ts +37 -41
  127. package/src/caddy/adopt-scope.test.ts +1 -1
  128. package/src/caddy/caddy-http-client.ts +229 -0
  129. package/src/caddy/config-adopt-provider.test.ts +139 -0
  130. package/src/caddy/config-adopt.test.ts +60 -126
  131. package/src/caddy/config-errors.ts +47 -0
  132. package/src/caddy/config-lifecycle.test.ts +55 -42
  133. package/src/caddy/config-lifecycle.ts +72 -162
  134. package/src/caddy/config-reconcile.ts +138 -0
  135. package/src/caddy/config.ts +36 -42
  136. package/src/caddy/fake-caddy.ts +20 -2
  137. package/src/caddy/index.ts +3 -8
  138. package/src/caddy/local-admin.test.ts +35 -21
  139. package/src/caddy/local-admin.ts +60 -167
  140. package/src/caddy/providers.test.ts +14 -6
  141. package/src/caddy/providers.ts +20 -4
  142. package/src/github/declare-repo-baseline.ts +6 -6
  143. package/src/github/index.ts +10 -4
  144. package/src/github/repo-baseline-data.test.ts +13 -0
  145. package/src/github/repo-baseline-data.ts +11 -2
  146. package/src/github/repo-policy-stack.test.ts +59 -0
  147. package/src/github/repo-policy.ts +3 -2
  148. package/src/github/repository-ruleset-errors.ts +51 -4
  149. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  150. package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
  151. package/src/github/repository-ruleset-form.ts +7 -3
  152. package/src/github/repository-ruleset-guards.ts +11 -4
  153. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  154. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  155. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  156. package/src/github/repository-ruleset-octokit.ts +121 -30
  157. package/src/github/repository-ruleset-probe.ts +2 -1
  158. package/src/github/repository-ruleset-props.ts +95 -0
  159. package/src/github/repository-ruleset-providers.ts +46 -0
  160. package/src/github/repository-ruleset-reconcile.ts +35 -0
  161. package/src/github/repository-ruleset-wire.test.ts +38 -27
  162. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  163. package/src/github/repository-ruleset.ts +53 -43
  164. package/src/litellm/fake-litellm.ts +83 -78
  165. package/src/litellm/fake-stack.ts +13 -5
  166. package/src/litellm/index.ts +7 -21
  167. package/src/litellm/operations.ts +149 -0
  168. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  169. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  170. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  171. package/src/litellm/pass-through-endpoint.ts +21 -31
  172. package/src/litellm/pass-through-errors.ts +37 -0
  173. package/src/litellm/pass-through-form.ts +20 -10
  174. package/src/litellm/providers.ts +8 -7
  175. package/src/paperless/custom-field.test.ts +49 -40
  176. package/src/paperless/custom-field.ts +57 -33
  177. package/src/paperless/document-type.test.ts +48 -0
  178. package/src/paperless/document-type.ts +51 -22
  179. package/src/paperless/fake-paperless.ts +63 -72
  180. package/src/paperless/index.ts +7 -13
  181. package/src/paperless/matching-locate.test.ts +102 -0
  182. package/src/paperless/matching-locate.ts +80 -86
  183. package/src/paperless/matching-types.ts +49 -16
  184. package/src/paperless/matching.ts +77 -90
  185. package/src/paperless/providers.ts +14 -10
  186. package/src/paperless/storage-path.test.ts +56 -0
  187. package/src/paperless/storage-path.ts +53 -24
  188. package/src/paperless/tag-identity.test.ts +127 -0
  189. package/src/paperless/tag.test.ts +100 -0
  190. package/src/paperless/tag.ts +62 -32
  191. package/src/proxmox/acl.test.ts +145 -0
  192. package/src/proxmox/acl.ts +128 -150
  193. package/src/proxmox/distilled-pve.test.ts +231 -0
  194. package/src/proxmox/distilled-pve.ts +147 -0
  195. package/src/proxmox/members-timeout.test.ts +161 -0
  196. package/src/proxmox/members.test.ts +9 -0
  197. package/src/proxmox/members.ts +36 -2
  198. package/dist/litellm/client.d.ts +0 -76
  199. package/dist/litellm/client.d.ts.map +0 -1
  200. package/dist/litellm/credentials.d.ts +0 -48
  201. package/dist/litellm/credentials.d.ts.map +0 -1
  202. package/dist/litellm/generated/pass-through.d.ts +0 -71
  203. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  204. package/dist/paperless/client.d.ts +0 -37
  205. package/dist/paperless/client.d.ts.map +0 -1
  206. package/dist/paperless/credentials.d.ts +0 -40
  207. package/dist/paperless/credentials.d.ts.map +0 -1
  208. package/dist/paperless/errors.d.ts +0 -49
  209. package/dist/paperless/errors.d.ts.map +0 -1
  210. package/src/litellm/client.ts +0 -195
  211. package/src/litellm/credentials.ts +0 -73
  212. package/src/litellm/generated/pass-through.ts +0 -73
  213. package/src/paperless/client.ts +0 -88
  214. package/src/paperless/credentials.ts +0 -69
  215. package/src/paperless/errors.ts +0 -62
  216. package/src/paperless/matching-identity.test.ts +0 -194
  217. package/src/paperless/matching.test.ts +0 -128
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Ownership with no state, through the REAL `CaddyConfig.Provider` (not the lifecycle functions
3
+ * config-adopt.test.ts drives directly) — `read`'s `Unowned` branding and `reconcile`'s `--adopt`
4
+ * resolution (`AdoptPolicy`, `AlchemyContext`), the way the engine actually calls them. Split out
5
+ * at the 250-line file cap; config-adopt.test.ts's module doc states the underlying decision.
6
+ */
7
+ import { afterEach, describe, expect, test } from 'bun:test';
8
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
9
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import type * as Caddy from '@distilled.cloud/caddy';
13
+ import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
14
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
15
+ import { configDigest } from './digest.ts';
16
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
17
+
18
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
19
+ /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
20
+ const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
21
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
22
+
23
+ let fake: FakeCaddy | undefined;
24
+ afterEach(() => {
25
+ fake?.stop();
26
+ fake = undefined;
27
+ });
28
+
29
+ const setup = (running?: unknown) => {
30
+ const made = fakeDefaultCaddy(running);
31
+ fake = made.caddy;
32
+ return made;
33
+ };
34
+
35
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
36
+
37
+ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
38
+
39
+ /** The provider over `admin`, run under the services a deploy provides (the CLI's `--adopt`). */
40
+ const withProvider = <A>(
41
+ admin: CaddyTransport,
42
+ deploy: Deploy,
43
+ use: (
44
+ p: Effect.Success<typeof CaddyConfig.Provider>,
45
+ ) => Effect.Effect<A, unknown, CaddyAdminService | Caddy.CaddyOpContext>,
46
+ ) => {
47
+ // ⚠️ `Layer.provideMerge`, not `Layer.provide` — providers.ts's own doc on `caddyAdminLayer` says
48
+ // why: `CaddyConfigProvider()`'s handlers still need these services every time they run, not
49
+ // just once while the provider is built.
50
+ let program = Effect.gen(function* () {
51
+ return yield* use(yield* CaddyConfig.Provider);
52
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(admin)))));
53
+ if (deploy.adopt !== undefined) {
54
+ program = program.pipe(Effect.provideService(AdoptPolicy, deploy.adopt));
55
+ }
56
+ if (deploy.contextAdopt !== undefined) {
57
+ const context = { adopt: deploy.contextAdopt, dev: false, dotAlchemy: '.alchemy' };
58
+ program = program.pipe(Effect.provideService(AlchemyContext, context));
59
+ }
60
+ return Effect.runPromise(program);
61
+ };
62
+
63
+ const read = (admin: CaddyTransport, caddyfile: unknown, output?: object) =>
64
+ withProvider(admin, {}, (provider) => {
65
+ if (provider.read === undefined) throw new Error('provider has no read handler');
66
+ return provider.read({ ...ids, olds: { caddyfile } as never, output: output as never });
67
+ });
68
+
69
+ /** Apply, then check that exactly the expected loads reached Caddy — or none, and why. */
70
+ const expectApply = async (applying: Promise<unknown>, caddy: FakeCaddy, loaded: boolean) => {
71
+ if (loaded) await applying;
72
+ else await expect(applying).rejects.toThrow(/Deploy with --adopt/);
73
+ expect(loads(caddy)).toHaveLength(loaded ? 1 : 0);
74
+ };
75
+
76
+ const reconcile = (admin: CaddyTransport, deploy: Deploy, output?: object) =>
77
+ withProvider(admin, deploy, (provider) =>
78
+ provider.reconcile({
79
+ ...ids,
80
+ bindings: [] as never,
81
+ news: { caddyfile: SITE },
82
+ olds: undefined,
83
+ output: output as never,
84
+ session: undefined as never,
85
+ }),
86
+ );
87
+
88
+ describe('CaddyConfigProvider', () => {
89
+ test('read: Unowned with no state, plain with state, plain when it is the declared config', async () => {
90
+ const { admin, caddy } = setup(FOREIGN);
91
+ const probe = await read(admin, SITE);
92
+ expect(Unowned.is(probe)).toBe(true);
93
+ expect(probe).toMatchObject({ configSha256: configDigest(FOREIGN) });
94
+ const owned = await read(admin, SITE, { configSha256: 'stored', endpoint: caddy.address });
95
+ expect(owned).toBeDefined();
96
+ expect(Unowned.is(owned)).toBe(false);
97
+ caddy.running = caddy.adapt(SITE);
98
+ const same = await read(admin, SITE);
99
+ expect(same).toBeDefined();
100
+ expect(Unowned.is(same)).toBe(false);
101
+ });
102
+
103
+ test('read with no state: nothing served is a create; an unreadable declaration is Unowned', async () => {
104
+ expect(await read(setup().admin, SITE)).toBeUndefined();
105
+ fake?.stop();
106
+ const { admin } = setup(FOREIGN);
107
+ expect(Unowned.is(await read(admin, 'SYNTAX_ERROR'))).toBe(true);
108
+ expect(Unowned.is(await read(admin, {}))).toBe(true);
109
+ });
110
+
111
+ test.each([
112
+ ['no adopt setting at all', {}, false],
113
+ ['AdoptPolicy off', { adopt: false }, false],
114
+ ['--adopt (AdoptPolicy)', { adopt: true }, true],
115
+ ['--adopt reaching only AlchemyContext', { contextAdopt: true }, true],
116
+ ['AdoptPolicy off over AlchemyContext on', { adopt: false, contextAdopt: true }, false],
117
+ ] as const)('reconcile with no state, %s', async (_, deploy, loaded) => {
118
+ const { admin, caddy } = setup(FOREIGN);
119
+ await expectApply(reconcile(admin, deploy), caddy, loaded);
120
+ });
121
+
122
+ test('reconcile with state for this Caddy loads over anything, without --adopt', async () => {
123
+ // ★ An update correcting drift (a hand edit since the stored digest), or an adopted create.
124
+ const { admin, caddy } = setup(FOREIGN);
125
+ const output = { configSha256: 'before-a-hand-edit', endpoint: caddy.address };
126
+ await expectApply(reconcile(admin, {}, output), caddy, true);
127
+ });
128
+
129
+ test.each([
130
+ ['a config it never stored, without --adopt', 'stored-elsewhere', {}, false],
131
+ ['a config it never stored, with --adopt', 'stored-elsewhere', { adopt: true }, true],
132
+ ['the config it last stored (the same Caddy, renamed)', configDigest(FOREIGN), {}, true],
133
+ ] as const)('reconcile with state from another endpoint: %s', async (_, sha, deploy, loaded) => {
134
+ // ⛔ State vouches for the Caddy it was applied to — not for whatever the transport now reaches.
135
+ const { admin, caddy } = setup(FOREIGN);
136
+ const output = { configSha256: sha, endpoint: 'unix:///run/elsewhere.sock' };
137
+ await expectApply(reconcile(admin, deploy, output), caddy, loaded);
138
+ });
139
+ });
@@ -3,23 +3,25 @@
3
3
  * probe (`read` with no output) and the apply of a create the engine never probed must agree on
4
4
  * what is claimable: the declared config itself, or a Caddy serving nothing. Anything else is
5
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.
6
+ * lends authority only over the Caddy it was applied to. Both drive the LIFECYCLE functions
7
+ * directly; config-adopt-provider.test.ts drives the same scenarios through the real
8
+ * `CaddyConfig.Provider` (its `Unowned` branding, its `--adopt` resolution).
7
9
  */
8
10
  import { afterEach, describe, expect, test } from 'bun:test';
9
- import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
10
- import { AlchemyContext } from 'alchemy/AlchemyContext';
11
11
  import * as Effect from 'effect/Effect';
12
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';
13
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
14
+ import * as HttpClientError from 'effect/unstable/http/HttpClientError';
15
+ import type { CaddyTransport } from './admin.ts';
16
+ import { isUnreachable } from './caddy-http-client.ts';
17
+ import { probeLive } from './config-lifecycle.ts';
18
+ import { reconcileConfig } from './config-reconcile.ts';
16
19
  import { configDigest } from './digest.ts';
17
- import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
20
+ import { type FakeCaddy, fakeDefaultCaddy, runCaddy } from './fake-caddy.ts';
18
21
 
19
22
  const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
20
23
  /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
21
24
  const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
22
- const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
23
25
 
24
26
  let fake: FakeCaddy | undefined;
25
27
  afterEach(() => {
@@ -42,10 +44,37 @@ const runningDeclared = () => {
42
44
 
43
45
  const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
44
46
 
47
+ /**
48
+ * The same transport, except `/adapt` fails as though nothing were listening — for "Caddy
49
+ * vanishing mid-probe": `probeLive` calls `GET /config/` first and `POST /adapt` second, so
50
+ * stopping the fake server between them can't be timed from outside one Effect. Wrapping the
51
+ * transport's `HttpClient.HttpClient` at exactly one path is the deterministic equivalent.
52
+ */
53
+ const goneOnAdapt = (admin: CaddyTransport): CaddyTransport => ({
54
+ ...admin,
55
+ layer: Layer.effect(
56
+ HttpClient.HttpClient,
57
+ Effect.map(HttpClient.HttpClient, (real) =>
58
+ HttpClient.make((request, url, signal, fiber) =>
59
+ url.pathname === '/adapt'
60
+ ? Effect.fail(
61
+ new HttpClientError.HttpClientError({
62
+ reason: new HttpClientError.TransportError({
63
+ cause: Object.assign(new Error('gone'), { code: 'ECONNREFUSED' }),
64
+ request,
65
+ }),
66
+ }),
67
+ )
68
+ : real.execute(request),
69
+ ),
70
+ ),
71
+ ).pipe(Layer.provideMerge(admin.layer)),
72
+ });
73
+
45
74
  describe('the probe: read with no state', () => {
46
75
  test('a Caddy already running the declared config is ours — adopting it changes nothing', async () => {
47
76
  const { admin, caddy } = runningDeclared();
48
- const probe = await probeLive(admin, SITE);
77
+ const probe = await runCaddy(probeLive(SITE), admin);
49
78
  expect(probe).toMatchObject({ attributes: { configSha256: configDigest(caddy.adapt(SITE)) } });
50
79
  expect(probe?.ours).toBe(true);
51
80
  expect(loads(caddy)).toHaveLength(0);
@@ -53,7 +82,7 @@ describe('the probe: read with no state', () => {
53
82
 
54
83
  test('a Caddy running any other config is not ours', async () => {
55
84
  const { admin } = setup(FOREIGN);
56
- const probe = await probeLive(admin, SITE, '/usr/local/etc/Caddyfile');
85
+ const probe = await runCaddy(probeLive(SITE, '/usr/local/etc/Caddyfile'), admin);
57
86
  expect(probe?.ours).toBe(false);
58
87
  expect(probe?.unchecked).toBeUndefined();
59
88
  expect(probe?.attributes).toEqual({
@@ -68,7 +97,7 @@ describe('the probe: read with no state', () => {
68
97
  ['only an admin block', { admin: { listen: 'localhost:2019' } }],
69
98
  ])('a Caddy serving nothing (%s) reads as absent: a create', async (_, running) => {
70
99
  const { admin } = setup(running);
71
- expect(await probeLive(admin, SITE)).toBeUndefined();
100
+ expect(await runCaddy(probeLive(SITE), admin)).toBeUndefined();
72
101
  });
73
102
 
74
103
  test.each([
@@ -78,21 +107,18 @@ describe('the probe: read with no state', () => {
78
107
  // ⛔ The engine replays this read with an interrupted create's props: a throw here would fail
79
108
  // every later plan, the one carrying the fix included.
80
109
  const { admin } = setup(FOREIGN);
81
- const probe = await probeLive(admin, text);
110
+ const probe = await runCaddy(probeLive(text), admin);
82
111
  expect(probe?.ours).toBe(false);
83
112
  expect(probe?.unchecked).toMatch(reason);
84
113
  });
85
114
 
86
115
  test('Caddy vanishing mid-probe stays "no Caddy", never "not ours"', async () => {
87
116
  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');
117
+ const failure = runCaddy(probeLive(SITE), goneOnAdapt(admin));
118
+ // ★ Still the raw, narrowable HttpClientError (config.ts's own catch is what turns THIS into
119
+ // "no Caddy" at plan time) — never swallowed into `{ ours: false, unchecked }`.
120
+ await expect(failure).rejects.toBeInstanceOf(HttpClientError.HttpClientError);
121
+ await expect(failure.catch((error: unknown) => isUnreachable(error))).resolves.toBe(true);
96
122
  });
97
123
  });
98
124
 
@@ -100,23 +126,29 @@ describe('the probe: read with no state', () => {
100
126
  describe('the apply without authority over the running config', () => {
101
127
  test('without authority, a config the stack cannot claim is refused before any load', async () => {
102
128
  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
- );
129
+ await expect(
130
+ runCaddy(reconcileConfig({ caddyfile: SITE }, { takeOver: false }), admin),
131
+ ).rejects.toThrow(/which this stack did not load.*Deploy with --adopt/);
106
132
  expect(loads(caddy)).toHaveLength(0);
107
133
  expect(caddy.running).toEqual(FOREIGN);
108
134
  });
109
135
 
110
136
  test('the declared config already running is taken as it is — no load', async () => {
111
137
  const { admin, caddy } = runningDeclared();
112
- const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
138
+ const applied = await runCaddy(
139
+ reconcileConfig({ caddyfile: SITE }, { takeOver: false }),
140
+ admin,
141
+ );
113
142
  expect(applied.loaded).toBe(false);
114
143
  expect(loads(caddy)).toHaveLength(0);
115
144
  });
116
145
 
117
146
  test('a Caddy serving nothing is loaded — there is nothing to take over', async () => {
118
147
  const { admin, caddy } = setup();
119
- const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
148
+ const applied = await runCaddy(
149
+ reconcileConfig({ caddyfile: SITE }, { takeOver: false }),
150
+ admin,
151
+ );
120
152
  expect(applied.loaded).toBe(true);
121
153
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
122
154
  });
@@ -124,112 +156,14 @@ describe('the apply without authority over the running config', () => {
124
156
  test('the config the state last stored is known — the same Caddy at a new endpoint', async () => {
125
157
  const { admin } = setup(FOREIGN);
126
158
  const authority = { stored: configDigest(FOREIGN), takeOver: false };
127
- expect((await reconcileConfig(admin, { caddyfile: SITE }, authority)).loaded).toBe(true);
159
+ const applied = await runCaddy(reconcileConfig({ caddyfile: SITE }, authority), admin);
160
+ expect(applied.loaded).toBe(true);
128
161
  });
129
162
 
130
163
  test('with authority, the foreign config is replaced', async () => {
131
164
  const { admin, caddy } = setup(FOREIGN);
132
- const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: true });
165
+ const applied = await runCaddy(reconcileConfig({ caddyfile: SITE }, { takeOver: true }), admin);
133
166
  expect(applied.loaded).toBe(true);
134
167
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
135
168
  });
136
169
  });
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
- });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Turning a distilled `@distilled.cloud/caddy` operation failure into a readable refusal — shared
3
+ * by config-lifecycle.ts and config-reconcile.ts, so both read the same way.
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import type * as Caddy from '@distilled.cloud/caddy';
7
+ import { isUnreachable } from './caddy-http-client.ts';
8
+
9
+ export const refuse = (endpoint: string, message: string): Error =>
10
+ new Error(`Caddy.Config at ${endpoint}: ${message}`);
11
+
12
+ export const short = (digest: string): string => digest.slice(0, 12);
13
+
14
+ /** Every `CaddyOpError` member carries `.message` (a `Schema.TaggedError` field) — see errors.ts. */
15
+ export const messageOf = (error: Caddy.CaddyOpError): string =>
16
+ 'message' in error && typeof error.message === 'string' ? error.message : String(error);
17
+
18
+ /**
19
+ * A distilled operation's typed failure, read the way this resource's own refusals read:
20
+ * `LoadRefused` (the 200-embedded-error trap admin-calls.ts's module doc describes) says so
21
+ * explicitly, so a grep for "kept the previous config" is never mistaken for an ordinary 4xx/5xx.
22
+ */
23
+ export const operationFailure = (endpoint: string, op: string, error: Caddy.CaddyOpError): Error =>
24
+ error._tag === 'LoadRefused'
25
+ ? refuse(
26
+ endpoint,
27
+ `Caddy refused the Caddyfile (${error._tag}, a 200 whose body carried the error, not a ` +
28
+ `failure status) — ${error.message}`,
29
+ )
30
+ : refuse(endpoint, `${op} failed (${error._tag}) — ${messageOf(error)}`);
31
+
32
+ /**
33
+ * Humanizes every failure from a distilled operation EXCEPT one nothing accepted the connection
34
+ * for — that stays the raw, still-typed `HttpClientError` so config.ts's plan-time escape hatch
35
+ * (`Effect.catchIf(isUnreachable, …)`) can keep narrowing to it after this wrapping, the same way
36
+ * it could inspect the un-wrapped error before this function ran.
37
+ */
38
+ export const wrapOperation =
39
+ (endpoint: string, op: string) =>
40
+ <A>(
41
+ effect: Effect.Effect<A, Caddy.CaddyOpError, Caddy.CaddyOpContext>,
42
+ ): Effect.Effect<A, Error | Caddy.CaddyOpError, Caddy.CaddyOpContext> =>
43
+ effect.pipe(
44
+ Effect.mapError((error) =>
45
+ isUnreachable(error) ? error : operationFailure(endpoint, op, error),
46
+ ),
47
+ );