@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.
- package/README.md +42 -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/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- 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 +20 -0
- package/docs/discord.md +129 -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 +53 -0
- package/package.json +13 -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/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -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
|
@@ -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).
|
package/docs/grafana.md
ADDED
|
@@ -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.
|
|
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 &&
|
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.";
|