@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,128 +1,76 @@
1
1
  /**
2
- * The three admin API calls the provider makes, each classified into a value or a readable Error
3
- * that carries Caddy's own message.
2
+ * The three admin API calls the provider makes, through `@distilled.cloud/caddy`'s typed `admin`
3
+ * operations — `catchTag`-able errors, never a status check in this file.
4
4
  *
5
5
  * Read in caddyserver/caddy v2.11.4 (and the API docs, caddyserver.com/docs/api):
6
- * · `POST /adapt` — adapts without running (load.go handleAdapt): 200 `{warnings?, result}`,
7
- * or 400 `{"error": …}`. Side-effect free, so it is what plan-time validation uses.
8
- * · `POST /load` — Content-Type `text/caddyfile` picks the adapter (load.go adaptByContentType);
9
- * "if the new config fails for any reason, the old config is rolled back into place without
10
- * downtime" (docs; caddy.go changeConfig restores the previous raw config). An identical
11
- * config is a no-op without `Cache-Control: must-revalidate`.
12
- * · `GET /config/` — the running config as JSON, `null` when empty (admin.go handleConfig).
6
+ * · `POST /adapt` (`adaptConfig`) — adapts without running (load.go handleAdapt). Side-effect
7
+ * free, so it is what plan-time validation uses.
8
+ * · `POST /load` (`loadConfig`) — Content-Type `text/caddyfile` picks the adapter (load.go
9
+ * adaptByContentType); "if the new config fails for any reason, the old config is rolled back
10
+ * into place without downtime" (docs; caddy.go changeConfig restores the previous raw config).
11
+ * An identical config is a no-op without `Cache-Control: must-revalidate`.
12
+ * · `GET /config/` (`getConfig`) — the running config as JSON, `null` when empty (admin.go
13
+ * handleConfig).
13
14
  * ⛔ A FAILED LOAD CAN ANSWER 200. load.go writes the adapter's WARNINGS to the body before it
14
15
  * runs the config; if the run then fails, the error JSON is appended to a response whose status
15
- * is already 200 (Go's first Write sends 200; the later WriteHeader(400) is ignored). The
16
- * Caddyfile adapter warns on every unformatted input (adapter.go FormattingDifference), so this
17
- * is the COMMON failure shape, not an edge. Hence: look for the error in the body whatever the
18
- * status — and the lifecycle reads the config back after every load anyway.
19
- * MEASURED 2026-09-21 on a throwaway Caddy 2.11.4 (loopback ports, isolated XDG dirs): an
20
- * unformatted Caddyfile whose port was taken answered `200` with body
21
- * `[{"file":"Caddyfile","line":8,"message":"Caddyfile input is not formatted; …"}]{"error":"loading
22
- * config: … bind: address already in use"}`, and Caddy kept serving the previous config.
16
+ * is already 200. The Caddyfile adapter warns on every unformatted input (adapter.go
17
+ * FormattingDifference), so this is the COMMON failure shape, not an edge. The SDK's own
18
+ * `CaddyProtocol` (`packages/distilled-caddy/src/protocol.ts`) scans `loadConfig`'s body for the
19
+ * embedded error on every status and fails with the typed `LoadRefused` — `catchTag`'d below,
20
+ * never a status check here. The lifecycle also reads the config back after every load anyway
21
+ * (config-lifecycle.ts).
23
22
  */
24
- import type { CaddyAdmin, CaddyAdminResponse } from './admin.ts';
25
- import { parseConfig } from './digest.ts';
26
-
27
- export class CaddyAdminError extends Error {
28
- constructor(
29
- readonly status: number,
30
- readonly method: string,
31
- readonly path: string,
32
- reason: string,
33
- ) {
34
- super(`Caddy ${method} ${path} → ${String(status)}: ${reason}`);
35
- this.name = 'CaddyAdminError';
36
- }
37
- }
23
+ import * as Effect from 'effect/Effect';
24
+ import * as Caddy from '@distilled.cloud/caddy';
38
25
 
39
26
  const CADDYFILE = 'text/caddyfile';
40
- /** APIError marshals as exactly this key (admin.go APIError, `json:"error"`). */
41
- const ERROR_OPEN = '{"error":';
42
27
 
43
- type Warning = { file?: string; line?: number; message?: string };
28
+ type Warning = { readonly file?: string; readonly line?: number; readonly message?: string };
44
29
 
45
30
  const describe = (warning: Warning): string =>
46
31
  `${warning.file ?? 'Caddyfile'}:${String(warning.line ?? 0)}: ${warning.message ?? '(no message)'}`;
47
32
 
48
- /** Caddy's `error` string from a body that holds one, or `undefined`. */
49
- const errorIn = (body: string): string | undefined => {
50
- const at = body.lastIndexOf(ERROR_OPEN);
51
- if (at === -1) return undefined;
52
- try {
53
- const parsed = JSON.parse(body.slice(at)) as { error?: unknown };
54
- return typeof parsed.error === 'string' ? parsed.error : undefined;
55
- } catch {
56
- return undefined;
57
- }
58
- };
59
-
60
- const warningsIn = (text: string): string[] => {
61
- if (text.trim() === '') return [];
62
- try {
63
- const parsed = JSON.parse(text) as unknown;
64
- return Array.isArray(parsed) ? (parsed as Warning[]).map(describe) : [];
65
- } catch {
66
- return [];
67
- }
68
- };
69
-
70
- const fail = (response: CaddyAdminResponse, method: string, path: string): never => {
71
- const reason = errorIn(response.body) ?? (response.body.trim().slice(0, 500) || '(empty body)');
72
- // ⚠️ Say so when the status lied (the ⛔ above), or "→ 200" reads like success.
73
- const shown = response.status === 200 ? `${reason} (error in a 200 body)` : reason;
74
- throw new CaddyAdminError(response.status, method, path, shown);
75
- };
76
-
77
33
  export type Adapted = { readonly config: unknown; readonly warnings: readonly string[] };
78
34
 
79
35
  /** Adapt a Caddyfile to JSON on the running Caddy, without loading it. */
80
- export const adaptCaddyfile = async (admin: CaddyAdmin, caddyfile: string): Promise<Adapted> => {
81
- const response = await admin.request({
82
- body: caddyfile,
83
- headers: { 'Content-Type': CADDYFILE },
84
- method: 'POST',
85
- path: '/adapt',
86
- });
87
- // ★ Status alone here: /adapt writes its body once, and a successful result is config JSON in
88
- // which an `error` handler's key could look like an error object to a substring search.
89
- if (response.status !== 200) fail(response, 'POST', '/adapt');
90
- const parsed = JSON.parse(response.body) as { result?: unknown; warnings?: Warning[] };
91
- return { config: parsed.result ?? null, warnings: (parsed.warnings ?? []).map(describe) };
92
- };
36
+ export const adaptCaddyfile = (
37
+ caddyfile: string,
38
+ ): Effect.Effect<Adapted, Caddy.CaddyOpError, Caddy.CaddyOpContext> =>
39
+ Effect.map(
40
+ Caddy.Services.admin.adaptConfig({ config: caddyfile, contentType: CADDYFILE }),
41
+ (response) => ({
42
+ config: response.result ?? null,
43
+ warnings: (response.warnings ?? []).map(describe),
44
+ }),
45
+ );
93
46
 
94
47
  /** The running config, parsed; `null` when Caddy runs with none. */
95
- export const readRunningConfig = async (admin: CaddyAdmin): Promise<unknown> => {
96
- const response = await admin.request({ method: 'GET', path: '/config/' });
97
- if (response.status !== 200) fail(response, 'GET', '/config/');
98
- return parseConfig(response.body);
99
- };
48
+ export const readRunningConfig = (): Effect.Effect<
49
+ unknown,
50
+ Caddy.CaddyOpError,
51
+ Caddy.CaddyOpContext
52
+ > => Effect.map(Caddy.Services.admin.getConfig({ path: '' }), (response) => response.value ?? null);
100
53
 
101
54
  /**
102
- * Apply a Caddyfile with `POST /load`; resolves with the adapter's warnings.
55
+ * Apply a Caddyfile with `POST /load`; resolves with the adapter's warnings. A refused load
56
+ * (`LoadRefused` — the ⛔ above) propagates as that typed error, unretried by anything in this
57
+ * file — config-lifecycle.ts's `reconcileConfig` is where a refusal turns into a message naming
58
+ * what is still running.
103
59
  *
104
60
  * ★ `sourceFile` RIDES AS `Caddy-Config-Source-File` (+ `-Adapter: caddyfile`), the headers
105
61
  * `caddy reload` sends (cmd/commandfuncs.go). A load WITHOUT them makes Caddy forget the file it
106
62
  * was started with (caddy.go ClearLastConfigIfDifferent), and SIGUSR1 then no longer reloads
107
63
  * from that file. With them, and the same path, it keeps it.
108
64
  */
109
- export const loadCaddyfile = async (
110
- admin: CaddyAdmin,
65
+ export const loadCaddyfile = (
111
66
  caddyfile: string,
112
67
  sourceFile?: string,
113
- ): Promise<readonly string[]> => {
114
- const response = await admin.request({
115
- body: caddyfile,
116
- headers: {
117
- 'Content-Type': CADDYFILE,
118
- ...(sourceFile === undefined
119
- ? {}
120
- : { 'Caddy-Config-Source-Adapter': 'caddyfile', 'Caddy-Config-Source-File': sourceFile }),
121
- },
122
- method: 'POST',
123
- path: '/load',
124
- });
125
- const refused = errorIn(response.body);
126
- if (response.status !== 200 || refused !== undefined) fail(response, 'POST', '/load');
127
- return warningsIn(response.body);
128
- };
68
+ ): Effect.Effect<readonly string[], Caddy.CaddyOpError, Caddy.CaddyOpContext> =>
69
+ Effect.map(
70
+ Caddy.Services.admin.loadConfig({
71
+ config: caddyfile,
72
+ contentType: CADDYFILE,
73
+ ...(sourceFile === undefined ? {} : { sourceAdapter: 'caddyfile', sourceFile }),
74
+ }),
75
+ (response) => (response.warnings ?? []).map(describe),
76
+ );
@@ -1,34 +1,26 @@
1
1
  /**
2
- * The one seam between the Caddy provider and a running Caddy: every call to Caddy's admin API goes
3
- * through a CaddyAdmin, and nothing in this directory reaches Caddy any other way.
2
+ * The one seam between the Caddy provider and a running Caddy: every operation goes through
3
+ * `@distilled.cloud/caddy`'s typed `Services.admin.*` calls (admin-calls.ts), over the
4
+ * `CaddyAdminService` metadata and the `Credentials` + `HttpClient` layers `localCaddyAdmin()`
5
+ * builds (local-admin.ts) — nothing in this directory reaches Caddy any other way.
4
6
  *
5
7
  * ★ CADDY's OWN MANAGEMENT API, NOT A FILE AND A SIGNAL. Decision 23 (vault consolidation plan,
6
8
  * 2026-09-21): `POST /load` applies a Caddyfile with a graceful reload and rolls back a config
7
9
  * Caddy refuses; `GET /config/` reports what is running. Read in caddyserver/caddy v2.11.4,
8
10
  * caddyconfig/load.go and admin.go, and in the API docs (caddyserver.com/docs/api).
9
- * ★ A SEAM, THE SAME SHAPE AS launchd's HostRunner, so the lifecycle runs against a fake admin
10
- * server in tests and a consumer can plug in another route to the API (an SSH-forwarded socket
11
- * today; a remote runner later) without touching the provider. PROMISES, NOT EFFECTS, for the
12
- * same reason as HostRunner: a consumer writing their own implements plain async functions.
11
+ * ★ `CaddyAdminService` CARRIES ONLY MESSAGE/GUARD METADATA (endpoint, listener) — never a
12
+ * credential, and never the transport itself. The transport is `@distilled.cloud/caddy`'s own
13
+ * `Credentials` and `HttpClient.HttpClient` services, provided alongside it by `caddyAdminLayer`,
14
+ * so admin-calls.ts calls the SDK's operations directly rather than through a house-specific
15
+ * `.request()` seam. A consumer plugs in another route to the API (an SSH-forwarded socket today;
16
+ * a remote runner later) by providing a different `CaddyTransport` to `caddyAdminLayer`.
13
17
  * ⛔ THE ADMIN API HAS NO AUTHENTICATION. Anyone who reaches it can replace every site. It stays on
14
18
  * loopback or a unix socket; localCaddyAdmin() refuses anything else (local-admin.ts).
15
19
  */
20
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
21
  import * as Context from 'effect/Context';
17
22
  import * as Layer from 'effect/Layer';
18
-
19
- /** One admin API exchange. ⛔ `body` may hold the running config — never log it whole. */
20
- export type CaddyAdminResponse = {
21
- readonly status: number;
22
- readonly body: string;
23
- };
24
-
25
- export type CaddyAdminRequest = {
26
- readonly method: 'GET' | 'POST';
27
- /** Absolute API path: `/load`, `/adapt`, `/config/`. */
28
- readonly path: string;
29
- readonly headers?: Readonly<Record<string, string>>;
30
- readonly body?: string;
31
- };
23
+ import type { Credentials } from '@distilled.cloud/caddy';
32
24
 
33
25
  /**
34
26
  * Where the admin listener is, in the terms Caddy's own `admin` option uses — so a Caddyfile that
@@ -38,37 +30,41 @@ export type CaddyAdminListener =
38
30
  | { readonly kind: 'tcp'; readonly hostHeader: string; readonly port: number }
39
31
  | { readonly kind: 'unix'; readonly path: string };
40
32
 
41
- export interface CaddyAdmin {
42
- /** Where requests go, for error messages and the resource's attributes. Never a credential. */
33
+ /** Message/guard metadata for the Caddy this stack talks to — never a credential. */
34
+ export interface CaddyTarget {
35
+ /** Where requests go, for error messages and the resource's attributes. */
43
36
  readonly endpoint: string;
44
37
  /**
45
38
  * The listener as Caddy sees it. `hostHeader` is the Host every request carries: Caddy checks it
46
39
  * against `admin.origins` (admin.go checkHost) on any loopback TCP listener.
47
40
  */
48
41
  readonly listener: CaddyAdminListener;
49
- /**
50
- * Send one request. Resolves with ANY status — classifying it is the caller's job
51
- * (admin-calls.ts). Rejects only when there was no HTTP exchange at all: with a
52
- * CaddyUnreachableError when nothing accepted the connection, any other Error otherwise.
53
- */
54
- request(request: CaddyAdminRequest): Promise<CaddyAdminResponse>;
55
42
  }
56
43
 
57
- /**
58
- * Nothing accepted the connection — Caddy is not running, or not listening yet. A transport rejects
59
- * with THIS only when the request provably never reached Caddy (refused, no socket file), because
60
- * the provider treats it differently from every other failure: a stopped Caddy must not fail the
61
- * PLAN that would bring it back (its launchd job in the same stack) — see config.ts.
62
- */
63
- export class CaddyUnreachableError extends Error {
64
- override readonly name = 'CaddyUnreachableError';
44
+ /** What `localCaddyAdmin()` (or a consumer's own transport) hands `caddyAdminLayer`. */
45
+ export interface CaddyTransport extends CaddyTarget {
46
+ /** Provides the SDK's own `Credentials` and `HttpClient.HttpClient` — see local-admin.ts. */
47
+ readonly layer: Layer.Layer<Credentials | HttpClient.HttpClient>;
65
48
  }
66
49
 
67
- /** The Effect service the Caddy provider reads its admin transport from. */
68
- export class CaddyAdminService extends Context.Service<CaddyAdminService, CaddyAdmin>()(
50
+ /** The Effect service the Caddy provider reads its admin target's metadata from. */
51
+ export class CaddyAdminService extends Context.Service<CaddyAdminService, CaddyTarget>()(
69
52
  'homeflare/caddy/CaddyAdmin',
70
53
  ) {}
71
54
 
72
- /** Provide a transport to the provider: `Layer.provide(caddyAdminLayer(localCaddyAdmin()))`. */
73
- export const caddyAdminLayer = (admin: CaddyAdmin): Layer.Layer<CaddyAdminService> =>
74
- Layer.succeed(CaddyAdminService, admin);
55
+ /**
56
+ * Provide a transport to the provider: `CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(localCaddyAdmin())))`
57
+ * — never plain `Layer.provide` here (see providers.ts's own ⚠️): `CaddyConfigProvider()`'s handlers
58
+ * keep needing these services every time the engine calls them, not just once while it is built. Merges
59
+ * the target metadata with the SDK's `Credentials`/`HttpClient` layers `transport.layer` carries.
60
+ */
61
+ export const caddyAdminLayer = (
62
+ transport: CaddyTransport,
63
+ ): Layer.Layer<CaddyAdminService | Credentials | HttpClient.HttpClient> =>
64
+ Layer.mergeAll(
65
+ Layer.succeed(CaddyAdminService, {
66
+ endpoint: transport.endpoint,
67
+ listener: transport.listener,
68
+ }),
69
+ transport.layer,
70
+ );
@@ -58,7 +58,7 @@ const applyFirstDeploy = (scope: Scope, flag: boolean) => {
58
58
  session: undefined as never,
59
59
  });
60
60
  }).pipe(
61
- Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(made.admin)))),
61
+ Effect.provide(CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(made.admin)))),
62
62
  Effect.provideService(AdoptPolicy, flag),
63
63
  Effect.provideService(Stack, spec as never),
64
64
  );
@@ -0,0 +1,229 @@
1
+ /**
2
+ * An Effect `HttpClient.HttpClient` over `node:http`, dialling a unix socket or loopback TCP
3
+ * directly — the SDK's own transport layer for `@distilled.cloud/caddy`'s typed operations.
4
+ *
5
+ * ★ node:http, NOT `effect/unstable/http/FetchHttpClient`. MEASURED 2026-09-21 on bun 1.4.0 and
6
+ * node 26.7.0 (local-admin.ts's history): node:http dials a unix socket on BOTH runtimes, while
7
+ * node's fetch has no `unix` option at all (only Bun's does) — `@effect/platform-node`'s own
8
+ * `FetchHttpClient`/`NodeHttpClient.makeNodeHttp` (node:http, but URL-only — no `socketPath` seam)
9
+ * cannot speak a unix socket either. The published dist loads under node too (launchd's
10
+ * local-runner.ts makes the same call for the same reason), so this stays runtime-agnostic.
11
+ * ★ A CONNECTION NOTHING ACCEPTED IS RETRIED; ONE CADDY ALREADY SAW IS NEVER RE-SENT. Only
12
+ * `ECONNREFUSED` (nothing listening) and `ENOENT` (no unix socket file yet) mean the request
13
+ * never reached Caddy — a launchd job bootstrapped a moment ago. Anything else (a reset, a
14
+ * timeout) may have reached Caddy, so `POST /load` is never resent for it: `isUnreachable`
15
+ * (local-admin.ts) is what tells `Caddy.Config`'s plan-time read to proceed without Caddy instead
16
+ * of failing the whole plan (config.ts's `planWithoutCaddy`).
17
+ */
18
+ import * as http from 'node:http';
19
+ import { Readable } from 'node:stream';
20
+ import * as Duration from 'effect/Duration';
21
+ import * as Effect from 'effect/Effect';
22
+ import * as Layer from 'effect/Layer';
23
+ import * as Result from 'effect/Result';
24
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
25
+ import * as HttpClientError from 'effect/unstable/http/HttpClientError';
26
+ import type * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
27
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
28
+ import type { Target } from './admin-address.ts';
29
+
30
+ export type LocalHttpClientOptions = {
31
+ /**
32
+ * Abort an exchange after this long. @default 60_000
33
+ * ⚠️ A `shutdown_delay` in the Caddyfile makes `/load` block that long (caddyhttp app.go Stop);
34
+ * a timeout then fails the deploy while Caddy goes on to apply — the next plan converges.
35
+ */
36
+ readonly timeoutMs?: number;
37
+ /** Retries when the connection is REFUSED — see the module doc. @default 2 */
38
+ readonly retries?: number;
39
+ /** @default 500 */
40
+ readonly retryDelayMs?: number;
41
+ };
42
+
43
+ const NEVER_CONNECTED = new Set(['ECONNREFUSED', 'ENOENT']);
44
+
45
+ const codeOf = (cause: unknown): string | undefined =>
46
+ typeof cause === 'object' && cause !== null && 'code' in cause
47
+ ? String((cause as { code: unknown }).code)
48
+ : undefined;
49
+
50
+ /**
51
+ * Nothing accepted the connection — see the module doc's ★. A type guard, not just a predicate, so
52
+ * config-lifecycle.ts's error-wrapping can pass through the raw error unchanged (still typed) and
53
+ * config.ts's plan-time escape hatch (`Effect.catchIf`) narrows to it directly.
54
+ */
55
+ export const isUnreachable = (error: unknown): error is HttpClientError.HttpClientError =>
56
+ error instanceof HttpClientError.HttpClientError &&
57
+ error.reason._tag === 'TransportError' &&
58
+ NEVER_CONNECTED.has(codeOf(error.reason.cause) ?? '');
59
+
60
+ const transportFailure = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>
61
+ new HttpClientError.HttpClientError({
62
+ reason: new HttpClientError.TransportError({ request, cause }),
63
+ });
64
+
65
+ /** Node's response headers (string | string[] | undefined) → the single-valued form Web `Response` wants. Caddy never repeats a header this transport reads. */
66
+ const flattenHeaders = (headers: http.IncomingHttpHeaders): Record<string, string> => {
67
+ const flat: Record<string, string> = {};
68
+ for (const [key, value] of Object.entries(headers)) {
69
+ if (value !== undefined) flat[key] = Array.isArray(value) ? value.join(', ') : value;
70
+ }
71
+ return flat;
72
+ };
73
+
74
+ const waitForResponse = (
75
+ nodeRequest: http.ClientRequest,
76
+ request: HttpClientRequest.HttpClientRequest,
77
+ ) =>
78
+ Effect.callback<http.IncomingMessage, HttpClientError.HttpClientError>((resume) => {
79
+ const onError = (cause: Error): void => {
80
+ resume(Effect.fail(transportFailure(request, cause)));
81
+ };
82
+ nodeRequest.on('error', onError);
83
+ const onResponse = (response: http.IncomingMessage): void => {
84
+ nodeRequest.off('error', onError);
85
+ resume(Effect.succeed(response));
86
+ };
87
+ nodeRequest.on('response', onResponse);
88
+ return Effect.sync(() => {
89
+ nodeRequest.off('error', onError);
90
+ nodeRequest.off('response', onResponse);
91
+ });
92
+ });
93
+
94
+ const waitForFinish = (
95
+ nodeRequest: http.ClientRequest,
96
+ request: HttpClientRequest.HttpClientRequest,
97
+ ) =>
98
+ Effect.callback<void, HttpClientError.HttpClientError>((resume) => {
99
+ const onError = (cause: Error): void => {
100
+ resume(Effect.fail(transportFailure(request, cause)));
101
+ };
102
+ nodeRequest.once('error', onError);
103
+ const onFinish = (): void => {
104
+ nodeRequest.off('error', onError);
105
+ resume(Effect.void);
106
+ };
107
+ nodeRequest.once('finish', onFinish);
108
+ return Effect.sync(() => {
109
+ nodeRequest.off('error', onError);
110
+ nodeRequest.off('finish', onFinish);
111
+ });
112
+ });
113
+
114
+ /**
115
+ * Every Caddy operation's request body is `Empty` (GET/DELETE/`/stop`) or a `Uint8Array` — the
116
+ * `T.HttpBody()`/JSON-body cascade never produces `Raw`, `FormData` or `Stream` for this SDK
117
+ * (protocol.ts). A typed refusal, not a defect (S20), for a body shape that would mean the SDK grew
118
+ * an operation this transport was never taught to send.
119
+ */
120
+ const sendBody = (
121
+ nodeRequest: http.ClientRequest,
122
+ request: HttpClientRequest.HttpClientRequest,
123
+ ): Effect.Effect<void, HttpClientError.HttpClientError> =>
124
+ Effect.suspend(() => {
125
+ const body = request.body;
126
+ if (body._tag === 'Empty') {
127
+ nodeRequest.end();
128
+ return waitForFinish(nodeRequest, request);
129
+ }
130
+ if (body._tag === 'Uint8Array') {
131
+ // ⚠️ `body.contentLength` is never nullish, so `?? body.body.byteLength` reads as dead code
132
+ // to TypeScript's `??` narrowing — which then (a real quirk, not a defect here) infers
133
+ // `body` itself as `never` on that unreachable side. Two plain statements instead.
134
+ nodeRequest.setHeader('content-length', body.contentLength);
135
+ nodeRequest.end(body.body);
136
+ return waitForFinish(nodeRequest, request);
137
+ }
138
+ return Effect.fail(
139
+ new HttpClientError.HttpClientError({
140
+ reason: new HttpClientError.EncodeError({
141
+ request,
142
+ description: `Caddy admin transport: unsupported request body "${body._tag}" — every Caddy operation sends Empty or a Uint8Array body`,
143
+ }),
144
+ }),
145
+ );
146
+ });
147
+
148
+ const toWebResponse = (source: http.IncomingMessage): Response =>
149
+ new Response(Readable.toWeb(source) as unknown as ReadableStream, {
150
+ headers: flattenHeaders(source.headers),
151
+ status: source.statusCode ?? 0,
152
+ });
153
+
154
+ const exchangeOnce = (
155
+ target: Target,
156
+ request: HttpClientRequest.HttpClientRequest,
157
+ url: URL,
158
+ signal: AbortSignal,
159
+ timeoutMs: number,
160
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> =>
161
+ Effect.suspend(() => {
162
+ const nodeRequest = http.request({
163
+ headers: request.headers,
164
+ method: request.method,
165
+ path: `${url.pathname}${url.search}`,
166
+ signal,
167
+ timeout: timeoutMs,
168
+ ...(target.kind === 'unix'
169
+ ? { socketPath: target.socketPath }
170
+ : { host: target.host, port: target.port }),
171
+ });
172
+ // node:http's own inactivity timeout — a hang (e.g. a `shutdown_delay` blocking `/load`, see
173
+ // the module doc) destroys the request, which then surfaces through the same 'error' handler
174
+ // `waitForResponse`/`waitForFinish` already listen on.
175
+ nodeRequest.on('timeout', () => {
176
+ nodeRequest.destroy(new Error(`no answer in ${String(timeoutMs)} ms`));
177
+ });
178
+ return Effect.raceFirst(
179
+ waitForResponse(nodeRequest, request),
180
+ sendBody(nodeRequest, request).pipe(Effect.andThen(Effect.never)),
181
+ ).pipe(
182
+ Effect.onError(() => Effect.sync(() => nodeRequest.destroy())),
183
+ Effect.map((source) => HttpClientResponse.fromWeb(request, toWebResponse(source))),
184
+ );
185
+ });
186
+
187
+ /**
188
+ * Retry ONLY a connection nothing accepted, up to `retries` times, spaced `retryDelayMs` apart —
189
+ * a plain loop over `Effect.result`, not `Effect.retry`'s `{schedule, times, while}` options
190
+ * object. MEASURED 2026-09-23: with a `Schedule` given alongside `times`/`while`, that combined
191
+ * form retried an ECONNRESET past both — `while` kept correctly returning `false` every attempt,
192
+ * logged and confirmed, yet the schedule's own unbounded `spaced` kept driving retries anyway. A
193
+ * loop leaves no such ambiguity: the SAME `isUnreachable` check that would gate `while` here gates
194
+ * every iteration directly, one attempt at a time.
195
+ */
196
+ const withUnreachableRetry = (
197
+ attempt: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
198
+ retries: number,
199
+ retryDelayMs: number,
200
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> =>
201
+ Effect.gen(function* () {
202
+ let attemptsLeft = retries;
203
+ for (;;) {
204
+ const outcome = yield* Effect.result(attempt);
205
+ if (Result.isSuccess(outcome)) return outcome.success;
206
+ if (attemptsLeft <= 0 || !isUnreachable(outcome.failure)) {
207
+ return yield* Effect.fail(outcome.failure);
208
+ }
209
+ attemptsLeft -= 1;
210
+ yield* Effect.sleep(Duration.millis(retryDelayMs));
211
+ }
212
+ });
213
+
214
+ /** The transport `localCaddyAdmin()` provides as `HttpClient.HttpClient` — see local-admin.ts. */
215
+ export const makeLocalHttpClient = (
216
+ target: Target,
217
+ options: LocalHttpClientOptions = {},
218
+ ): Layer.Layer<HttpClient.HttpClient> => {
219
+ const timeoutMs = options.timeoutMs ?? 60_000;
220
+ const retries = options.retries ?? 2;
221
+ const retryDelayMs = options.retryDelayMs ?? 500;
222
+ return Layer.succeed(
223
+ HttpClient.HttpClient,
224
+ HttpClient.make((request, url, signal) => {
225
+ const attempt = exchangeOnce(target, request, url, signal, timeoutMs);
226
+ return retries <= 0 ? attempt : withUnreachableRetry(attempt, retries, retryDelayMs);
227
+ }),
228
+ );
229
+ };