@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.
Files changed (87) hide show
  1. package/README.md +20 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/google-workspace/credentials.d.ts +54 -0
  13. package/dist/google-workspace/credentials.d.ts.map +1 -0
  14. package/dist/google-workspace/domain-alias.d.ts +74 -0
  15. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  16. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  17. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  18. package/dist/google-workspace/group-member.d.ts +73 -0
  19. package/dist/google-workspace/group-member.d.ts.map +1 -0
  20. package/dist/google-workspace/group.d.ts +87 -0
  21. package/dist/google-workspace/group.d.ts.map +1 -0
  22. package/dist/google-workspace/index.d.ts +19 -0
  23. package/dist/google-workspace/index.d.ts.map +1 -0
  24. package/dist/google-workspace/index.js +283 -0
  25. package/dist/google-workspace/index.js.map +17 -0
  26. package/dist/google-workspace/org-unit.d.ts +75 -0
  27. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  28. package/dist/google-workspace/providers.d.ts +16 -0
  29. package/dist/google-workspace/providers.d.ts.map +1 -0
  30. package/dist/google-workspace/resource.d.ts +80 -0
  31. package/dist/google-workspace/resource.d.ts.map +1 -0
  32. package/dist/google-workspace/values.d.ts +28 -0
  33. package/dist/google-workspace/values.d.ts.map +1 -0
  34. package/dist/grafana/credentials.d.ts +38 -0
  35. package/dist/grafana/credentials.d.ts.map +1 -0
  36. package/dist/grafana/datasource.d.ts +90 -0
  37. package/dist/grafana/datasource.d.ts.map +1 -0
  38. package/dist/grafana/fake-grafana.d.ts +48 -0
  39. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  40. package/dist/grafana/index.d.ts +17 -0
  41. package/dist/grafana/index.d.ts.map +1 -0
  42. package/dist/grafana/index.js +149 -0
  43. package/dist/grafana/index.js.map +13 -0
  44. package/dist/grafana/providers.d.ts +17 -0
  45. package/dist/grafana/providers.d.ts.map +1 -0
  46. package/dist/grafana/resource.d.ts +68 -0
  47. package/dist/grafana/resource.d.ts.map +1 -0
  48. package/docs/caddy-fmt.md +59 -0
  49. package/docs/caddy.md +7 -0
  50. package/docs/credentials.md +11 -0
  51. package/docs/google-workspace.md +105 -0
  52. package/docs/grafana.md +102 -0
  53. package/docs/peers.md +9 -0
  54. package/docs/upstream-conformance.md +37 -0
  55. package/package.json +9 -1
  56. package/src/caddy/config-format-warning.test.ts +91 -0
  57. package/src/caddy/config-reconcile.ts +6 -0
  58. package/src/caddy/config.ts +8 -1
  59. package/src/caddy/format-warnings.test.ts +50 -0
  60. package/src/caddy/format-warnings.ts +39 -0
  61. package/src/caddy/format.test.ts +105 -0
  62. package/src/caddy/format.ts +121 -0
  63. package/src/caddy/index.ts +7 -0
  64. package/src/caddy/providers.test.ts +8 -0
  65. package/src/google-workspace/credentials.test.ts +40 -0
  66. package/src/google-workspace/credentials.ts +75 -0
  67. package/src/google-workspace/domain-alias.test.ts +69 -0
  68. package/src/google-workspace/domain-alias.ts +116 -0
  69. package/src/google-workspace/fake-google-workspace.ts +54 -0
  70. package/src/google-workspace/group-member.test.ts +91 -0
  71. package/src/google-workspace/group-member.ts +126 -0
  72. package/src/google-workspace/group.test.ts +93 -0
  73. package/src/google-workspace/group.ts +128 -0
  74. package/src/google-workspace/index.ts +54 -0
  75. package/src/google-workspace/org-unit.test.ts +81 -0
  76. package/src/google-workspace/org-unit.ts +143 -0
  77. package/src/google-workspace/providers.ts +26 -0
  78. package/src/google-workspace/resource.test.ts +186 -0
  79. package/src/google-workspace/resource.ts +162 -0
  80. package/src/google-workspace/values.ts +36 -0
  81. package/src/grafana/credentials.ts +65 -0
  82. package/src/grafana/datasource.test.ts +183 -0
  83. package/src/grafana/datasource.ts +171 -0
  84. package/src/grafana/fake-grafana.ts +64 -0
  85. package/src/grafana/index.ts +22 -0
  86. package/src/grafana/providers.ts +19 -0
  87. 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.29.1",
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 &&
@@ -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
- for (const warning of applied.warnings) {
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
+ });
@@ -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
+ });