@homeflare/alchemy 0.29.1 → 0.31.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 (118) hide show
  1. package/README.md +42 -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/discord/application-command.d.ts +52 -0
  13. package/dist/discord/application-command.d.ts.map +1 -0
  14. package/dist/discord/command-form.d.ts +62 -0
  15. package/dist/discord/command-form.d.ts.map +1 -0
  16. package/dist/discord/credentials.d.ts +35 -0
  17. package/dist/discord/credentials.d.ts.map +1 -0
  18. package/dist/discord/fake-discord.d.ts +44 -0
  19. package/dist/discord/fake-discord.d.ts.map +1 -0
  20. package/dist/discord/guild-application-command.d.ts +53 -0
  21. package/dist/discord/guild-application-command.d.ts.map +1 -0
  22. package/dist/discord/index.d.ts +12 -0
  23. package/dist/discord/index.d.ts.map +1 -0
  24. package/dist/discord/index.js +193 -0
  25. package/dist/discord/index.js.map +15 -0
  26. package/dist/discord/providers.d.ts +15 -0
  27. package/dist/discord/providers.d.ts.map +1 -0
  28. package/dist/discord/resource.d.ts +75 -0
  29. package/dist/discord/resource.d.ts.map +1 -0
  30. package/dist/google-workspace/credentials.d.ts +54 -0
  31. package/dist/google-workspace/credentials.d.ts.map +1 -0
  32. package/dist/google-workspace/domain-alias.d.ts +74 -0
  33. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  34. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  35. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  36. package/dist/google-workspace/group-member.d.ts +73 -0
  37. package/dist/google-workspace/group-member.d.ts.map +1 -0
  38. package/dist/google-workspace/group.d.ts +87 -0
  39. package/dist/google-workspace/group.d.ts.map +1 -0
  40. package/dist/google-workspace/index.d.ts +19 -0
  41. package/dist/google-workspace/index.d.ts.map +1 -0
  42. package/dist/google-workspace/index.js +283 -0
  43. package/dist/google-workspace/index.js.map +17 -0
  44. package/dist/google-workspace/org-unit.d.ts +75 -0
  45. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  46. package/dist/google-workspace/providers.d.ts +16 -0
  47. package/dist/google-workspace/providers.d.ts.map +1 -0
  48. package/dist/google-workspace/resource.d.ts +80 -0
  49. package/dist/google-workspace/resource.d.ts.map +1 -0
  50. package/dist/google-workspace/values.d.ts +28 -0
  51. package/dist/google-workspace/values.d.ts.map +1 -0
  52. package/dist/grafana/credentials.d.ts +38 -0
  53. package/dist/grafana/credentials.d.ts.map +1 -0
  54. package/dist/grafana/datasource.d.ts +90 -0
  55. package/dist/grafana/datasource.d.ts.map +1 -0
  56. package/dist/grafana/fake-grafana.d.ts +48 -0
  57. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  58. package/dist/grafana/index.d.ts +17 -0
  59. package/dist/grafana/index.d.ts.map +1 -0
  60. package/dist/grafana/index.js +149 -0
  61. package/dist/grafana/index.js.map +13 -0
  62. package/dist/grafana/providers.d.ts +17 -0
  63. package/dist/grafana/providers.d.ts.map +1 -0
  64. package/dist/grafana/resource.d.ts +68 -0
  65. package/dist/grafana/resource.d.ts.map +1 -0
  66. package/docs/caddy-fmt.md +59 -0
  67. package/docs/caddy.md +7 -0
  68. package/docs/credentials.md +20 -0
  69. package/docs/discord.md +129 -0
  70. package/docs/google-workspace.md +105 -0
  71. package/docs/grafana.md +102 -0
  72. package/docs/peers.md +9 -0
  73. package/docs/upstream-conformance.md +53 -0
  74. package/package.json +13 -1
  75. package/src/caddy/config-format-warning.test.ts +91 -0
  76. package/src/caddy/config-reconcile.ts +6 -0
  77. package/src/caddy/config.ts +8 -1
  78. package/src/caddy/format-warnings.test.ts +50 -0
  79. package/src/caddy/format-warnings.ts +39 -0
  80. package/src/caddy/format.test.ts +105 -0
  81. package/src/caddy/format.ts +121 -0
  82. package/src/caddy/index.ts +7 -0
  83. package/src/caddy/providers.test.ts +8 -0
  84. package/src/discord/application-command.test.ts +103 -0
  85. package/src/discord/application-command.ts +109 -0
  86. package/src/discord/command-form.test.ts +144 -0
  87. package/src/discord/command-form.ts +133 -0
  88. package/src/discord/credentials.ts +36 -0
  89. package/src/discord/fake-discord.ts +70 -0
  90. package/src/discord/guild-application-command.test.ts +215 -0
  91. package/src/discord/guild-application-command.ts +129 -0
  92. package/src/discord/index.ts +31 -0
  93. package/src/discord/providers.ts +18 -0
  94. package/src/discord/resource.ts +164 -0
  95. package/src/discord/retry.test.ts +74 -0
  96. package/src/google-workspace/credentials.test.ts +40 -0
  97. package/src/google-workspace/credentials.ts +75 -0
  98. package/src/google-workspace/domain-alias.test.ts +69 -0
  99. package/src/google-workspace/domain-alias.ts +116 -0
  100. package/src/google-workspace/fake-google-workspace.ts +54 -0
  101. package/src/google-workspace/group-member.test.ts +91 -0
  102. package/src/google-workspace/group-member.ts +126 -0
  103. package/src/google-workspace/group.test.ts +93 -0
  104. package/src/google-workspace/group.ts +128 -0
  105. package/src/google-workspace/index.ts +54 -0
  106. package/src/google-workspace/org-unit.test.ts +81 -0
  107. package/src/google-workspace/org-unit.ts +143 -0
  108. package/src/google-workspace/providers.ts +26 -0
  109. package/src/google-workspace/resource.test.ts +186 -0
  110. package/src/google-workspace/resource.ts +162 -0
  111. package/src/google-workspace/values.ts +36 -0
  112. package/src/grafana/credentials.ts +65 -0
  113. package/src/grafana/datasource.test.ts +183 -0
  114. package/src/grafana/datasource.ts +171 -0
  115. package/src/grafana/fake-grafana.ts +64 -0
  116. package/src/grafana/index.ts +22 -0
  117. package/src/grafana/providers.ts +19 -0
  118. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,105 @@
1
+ # Google Workspace — `@homeflare/alchemy/google-workspace`
2
+
3
+ Four Admin SDK Directory objects: `Group`, `GroupMember`, `DomainAlias`, `OrgUnit`. Built on
4
+ `@distilled.cloud/google-workspace@1.0.0-rc.12`'s `admin_directory_v1` service (S23) — every call
5
+ is one of its typed operations, every error a `catchTag`.
6
+
7
+ ## Credential setup Tim needs
8
+
9
+ ⛔ **The distilled SDK takes one thing: an already-minted OAuth2 bearer access token.** Its
10
+ `Credentials` service (`@distilled.cloud/google-workspace/Credentials`) has no service-account
11
+ JSON shape and no domain-wide-delegation (DWD) JWT exchange — read at the installed package,
12
+ 2026-09-24: "API-compatible port of the distilled gcp credentials module … no ADC, no
13
+ service-account signing, no refresh flow". Minting the token is entirely outside the SDK.
14
+
15
+ ★ **So this follows the house's existing pattern (H8), unchanged.** A Bun wrapper — the same
16
+ shape the kit's Proxmox subpath already uses for its own OpenBao-sourced token — mints a
17
+ short-lived token and exports it into the DEPLOY PROCESS's environment only:
18
+
19
+ | variable | holds |
20
+ | --------------------- | -------------------------------------------------------------------- |
21
+ | `GOOGLE_ACCESS_TOKEN` | a short-lived OAuth2 bearer token, delegated to a Workspace admin |
22
+ | `GOOGLE_PROJECT_ID` | optional — only if a future resource needs to scope to a GCP project |
23
+
24
+ This package does not write that wrapper. `credentials.ts` exports `GoogleWorkspaceKeyRef` — a
25
+ typed REFERENCE (an OpenBao path, the delegated admin, the scopes) a stack file can keep next to
26
+ its declarations for review, and `describeKeyRef()` to render it as one line of text. Neither
27
+ holds, reads or accepts anything shaped like a key (`credentials.test.ts` asserts the refusal).
28
+
29
+ ### What Tim has to set up, once
30
+
31
+ 1. **A service account** in a Google Cloud project linked to the Workspace account, with
32
+ **domain-wide delegation** enabled in the Admin console (Security → API controls →
33
+ Domain-wide delegation) for the service account's client ID.
34
+ 2. **The least OAuth scopes the four resources need** — grant exactly these, nothing broader:
35
+
36
+ | resource | scope |
37
+ | ---------------------- | --------------------------------------------------------- |
38
+ | `Group`, `GroupMember` | `https://www.googleapis.com/auth/admin.directory.group` |
39
+ | `DomainAlias` | `https://www.googleapis.com/auth/admin.directory.domain` |
40
+ | `OrgUnit` | `https://www.googleapis.com/auth/admin.directory.orgunit` |
41
+
42
+ `admin.directory.group` covers group **and** member CRUD — Google does not split them further
43
+ without also losing group create/update. A credential that only ever manages membership (never
44
+ creates or edits a group) can narrow to `admin.directory.group.member` instead.
45
+
46
+ 3. **The service account's key** goes in OpenBao, never on disk in this repo or anywhere a stack
47
+ file can reach: `kv/google-workspace/service-accounts/<name>`, matching the mount/catalog
48
+ pattern in `~/.claude/AGENTS.md`'s Cloudflare-credential section (same "mint, never reuse"
49
+ posture, different vendor).
50
+ 4. **The wrapper** reads that key from OpenBao, performs the DWD JWT-bearer exchange
51
+ (`https://oauth2.googleapis.com/token`, `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,
52
+ `sub` = the delegated admin) for a token scoped to exactly the table above, and exports it as
53
+ `GOOGLE_ACCESS_TOKEN` before `alchemy.run.ts` runs — never printed, never logged, never written
54
+ to a file. This is the one piece this PR does not build; see
55
+ [upstream-conformance.md](./upstream-conformance.md) for why (S23: distilled owns the SDK
56
+ surface, the house owns minting, and the two should not be re-blended into a second hand-rolled
57
+ auth flow).
58
+
59
+ ### Read-only credential, for the census handoff
60
+
61
+ The handoff script at `scratchpad/handoffs/23-workspace-census.sh` only lists — it never writes.
62
+ Mint it a SEPARATE, narrower credential: `admin.directory.group.readonly`,
63
+ `admin.directory.domain.readonly`, `admin.directory.orgunit.readonly`. Do not reuse the write
64
+ credential above for a read-only script.
65
+
66
+ ## Adopt is the default posture
67
+
68
+ `reconcile` fetches by key (email, alias name, or org unit path) before it writes — no NetBox-style
69
+ list-then-disambiguate, because every object here has a stable key a human would type. An
70
+ instance that already has "Schenanigans - Admin" or "Schenanigans - Google" (the groups the
71
+ estate's Cloudflare Access policies already name — `docs/zero-trust-access-applications.md` in
72
+ homeflare-landscape) is BOUND, never duplicated, and `attributes` always maps the FULL live row —
73
+ the exact-as-live half of `adopt(true)`.
74
+
75
+ ## Users are out of scope
76
+
77
+ `GoogleWorkspace.User` is not built. `insertUsers`/`patchUsers` accept a `password` field on the
78
+ wire — one keystroke from becoming a prop, and Alchemy persists props unencrypted. No stack in
79
+ this estate declares a Workspace user today (measured 2026-09-24). `GroupMember.email` references
80
+ a user without ever creating, reading or storing one.
81
+
82
+ ## Removal is `retain` for everything but membership
83
+
84
+ `Group`, `DomainAlias` and `OrgUnit` default to `retain` — each governs something else (Access
85
+ policies, mail routing, service visibility) that outlives the declaration removing it.
86
+ `GroupMember` does not: adding someone back to a group is cheap and reversible, so it uses the
87
+ engine default (`destroy`).
88
+
89
+ ## SDK gaps, measured 2026-09-24
90
+
91
+ - **`admin_directory_v1` lives under `unstable/`, not the SDK's preferred `services/` tree**
92
+ (`@distilled.cloud/google-workspace/unstable/admin_directory_v1`). The package's own `index.ts`
93
+ says "non-preferred versions" — this is the only Directory API version distilled generates, so
94
+ "non-preferred" describes distilled's own service catalog, not a deprecated Google API. Nothing
95
+ about the operations this family calls looked incomplete or wrong; flagged here per S23/S33 so a
96
+ future upgrade that moves it to `services/` is a one-line import change, not a surprise.
97
+ - **No per-operation typed error beyond the shared `NotFound | Forbidden | BadRequest | Conflict`
98
+ set.** A cycle in group membership, an unverified domain on `insertDomainAliases`, or a
99
+ reparent-with-children refusal on `deleteOrgunits` all surface as `BadRequest` with a message,
100
+ not a resource-specific tag. S21 says a missing tag is fixed in the SDK, never worked around in
101
+ the resource — none of these three cases are worked around here; they propagate as `BadRequest`
102
+ same as any other 400.
103
+ - **No `updateGroupsAliases`/`patchGroupsAliases`.** Group aliases are add/list/delete only,
104
+ matching `DomainAlias`'s own no-update shape — not modelled as a fifth resource in this PR
105
+ (out of scope: the task named domain aliases, not group aliases).
@@ -0,0 +1,102 @@
1
+ # Grafana — `@homeflare/alchemy/grafana`
2
+
3
+ `Grafana.Datasource` declares one Grafana data source, keyed by `uid`. Every call goes through
4
+ `@distilled.cloud/grafana@1.0.0-rc.12`'s typed operations, `catchTag('NotFound', ...)` in place of
5
+ any status check. Built 2026-09-24 against the live target `teslamate-grafana.service` on CT100 —
6
+ measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
7
+ publicly at `tesla.homeflare.dev/dash/`.
8
+
9
+ ## The SDK gap — why only `Datasource` ships
10
+
11
+ Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
12
+ `lib/services/grafana.d.ts`, 4,938 lines): the package has **no create/read/update/delete
13
+ operations for folders, dashboards, alert rules or contact points**. It covers datasources in
14
+ full (`addDataSource`, `getDataSourceByUID`, `updateDataSourceByUID`, `deleteDataSourceByUID`,
15
+ `getDataSources`), plus org/user/team/role/report/annotation/library-element/public-dashboard/
16
+ snapshot management — but:
17
+
18
+ - **Folders:** only `updateFolderPermissions` exists. No `createFolder`, `getFolder` or
19
+ `deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes are entirely absent.
20
+ - **Dashboards:** only snapshot and public-dashboard routes exist (`createDashboardSnapshot`,
21
+ `getPublicDashboard`, …). No `POST /api/dashboards/db`, no `GET /api/dashboards/uid/{uid}`, no
22
+ `DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family is absent.
23
+ - **Alert rules:** only `routeGetAlertRuleExport`/`routeGetAlertRuleGroupExport`/
24
+ `routeGetAlertRulesExport` exist — export (read-only) endpoints under `/api/v1/provisioning/`.
25
+ No create/update/delete for `/api/v1/provisioning/alert-rules{,/{uid}}`.
26
+ - **Contact points:** only `routeGetContactpointsExport` exists, same story — no
27
+ `/api/v1/provisioning/contact-points{,/{uid}}` CRUD.
28
+
29
+ This is recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
30
+ around. Per S23, the fix is a distilled PR (regenerate against a spec that includes these routes,
31
+ or patch them in per S22), not a hand-rolled `HttpClient` client in this package for the missing
32
+ pieces alone — that would put half a family through the SDK and half through a second, parallel
33
+ client, exactly what S23 exists to prevent.
34
+
35
+ `teslamate-grafana` itself is consistent with this gap being real, not merely unexploited: its one
36
+ datasource is API-manageable and already exists; its dashboards ship baked into the
37
+ `teslamate/grafana` image and are provisioned by the container, not by any API call; it has no
38
+ folders, alert rules or contact points configured at all (measured over ssh, read-only, 2026-09-24).
39
+
40
+ ## Credentials
41
+
42
+ A `GrafanaTarget` — an instance origin plus a token env var NAME, never a literal value (S25):
43
+
44
+ ```ts
45
+ import { grafanaProviders } from '@homeflare/alchemy/grafana';
46
+
47
+ const teslamateGrafana = grafanaProviders({
48
+ baseUrl: 'https://tesla.homeflare.dev/dash',
49
+ tokenEnv: 'TESLAMATE_GRAFANA_TOKEN',
50
+ });
51
+ ```
52
+
53
+ `tokenEnv` is read fresh from `process.env` inside each operation's own effect (S24) — a layer
54
+ built once still re-reads the environment on every request. This house wrapper exists because,
55
+ unlike Forgejo (one instance for this estate), Grafana is not: `grafana.homeflare.dev` and
56
+ `teslamate-grafana` are two instances with two different tokens, and the unit file for the latter
57
+ says explicitly it uses "Own Access SaaS OIDC client, never grafana.homeflare.dev's" — one global
58
+ `GRAFANA_TOKEN`/`GRAFANA_URL` pair (the SDK's own `CredentialsFromEnv`) cannot address both. A
59
+ stack declaring resources against both instances calls `grafanaProviders` twice, once per target;
60
+ the `Grafana.Datasource` resource type itself is one global registration shared by both calls.
61
+
62
+ A datasource's `secureJsonData` (e.g. its password) follows the same rule: `secureJsonDataRefs`
63
+ takes a map of field name to env var NAME, resolved the same way, never returned in `attributes` —
64
+ Grafana's own read API agrees, since `DataSource.secureJsonFields` on a GET is a map of booleans
65
+ (is-it-set), never the value.
66
+
67
+ ## Adopt semantics
68
+
69
+ `reconcile` observes before it writes (S9/S10): a `uid` the target instance already has is bound,
70
+ and when the declaration already matches what's live, nothing is sent — no create, no update.
71
+ Declaring exactly what's live changes nothing. This is why `uid` is a **required** prop rather than
72
+ generated: `getDataSourceByUID`/`updateDataSourceByUID`/`deleteDataSourceByUID` are all UID-keyed,
73
+ and `getDataSources` (list) takes no filter, so locating an object this resource didn't create
74
+ would mean listing the whole org and matching by name — the same ambiguity
75
+ `netbox/resource.ts`'s `soleMatch` exists to catch. Requiring `uid` up front (Grafana accepts a
76
+ caller-chosen one on create) sidesteps it: every operation keys on the one field the API itself is
77
+ keyed on.
78
+
79
+ ## Example
80
+
81
+ ```ts
82
+ import { GrafanaDatasource, grafanaProviders } from '@homeflare/alchemy/grafana';
83
+
84
+ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource', {
85
+ uid: '<the live uid — see the census handoff>',
86
+ name: 'TeslaMate',
87
+ type: 'postgres',
88
+ access: 'proxy',
89
+ isDefault: true,
90
+ }) {}
91
+ ```
92
+
93
+ ```ts
94
+ // …then provide `grafanaProviders({ baseUrl, tokenEnv })` alongside the stack's other providers.
95
+ ```
96
+
97
+ ## Not covered
98
+
99
+ - **Folders, dashboards, alert rules, contact points.** Blocked on the SDK gap above — a
100
+ maintainer decision on whether to patch distilled or wait for upstream to add the routes.
101
+ - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
102
+ still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
package/docs/peers.md CHANGED
@@ -54,6 +54,15 @@ enforces it. `@effect/sql-pg` is optional on `alchemy`'s own manifest (its modul
54
54
  does not reach `SQL/Postgres` unless a stack imports it); here it stays a plain peer like
55
55
  every other one.
56
56
 
57
+ ⚠️ **`@distilled.cloud/grafana` (added 2026-09-24, kit PR 222, ahead of `/grafana`) is
58
+ required for the same reason `@distilled.cloud/forgejo` is: a plain dependency of this
59
+ package, not something a hoisting installer can hide from a strict one.** Pinned to
60
+ `1.0.0-rc.12`, the same distilled release line as every other pin on this page. Unlike
61
+ Forgejo, this family is credential-parameterized per instance (`grafanaCredentials`/
62
+ `grafanaProviders` take a `GrafanaTarget`, not a fixed env-var pair) — see
63
+ [grafana.md](./grafana.md) for why one instance was never going to be enough for this
64
+ estate. The peer itself does not change for that: it is still one package, one pin.
65
+
57
66
  ★ **`@distilled.cloud/netbox` (added 2026-09-23, moving `/netbox` off a hand-rolled
58
67
  `HttpClient` client the same way `/forgejo` did) is NOT on this install line, and that is
59
68
  deliberate — see [distilled-interim.md](./distilled-interim.md).** The real
@@ -224,6 +224,45 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
224
224
  the house's glyph rationale into `//` comments.
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
+ 14. **`discord/*` is new (2026-09-24, task-authorized, kit PR TBD) and built directly on
228
+ `@distilled.cloud/discord` — no hand-rolled `client.ts` ever existed to retire.**
229
+ `Discord.ApplicationCommand` and `Discord.GuildApplicationCommand` call the SDK's
230
+ typed operations, `catchTag`'d through the shared `DiscordOpError` union (S21), with no
231
+ status sniffing anywhere in `resource.ts`. It follows upstream's `Snippet.ts` reference
232
+ exactly for a marker-less API (S7, S8): a cold `read` returns `Unowned(attrs)`, and both
233
+ convenience constructors pipe `adopt(true)` (H5) — a stricter posture than `netbox/*`'s
234
+ documented H1 gap, not a repeat of it. Rate-limit handling is the SDK's own default
235
+ `Retry` policy (bounded: `Schedule.recurs(8)`, S26) — this family adds nothing on top.
236
+ Full detail, the live Halibut census and its ownership-handover sequence, and every SDK
237
+ gap: [`discord.md`](./discord.md). ⛔ **Diverges on S24/S25 the same way every distilled
238
+ family in this ledger does:** credentials are read at call time through the SDK's own
239
+ `CredentialsFromEnv`, not a house `alchemy/Auth` provider — unchanged from `netbox/*`
240
+ and `litellm/*`'s entries above. **Gap, not yet fixed:** no vendor constraint table
241
+ (unlike NetBox/Paperless); `options` passed through opaquely rather than modeled from
242
+ the schema (`docs/discord.md#sdk-gaps`).
243
+
244
+ 15. **`grafana/*` (added 2026-09-24) ships only `Datasource` — a real SDK gap, not scope-trimming.**
245
+ Measured against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball's
246
+ `lib/services/grafana.d.ts` (4,938 lines): the package has no create/read/update/delete
247
+ operations for folders (only `updateFolderPermissions` exists), plain dashboards (only
248
+ snapshot/public-dashboard routes exist — no `POST /dashboards/db`, no
249
+ `GET/DELETE /dashboards/uid/{uid}`), alert rules or contact points (both have only a
250
+ `routeGet*Export` read-only route under `/v1/provisioning/`, no create/update/delete). S1
251
+ found no upstream `Alchemy` family for any of these either. Full detail:
252
+ [grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships). **Decision:** maintainer,
253
+ on whether the kit patches distilled (S22) or waits for upstream to add the routes — S23
254
+ forbids a hand-rolled `HttpClient` client for the missing pieces alone, half a family through
255
+ the SDK and half through a second client.
256
+ - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
257
+ (H1), and credentials come from an explicit env var NAME at call time rather than an
258
+ `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
259
+ SDK's `CredentialsFromEnv`, because more than one Grafana instance exists on this estate
260
+ (grafana.md's own Credentials section).
261
+ - Measured, read-only, on `teslamate-grafana.service` (CT100, `teslamate/grafana:4.2.0`,
262
+ Grafana 13.1.3): one datasource (file-provisioned, not API-managed — a live example this
263
+ family COULD adopt), dashboards baked into the image (not API-managed either way), and no
264
+ folders, alert rules or contact points configured — consistent with the gap being real
265
+ rather than merely unexploited. No stack yet imports `@homeflare/alchemy/grafana`.
227
266
 
228
267
  ## Conforms
229
268
 
@@ -263,6 +302,20 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
263
302
  an Output. That fix also belongs in the engine upstream.
264
303
  - **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
265
304
  labels every cold adoption as an update (H6).
305
+ - **`google-workspace/*`** (new 2026-09-24, `Group`, `GroupMember`, `DomainAlias`, `OrgUnit`)
306
+ is built directly on `@distilled.cloud/google-workspace@1.0.0-rc.12`'s typed
307
+ `unstable/admin_directory_v1` operations from the first commit — no hand-rolled client ever
308
+ existed for it to migrate off (S23), so there is no `client.ts` deletion to record. Every
309
+ `fetchLive` is get-by-key with its own `catchTag('NotFound', …)`, the same split forgejo's and
310
+ netbox's engines use (resource.ts). Get-by-key rather than NetBox's list-then-disambiguate,
311
+ since Directory addresses every object this family models by a stable key. ⛔ **Diverges on
312
+ credentials, same shape as the rest of this ledger (S24):** `GOOGLE_ACCESS_TOKEN` is read at
313
+ call time through the SDK's own `CredentialsFromEnv` — but here the SDK provides no way to
314
+ MINT that token at all (no service-account/DWD support), so a Bun wrapper outside this
315
+ package (H8, unwritten by this PR) is load-bearing in a way NetBox's/Forgejo's simple
316
+ API-token env vars are not. SDK gaps (the `unstable/` service tree, no per-operation error
317
+ beyond the shared 4xx set, no group-alias update) and the full credential/scope setup:
318
+ [google-workspace.md](./google-workspace.md).
266
319
 
267
320
  ## The Bun line
268
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.29.1",
3
+ "version": "0.31.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,10 +84,22 @@
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"
90
98
  },
99
+ "./discord": {
100
+ "types": "./dist/discord/index.d.ts",
101
+ "default": "./dist/discord/index.js"
102
+ },
91
103
  "./package.json": "./package.json"
92
104
  },
93
105
  "publishConfig": {
@@ -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.";