@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.
- package/README.md +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +9 -5
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +161 -25
- package/dist/github/index.js.map +15 -13
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +37 -3
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +15 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
- package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +91 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +28 -34
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +9 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline.ts +6 -6
- package/src/github/index.ts +10 -4
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +3 -2
- package/src/github/repository-ruleset-errors.ts +51 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +11 -4
- package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
- package/src/github/repository-ruleset-narrowing.test.ts +210 -0
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +95 -0
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +53 -43
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
package/src/caddy/admin-calls.ts
CHANGED
|
@@ -1,128 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The three admin API calls the provider makes,
|
|
3
|
-
*
|
|
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)
|
|
7
|
-
*
|
|
8
|
-
* · `POST /load` — Content-Type `text/caddyfile` picks the adapter (load.go
|
|
9
|
-
* "if the new config fails for any reason, the old config is rolled back
|
|
10
|
-
* downtime" (docs; caddy.go changeConfig restores the previous raw config).
|
|
11
|
-
* config is a no-op without `Cache-Control: must-revalidate`.
|
|
12
|
-
* · `GET /config/` — the running config as JSON, `null` when empty (admin.go
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* status
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
25
|
-
import
|
|
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 =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
110
|
-
admin: CaddyAdmin,
|
|
65
|
+
export const loadCaddyfile = (
|
|
111
66
|
caddyfile: string,
|
|
112
67
|
sourceFile?: string,
|
|
113
|
-
):
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
...(sourceFile === undefined
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
);
|
package/src/caddy/admin.ts
CHANGED
|
@@ -1,34 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one seam between the Caddy provider and a running Caddy: every
|
|
3
|
-
*
|
|
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
|
-
* ★
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
68
|
-
export class CaddyAdminService extends Context.Service<CaddyAdminService,
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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
|
+
};
|