@homeflare/alchemy 0.29.1 → 0.30.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 +20 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/format-warnings.d.ts +29 -0
- package/dist/caddy/format-warnings.d.ts.map +1 -0
- package/dist/caddy/format.d.ts +50 -0
- package/dist/caddy/format.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +1 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +78 -10
- package/dist/caddy/index.js.map +7 -5
- package/dist/google-workspace/credentials.d.ts +54 -0
- package/dist/google-workspace/credentials.d.ts.map +1 -0
- package/dist/google-workspace/domain-alias.d.ts +74 -0
- package/dist/google-workspace/domain-alias.d.ts.map +1 -0
- package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
- package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
- package/dist/google-workspace/group-member.d.ts +73 -0
- package/dist/google-workspace/group-member.d.ts.map +1 -0
- package/dist/google-workspace/group.d.ts +87 -0
- package/dist/google-workspace/group.d.ts.map +1 -0
- package/dist/google-workspace/index.d.ts +19 -0
- package/dist/google-workspace/index.d.ts.map +1 -0
- package/dist/google-workspace/index.js +283 -0
- package/dist/google-workspace/index.js.map +17 -0
- package/dist/google-workspace/org-unit.d.ts +75 -0
- package/dist/google-workspace/org-unit.d.ts.map +1 -0
- package/dist/google-workspace/providers.d.ts +16 -0
- package/dist/google-workspace/providers.d.ts.map +1 -0
- package/dist/google-workspace/resource.d.ts +80 -0
- package/dist/google-workspace/resource.d.ts.map +1 -0
- package/dist/google-workspace/values.d.ts +28 -0
- package/dist/google-workspace/values.d.ts.map +1 -0
- package/dist/grafana/credentials.d.ts +38 -0
- package/dist/grafana/credentials.d.ts.map +1 -0
- package/dist/grafana/datasource.d.ts +90 -0
- package/dist/grafana/datasource.d.ts.map +1 -0
- package/dist/grafana/fake-grafana.d.ts +48 -0
- package/dist/grafana/fake-grafana.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +17 -0
- package/dist/grafana/index.d.ts.map +1 -0
- package/dist/grafana/index.js +149 -0
- package/dist/grafana/index.js.map +13 -0
- package/dist/grafana/providers.d.ts +17 -0
- package/dist/grafana/providers.d.ts.map +1 -0
- package/dist/grafana/resource.d.ts +68 -0
- package/dist/grafana/resource.d.ts.map +1 -0
- package/docs/caddy-fmt.md +59 -0
- package/docs/caddy.md +7 -0
- package/docs/credentials.md +11 -0
- package/docs/google-workspace.md +105 -0
- package/docs/grafana.md +102 -0
- package/docs/peers.md +9 -0
- package/docs/upstream-conformance.md +37 -0
- package/package.json +9 -1
- package/src/caddy/config-format-warning.test.ts +91 -0
- package/src/caddy/config-reconcile.ts +6 -0
- package/src/caddy/config.ts +8 -1
- package/src/caddy/format-warnings.test.ts +50 -0
- package/src/caddy/format-warnings.ts +39 -0
- package/src/caddy/format.test.ts +105 -0
- package/src/caddy/format.ts +121 -0
- package/src/caddy/index.ts +7 -0
- package/src/caddy/providers.test.ts +8 -0
- package/src/google-workspace/credentials.test.ts +40 -0
- package/src/google-workspace/credentials.ts +75 -0
- package/src/google-workspace/domain-alias.test.ts +69 -0
- package/src/google-workspace/domain-alias.ts +116 -0
- package/src/google-workspace/fake-google-workspace.ts +54 -0
- package/src/google-workspace/group-member.test.ts +91 -0
- package/src/google-workspace/group-member.ts +126 -0
- package/src/google-workspace/group.test.ts +93 -0
- package/src/google-workspace/group.ts +128 -0
- package/src/google-workspace/index.ts +54 -0
- package/src/google-workspace/org-unit.test.ts +81 -0
- package/src/google-workspace/org-unit.ts +143 -0
- package/src/google-workspace/providers.ts +26 -0
- package/src/google-workspace/resource.test.ts +186 -0
- package/src/google-workspace/resource.ts +162 -0
- package/src/google-workspace/values.ts +36 -0
- package/src/grafana/credentials.ts +65 -0
- package/src/grafana/datasource.test.ts +183 -0
- package/src/grafana/datasource.ts +171 -0
- package/src/grafana/fake-grafana.ts +64 -0
- package/src/grafana/index.ts +22 -0
- package/src/grafana/providers.ts +19 -0
- package/src/grafana/resource.ts +120 -0
|
@@ -225,6 +225,29 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
225
225
|
13. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
|
|
226
226
|
fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
|
|
227
227
|
|
|
228
|
+
14. **`grafana/*` (added 2026-09-24) ships only `Datasource` — a real SDK gap, not scope-trimming.**
|
|
229
|
+
Measured against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball's
|
|
230
|
+
`lib/services/grafana.d.ts` (4,938 lines): the package has no create/read/update/delete
|
|
231
|
+
operations for folders (only `updateFolderPermissions` exists), plain dashboards (only
|
|
232
|
+
snapshot/public-dashboard routes exist — no `POST /dashboards/db`, no
|
|
233
|
+
`GET/DELETE /dashboards/uid/{uid}`), alert rules or contact points (both have only a
|
|
234
|
+
`routeGet*Export` read-only route under `/v1/provisioning/`, no create/update/delete). S1
|
|
235
|
+
found no upstream `Alchemy` family for any of these either. Full detail:
|
|
236
|
+
[grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships). **Decision:** maintainer,
|
|
237
|
+
on whether the kit patches distilled (S22) or waits for upstream to add the routes — S23
|
|
238
|
+
forbids a hand-rolled `HttpClient` client for the missing pieces alone, half a family through
|
|
239
|
+
the SDK and half through a second client.
|
|
240
|
+
- ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
|
|
241
|
+
(H1), and credentials come from an explicit env var NAME at call time rather than an
|
|
242
|
+
`alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
|
|
243
|
+
SDK's `CredentialsFromEnv`, because more than one Grafana instance exists on this estate
|
|
244
|
+
(grafana.md's own Credentials section).
|
|
245
|
+
- Measured, read-only, on `teslamate-grafana.service` (CT100, `teslamate/grafana:4.2.0`,
|
|
246
|
+
Grafana 13.1.3): one datasource (file-provisioned, not API-managed — a live example this
|
|
247
|
+
family COULD adopt), dashboards baked into the image (not API-managed either way), and no
|
|
248
|
+
folders, alert rules or contact points configured — consistent with the gap being real
|
|
249
|
+
rather than merely unexploited. No stack yet imports `@homeflare/alchemy/grafana`.
|
|
250
|
+
|
|
228
251
|
## Conforms
|
|
229
252
|
|
|
230
253
|
- **`paperless/*` (`Tag`, `DocumentType`, `StoragePath`, `CustomField`)** — ✅ **S23 fixed
|
|
@@ -263,6 +286,20 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
263
286
|
an Output. That fix also belongs in the engine upstream.
|
|
264
287
|
- **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
|
|
265
288
|
labels every cold adoption as an update (H6).
|
|
289
|
+
- **`google-workspace/*`** (new 2026-09-24, `Group`, `GroupMember`, `DomainAlias`, `OrgUnit`)
|
|
290
|
+
is built directly on `@distilled.cloud/google-workspace@1.0.0-rc.12`'s typed
|
|
291
|
+
`unstable/admin_directory_v1` operations from the first commit — no hand-rolled client ever
|
|
292
|
+
existed for it to migrate off (S23), so there is no `client.ts` deletion to record. Every
|
|
293
|
+
`fetchLive` is get-by-key with its own `catchTag('NotFound', …)`, the same split forgejo's and
|
|
294
|
+
netbox's engines use (resource.ts). Get-by-key rather than NetBox's list-then-disambiguate,
|
|
295
|
+
since Directory addresses every object this family models by a stable key. ⛔ **Diverges on
|
|
296
|
+
credentials, same shape as the rest of this ledger (S24):** `GOOGLE_ACCESS_TOKEN` is read at
|
|
297
|
+
call time through the SDK's own `CredentialsFromEnv` — but here the SDK provides no way to
|
|
298
|
+
MINT that token at all (no service-account/DWD support), so a Bun wrapper outside this
|
|
299
|
+
package (H8, unwritten by this PR) is load-bearing in a way NetBox's/Forgejo's simple
|
|
300
|
+
API-token env vars are not. SDK gaps (the `unstable/` service tree, no per-operation error
|
|
301
|
+
beyond the shared 4xx set, no group-alias update) and the full credential/scope setup:
|
|
302
|
+
[google-workspace.md](./google-workspace.md).
|
|
266
303
|
|
|
267
304
|
## The Bun line
|
|
268
305
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -84,6 +84,14 @@
|
|
|
84
84
|
"types": "./dist/litellm/index.d.ts",
|
|
85
85
|
"default": "./dist/litellm/index.js"
|
|
86
86
|
},
|
|
87
|
+
"./google-workspace": {
|
|
88
|
+
"types": "./dist/google-workspace/index.d.ts",
|
|
89
|
+
"default": "./dist/google-workspace/index.js"
|
|
90
|
+
},
|
|
91
|
+
"./grafana": {
|
|
92
|
+
"types": "./dist/grafana/index.d.ts",
|
|
93
|
+
"default": "./dist/grafana/index.js"
|
|
94
|
+
},
|
|
87
95
|
"./verify": {
|
|
88
96
|
"types": "./dist/verify/index.d.ts",
|
|
89
97
|
"default": "./dist/verify/index.js"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Caddyfile is not formatted" warning gets its OWN clear line, at both PLAN time
|
|
3
|
+
* (config-reconcile.ts's diffConfig) and DEPLOY time (config.ts's CaddyConfigProvider reconcile)
|
|
4
|
+
* — format-warnings.ts's split, wired into both. Captured with a Logger, the house pattern for
|
|
5
|
+
* asserting on `Effect.logWarning` (proxmox/lxc-harness.ts's `collect`).
|
|
6
|
+
*
|
|
7
|
+
* format-warnings.test.ts already covers the split itself in isolation; this file is the proof
|
|
8
|
+
* that plan and deploy actually call it, so a Caddyfile nobody ran through `caddy fmt` gets the
|
|
9
|
+
* fix line — not silence, and not just the adapter's own warning buried among others.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as Logger from 'effect/Logger';
|
|
16
|
+
import { caddyAdminLayer } from './admin.ts';
|
|
17
|
+
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
18
|
+
import { diffConfig } from './config-reconcile.ts';
|
|
19
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
20
|
+
|
|
21
|
+
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
22
|
+
const UNFORMATTED = `${SITE}\t`; // fake-caddy.ts: a TAB anywhere is its "not formatted" trigger
|
|
23
|
+
const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
|
|
24
|
+
|
|
25
|
+
let fake: FakeCaddy | undefined;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fake?.stop();
|
|
28
|
+
fake = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const setup = () => {
|
|
32
|
+
const made = fakeDefaultCaddy();
|
|
33
|
+
fake = made.caddy;
|
|
34
|
+
return made;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Runs `effect` and returns its result plus every `Effect.logWarning` line it emitted. */
|
|
38
|
+
const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
|
|
39
|
+
const warnings: string[] = [];
|
|
40
|
+
const collect = Logger.make((options) => {
|
|
41
|
+
if (options.logLevel === 'Warn') warnings.push(String(options.message));
|
|
42
|
+
});
|
|
43
|
+
const value = await Effect.runPromise(effect.pipe(Effect.provide(Logger.layer([collect]))));
|
|
44
|
+
return { value, warnings };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('the formatting warning is surfaced distinctly', () => {
|
|
48
|
+
test('plan: diffConfig logs the fix line for an unformatted Caddyfile', async () => {
|
|
49
|
+
const { admin } = setup();
|
|
50
|
+
const output = { configSha256: 'stub', endpoint: admin.endpoint };
|
|
51
|
+
const { value, warnings } = await withWarnings(
|
|
52
|
+
diffConfig({ caddyfile: UNFORMATTED }, output).pipe(Effect.provide(caddyAdminLayer(admin))),
|
|
53
|
+
);
|
|
54
|
+
expect(value).toEqual({ action: 'update' });
|
|
55
|
+
expect(warnings).toHaveLength(1);
|
|
56
|
+
expect(warnings[0]).toContain('caddy fmt');
|
|
57
|
+
expect(warnings[0]).toContain(admin.endpoint);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('plan: a formatted Caddyfile logs nothing', async () => {
|
|
61
|
+
const { admin } = setup();
|
|
62
|
+
const output = { configSha256: 'stub', endpoint: admin.endpoint };
|
|
63
|
+
const { warnings } = await withWarnings(
|
|
64
|
+
diffConfig({ caddyfile: SITE }, output).pipe(Effect.provide(caddyAdminLayer(admin))),
|
|
65
|
+
);
|
|
66
|
+
expect(warnings).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('deploy: the real CaddyConfigProvider logs the fix line, not the raw adapter text', async () => {
|
|
70
|
+
const { admin } = setup();
|
|
71
|
+
const program = Effect.gen(function* () {
|
|
72
|
+
const provider = yield* CaddyConfig.Provider;
|
|
73
|
+
return yield* provider.reconcile({
|
|
74
|
+
...ids,
|
|
75
|
+
bindings: [] as never,
|
|
76
|
+
news: { caddyfile: UNFORMATTED },
|
|
77
|
+
olds: undefined,
|
|
78
|
+
output: undefined as never,
|
|
79
|
+
session: undefined as never,
|
|
80
|
+
});
|
|
81
|
+
}).pipe(
|
|
82
|
+
Effect.provide(CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(admin)))),
|
|
83
|
+
// reconcile with no state needs an adopt decision; a fresh Caddy has nothing to adopt.
|
|
84
|
+
Effect.provideService(AdoptPolicy, true),
|
|
85
|
+
);
|
|
86
|
+
const { warnings } = await withWarnings(program as Effect.Effect<unknown, unknown, never>);
|
|
87
|
+
expect(warnings).toHaveLength(1);
|
|
88
|
+
expect(warnings[0]).toContain('caddy fmt');
|
|
89
|
+
expect(warnings.some((w) => w.includes('Caddyfile input is not formatted'))).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -14,6 +14,7 @@ import { messageOf, refuse, short, wrapOperation } from './config-errors.ts';
|
|
|
14
14
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
15
15
|
import { claimable, desiredConfig, readLive } from './config-lifecycle.ts';
|
|
16
16
|
import { configDigest } from './digest.ts';
|
|
17
|
+
import { formattingFixLine, splitFormattingWarning } from './format-warnings.ts';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
|
|
@@ -27,6 +28,11 @@ export const diffConfig = (
|
|
|
27
28
|
Effect.gen(function* () {
|
|
28
29
|
const { endpoint } = yield* CaddyAdminService;
|
|
29
30
|
const want = yield* desiredConfig(news);
|
|
31
|
+
// ★ SURFACED AT PLAN TIME, NOT JUST DEPLOY TIME. `want.warnings` otherwise goes nowhere here —
|
|
32
|
+
// only the formatting warning gets its own clear line; the rest keep logging at reconcile
|
|
33
|
+
// (config.ts), same as before this change.
|
|
34
|
+
const { formatting } = splitFormattingWarning(want.warnings);
|
|
35
|
+
if (formatting.length > 0) yield* Effect.logWarning(formattingFixLine(endpoint));
|
|
30
36
|
const live = yield* readLive();
|
|
31
37
|
const converged =
|
|
32
38
|
want.digest === live.configSha256 &&
|
package/src/caddy/config.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { isUnreachable } from './caddy-http-client.ts';
|
|
|
35
35
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
36
36
|
import { probeLive, readLive } from './config-lifecycle.ts';
|
|
37
37
|
import { diffConfig, reconcileConfig } from './config-reconcile.ts';
|
|
38
|
+
import { formattingFixLine, splitFormattingWarning } from './format-warnings.ts';
|
|
38
39
|
|
|
39
40
|
export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
40
41
|
|
|
@@ -136,7 +137,13 @@ export const CaddyConfigProvider = () =>
|
|
|
136
137
|
const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
|
|
137
138
|
const stored = output === undefined ? {} : { stored: output.configSha256 };
|
|
138
139
|
const applied = yield* reconcileConfig(news, { takeOver, ...stored });
|
|
139
|
-
|
|
140
|
+
// ★ THE FORMATTING WARNING GETS ITS OWN LINE, NAMING THE FIX — separate from the rest,
|
|
141
|
+
// which keep logging exactly as before (format-warnings.ts).
|
|
142
|
+
const { formatting, rest } = splitFormattingWarning(applied.warnings);
|
|
143
|
+
if (formatting.length > 0) {
|
|
144
|
+
yield* Effect.logWarning(formattingFixLine(admin.endpoint));
|
|
145
|
+
}
|
|
146
|
+
for (const warning of rest) {
|
|
140
147
|
yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
|
|
141
148
|
}
|
|
142
149
|
return applied.attributes;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* splitFormattingWarning() / formattingFixLine() — pure, so no fake Caddy is needed: the
|
|
3
|
+
* behavior that matters is which warning(s) get pulled out and what the fix line says.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { formattingFixLine, splitFormattingWarning } from './format-warnings.ts';
|
|
7
|
+
|
|
8
|
+
const FORMATTING = 'Caddyfile:1: Caddyfile input is not formatted';
|
|
9
|
+
const OTHER = 'Caddyfile:3: unrecognized directive: reverse_proxyy';
|
|
10
|
+
|
|
11
|
+
describe('splitFormattingWarning', () => {
|
|
12
|
+
test('pulls the formatting warning out, leaving the rest untouched', () => {
|
|
13
|
+
expect(splitFormattingWarning([OTHER, FORMATTING])).toEqual({
|
|
14
|
+
formatting: [FORMATTING],
|
|
15
|
+
rest: [OTHER],
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('formatting is empty, and rest unchanged, when there is no formatting warning', () => {
|
|
20
|
+
expect(splitFormattingWarning([OTHER])).toEqual({ formatting: [], rest: [OTHER] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('an empty list splits into no formatting warning and no rest', () => {
|
|
24
|
+
expect(splitFormattingWarning([])).toEqual({ formatting: [], rest: [] });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('matches by substring, so a differing file:line still counts as the formatting warning', () => {
|
|
28
|
+
const elsewhere = '/etc/Caddyfile:42: Caddyfile input is not formatted';
|
|
29
|
+
expect(splitFormattingWarning([elsewhere])).toEqual({ formatting: [elsewhere], rest: [] });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('TWO formatting warnings (e.g. an unformatted import) both survive — neither is dropped', () => {
|
|
33
|
+
// ⛔ Regression: a `.find()` here would keep only the first AND (via `.filter()`) strip the
|
|
34
|
+
// second out of `rest` too — silently discarding it from both branches at once.
|
|
35
|
+
const second = '/etc/Caddyfile.d/site.caddy:1: Caddyfile input is not formatted';
|
|
36
|
+
expect(splitFormattingWarning([FORMATTING, OTHER, second])).toEqual({
|
|
37
|
+
formatting: [FORMATTING, second],
|
|
38
|
+
rest: [OTHER],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('formattingFixLine', () => {
|
|
44
|
+
test('names the endpoint and both fixes, never just the adapter’s own warning text', () => {
|
|
45
|
+
const line = formattingFixLine('http://127.0.0.1:2019');
|
|
46
|
+
expect(line).toContain('http://127.0.0.1:2019');
|
|
47
|
+
expect(line).toContain('caddy fmt');
|
|
48
|
+
expect(line).toContain('formatCaddyfile()');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls Caddy's adapter "Caddyfile input is not formatted" warning(s) out of a warnings list, so
|
|
3
|
+
* config.ts's reconcile (deploy) and config-reconcile.ts's diffConfig (plan) can show it as one
|
|
4
|
+
* clear, actionable line instead of burying it among the rest.
|
|
5
|
+
*
|
|
6
|
+
* ★ THIS IS THE COMMON CASE, NOT AN EDGE ONE. admin-calls.ts's module doc: the Caddyfile adapter
|
|
7
|
+
* warns on EVERY unformatted input (adapter.go FormattingDifference) — every declared Caddyfile
|
|
8
|
+
* that was not run through `caddy fmt` (or format.ts's formatCaddyfile()) carries this warning
|
|
9
|
+
* on every plan and every deploy, indistinguishable from the rest unless split out.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The exact adapter message (caddyserver/caddy v2.11.4, adapter.go FormattingDifference). */
|
|
13
|
+
const NOT_FORMATTED = 'Caddyfile input is not formatted';
|
|
14
|
+
|
|
15
|
+
export type SplitWarnings = {
|
|
16
|
+
/**
|
|
17
|
+
* Every warning THAT IS the formatting one — usually one, but an `import`ed file can add
|
|
18
|
+
* another with its own file:line, and this keeps every one of them (never just the first): a
|
|
19
|
+
* `.find()` here would silently drop the rest, not merely leave them uncounted.
|
|
20
|
+
*/
|
|
21
|
+
readonly formatting: readonly string[];
|
|
22
|
+
/** Every other warning, in order, formatting removed. */
|
|
23
|
+
readonly rest: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Separates the formatting warning(s) (by substring, matching admin-calls.ts's `describe()`). */
|
|
27
|
+
export const splitFormattingWarning = (warnings: readonly string[]): SplitWarnings => ({
|
|
28
|
+
formatting: warnings.filter((warning) => warning.includes(NOT_FORMATTED)),
|
|
29
|
+
rest: warnings.filter((warning) => !warning.includes(NOT_FORMATTED)),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One clear line naming the fix — never just the adapter's own warning text, and never repeated
|
|
34
|
+
* per formatting warning: however many `formatting` holds, this is logged once (config.ts,
|
|
35
|
+
* config-reconcile.ts), since restating the same fix N times is its own noise.
|
|
36
|
+
*/
|
|
37
|
+
export const formattingFixLine = (endpoint: string): string =>
|
|
38
|
+
`Caddy.Config at ${endpoint}: Caddyfile is not formatted — run \`caddy fmt\` on it, or format ` +
|
|
39
|
+
"it with this package's formatCaddyfile() before declaring it, to silence this warning.";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formatCaddyfile()'s Effect wiring, proven against a fake `caddy` — a tiny shell script that
|
|
3
|
+
* "formats" by stripping trailing whitespace per line — so stdin-in/stdout-out plumbing, the
|
|
4
|
+
* exit-code check and missing-binary detection are exercised without a real Caddy on the runner.
|
|
5
|
+
* The last block runs the REAL `caddy fmt` and is skipped, its name saying why, when there is none
|
|
6
|
+
* on PATH (S42: tests are Bun-native, so `Bun.which` decides, not a hand-rolled PATH search).
|
|
7
|
+
*/
|
|
8
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { layer as BunServicesLayer } from '@effect/platform-bun/BunServices';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import { CaddyFmtFailed, CaddyFmtNotFound, formatCaddyfile } from './format.ts';
|
|
15
|
+
|
|
16
|
+
/** Only understands `fmt -`: stdin in, "formatted" stdout out — `FAIL_MARKER` fakes a bad parse. */
|
|
17
|
+
const FAKE_SCRIPT = `#!/bin/sh
|
|
18
|
+
if [ "$1" != "fmt" ] || [ "$2" != "-" ]; then
|
|
19
|
+
echo "fake caddy: unexpected args: $*" >&2
|
|
20
|
+
exit 2
|
|
21
|
+
fi
|
|
22
|
+
input="$(cat)"
|
|
23
|
+
case "$input" in
|
|
24
|
+
*FAIL_MARKER*)
|
|
25
|
+
echo "fake caddy: syntax error" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
;;
|
|
28
|
+
esac
|
|
29
|
+
printf '%s' "$input" | sed -E 's/[[:space:]]+$//'
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-caddy-fmt-'));
|
|
33
|
+
const fakeBinary = join(dir, 'hf-fake-caddy');
|
|
34
|
+
writeFileSync(fakeBinary, FAKE_SCRIPT, { mode: 0o755 });
|
|
35
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
36
|
+
|
|
37
|
+
const run = (text: string, options?: Parameters<typeof formatCaddyfile>[1]) =>
|
|
38
|
+
Effect.runPromise(formatCaddyfile(text, options).pipe(Effect.provide(BunServicesLayer)));
|
|
39
|
+
|
|
40
|
+
const runFailure = (text: string, options?: Parameters<typeof formatCaddyfile>[1]) =>
|
|
41
|
+
Effect.runPromise(
|
|
42
|
+
formatCaddyfile(text, options).pipe(Effect.provide(BunServicesLayer), Effect.flip),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe('formatCaddyfile — Effect wiring against a fake binary', () => {
|
|
46
|
+
test('pipes text to stdin and returns the formatted stdout', async () => {
|
|
47
|
+
// ⚠️ The fake reads stdin through `$(cat)`, which — like real shell command substitution —
|
|
48
|
+
// drops a trailing newline; that is the fake's plumbing, not formatCaddyfile()'s.
|
|
49
|
+
const out = await run('foo \nbar\t\n', { binaryPath: fakeBinary });
|
|
50
|
+
expect(out).toBe('foo\nbar');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a non-zero exit becomes a typed CaddyFmtFailed, stderr included — never the input back', async () => {
|
|
54
|
+
const failure = await runFailure('site FAIL_MARKER', { binaryPath: fakeBinary });
|
|
55
|
+
expect(failure).toBeInstanceOf(CaddyFmtFailed);
|
|
56
|
+
expect((failure as CaddyFmtFailed).exitCode).toBe(1);
|
|
57
|
+
expect(failure.message).toContain('syntax error');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a missing binary fails with the typed CaddyFmtNotFound — never silently skipped', async () => {
|
|
61
|
+
const missing = join(dir, 'does-not-exist-caddy');
|
|
62
|
+
const failure = await runFailure('x', { binaryPath: missing });
|
|
63
|
+
expect(failure).toBeInstanceOf(CaddyFmtNotFound);
|
|
64
|
+
expect((failure as CaddyFmtNotFound).binaryPath).toBe(missing);
|
|
65
|
+
expect(failure.message).toContain(missing);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the default binary name resolves through PATH, not only an absolute path', async () => {
|
|
69
|
+
const pathDir = mkdtempSync(join(tmpdir(), 'hf-caddy-fmt-path-'));
|
|
70
|
+
writeFileSync(join(pathDir, 'caddy'), FAKE_SCRIPT, { mode: 0o755 });
|
|
71
|
+
const original = process.env['PATH'];
|
|
72
|
+
// ★ Prepended, so this fake wins even when a real `caddy` also sits on PATH.
|
|
73
|
+
process.env['PATH'] = `${pathDir}:${original ?? ''}`;
|
|
74
|
+
try {
|
|
75
|
+
expect(await run('baz \n')).toBe('baz');
|
|
76
|
+
} finally {
|
|
77
|
+
process.env['PATH'] = original;
|
|
78
|
+
rmSync(pathDir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const realCaddy = Bun.which('caddy');
|
|
84
|
+
// ⚠️ DELIBERATELY NOT `caddy fmt`'s own canonical style — no space before `{`, spaces instead of
|
|
85
|
+
// a tab, and the closing brace indented — every one of which `caddy fmt` actually rewrites, so
|
|
86
|
+
// `expect(formatted).not.toBe(UNFORMATTED)` below cannot pass by accident on already-formatted
|
|
87
|
+
// input. (Caught in review: a tab-indented, flush-brace fixture here IS canonical caddy fmt
|
|
88
|
+
// style, which would make that assertion fail on a correctly working formatCaddyfile().)
|
|
89
|
+
const UNFORMATTED = 'example.com{\n reverse_proxy 127.0.0.1:8080\n }\n';
|
|
90
|
+
|
|
91
|
+
describe('formatCaddyfile — real caddy binary', () => {
|
|
92
|
+
test.skipIf(realCaddy === null)(
|
|
93
|
+
realCaddy === null
|
|
94
|
+
? 'formats a known-unformatted Caddyfile (skipped: no caddy on PATH)'
|
|
95
|
+
: 'formats a known-unformatted Caddyfile, and formatting twice is idempotent',
|
|
96
|
+
async () => {
|
|
97
|
+
const formatted = await run(UNFORMATTED);
|
|
98
|
+
expect(formatted).not.toBe(UNFORMATTED);
|
|
99
|
+
// ★ THE REAL PROOF IT IS FORMATTED: running caddy's own formatter over its own output changes
|
|
100
|
+
// nothing. (Never asserted against a fixed expected string — that would pin this test to
|
|
101
|
+
// one caddy build's exact formatting choices.)
|
|
102
|
+
expect(await run(formatted)).toBe(formatted);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `formatCaddyfile(text)` — pipes a Caddyfile through the LOCAL `caddy fmt -` binary (stdin in,
|
|
3
|
+
* formatted text out), the same formatter the `caddy fmt` CLI command runs
|
|
4
|
+
* (caddyconfig/caddyfile Format in caddyserver/caddy).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THERE IS NO ADMIN API FOR THIS. `caddy fmt` is a CLI-only feature (cmd/commandfuncs.go); no
|
|
7
|
+
* `/adapt`, `/load` or other admin endpoint formats a Caddyfile, so this never belongs in
|
|
8
|
+
* `@distilled.cloud/caddy` — measured 2026-09-24. It shells out instead, the same pattern
|
|
9
|
+
* talos/talosctl.ts uses for a vendor CLI Alchemy does not wrap.
|
|
10
|
+
* ⛔ NEVER REIMPLEMENTED IN TS. The Caddyfile grammar's formatting rules live in the Go adapter;
|
|
11
|
+
* a hand-rolled formatter would drift from it silently. This module only ever runs the real
|
|
12
|
+
* binary.
|
|
13
|
+
* ⛔ A MISSING OR FAILING BINARY FAILS THE EFFECT — IT NEVER RETURNS THE INPUT UNCHANGED.
|
|
14
|
+
* `CaddyFmtNotFound` and `CaddyFmtFailed` are typed, catchTag-able failures (S21); there is no
|
|
15
|
+
* fallback path that silently skips formatting.
|
|
16
|
+
* ★ WHY THIS MATTERS: admin-calls.ts's module doc — Caddy's adapter warns "Caddyfile input is not
|
|
17
|
+
* formatted" on EVERY unformatted input (adapter.go FormattingDifference), which shares the
|
|
18
|
+
* `/load` response body with the refused-but-200 trap. format-warnings.ts surfaces that warning
|
|
19
|
+
* distinctly in plan/deploy output; this function is how a caller silences it — by formatting
|
|
20
|
+
* the Caddyfile before declaring it, not by ignoring the warning.
|
|
21
|
+
*/
|
|
22
|
+
import * as Effect from 'effect/Effect';
|
|
23
|
+
import * as Stream from 'effect/Stream';
|
|
24
|
+
import * as ChildProcess from 'effect/unstable/process/ChildProcess';
|
|
25
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
26
|
+
import type * as PlatformError from 'effect/PlatformError';
|
|
27
|
+
|
|
28
|
+
/** `caddy` resolved on `PATH`, unless the caller points at a specific build. */
|
|
29
|
+
export const DEFAULT_CADDY_BINARY = 'caddy';
|
|
30
|
+
|
|
31
|
+
/** The configured binary is not on `PATH` (or not executable) — install Caddy, or fix the path. */
|
|
32
|
+
export class CaddyFmtNotFound extends Error {
|
|
33
|
+
readonly _tag = 'CaddyFmtNotFound';
|
|
34
|
+
constructor(
|
|
35
|
+
readonly binaryPath: string,
|
|
36
|
+
cause: PlatformError.PlatformError,
|
|
37
|
+
) {
|
|
38
|
+
super(
|
|
39
|
+
`formatCaddyfile: "${binaryPath}" was not found (${cause.message}). Install Caddy so it is ` +
|
|
40
|
+
'on PATH, or pass { binaryPath } naming the exact build the target Caddy runs.',
|
|
41
|
+
);
|
|
42
|
+
this.name = 'CaddyFmtNotFound';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The binary ran but exited non-zero — a syntax error in `text`, most likely. */
|
|
47
|
+
export class CaddyFmtFailed extends Error {
|
|
48
|
+
readonly _tag = 'CaddyFmtFailed';
|
|
49
|
+
constructor(
|
|
50
|
+
readonly binaryPath: string,
|
|
51
|
+
readonly exitCode: number,
|
|
52
|
+
stderr: string,
|
|
53
|
+
) {
|
|
54
|
+
super(
|
|
55
|
+
`formatCaddyfile: "${binaryPath} fmt -" exited ${String(exitCode)}: ${stderr.slice(0, 300)}`,
|
|
56
|
+
);
|
|
57
|
+
this.name = 'CaddyFmtFailed';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type FormatCaddyfileOptions = {
|
|
62
|
+
/** The `caddy` binary to run `fmt -` against. @default DEFAULT_CADDY_BINARY, resolved on PATH. */
|
|
63
|
+
readonly binaryPath?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Format a Caddyfile the way `caddy fmt` would, by actually running it — never a TS
|
|
68
|
+
* reimplementation. Requires `ChildProcessSpawner` (S19: no `node:child_process`, no
|
|
69
|
+
* `async`/`await`); provide `@effect/platform-node`'s or `@effect/platform-bun`'s layer.
|
|
70
|
+
*/
|
|
71
|
+
export const formatCaddyfile = (
|
|
72
|
+
text: string,
|
|
73
|
+
options: FormatCaddyfileOptions = {},
|
|
74
|
+
): Effect.Effect<
|
|
75
|
+
string,
|
|
76
|
+
// ⚠️ A raw PlatformError still escapes for anything that is not "missing binary" (a permission
|
|
77
|
+
// problem, a process the OS killed mid-run) — narrower than that would misname what happened.
|
|
78
|
+
CaddyFmtNotFound | CaddyFmtFailed | PlatformError.PlatformError,
|
|
79
|
+
ChildProcessSpawner.ChildProcessSpawner
|
|
80
|
+
> =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const binaryPath = options.binaryPath ?? DEFAULT_CADDY_BINARY;
|
|
83
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
84
|
+
const input = new TextEncoder().encode(text);
|
|
85
|
+
const result = yield* ChildProcess.make(binaryPath, ['fmt', '-'], {
|
|
86
|
+
detached: false,
|
|
87
|
+
stderr: 'pipe',
|
|
88
|
+
stdin: Stream.make(input),
|
|
89
|
+
stdout: 'pipe',
|
|
90
|
+
}).pipe(
|
|
91
|
+
spawner.spawn,
|
|
92
|
+
Effect.flatMap((child) =>
|
|
93
|
+
Effect.all(
|
|
94
|
+
{
|
|
95
|
+
exitCode: child.exitCode,
|
|
96
|
+
stderr: child.stderr.pipe(
|
|
97
|
+
Stream.decodeText,
|
|
98
|
+
Stream.mkString,
|
|
99
|
+
Effect.map((out) => out.trim()),
|
|
100
|
+
),
|
|
101
|
+
stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
|
|
102
|
+
},
|
|
103
|
+
{ concurrency: 'unbounded' },
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
Effect.scoped,
|
|
107
|
+
// ⛔ ENOENT (the binary is missing) becomes the typed, named failure above — every other
|
|
108
|
+
// PlatformError (a permission problem, a killed process) propagates as-is, still typed.
|
|
109
|
+
Effect.catchTag(
|
|
110
|
+
'PlatformError',
|
|
111
|
+
(error): Effect.Effect<never, CaddyFmtNotFound | PlatformError.PlatformError> =>
|
|
112
|
+
error.reason._tag === 'NotFound'
|
|
113
|
+
? Effect.fail(new CaddyFmtNotFound(binaryPath, error))
|
|
114
|
+
: Effect.fail(error),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
if (result.exitCode !== 0) {
|
|
118
|
+
return yield* Effect.fail(new CaddyFmtFailed(binaryPath, result.exitCode, result.stderr));
|
|
119
|
+
}
|
|
120
|
+
return result.stdout;
|
|
121
|
+
});
|
package/src/caddy/index.ts
CHANGED
|
@@ -24,6 +24,13 @@ export { CaddyAdminService, caddyAdminLayer } from './admin.ts';
|
|
|
24
24
|
export { isUnreachable } from './caddy-http-client.ts';
|
|
25
25
|
export type { CaddyConfigAttributes, CaddyConfigProps } from './config.ts';
|
|
26
26
|
export { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
27
|
+
export {
|
|
28
|
+
CaddyFmtFailed,
|
|
29
|
+
CaddyFmtNotFound,
|
|
30
|
+
DEFAULT_CADDY_BINARY,
|
|
31
|
+
formatCaddyfile,
|
|
32
|
+
type FormatCaddyfileOptions,
|
|
33
|
+
} from './format.ts';
|
|
27
34
|
export type { LocalCaddyAdminOptions } from './local-admin.ts';
|
|
28
35
|
export { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin } from './local-admin.ts';
|
|
29
36
|
export { caddyProviders } from './providers.ts';
|
|
@@ -224,13 +224,21 @@ describe('the barrel', () => {
|
|
|
224
224
|
'CaddyAdminService',
|
|
225
225
|
'CaddyConfig',
|
|
226
226
|
'CaddyConfigProvider',
|
|
227
|
+
// The two typed failures formatCaddyfile() can raise — see format.ts.
|
|
228
|
+
'CaddyFmtFailed',
|
|
229
|
+
'CaddyFmtNotFound',
|
|
227
230
|
'DEFAULT_ADMIN_ADDRESS',
|
|
231
|
+
// `caddy` resolved on PATH, unless formatCaddyfile()'s caller points at a build — format.ts.
|
|
232
|
+
'DEFAULT_CADDY_BINARY',
|
|
228
233
|
// Caddyfile TEXT for an Access-protected route, not a resource — see
|
|
229
234
|
// access-forward-auth.ts. It pairs with @homeflare/cloudflare/access-auth.
|
|
230
235
|
'accessForwardAuth',
|
|
231
236
|
'caddyAdminLayer',
|
|
232
237
|
'caddyProviders',
|
|
233
238
|
'caddyWithFile',
|
|
239
|
+
// `caddy fmt -`, piped through the local binary — see format.ts's module doc for why this
|
|
240
|
+
// is never a TS reimplementation and never belongs in @distilled.cloud/caddy.
|
|
241
|
+
'formatCaddyfile',
|
|
234
242
|
// The type guard config.ts's plan-time escape hatch narrows unreachable failures with —
|
|
235
243
|
// exported so a consumer's own transport (an SSH forward, a remote runner) can reuse it.
|
|
236
244
|
'isUnreachable',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `describeKeyRef` — proves it formats a plain reference and refuses anything key-shaped, so the
|
|
3
|
+
* one place this family turns a `GoogleWorkspaceKeyRef` into text (docs, the census handoff
|
|
4
|
+
* header) cannot be handed a real credential by mistake.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { type GoogleWorkspaceKeyRef, describeKeyRef } from './credentials.ts';
|
|
8
|
+
|
|
9
|
+
const REF: GoogleWorkspaceKeyRef = {
|
|
10
|
+
delegatedAdmin: 'admin@schenanigans.com',
|
|
11
|
+
openBaoPath: 'kv/google-workspace/service-accounts/directory-admin',
|
|
12
|
+
scopes: ['https://www.googleapis.com/auth/admin.directory.group.readonly'],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('describeKeyRef', () => {
|
|
16
|
+
test('formats a plain reference — path, delegated admin, scopes', () => {
|
|
17
|
+
const line = describeKeyRef(REF);
|
|
18
|
+
expect(line).toContain('kv/google-workspace/service-accounts/directory-admin');
|
|
19
|
+
expect(line).toContain('admin@schenanigans.com');
|
|
20
|
+
expect(line).toContain('admin.directory.group.readonly');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('refuses an openBaoPath that looks like a PEM private key', () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
describeKeyRef({ ...REF, openBaoPath: '-----BEGIN PRIVATE KEY-----\nMIIE...' }),
|
|
26
|
+
).toThrow(/looks like key material/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('refuses a scopes entry that looks like a service-account JSON blob', () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
describeKeyRef({ ...REF, scopes: ['{"type": "service_account", "private_key": "x"}'] }),
|
|
32
|
+
).toThrow(/looks like key material/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('refuses a delegatedAdmin field carrying a private_key fragment', () => {
|
|
36
|
+
expect(() => describeKeyRef({ ...REF, delegatedAdmin: 'private_key leaked here' })).toThrow(
|
|
37
|
+
/looks like key material/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|