@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,149 @@
1
+ // src/grafana/datasource.ts
2
+ import { Resource } from "alchemy";
3
+ import * as Provider from "alchemy/Provider";
4
+ import * as grafana from "@distilled.cloud/grafana";
5
+ import * as Data from "effect/Data";
6
+ import * as Effect2 from "effect/Effect";
7
+
8
+ // src/grafana/resource.ts
9
+ import { isResolved } from "alchemy/Diff";
10
+ import * as Effect from "effect/Effect";
11
+ var grafanaOperations = (spec) => {
12
+ const read = (props) => spec.fetchLive(props).pipe(Effect.map((live) => live === undefined ? undefined : spec.attributes(live, props)));
13
+ return {
14
+ read,
15
+ diff: (news, output) => Effect.gen(function* () {
16
+ if (output === undefined || !isResolved(news))
17
+ return;
18
+ const live = yield* read(news);
19
+ if (live === undefined)
20
+ return { action: "update" };
21
+ if (spec.matches(live, news))
22
+ return { action: "noop" };
23
+ return spec.update === undefined ? { action: "replace" } : { action: "update" };
24
+ }),
25
+ reconcile: (news) => Effect.gen(function* () {
26
+ const before = yield* spec.fetchLive(news);
27
+ if (before === undefined) {
28
+ yield* spec.create(news);
29
+ } else if (spec.update !== undefined) {
30
+ const mapped = spec.attributes(before, news);
31
+ if (mapped === undefined || spec.matches(mapped, news)) {} else {
32
+ yield* spec.update(news, before);
33
+ }
34
+ }
35
+ const after = yield* read(news);
36
+ if (after === undefined) {
37
+ return yield* Effect.die(new Error("grafanaOperations: the write returned no error but the object is still absent. " + "Read back rather than trusting the response status."));
38
+ }
39
+ return after;
40
+ }),
41
+ destroy: (olds) => Effect.gen(function* () {
42
+ const live = yield* spec.fetchLive(olds);
43
+ if (live === undefined)
44
+ return;
45
+ yield* spec.destroy(olds, live);
46
+ })
47
+ };
48
+ };
49
+ var grafanaHandlers = (spec) => {
50
+ const ops = grafanaOperations(spec);
51
+ return {
52
+ list: () => Effect.succeed([]),
53
+ read: ({ olds }) => ops.read(olds),
54
+ diff: ({ news, output }) => ops.diff(news, output),
55
+ reconcile: ({ news }) => ops.reconcile(news),
56
+ delete: ({ olds }) => ops.destroy(olds)
57
+ };
58
+ };
59
+
60
+ // src/grafana/datasource.ts
61
+ var GrafanaDatasource = Resource("Grafana.Datasource");
62
+
63
+ class GrafanaSecretRefUnsetError extends Data.TaggedError("GrafanaSecretRefUnsetError") {
64
+ }
65
+ var resolveSecureJsonData = (refs) => Effect2.gen(function* () {
66
+ if (refs === undefined)
67
+ return;
68
+ const out = {};
69
+ for (const [field, envVar] of Object.entries(refs)) {
70
+ const raw = process.env[envVar];
71
+ if (raw === undefined || raw.trim() === "") {
72
+ return yield* Effect2.fail(new GrafanaSecretRefUnsetError({
73
+ message: `${envVar} is unset. Export the value of secureJsonData.${field} there — ` + "never as an Alchemy prop."
74
+ }));
75
+ }
76
+ out[field] = raw.trim();
77
+ }
78
+ return out;
79
+ });
80
+ var fields = (props) => ({
81
+ name: props.name,
82
+ type: props.type,
83
+ ...props.access === undefined ? {} : { access: props.access },
84
+ ...props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth },
85
+ ...props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser },
86
+ ...props.database === undefined ? {} : { database: props.database },
87
+ ...props.isDefault === undefined ? {} : { isDefault: props.isDefault },
88
+ ...props.jsonData === undefined ? {} : { jsonData: props.jsonData },
89
+ ...props.url === undefined ? {} : { url: props.url },
90
+ ...props.user === undefined ? {} : { user: props.user },
91
+ ...props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }
92
+ });
93
+ var spec = {
94
+ attributes: (live) => ({
95
+ access: live.access ?? "",
96
+ id: live.id ?? 0,
97
+ isDefault: live.isDefault ?? false,
98
+ name: live.name ?? "",
99
+ orgId: live.orgId ?? 0,
100
+ readOnly: live.readOnly ?? false,
101
+ type: live.type ?? "",
102
+ uid: live.uid ?? "",
103
+ url: live.url ?? "",
104
+ version: live.version ?? 0
105
+ }),
106
+ create: (props) => Effect2.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) => grafana.addDataSource({
107
+ ...fields(props),
108
+ ...secureJsonData === undefined ? {} : { secureJsonData },
109
+ uid: props.uid
110
+ })),
111
+ destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),
112
+ fetchLive: (props) => grafana.getDataSourceByUID({ uid: props.uid }).pipe(Effect2.catchTag("NotFound", () => Effect2.succeed(undefined))),
113
+ matches: (attributes, props) => (props.name === undefined || attributes.name === props.name) && (props.type === undefined || attributes.type === props.type) && (props.url === undefined || attributes.url === props.url) && (props.access === undefined || attributes.access === props.access) && (props.isDefault === undefined || attributes.isDefault === props.isDefault),
114
+ update: (props, live) => Effect2.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) => grafana.updateDataSourceByUID({
115
+ ...fields(props),
116
+ ...secureJsonData === undefined ? {} : { secureJsonData },
117
+ ...live.version === undefined ? {} : { version: live.version },
118
+ uid: props.uid
119
+ }))
120
+ };
121
+ var handlers = grafanaHandlers(spec);
122
+ var GrafanaDatasourceProvider = () => Provider.effect(GrafanaDatasource, Effect2.succeed(GrafanaDatasource.Provider.of(handlers)));
123
+ // src/grafana/credentials.ts
124
+ import { Credentials } from "@distilled.cloud/grafana/Credentials";
125
+ import { ConfigError } from "@distilled.cloud/grafana/Errors";
126
+ import * as Effect3 from "effect/Effect";
127
+ import * as Layer from "effect/Layer";
128
+ import * as Redacted from "effect/Redacted";
129
+ var resolve = (target) => Effect3.sync(() => process.env[target.tokenEnv]).pipe(Effect3.flatMap((raw) => raw === undefined || raw.trim() === "" ? Effect3.fail(new ConfigError({
130
+ message: `${target.tokenEnv} is not set. Export the Grafana service-account token there ` + `— never as an Alchemy prop — before running a stack that declares a resource ` + `against ${target.baseUrl}.`
131
+ })) : Effect3.succeed({
132
+ apiBaseUrl: target.baseUrl.replace(/\/+$/, ""),
133
+ token: Redacted.make(raw.trim()),
134
+ ...target.orgId === undefined ? {} : { orgId: target.orgId }
135
+ })), Effect3.orDie);
136
+ var grafanaCredentials = (target) => Layer.succeed(Credentials, resolve(target));
137
+ // src/grafana/providers.ts
138
+ import * as Layer2 from "effect/Layer";
139
+ var grafanaProviders = (target) => GrafanaDatasourceProvider().pipe(Layer2.provide(grafanaCredentials(target)));
140
+ export {
141
+ GrafanaDatasource,
142
+ GrafanaDatasourceProvider,
143
+ GrafanaSecretRefUnsetError,
144
+ grafanaCredentials,
145
+ grafanaProviders
146
+ };
147
+
148
+ //# debugId=DA921F3915B68A6164756E2164756E21
149
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/grafana/datasource.ts", "../src/grafana/resource.ts", "../src/grafana/credentials.ts", "../src/grafana/providers.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * `Grafana.Datasource` — one Grafana data source, keyed by `uid`.\n *\n * ★ WHY `uid` IS REQUIRED, NOT GENERATED. `getDataSourceByUID`/`updateDataSourceByUID`/\n * `deleteDataSourceByUID` are all UID-keyed, and `getDataSources` (list) takes no filter — so\n * locating a datasource this resource didn't create means listing the whole org and matching by\n * name, the same ambiguity netbox/resource.ts's `soleMatch` exists to catch. Requiring `uid` up\n * front (Grafana accepts a caller-chosen one on create — `AddDataSourceRequest.uid`) sidesteps\n * it entirely: `read`/`reconcile`/`destroy` all key on the one field the API itself is keyed on.\n * `teslamate-grafana`'s live datasource (provisioned from a file, not this resource) has a\n * Grafana-generated uid; the census in the handoff script records it so a first declaration can\n * name it explicitly.\n *\n * ⛔ `secureJsonData` (e.g. a datasource password) NEVER APPEARS AS A LITERAL PROP (S25).\n * `secureJsonDataRefs` takes env var NAMES instead; `resolveSecureJsonData` reads them from\n * `process.env` fresh inside `create`/`update`'s own effect, never at plan time and never\n * returned in `attributes` — Grafana's own read API agrees: `DataSource.secureJsonFields` on a\n * GET is a map of booleans (is-it-set), never the value.\n */\nimport { Resource } from 'alchemy';\nimport * as Provider from 'alchemy/Provider';\nimport * as grafana from '@distilled.cloud/grafana';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\nimport { type GrafanaSpec, grafanaHandlers } from './resource.ts';\n\nexport interface DatasourceProps {\n uid: string;\n name: string;\n /** e.g. `postgres`, `prometheus`, `influxdb` — the Grafana plugin id. */\n type: string;\n url?: string;\n access?: 'proxy' | 'direct';\n database?: string;\n user?: string;\n basicAuth?: boolean;\n basicAuthUser?: string;\n isDefault?: boolean;\n withCredentials?: boolean;\n jsonData?: Record<string, unknown>;\n /** Secure field name (e.g. `password`) -> env var NAME holding its value. Never the value. */\n secureJsonDataRefs?: Record<string, string>;\n}\n\nexport interface DatasourceAttributes {\n uid: string;\n id: number;\n name: string;\n type: string;\n url: string;\n access: string;\n orgId: number;\n isDefault: boolean;\n readOnly: boolean;\n version: number;\n}\n\nexport interface GrafanaDatasource extends Resource<\n 'Grafana.Datasource',\n DatasourceProps,\n DatasourceAttributes,\n never\n> {}\n\nexport const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');\n\n/** A named secure-field env var is unset — a domain refusal, not a distilled error. */\nexport class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{\n readonly message: string;\n}> {}\n\n/** Reads every named env var fresh, inside the caller's effect (S24) — never at plan time. */\nconst resolveSecureJsonData = (\n refs: Record<string, string> | undefined,\n): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>\n Effect.gen(function* () {\n if (refs === undefined) return undefined;\n const out: Record<string, string> = {};\n for (const [field, envVar] of Object.entries(refs)) {\n const raw = process.env[envVar];\n if (raw === undefined || raw.trim() === '') {\n return yield* Effect.fail(\n new GrafanaSecretRefUnsetError({\n message:\n `${envVar} is unset. Export the value of secureJsonData.${field} there — ` +\n 'never as an Alchemy prop.',\n }),\n );\n }\n out[field] = raw.trim();\n }\n return out;\n });\n\n/** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */\nconst fields = (props: DatasourceProps) => ({\n name: props.name,\n type: props.type,\n ...(props.access === undefined ? {} : { access: props.access }),\n ...(props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth }),\n ...(props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser }),\n ...(props.database === undefined ? {} : { database: props.database }),\n ...(props.isDefault === undefined ? {} : { isDefault: props.isDefault }),\n ...(props.jsonData === undefined ? {} : { jsonData: props.jsonData }),\n ...(props.url === undefined ? {} : { url: props.url }),\n ...(props.user === undefined ? {} : { user: props.user }),\n ...(props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }),\n});\n\nexport const spec: GrafanaSpec<\n DatasourceProps,\n grafana.DataSource,\n DatasourceAttributes,\n | grafana.AddDataSourceError\n | grafana.GetDataSourceByUIDError\n | grafana.UpdateDataSourceByUIDError\n | grafana.DeleteDataSourceByUIDError\n | GrafanaSecretRefUnsetError\n> = {\n attributes: (live) => ({\n access: live.access ?? '',\n id: live.id ?? 0,\n isDefault: live.isDefault ?? false,\n name: live.name ?? '',\n orgId: live.orgId ?? 0,\n readOnly: live.readOnly ?? false,\n type: live.type ?? '',\n uid: live.uid ?? '',\n url: live.url ?? '',\n version: live.version ?? 0,\n }),\n create: (props) =>\n Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>\n grafana.addDataSource({\n ...fields(props),\n ...(secureJsonData === undefined ? {} : { secureJsonData }),\n uid: props.uid,\n }),\n ),\n destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),\n fetchLive: (props) =>\n grafana\n .getDataSourceByUID({ uid: props.uid })\n .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),\n /**\n * ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the same rule every family in this\n * estate follows. `secureJsonDataRefs` is never compared: Grafana never returns the value to\n * diff against, only whether a field is set (`secureJsonFields`), so this resource cannot\n * detect drift in a secret's value — only in whether one is configured at all.\n */\n matches: (attributes, props) =>\n (props.name === undefined || attributes.name === props.name) &&\n (props.type === undefined || attributes.type === props.type) &&\n (props.url === undefined || attributes.url === props.url) &&\n (props.access === undefined || attributes.access === props.access) &&\n (props.isDefault === undefined || attributes.isDefault === props.isDefault),\n update: (props, live) =>\n Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>\n grafana.updateDataSourceByUID({\n ...fields(props),\n ...(secureJsonData === undefined ? {} : { secureJsonData }),\n ...(live.version === undefined ? {} : { version: live.version }),\n uid: props.uid,\n }),\n ),\n};\n\nexport const handlers = grafanaHandlers(spec);\n\nexport const GrafanaDatasourceProvider = () =>\n Provider.effect(GrafanaDatasource, Effect.succeed(GrafanaDatasource.Provider.of(handlers)));\n",
6
+ "/**\n * One shape for every Grafana object this family declares — mirroring `../forgejo/resource.ts`\n * and `../netbox/resource.ts` exactly, minus the credentials wrapping those two bake in.\n *\n * ★ CREDENTIALS ARE NOT PROVIDED HERE. Forgejo and NetBox each manage exactly one instance for\n * this house, so their engines close over a hardcoded `CredentialsFromEnv`. Grafana does not —\n * see `credentials.ts`'s note on `grafana.homeflare.dev` vs `teslamate-grafana` — so `Credentials`\n * stays a context requirement on every handler here, and `providers.ts`'s `grafanaProviders`\n * supplies it per target with `Layer.provide`, the same composition `litellm/providers.ts` uses.\n *\n * ⚠️ `NotFound` IS FOLDED TO \"ABSENT\" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the\n * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs\n * a concrete error union to resolve `\"NotFound\"` against; a spec's `E` is only concrete at each\n * resource file's own call site.\n */\nimport { isResolved } from 'alchemy/Diff';\nimport type { Input } from 'alchemy/Input';\nimport type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';\nimport * as Effect from 'effect/Effect';\n\n/**\n * One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —\n * no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union\n * of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref\n * env var), so a call site's precise error union flows straight through instead of being widened\n * by hand here.\n */\nexport type GrafanaSpec<Props extends object, Live, Attributes, E> = {\n /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */\n readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;\n readonly attributes: (live: Live, props: Props) => Attributes | undefined;\n readonly matches: (attributes: Attributes, props: Props) => boolean;\n readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;\n readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;\n readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;\n};\n\nexport const grafanaOperations = <Props extends object, Live, Attributes, E>(\n spec: GrafanaSpec<Props, Live, Attributes, E>,\n) => {\n const read = (props: Props) =>\n spec\n .fetchLive(props)\n .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));\n\n return {\n read,\n\n diff: (news: Input<Props>, output: Attributes | undefined) =>\n Effect.gen(function* () {\n if (output === undefined || !isResolved(news)) return undefined;\n const live = yield* read(news);\n if (live === undefined) return { action: 'update' } as const;\n if (spec.matches(live, news)) return { action: 'noop' } as const;\n return spec.update === undefined\n ? ({ action: 'replace' } as const)\n : ({ action: 'update' } as const);\n }),\n\n /**\n * ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF \"ADOPT\" FOR THIS FAMILY. A `uid` this\n * Grafana instance already has is bound and, when it already matches the declaration,\n * left alone; only a genuinely absent `uid` is created. Declaring exactly what is live\n * changes nothing on `reconcile`, the same posture netbox/resource.ts documents as\n * \"adopt is the default posture, not a flag\".\n */\n reconcile: (news: Props) =>\n Effect.gen(function* () {\n const before = yield* spec.fetchLive(news);\n if (before === undefined) {\n yield* spec.create(news);\n } else if (spec.update !== undefined) {\n const mapped = spec.attributes(before, news);\n if (mapped === undefined || spec.matches(mapped, news)) {\n /* adoption or already converged — no write */\n } else {\n yield* spec.update(news, before);\n }\n }\n const after = yield* read(news);\n if (after === undefined) {\n return yield* Effect.die(\n new Error(\n 'grafanaOperations: the write returned no error but the object is still absent. ' +\n 'Read back rather than trusting the response status.',\n ),\n );\n }\n return after;\n }),\n\n destroy: (olds: Props) =>\n Effect.gen(function* () {\n const live = yield* spec.fetchLive(olds);\n if (live === undefined) return;\n yield* spec.destroy(olds, live);\n }),\n };\n};\n\n/**\n * The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and\n * `HttpClient.HttpClient` stay ambient context requirements — see the file header.\n *\n * ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for\n * adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.\n */\nexport const grafanaHandlers = <Props extends object, Live, Attributes extends object, E>(\n spec: GrafanaSpec<Props, Live, Attributes, E>,\n) => {\n const ops = grafanaOperations(spec);\n return {\n list: () => Effect.succeed([]),\n read: ({ olds }: { olds: Props }) => ops.read(olds),\n diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>\n ops.diff(news, output),\n reconcile: ({ news }: { news: Props }) => ops.reconcile(news),\n delete: ({ olds }: { olds: Props }) => ops.destroy(olds),\n };\n};\n",
7
+ "/**\n * Grafana credentials for this family — a URL plus a TOKEN REFERENCE (an env var NAME), never a\n * literal secret. Wraps `@distilled.cloud/grafana`'s own `Credentials` context directly (S23 —\n * reuse the SDK's own client rather than inventing a second one); this file only adds the house's\n * lazy, per-instance resolution on top of it.\n *\n * ★ ONE `GRAFANA_TOKEN`/`GRAFANA_URL` PAIR ISN'T ENOUGH FOR THIS HOUSE. Unlike Forgejo (one\n * instance), the estate runs at least two Grafanas — `grafana.homeflare.dev` and\n * `teslamate-grafana` on CT100, which the unit's own comment says has \"Own Access SaaS OIDC\n * client, never grafana.homeflare.dev's\". The SDK's `CredentialsFromEnv` reads one fixed pair\n * of names, so a stack declaring against more than one instance needs a target per instance —\n * `grafanaCredentials(target)` below, parameterized the way `litellm/credentials.ts` and\n * `proxmox/credentials.ts` already are in this kit.\n *\n * ⛔ THE TOKEN NEVER TOUCHES A PROP OR AN ATTRIBUTE (S25). `tokenEnv` is a NAME, resolved from\n * `process.env` fresh inside the credentials effect on every request (S24) — a layer built once\n * still re-reads the environment each time an operation runs, the same laziness\n * `litellm/credentials.ts` and the SDK's own `forgejo`/`netbox` `CredentialsFromEnv` have.\n *\n * ⚠️ THE SDK's `Credentials` SERVICE VALUE TYPE IS `Effect.Effect<Config, never, never>` — the\n * error channel is `never` (credentials.d.ts). A missing env var can only surface as a defect\n * here, via `Effect.orDie`, the same trade-off `forgejo/resource.ts` and `netbox/resource.ts`\n * already accept for their own `CredentialsFromEnv`; it is not a choice this file introduces.\n */\nimport { type Config, Credentials } from '@distilled.cloud/grafana/Credentials';\nimport { ConfigError } from '@distilled.cloud/grafana/Errors';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\nimport * as Redacted from 'effect/Redacted';\n\nexport interface GrafanaTarget {\n /** Instance origin, e.g. `https://tesla.homeflare.dev/dash` — never `/api`; the SDK appends it. */\n readonly baseUrl: string;\n /** Env var NAME holding a Grafana service-account token. Never the value itself. */\n readonly tokenEnv: string;\n /** Selects the org a call applies to, via `X-Grafana-Org-Id`. Omit for the default org. */\n readonly orgId?: number | string;\n}\n\nconst resolve = (target: GrafanaTarget): Effect.Effect<Config, never, never> =>\n Effect.sync(() => process.env[target.tokenEnv]).pipe(\n Effect.flatMap((raw) =>\n raw === undefined || raw.trim() === ''\n ? Effect.fail(\n new ConfigError({\n message:\n `${target.tokenEnv} is not set. Export the Grafana service-account token there ` +\n `— never as an Alchemy prop — before running a stack that declares a resource ` +\n `against ${target.baseUrl}.`,\n }),\n )\n : Effect.succeed({\n apiBaseUrl: target.baseUrl.replace(/\\/+$/, ''),\n token: Redacted.make(raw.trim()),\n ...(target.orgId === undefined ? {} : { orgId: target.orgId }),\n } satisfies Config),\n ),\n Effect.orDie,\n );\n\n/** The lazy, per-instance layer `grafanaProviders(target)` composes by default. */\nexport const grafanaCredentials = (target: GrafanaTarget): Layer.Layer<Credentials> =>\n Layer.succeed(Credentials, resolve(target));\n\nexport type { Credentials };\n",
8
+ "/**\n * The Grafana providers for one instance, with its credentials, as one layer for a stack.\n *\n * Layer.mergeAll(grafanaProviders({ baseUrl: '...', tokenEnv: 'GRAFANA_TOKEN' }), …)\n *\n * ★ ONE CALL PER INSTANCE. A stack declaring resources against both `grafana.homeflare.dev` and\n * `teslamate-grafana` composes `grafanaProviders(targetA)` and `grafanaProviders(targetB)` —\n * each resource type's `Resource` constructor is a single global registration, so the SAME\n * `Grafana.Datasource` type serves both; only the credential layer differs per call.\n * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime, the same as\n * `litellmProviders`/`netboxProviders`-style families in this kit assume; a stack that already\n * deploys anything over HTTP already has one.\n */\nimport * as Layer from 'effect/Layer';\nimport { type GrafanaTarget, grafanaCredentials } from './credentials.ts';\nimport { GrafanaDatasourceProvider } from './datasource.ts';\n\nexport const grafanaProviders = (target: GrafanaTarget) =>\n GrafanaDatasourceProvider().pipe(Layer.provide(grafanaCredentials(target)));\n"
9
+ ],
10
+ "mappings": "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",
11
+ "debugId": "DA921F3915B68A6164756E2164756E21",
12
+ "names": []
13
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The Grafana providers for one instance, with its credentials, as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(grafanaProviders({ baseUrl: '...', tokenEnv: 'GRAFANA_TOKEN' }), …)
5
+ *
6
+ * ★ ONE CALL PER INSTANCE. A stack declaring resources against both `grafana.homeflare.dev` and
7
+ * `teslamate-grafana` composes `grafanaProviders(targetA)` and `grafanaProviders(targetB)` —
8
+ * each resource type's `Resource` constructor is a single global registration, so the SAME
9
+ * `Grafana.Datasource` type serves both; only the credential layer differs per call.
10
+ * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime, the same as
11
+ * `litellmProviders`/`netboxProviders`-style families in this kit assume; a stack that already
12
+ * deploys anything over HTTP already has one.
13
+ */
14
+ import * as Layer from 'effect/Layer';
15
+ import { type GrafanaTarget } from './credentials.ts';
16
+ export declare const grafanaProviders: (target: GrafanaTarget) => Layer.Layer<import("alchemy").Provider<import("./datasource.ts").GrafanaDatasource>, never, import("effect/unstable/http/HttpClient").HttpClient>;
17
+ //# sourceMappingURL=providers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../src/grafana/providers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AAG1E,eAAO,MAAM,gBAAgB,WAAY,aAAa,sJACuB,CAAC"}
@@ -0,0 +1,68 @@
1
+ import type { Input } from 'alchemy/Input';
2
+ import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
3
+ import * as Effect from 'effect/Effect';
4
+ /**
5
+ * One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —
6
+ * no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union
7
+ * of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref
8
+ * env var), so a call site's precise error union flows straight through instead of being widened
9
+ * by hand here.
10
+ */
11
+ export type GrafanaSpec<Props extends object, Live, Attributes, E> = {
12
+ /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
13
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;
14
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
15
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
16
+ readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;
17
+ readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
18
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
19
+ };
20
+ export declare const grafanaOperations: <Props extends object, Live, Attributes, E>(spec: GrafanaSpec<Props, Live, Attributes, E>) => {
21
+ read: (props: Props) => Effect.Effect<Attributes | undefined, E, GrafanaOpContext>;
22
+ diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
23
+ readonly action: 'update';
24
+ } | {
25
+ readonly action: 'noop';
26
+ } | {
27
+ readonly action: 'replace';
28
+ } | undefined, E, GrafanaOpContext>;
29
+ /**
30
+ * ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF "ADOPT" FOR THIS FAMILY. A `uid` this
31
+ * Grafana instance already has is bound and, when it already matches the declaration,
32
+ * left alone; only a genuinely absent `uid` is created. Declaring exactly what is live
33
+ * changes nothing on `reconcile`, the same posture netbox/resource.ts documents as
34
+ * "adopt is the default posture, not a flag".
35
+ */
36
+ reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null), E, GrafanaOpContext>;
37
+ destroy: (olds: Props) => Effect.Effect<void, E, GrafanaOpContext>;
38
+ };
39
+ /**
40
+ * The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and
41
+ * `HttpClient.HttpClient` stay ambient context requirements — see the file header.
42
+ *
43
+ * ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for
44
+ * adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.
45
+ */
46
+ export declare const grafanaHandlers: <Props extends object, Live, Attributes extends object, E>(spec: GrafanaSpec<Props, Live, Attributes, E>) => {
47
+ list: () => Effect.Effect<never[], never, never>;
48
+ read: ({ olds }: {
49
+ olds: Props;
50
+ }) => Effect.Effect<Attributes | undefined, E, GrafanaOpContext>;
51
+ diff: ({ news, output }: {
52
+ news: Input<Props>;
53
+ output: Attributes | undefined;
54
+ }) => Effect.Effect<{
55
+ readonly action: 'update';
56
+ } | {
57
+ readonly action: 'noop';
58
+ } | {
59
+ readonly action: 'replace';
60
+ } | undefined, E, GrafanaOpContext>;
61
+ reconcile: ({ news }: {
62
+ news: Props;
63
+ }) => Effect.Effect<Attributes, E, GrafanaOpContext>;
64
+ delete: ({ olds }: {
65
+ olds: Props;
66
+ }) => Effect.Effect<void, E, GrafanaOpContext>;
67
+ };
68
+ //# sourceMappingURL=resource.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/grafana/resource.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,IAAI;IACnE,qGAAqG;IACrG,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC3F,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,GAAG,SAAS,CAAC;IAC1E,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC5F,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;CAC7F,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,QACnE,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;kBAExB,KAAK;iBAQX,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;IAI5B;;;;;;OAMG;sBACe,KAAK;oBAyBP,KAAK;CAOxB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAChF,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAI3C,IAAI;IACJ,IAAI,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IAChC,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBA9DlC,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;IA6D5B,SAAS,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACrC,MAAM,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAErC,CAAC"}
@@ -0,0 +1,59 @@
1
+ # Formatting a Caddyfile — `formatCaddyfile()`
2
+
3
+ `caddy fmt` is a CLI-only feature (`cmd/commandfuncs.go`); nothing under `/adapt`, `/load` or any
4
+ other admin endpoint formats a Caddyfile — measured 2026-09-24. `formatCaddyfile(text)` shells out
5
+ to the LOCAL `caddy` binary instead — `caddy fmt -`, the text on stdin, the formatted text on
6
+ stdout — the same formatter the CLI command runs. It never reimplements the Caddyfile grammar's
7
+ formatting rules in TypeScript.
8
+
9
+ ```ts
10
+ import * as Effect from 'effect/Effect';
11
+ import { layer as BunServicesLayer } from '@effect/platform-bun/BunServices'; // or platform-node's
12
+ import { formatCaddyfile } from '@homeflare/alchemy/caddy';
13
+
14
+ const formatted = await Effect.runPromise(
15
+ formatCaddyfile(caddyfile).pipe(Effect.provide(BunServicesLayer)),
16
+ );
17
+ ```
18
+
19
+ | failure | when |
20
+ | ------------------ | ---------------------------------------------------------------------------------- |
21
+ | `CaddyFmtNotFound` | the binary is not on `PATH` (or not executable) |
22
+ | `CaddyFmtFailed` | it ran but exited non-zero — a syntax error in the text, most likely |
23
+ | `PlatformError` | anything else the OS reported spawning it (a permission problem, a killed process) |
24
+
25
+ ⛔ **Never silently skipped.** A missing or failing binary always fails the Effect — there is no
26
+ fallback path that returns the input back unformatted. Point at a specific build with
27
+ `{ binaryPath }` (default: `DEFAULT_CADDY_BINARY`, i.e. `caddy`, resolved on `PATH`) — ideally the
28
+ exact one the target Caddy runs, since `caddy fmt`'s own output can change between versions.
29
+
30
+ Requires `ChildProcessSpawner` in scope (S19: no `node:child_process`, no `async`/`await`) —
31
+ `@effect/platform-bun`'s or `@effect/platform-node`'s layer, or anything smaller that provides
32
+ just that service.
33
+
34
+ ## Why it matters: the adapter's own warning
35
+
36
+ Caddy's Caddyfile adapter warns `Caddyfile input is not formatted` on **every** unformatted input
37
+ (`adapter.go` `FormattingDifference`) — not an edge case, the common one. That warning shares the
38
+ `/load` response body with the refused-but-200 trap [caddy.md](./caddy.md)'s digest note and
39
+ admin-calls.ts's module doc describe, so on its own it is easy to miss among other diagnostics.
40
+
41
+ `Caddy.Config`'s plan (`diffConfig`) and deploy (`reconcile`) both pull it out of the warning list
42
+ and log it as its own clear line naming the fix, separately from any other adapter warnings:
43
+
44
+ > `Caddy.Config at <endpoint>: Caddyfile is not formatted — run `caddy fmt` on it, or format it
45
+ with this package's formatCaddyfile() before declaring it, to silence this warning.`
46
+
47
+ Before this, the warning was invisible at plan time (never logged at all) and, at deploy time,
48
+ just one more line among the rest — easy to read past.
49
+
50
+ ★ **Formatting should never change the adapted JSON, so it should never cause drift.** `digest.ts`
51
+ compares the adapted config; `caddy fmt` only changes whitespace and brace style in the Caddyfile
52
+ TEXT, and the Caddyfile grammar treats runs of whitespace as plain token separators outside a
53
+ quoted value, so re-flowing them should not change the token stream `/adapt` produces from it.
54
+ ⚠️ **Reasoned, not measured against a real Caddy in this repo** — this package has no caddy binary
55
+ to adapt both forms and diff the JSON against. homeflare-mini's own "adapts with zero warnings"
56
+ test (its own docs) is where that gets checked against a real `/adapt` call, on real rendered
57
+ Caddyfiles. Running a declared Caddyfile through `formatCaddyfile()` before handing it to
58
+ `CaddyConfig` silences the warning; it is not expected to ever show up as an update on the next
59
+ plan, but that expectation rests on the grammar, not on a measurement taken here.
package/docs/caddy.md CHANGED
@@ -81,6 +81,13 @@ the adapter's warnings, which the provider still reports as a refusal.
81
81
  `read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
82
82
  still needs Caddy, and fails loudly until it answers.
83
83
 
84
+ ## Formatting
85
+
86
+ `formatCaddyfile(text)` runs the local `caddy fmt -` binary (there is no admin API for it). Plan
87
+ and deploy both surface Caddy's own "not formatted" warning as one clear line naming the fix —
88
+ formatting never changes the adapted JSON, so it can never cause drift. Details, the error types
89
+ and the exact log line: [caddy-fmt.md](./caddy-fmt.md).
90
+
84
91
  ## Order: file, then `/load`
85
92
 
86
93
  `caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
@@ -45,3 +45,23 @@ the same two variable names the retired hand-rolled `credentials.ts` read (LiteL
45
45
  CLI client's variable names, not a house choice), still resolved on the calling fiber per request,
46
46
  never captured at module scope. `Authorization: Bearer <value>` is built by the package's protocol
47
47
  layer, not by this package. ⛔ The key is never a prop, for the same reason as above.
48
+
49
+ The Discord subpath (2026-09-24, new — `@distilled.cloud/discord`, no prior hand-rolled client)
50
+ resolves credentials through the SDK's own `CredentialsFromEnv` layer, which reads
51
+ `DISCORD_BOT_TOKEN` (falling back to `DISCORD_TOKEN`), resolved on the calling fiber per request,
52
+ never captured at module scope. `Authorization: Bot <value>` is built by the package's protocol
53
+ layer. On CT100, `hf-discord-halibut.service` holds its own bot token at
54
+ `/opt/homeflare/env/discord-halibut.env`; this package never reads that file. ⛔ The token is
55
+ never a prop, for the same reason as above. See [discord.md](./discord.md) for the live census
56
+ this credential would let a deploy act against.
57
+
58
+ The Google Workspace subpath (new 2026-09-24, on `@distilled.cloud/google-workspace`) resolves
59
+ credentials through the SDK's own `CredentialsFromEnv`, which reads `GOOGLE_ACCESS_TOKEN` — but
60
+ unlike NetBox/Forgejo/Paperless/LiteLLM's API-token schemes, this variable holds an OAuth2 bearer
61
+ access token the SDK does not know how to mint: no ADC, no service-account signing, no refresh
62
+ flow. A Bun wrapper (H8's shape, unwritten by this package) performs the domain-wide-delegation
63
+ JWT exchange against a service-account key in OpenBao and exports the short-lived result into
64
+ this one variable. `Authorization: Bearer <value>` is still built by the package's protocol layer.
65
+ `GoogleWorkspaceKeyRef`/`describeKeyRef` (this subpath's own `credentials.ts`) are a typed,
66
+ key-free REFERENCE to where that service-account key lives, for a stack file to keep alongside its
67
+ declarations — full setup: [google-workspace.md](./google-workspace.md).
@@ -0,0 +1,129 @@
1
+ # Discord — `@homeflare/alchemy/discord`
2
+
3
+ Declares Discord application commands (global and guild-scoped) against
4
+ `@distilled.cloud/discord@1.0.0-rc.12`. Built 2026-09-24, walked against the live target,
5
+ `hf-discord-halibut.service` on CT100 — read-only; nothing in this walk deployed anything or
6
+ touched a real token. See [`upstream-conformance.md`](./upstream-conformance.md#discord) for the
7
+ rule-by-rule accounting.
8
+
9
+ ## Resource set, and why it stops there
10
+
11
+ Two resources: `Discord.ApplicationCommand` (global) and `Discord.GuildApplicationCommand`
12
+ (guild-scoped). The SDK's `discord.ts` service also generates guild role, guild channel and
13
+ webhook operations (`createGuildRole`, `createGuildChannel`, `createWebhook`, …), but
14
+ `hf-discord-halibut.service` — the only live Discord workload the estate runs — touches none of
15
+ them: it registers slash commands and nothing else (measured on CT100, see below). "Start with
16
+ what Halibut actually uses" (the task this shipped under) means roles, channels and webhooks stay
17
+ unbuilt until a real stack needs to declare one; the engine in `resource.ts` is generic enough
18
+ that adding `Discord.GuildRole` later is a new spec file, not a new engine.
19
+
20
+ ## Halibut, measured on CT100 (read-only, 2026-09-24)
21
+
22
+ - **Application/bot**: `hf-discord-halibut.service`, `/opt/homeflare/discord-bot/halibut`,
23
+ Sapphire on discord.js. Token in `/opt/homeflare/env/discord-halibut.env`
24
+ (`DISCORD_TOKEN`, `0640 root:homeflare`), loaded by systemd's `EnvironmentFile=`. Never read by
25
+ this package.
26
+ - **Guild(s)**: `DISCORD_GUILD_ID`, a CSV of two snowflakes (commented `HomeFlare,schenanigans`
27
+ in `register.ts`) — guild-scoped registration only; `DISCORD_GUILD_ID` is always set in
28
+ production, so `guildIdsForRegister()` never falls through to global.
29
+ - **Commands** (`CURRENT_SLASH_NAMES`, `src/discord/register.ts`): `ping`, `whoami`, `session`,
30
+ `status`, `llmstats`, `run`, `sch`, `pr`, `voice` — nine guild-scoped slash commands.
31
+ - **Registration timing**: `applySapphireGuildRegister()` runs once **before login**, setting
32
+ Sapphire's `RegisterBehavior.BulkOverwrite` and its default guild ids. On every connect,
33
+ Sapphire then `application.commands.set()`s the exact `CURRENT_SLASH_NAMES` set — a PUT that
34
+ wipes anything else registered on those guilds ("Stale guild cmds … have no handler → Discord
35
+ 'interaction failed'", the file's own comment, measured 2026-09-04).
36
+ - **Webhooks, roles, channels**: none found. `grep -rli webhook` over `dist/` matched nothing;
37
+ the only role/channel-shaped env var is an unrelated `DISCORD_FORUM_TAG_MAP`.
38
+
39
+ ## 🔴 Ownership conflict: do not deploy `GuildApplicationCommand` against Halibut's guilds yet
40
+
41
+ Halibut's `BulkOverwrite` on every restart and this resource's `reconcile` (an upsert on every
42
+ apply where `matches` fails) are two independent writers converging the same object on two
43
+ different schedules. Declaring `Discord.GuildApplicationCommand` for `ping`/`whoami`/… on
44
+ Halibut's guilds today would not replace Halibut's registration — it would race it. Whichever ran
45
+ last after each of the bot's restarts and each `alchemy deploy` would win until the other ran
46
+ again, and `alchemy plan` would report drift the bot itself caused.
47
+
48
+ **The handover, when someone runs it (none of this is done by this change):**
49
+
50
+ 1. Freeze `CURRENT_SLASH_NAMES` — it is already the single source of truth for the set.
51
+ 2. Declare each command as a `guildApplicationCommand(...)` (the `adopt(true)` constructor) with
52
+ props matching what is live exactly. A verified apply should be a no-op — `hf-adopt-verify`
53
+ proves that without writing (H6).
54
+ 3. **In the same deploy window**, remove the `applySapphireGuildRegister()` call from Halibut's
55
+ `src/discord/register.ts` and redeploy the bot. Step 2 without step 3 leaves both writers
56
+ active; step 3 without step 2 leaves the commands with no owner at all until the next deploy.
57
+
58
+ Steps 1–3 are bot-runtime changes and out of scope for this family, which is declarative only.
59
+
60
+ ## Credentials
61
+
62
+ `DISCORD_BOT_TOKEN` (falling back to `DISCORD_TOKEN`), read **at call time**, never a prop —
63
+ `@distilled.cloud/discord/Credentials`' own `CredentialsFromEnv`, re-exported from
64
+ `credentials.ts` rather than re-implemented. No OpenBao mint yet (H8's target,
65
+ `Alchemy.Stack({ secrets })`, is unreleased); today a deploy process exports the variable itself.
66
+
67
+ ## Adopt is `adopt(true)`, not silent
68
+
69
+ Unlike NetBox (H1's documented gap), this family follows upstream's `Snippet.ts` reference for a
70
+ marker-less API exactly: a cold `read` (no persisted `output`) that finds a live command by name
71
+ returns `Unowned(attrs)`, so a first deploy against an existing command fails
72
+ `OwnedBySomeoneElse` **unless** `adopt(true)` is set. Both convenience constructors
73
+ (`applicationCommand`, `guildApplicationCommand`) pipe `adopt(true)` on by default (H5), matching
74
+ `cloudflare/website.ts`'s `astroWebsite`/`viteWebsite`.
75
+
76
+ ## Reconcile is one upsert call
77
+
78
+ Discord's own docs for both Create Global/Guild Application Command say a command with the same
79
+ name (and type) as an existing one overwrites it — the create endpoint is a true upsert, the same
80
+ shape `Snippet.ts`'s `putSnippet` uses. `reconcile` therefore has no separate PATCH path: it reads
81
+ the live command, and calls `create` only when `commandMatches` says the declaration and the live
82
+ object disagree. ⚠️ REASONED NOT MEASURED — this task had no token and made no real Discord API
83
+ call; it is documented Discord behaviour, unchanged across API v10, not something exercised here.
84
+
85
+ ## Rate limits: the SDK's default bound, not a house retry
86
+
87
+ Every generated operation threads a `Retry` context tag (`retry: Retry.Retry` in
88
+ `protocol.ts`/`api.ts`). With no policy installed, `@distilled.cloud/core`'s `makeDefault` applies:
89
+ capped exponential backoff (250ms × 2, capped at 5s), **`Schedule.recurs(8)`** (bounded — S26 asks
90
+ for 8–10 attempts), and a `429`'s `Retry-After` honored with precedence, itself capped at 60s
91
+ (`DEFAULT_SERVER_RETRY_HINT_CAP_MS`). This family adds nothing on top — no `Discord.Retry.policy`
92
+ call anywhere in `resource.ts` — because adding one could only make it either redundant with, or
93
+ looser than, the bound the SDK already guarantees. `retry.test.ts` proves both directions against
94
+ the fake: a rate-limited call is retried and eventually succeeds, AND a call rate-limited forever
95
+ still stops — `fake.seen.length` is asserted `<= 9` (1 attempt + `Schedule.recurs(8)`), not just
96
+ "eventually rejects", so the bound itself is what the test checks.
97
+
98
+ ## SDK gaps
99
+
100
+ - 🔴 **MEASURED: every generated operation's typed error union is narrower than the protocol's own
101
+ `HTTP_STATUS_MAP`.** `tsc` is what surfaced this — see `guild-application-command.test.ts`'s
102
+ 401 test and its own comment. `DiscordOpError` (every operation's declared error type) resolves
103
+ to `DefaultErrors | ConfigError | HttpClientError`, and core's `DefaultErrors` is built only from
104
+ `DEFAULT_ERROR_STATUSES` — 401, 429, 500, 502, 503, 504. `Forbidden` (403), `NotFound` (404),
105
+ `BadRequest` (400) and `Conflict` (409) are in `HTTP_STATUS_MAP` and so ARE constructed by the
106
+ protocol at runtime on those statuses, but they are absent from the exported error TYPE, so
107
+ `catchTag('Forbidden', …)` does not type-check anywhere in this family without an unsafe cast.
108
+ Nothing in `resource.ts` currently needs to catch any of those four (locate is list-based and
109
+ never 404s, the same reasoning `netbox/prefix.ts` documents), but a future resource that does —
110
+ or `destroy`'s own TOCTOU race, where the command could vanish between `fetchByName` and the
111
+ delete call and legitimately 404 — cannot `catchTag('NotFound', …)` today. That gap is real and
112
+ unhandled in `resource.ts#destroy`, documented rather than papered over with a cast.
113
+ - **`options` is passed through opaquely**, not re-modeled. The generated option union is ~40
114
+ nested schema types; `command-form.ts` types it as `readonly Record<string, unknown>[]` and
115
+ neither validates nor transforms it. A caller supplies Discord's own wire shape.
116
+ - **No vendor constraint table**, unlike NetBox/Paperless. Distilled's schema carries Discord's
117
+ field limits (name length, description length, max 25 options, …) but this change did not walk
118
+ `vendor-schema` for Discord; a bad declaration fails at the API call, not at plan time.
119
+ - **`default_member_permissions` type asymmetry**: the generated request types show
120
+ `number | null`, the response shows `string | null`. Discord's real wire contract is a
121
+ stringified bitfield on both sides; `command-form.ts` types the prop as `string` (matching the
122
+ response and Discord's docs) and casts through `unknown` at the SDK call boundary, the same
123
+ pattern `netbox/prefix.ts` uses for its own generated-type mismatches.
124
+ - **Global command propagation** (up to an hour, Discord's own docs) is not polled — S26 bounds
125
+ waits at ~60s, which cannot cover an hour. A `diff` run inside that window may report transient
126
+ drift. Documented in `application-command.ts`, not solved.
127
+ - **No live lifecycle test** (H12): tests run against `fake-discord.ts`, a loopback fake, not a
128
+ real Discord application. This task could not hold a token to run one even if the house allowed
129
+ agent-run live writes, which it currently does not.