@homeflare/alchemy 0.36.0 → 0.37.1

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 (116) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/litellm/index.js.map +2 -2
  27. package/dist/litellm/operations.d.ts.map +1 -1
  28. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  29. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  30. package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +40 -6
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
  35. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  36. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  37. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  38. package/dist/proxmox/ceph-fs.d.ts +30 -6
  39. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  40. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  41. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  43. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-osd.d.ts +6 -0
  45. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  46. package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
  49. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-pool.d.ts +10 -5
  51. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  52. package/dist/proxmox/group-wire.d.ts +8 -13
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +605 -483
  55. package/dist/proxmox/index.js.map +20 -17
  56. package/dist/proxmox/storage-wire.d.ts +15 -11
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/unreadable-read.d.ts +7 -7
  59. package/dist/proxmox/user-wire.d.ts +8 -11
  60. package/dist/proxmox/user-wire.d.ts.map +1 -1
  61. package/dist/proxmox/user.d.ts +5 -5
  62. package/docs/argocd-kubernetes.md +100 -0
  63. package/docs/argocd.md +139 -0
  64. package/docs/credentials.md +9 -0
  65. package/docs/distilled-interim.md +15 -3
  66. package/docs/litellm.md +17 -0
  67. package/docs/peers.md +7 -0
  68. package/docs/provider-standard.md +24 -6
  69. package/docs/proxmox.md +20 -0
  70. package/docs/unifi.md +45 -0
  71. package/docs/upstream-conformance-bun.md +67 -0
  72. package/docs/upstream-conformance-conforms.md +55 -0
  73. package/docs/upstream-conformance-families.md +95 -0
  74. package/docs/upstream-conformance-grafana.md +106 -0
  75. package/docs/upstream-conformance.md +20 -298
  76. package/package.json +8 -3
  77. package/src/argocd/cluster.test.ts +120 -0
  78. package/src/argocd/cluster.ts +222 -0
  79. package/src/argocd/credentials.ts +38 -0
  80. package/src/argocd/fake-argocd.ts +60 -0
  81. package/src/argocd/git-credentials.ts +71 -0
  82. package/src/argocd/index.ts +40 -0
  83. package/src/argocd/providers.ts +17 -0
  84. package/src/argocd/repo-creds.test.ts +123 -0
  85. package/src/argocd/repo-creds.ts +115 -0
  86. package/src/argocd/repository.test.ts +167 -0
  87. package/src/argocd/repository.ts +165 -0
  88. package/src/argocd/resource.ts +150 -0
  89. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  90. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  91. package/src/litellm/operations.ts +8 -4
  92. package/src/proxmox/ceph-daemon-form.ts +7 -8
  93. package/src/proxmox/ceph-daemon-wire.ts +112 -0
  94. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  95. package/src/proxmox/ceph-daemon.ts +70 -53
  96. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  97. package/src/proxmox/ceph-fs-distilled.ts +100 -0
  98. package/src/proxmox/ceph-fs-wire.ts +17 -31
  99. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  100. package/src/proxmox/ceph-fs.ts +68 -73
  101. package/src/proxmox/ceph-osd-tree.ts +14 -5
  102. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  103. package/src/proxmox/ceph-osd-write.ts +33 -6
  104. package/src/proxmox/ceph-osd.ts +6 -0
  105. package/src/proxmox/ceph-pool-settle.ts +24 -17
  106. package/src/proxmox/ceph-pool-wire.ts +101 -0
  107. package/src/proxmox/ceph-pool-write.test.ts +146 -0
  108. package/src/proxmox/ceph-pool.ts +87 -98
  109. package/src/proxmox/group-wire.ts +7 -12
  110. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  111. package/src/proxmox/storage-wire.ts +9 -10
  112. package/src/proxmox/typed-absence.test.ts +146 -0
  113. package/src/proxmox/unreadable-read.ts +7 -7
  114. package/src/proxmox/user-wire.ts +7 -10
  115. package/src/proxmox/user.ts +5 -5
  116. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `Argocd.Cluster` — a target Kubernetes cluster registered with an Argo CD instance
3
+ * (`/api/v1/clusters`) so Applications can deploy onto it. REST-managed, not a CRD; see
4
+ * `docs/argocd.md`. This is how the house will register the Talos-on-PVE cluster itself with
5
+ * Argo CD once both exist — Argo CD's own control-plane cluster (where Argo CD is installed) is
6
+ * registered implicitly as `https://kubernetes.default.svc` and never needs this resource.
7
+ *
8
+ * ⚠️ ONLY BEARER-TOKEN AND STATIC TLS CLIENT-CERT AUTH ARE SUPPORTED. `V1alpha1ClusterConfig` also
9
+ * carries `awsAuthConfig` (EKS IAM) and `execProviderConfig` (an exec plugin) — measured in
10
+ * `services/argocd.ts`, distilled homeflare/base — neither of which applies to a Talos cluster;
11
+ * left unbuilt rather than modeled and untested.
12
+ *
13
+ * ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE (2026-09-24) — see `docs/argocd.md`.
14
+ *
15
+ * ⛔ `defaultRemovalPolicy: 'retain'` — deregistering a cluster Applications still target breaks
16
+ * their sync. `delete` below is fully implemented; opt in with `.pipe(RemovalPolicy.destroy())`.
17
+ */
18
+ import { Resource } from 'alchemy';
19
+ import * as Provider from 'alchemy/Provider';
20
+ import * as argocd from '@distilled.cloud/argocd/argocd';
21
+ import * as Data from 'effect/Data';
22
+ import * as Effect from 'effect/Effect';
23
+ import { type FromEnv, resolveAll } from '../secrets/write-only.ts';
24
+ import { type ArgocdRequirements, type ArgocdSpec, argocdHandlers, present } from './resource.ts';
25
+
26
+ export interface ClusterCredentialRefs {
27
+ readonly password?: FromEnv;
28
+ readonly bearerToken?: FromEnv;
29
+ readonly tlsClientKeyData?: FromEnv;
30
+ }
31
+
32
+ export interface ClusterProps {
33
+ /** The cluster's API server URL — identity key; changing it is a replace. */
34
+ server: string;
35
+ name?: string;
36
+ /** Restricts this cluster to one Argo CD project; omit for every project. */
37
+ project?: string;
38
+ namespaces?: readonly string[];
39
+ clusterResources?: boolean;
40
+ labels?: Readonly<Record<string, string>>;
41
+ annotations?: Readonly<Record<string, string>>;
42
+ proxyUrl?: string;
43
+ username?: string;
44
+ tlsCaData?: string;
45
+ /** The client certificate itself — public, unlike its key. */
46
+ tlsCertData?: string;
47
+ tlsInsecure?: boolean;
48
+ tlsServerName?: string;
49
+ /** Write-only — never a value here, only env var names. See the module header. */
50
+ credentials?: ClusterCredentialRefs;
51
+ }
52
+
53
+ export interface ClusterAttributes {
54
+ server: string;
55
+ name: string | undefined;
56
+ project: string | undefined;
57
+ namespaces: readonly string[] | undefined;
58
+ clusterResources: boolean | undefined;
59
+ labels: Readonly<Record<string, string>> | undefined;
60
+ annotations: Readonly<Record<string, string>> | undefined;
61
+ proxyUrl: string | undefined;
62
+ username: string | undefined;
63
+ tlsCaData: string | undefined;
64
+ tlsCertData: string | undefined;
65
+ tlsInsecure: boolean | undefined;
66
+ tlsServerName: string | undefined;
67
+ }
68
+
69
+ export interface ArgocdCluster extends Resource<
70
+ 'Argocd.Cluster',
71
+ ClusterProps,
72
+ ClusterAttributes,
73
+ never,
74
+ ArgocdRequirements
75
+ > {}
76
+
77
+ export const ArgocdCluster = Resource<ArgocdCluster>('Argocd.Cluster', {
78
+ defaultRemovalPolicy: 'retain',
79
+ });
80
+
81
+ /** Domain refusal, not a distilled error — a declared credential's env var is unset at call time. */
82
+ export class ArgocdClusterSecretEnvUnsetError extends Data.TaggedError(
83
+ 'ArgocdClusterSecretEnvUnsetError',
84
+ )<{
85
+ readonly message: string;
86
+ }> {}
87
+
88
+ interface ResolvedClusterCredentials {
89
+ readonly password?: string;
90
+ readonly bearerToken?: string;
91
+ readonly tlsClientKeyData?: string;
92
+ }
93
+
94
+ const resolveClusterCredentials = (
95
+ refs: ClusterCredentialRefs,
96
+ ): Effect.Effect<ResolvedClusterCredentials, ArgocdClusterSecretEnvUnsetError> => {
97
+ const declared: Record<string, FromEnv> = {};
98
+ if (refs.password !== undefined) declared.password = refs.password;
99
+ if (refs.bearerToken !== undefined) declared.bearerToken = refs.bearerToken;
100
+ if (refs.tlsClientKeyData !== undefined) declared.tlsClientKeyData = refs.tlsClientKeyData;
101
+ const { values, missing } = resolveAll(declared);
102
+ if (missing.length > 0) {
103
+ return Effect.fail(
104
+ new ArgocdClusterSecretEnvUnsetError({
105
+ message:
106
+ `credential write requires ${missing.join(', ')}, unset or empty in the ` +
107
+ 'deploying environment. Export it and deploy again.',
108
+ }),
109
+ );
110
+ }
111
+ return Effect.succeed(values as ResolvedClusterCredentials);
112
+ };
113
+
114
+ /**
115
+ * `V1alpha1Cluster.labels`/`.annotations` decode as `{ [key: string]: string | undefined }` — a
116
+ * generated index-signature artifact, not a real possibility on the wire (a label/annotation map
117
+ * never carries an `undefined` value). Cast at this one boundary rather than threading the looser
118
+ * type through `ClusterAttributes`.
119
+ */
120
+ const attributesOf = (live: argocd.V1alpha1Cluster): ClusterAttributes => ({
121
+ annotations: live.annotations as Record<string, string> | undefined,
122
+ clusterResources: live.clusterResources,
123
+ labels: live.labels as Record<string, string> | undefined,
124
+ name: live.name,
125
+ namespaces: live.namespaces,
126
+ project: live.project,
127
+ proxyUrl: live.config?.proxyUrl,
128
+ server: live.server ?? '',
129
+ tlsCaData: live.config?.tlsClientConfig?.caData,
130
+ tlsCertData: live.config?.tlsClientConfig?.certData,
131
+ tlsInsecure: live.config?.tlsClientConfig?.insecure,
132
+ tlsServerName: live.config?.tlsClientConfig?.serverName,
133
+ username: live.config?.username,
134
+ });
135
+
136
+ const sameRecord = (
137
+ a: Readonly<Record<string, string>> | undefined,
138
+ b: Readonly<Record<string, string>> | undefined,
139
+ ) =>
140
+ JSON.stringify(Object.entries(a ?? {}).sort()) === JSON.stringify(Object.entries(b ?? {}).sort());
141
+
142
+ const sameList = (a: readonly string[] | undefined, b: readonly string[] | undefined) =>
143
+ JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
144
+
145
+ /** Only the plain fields — Argo CD never returns the password/bearer token/private key back. */
146
+ const plainMatches = (attrs: ClusterAttributes, props: ClusterProps): boolean =>
147
+ (props.name ?? undefined) === (attrs.name ?? undefined) &&
148
+ (props.project ?? undefined) === (attrs.project ?? undefined) &&
149
+ sameList(props.namespaces, attrs.namespaces) &&
150
+ Boolean(props.clusterResources) === Boolean(attrs.clusterResources) &&
151
+ sameRecord(props.labels, attrs.labels) &&
152
+ sameRecord(props.annotations, attrs.annotations) &&
153
+ (props.proxyUrl ?? undefined) === (attrs.proxyUrl ?? undefined) &&
154
+ (props.username ?? undefined) === (attrs.username ?? undefined) &&
155
+ (props.tlsCaData ?? undefined) === (attrs.tlsCaData ?? undefined) &&
156
+ (props.tlsCertData ?? undefined) === (attrs.tlsCertData ?? undefined) &&
157
+ Boolean(props.tlsInsecure) === Boolean(attrs.tlsInsecure) &&
158
+ (props.tlsServerName ?? undefined) === (attrs.tlsServerName ?? undefined);
159
+
160
+ type ClusterError =
161
+ | argocd.GetClusterServiceError
162
+ | argocd.CreateClusterServiceError
163
+ | argocd.DeleteClusterServiceError
164
+ | ArgocdClusterSecretEnvUnsetError;
165
+
166
+ /** ★ EXPORTED for direct testing against `fake-argocd.ts` — see `cluster.test.ts`. */
167
+ export const spec: ArgocdSpec<
168
+ ClusterProps,
169
+ argocd.V1alpha1Cluster,
170
+ ClusterAttributes,
171
+ ClusterError
172
+ > = {
173
+ attributes: (live) => attributesOf(live),
174
+ // ★ Catches the DELETE call's own NotFound too — closes the TOCTOU race between the pre-check
175
+ // `fetchLive` above and this call. Matches upstream's Hetzner/Certificate.ts `deleteById`.
176
+ destroy: (props) =>
177
+ argocd
178
+ .deleteClusterService({ id_value: props.server })
179
+ .pipe(Effect.catchTag('NotFound', () => Effect.void)),
180
+ identityOfAttributes: (attrs) => attrs.server,
181
+ identityOfProps: (props) => props.server,
182
+ fetchLive: (props) =>
183
+ argocd
184
+ .getClusterService({ id_value: props.server })
185
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
186
+ matches: plainMatches,
187
+ upsert: (props) =>
188
+ resolveClusterCredentials(props.credentials ?? {}).pipe(
189
+ Effect.flatMap((creds) =>
190
+ argocd.createClusterService(
191
+ present({
192
+ upsert: true,
193
+ annotations: props.annotations,
194
+ clusterResources: props.clusterResources,
195
+ config: present({
196
+ bearerToken: creds.bearerToken,
197
+ password: creds.password,
198
+ proxyUrl: props.proxyUrl,
199
+ tlsClientConfig: present({
200
+ caData: props.tlsCaData,
201
+ certData: props.tlsCertData,
202
+ insecure: props.tlsInsecure,
203
+ keyData: creds.tlsClientKeyData,
204
+ serverName: props.tlsServerName,
205
+ }),
206
+ username: props.username,
207
+ }),
208
+ labels: props.labels,
209
+ name: props.name,
210
+ namespaces: props.namespaces === undefined ? undefined : [...props.namespaces],
211
+ project: props.project,
212
+ server: props.server,
213
+ }),
214
+ ),
215
+ ),
216
+ ),
217
+ };
218
+
219
+ export const handlers = argocdHandlers(spec);
220
+
221
+ export const ArgocdClusterProvider = () =>
222
+ Provider.effect(ArgocdCluster, Effect.succeed(ArgocdCluster.Provider.of(handlers)));
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Argo CD credentials — a bearer-token REFERENCE, resolved lazily, never a prop.
3
+ *
4
+ * ⛔ NO TOKEN IS EVER A PROP OR AN ATTRIBUTE. Alchemy persists Attributes unencrypted
5
+ * (State/StateEncoding.ts@v2.0.0-beta.79#encodeState), so nothing a stack file holds may be a
6
+ * credential. `@distilled.cloud/argocd/Credentials` already ships exactly the shape this asks
7
+ * for: `Credentials` is a lazy `Context.Service<Credentials, Effect.Effect<Config>>`, resolved
8
+ * on the calling fiber inside each operation (`ArgocdProtocol`'s `credentials:` field), never at
9
+ * layer build. This file re-exports it rather than re-implementing it — the same choice
10
+ * `../discord/credentials.ts` and `../forgejo` (via `@distilled.cloud/forgejo/Credentials`) made.
11
+ *
12
+ * ★ `CredentialsFromEnv` reads `ARGOCD_TOKEN` (required) and `ARGOCD_SERVER` (optional instance
13
+ * origin, default `https://localhost:8080`) — the distilled package's own names, unchanged here
14
+ * so a consumer never has to guess which var this family reads.
15
+ *
16
+ * ⚠️ NO `credentials(...)` FACTORY EXISTS ON THIS SDK, unlike Discord/Grafana's — measured against
17
+ * `packages/argocd/src/credentials.ts` (distilled homeflare/base): only `fromToken({ token,
18
+ * apiBaseUrl? })` and `CredentialsFromEnv`. `fake-argocd.ts` builds its test layer from
19
+ * `fromToken` directly.
20
+ *
21
+ * ⚠️ NO OpenBao MINT YET, same as every other family in this kit today (`Alchemy.Stack({ secrets
22
+ * })` is unreleased, PR 1728, `main`-only). A deploy process exports `ARGOCD_TOKEN` itself —
23
+ * this package never reads it from a file.
24
+ *
25
+ * ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE YET (2026-09-24) — this family is built ahead
26
+ * of the Talos-on-PVE cluster it targets, per Tim's "get the providers ready even if we don't
27
+ * use it yet but we know we will." Nothing here has been exercised against a real server; every
28
+ * test in this family runs against `fake-argocd.ts`.
29
+ */
30
+ import type { Config } from '@distilled.cloud/argocd/Credentials';
31
+ import { Credentials, CredentialsFromEnv, fromToken } from '@distilled.cloud/argocd/Credentials';
32
+
33
+ /** Re-exported so a consumer never has to reach into `@distilled.cloud/argocd` by hand. */
34
+ export { Credentials, CredentialsFromEnv, fromToken };
35
+ export type { Config as ArgocdCredentialsConfig };
36
+
37
+ /** `ArgocdCredentials.fromEnv()` — the layer every `argocdProviders()` stack uses by default. */
38
+ export const fromEnv = () => CredentialsFromEnv;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A minimal in-memory Argo CD REST API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../discord/fake-discord.ts`
4
+ * (itself mirroring `../forgejo/fake-forgejo.ts` and `cloudflare/fake-mesh.ts`). Tests exercise
5
+ * distilled's REAL path assembly, JSON encode/decode and status→typed-error matching — nothing
6
+ * about any of that is re-implemented here, only the wire responses a scenario needs.
7
+ *
8
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
9
+ * `argocd.example.com`, and the token is a placeholder. NO LIVE ARGO CD INSTANCE EXISTS ON THE
10
+ * ESTATE (2026-09-24) — this is the only server any test in this family talks to.
11
+ */
12
+ import { type Credentials, fromToken } from '@distilled.cloud/argocd/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://argocd.example.com';
17
+ export const FAKE_TOKEN = 'placeholder-argocd-token';
18
+
19
+ export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
20
+
21
+ /** Argo CD's grpc-gateway `{ code?, message, error? }` failure envelope. */
22
+ export const fakeFailure = (status: number, message: string, code?: number) =>
23
+ Response.json({ code, error: message, message }, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
28
+ */
29
+ export const fakeArgocd = (
30
+ route: (method: string, url: URL, body: unknown, callNumber: number) => Response,
31
+ ) => {
32
+ const seen: Seen[] = [];
33
+ let callNumber = 0;
34
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
35
+ const request =
36
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
37
+ const url = new URL(request.url);
38
+ const bodyText = await request.clone().text();
39
+ const body: unknown = bodyText === '' ? undefined : JSON.parse(bodyText);
40
+ seen.push({ body, method: request.method, path: `${url.pathname}${url.search}` });
41
+ callNumber += 1;
42
+ return route(request.method, url, body, callNumber);
43
+ }) as typeof globalThis.fetch;
44
+ return { fetch, seen };
45
+ };
46
+
47
+ /**
48
+ * distilled Credentials + the real FetchHttpClient over the fake — `ArgocdOpContext`, what every
49
+ * resource file's exported `spec.fetchLive`/`spec.upsert`/`spec.destroy` needs.
50
+ *
51
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `argocdHandlers(spec)` export). `handlers`
52
+ * bakes in `CredentialsFromEnv`, which resolves through Effect's `Config` snapshot of
53
+ * `process.env` — untestable against a fake server without mutating the real environment. So a
54
+ * test calls `spec.fetchLive`/`spec.upsert`/`spec.destroy` directly, with this explicit layer,
55
+ * exercising exactly the same production code without that env seam.
56
+ */
57
+ export const fakeArgocdLayer = (
58
+ fetchFn: typeof globalThis.fetch,
59
+ creds: Layer.Layer<Credentials> = fromToken({ apiBaseUrl: FAKE_BASE, token: FAKE_TOKEN }),
60
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The write-only credential shape `Repository` and `RepoCreds` share — Argo CD's own REST schema
3
+ * gives both the same four secret-shaped fields (`password`, `sshPrivateKey`, `bearerToken`,
4
+ * `tlsClientCertKey` — measured against `V1alpha1Repository`/`V1alpha1RepoCreds` in the generated
5
+ * `services/argocd.ts`, distilled homeflare/base). One helper here instead of two copies.
6
+ *
7
+ * ⛔ NO SECRET VALUE IS EVER A PROP OR AN ATTRIBUTE — `../secrets/write-only.ts`'s rule. A prop
8
+ * carries only the NAME of the environment variable holding a value (`FromEnv`); the value is
9
+ * read at call time and sent on the wire, never stored.
10
+ *
11
+ * ⚠️ NO DRIFT DETECTION ON THE SECRET FIELDS, ON PURPOSE — same simplification
12
+ * `../forgejo/org-actions-secrets.ts` makes for the identical problem (Argo CD's GET never
13
+ * returns `password`/`sshPrivateKey`/`bearerToken`/`tlsClientCertKey` back, so there is nothing
14
+ * to diff against). `matches` in each resource file compares only the PLAIN fields the API does
15
+ * return; a changed credential is written once on create and never rotated automatically —
16
+ * rotate by destroy-then-create, or a manual call outside Alchemy. A seal-based drift check
17
+ * (`../secrets/write-only.ts#seal`) is possible later if that limitation becomes a real problem;
18
+ * `proxmox/pbs-notification-target-lifecycle.ts` is the reference for what that costs in file size.
19
+ */
20
+ import * as Data from 'effect/Data';
21
+ import * as Effect from 'effect/Effect';
22
+ import { type FromEnv, resolveAll } from '../secrets/write-only.ts';
23
+
24
+ export interface GitCredentialRefs {
25
+ readonly password?: FromEnv;
26
+ readonly sshPrivateKey?: FromEnv;
27
+ readonly bearerToken?: FromEnv;
28
+ readonly tlsClientCertKey?: FromEnv;
29
+ }
30
+
31
+ export interface ResolvedGitCredentials {
32
+ readonly password?: string;
33
+ readonly sshPrivateKey?: string;
34
+ readonly bearerToken?: string;
35
+ readonly tlsClientCertKey?: string;
36
+ }
37
+
38
+ /** Domain refusal, not a distilled error — a declared credential's env var is unset at call time. */
39
+ export class ArgocdSecretEnvUnsetError extends Data.TaggedError('ArgocdSecretEnvUnsetError')<{
40
+ readonly message: string;
41
+ }> {}
42
+
43
+ /** Only the refs actually declared — `resolveAll` takes no `undefined` values. */
44
+ const declaredRefs = (refs: GitCredentialRefs): Readonly<Record<string, FromEnv>> => {
45
+ const out: Record<string, FromEnv> = {};
46
+ if (refs.password !== undefined) out.password = refs.password;
47
+ if (refs.sshPrivateKey !== undefined) out.sshPrivateKey = refs.sshPrivateKey;
48
+ if (refs.bearerToken !== undefined) out.bearerToken = refs.bearerToken;
49
+ if (refs.tlsClientCertKey !== undefined) out.tlsClientCertKey = refs.tlsClientCertKey;
50
+ return out;
51
+ };
52
+
53
+ /**
54
+ * Resolves every declared ref from the deploying process's own environment, or fails with the
55
+ * names of whichever are missing — never a value, in the failure or anywhere else.
56
+ */
57
+ export const resolveGitCredentials = (
58
+ refs: GitCredentialRefs,
59
+ ): Effect.Effect<ResolvedGitCredentials, ArgocdSecretEnvUnsetError> => {
60
+ const { values, missing } = resolveAll(declaredRefs(refs));
61
+ if (missing.length > 0) {
62
+ return Effect.fail(
63
+ new ArgocdSecretEnvUnsetError({
64
+ message:
65
+ `credential write requires ${missing.join(', ')}, unset or empty in the ` +
66
+ 'deploying environment. Export it and deploy again.',
67
+ }),
68
+ );
69
+ }
70
+ return Effect.succeed(values as ResolvedGitCredentials);
71
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Argo CD providers for Alchemy.
3
+ *
4
+ * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. `resource.ts`
5
+ * and `git-credentials.ts` are internals the three resource files share — a consumer builds
6
+ * against the resources and `providers()`, not the engine underneath.
7
+ *
8
+ * ★ `Application` AND `AppProject` ARE NOT HERE — they are Kubernetes CRDs, not Argo CD REST-only
9
+ * objects, and the upstream-correct owner is Alchemy's own `Kubernetes.Manifest`
10
+ * (`alchemy/Kubernetes`, v2.0.0-beta.79). See `docs/argocd.md#applications-and-appprojects` for
11
+ * the measured reasoning and worked examples — no code for those two lives in this kit.
12
+ */
13
+ export {
14
+ ArgocdCluster,
15
+ ArgocdClusterProvider,
16
+ type ClusterAttributes,
17
+ type ClusterCredentialRefs,
18
+ type ClusterProps,
19
+ } from './cluster.ts';
20
+ export {
21
+ type ArgocdCredentialsConfig,
22
+ Credentials,
23
+ CredentialsFromEnv,
24
+ fromEnv,
25
+ fromToken,
26
+ } from './credentials.ts';
27
+ export { type ArgocdSecretEnvUnsetError, type GitCredentialRefs } from './git-credentials.ts';
28
+ export { providers } from './providers.ts';
29
+ export {
30
+ ArgocdRepoCreds,
31
+ ArgocdRepoCredsProvider,
32
+ type RepoCredsAttributes,
33
+ type RepoCredsProps,
34
+ } from './repo-creds.ts';
35
+ export {
36
+ ArgocdRepository,
37
+ ArgocdRepositoryProvider,
38
+ type RepositoryAttributes,
39
+ type RepositoryProps,
40
+ } from './repository.ts';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * All three Argo CD providers as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(argocdProviders(), …the stack's other providers)
5
+ * .pipe(Layer.provide(FetchHttpClient.layer))
6
+ *
7
+ * Mirrors `../discord/providers.ts`: `FetchHttpClient.layer` is the stack's own responsibility to
8
+ * provide (the shared transport every family needs), not something a single vendor's `providers()`
9
+ * should pin.
10
+ */
11
+ import * as Layer from 'effect/Layer';
12
+ import { ArgocdClusterProvider } from './cluster.ts';
13
+ import { ArgocdRepoCredsProvider } from './repo-creds.ts';
14
+ import { ArgocdRepositoryProvider } from './repository.ts';
15
+
16
+ export const providers = () =>
17
+ Layer.mergeAll(ArgocdRepositoryProvider(), ArgocdRepoCredsProvider(), ArgocdClusterProvider());
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `Argocd.RepoCreds`'s `spec` against a fake Argo CD.
3
+ *
4
+ * ⚠️ THERE IS NO GET-BY-URL OPERATION (see `repo-creds.ts`'s header) — `fetchLive` lists and
5
+ * finds, so the tests here cover "found in the list", "absent from a non-empty list" AND
6
+ * "empty list", not just a single not-found case.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { FAKE_BASE, fakeArgocd, fakeArgocdLayer, fakeFailure } from './fake-argocd.ts';
11
+ import { argocdOperations } from './resource.ts';
12
+ import { spec } from './repo-creds.ts';
13
+
14
+ const URL_PREFIX = `${FAKE_BASE}/homeflare/`;
15
+ const ENV_VAR = 'ARGOCD_REPOCREDS_HOMEFLARE_SSH_KEY';
16
+
17
+ const listWith = (...items: Record<string, unknown>[]) => Response.json({ items });
18
+
19
+ describe('Argocd.RepoCreds spec.fetchLive (list + find, no single Get)', () => {
20
+ test('finds a matching entry in a non-empty list', async () => {
21
+ const fake = fakeArgocd(() =>
22
+ listWith(
23
+ { type: 'git', url: URL_PREFIX, username: 'ci' },
24
+ { type: 'git', url: `${FAKE_BASE}/other/` },
25
+ ),
26
+ );
27
+ const live = await Effect.runPromise(
28
+ spec.fetchLive({ url: URL_PREFIX }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
29
+ );
30
+ expect(live?.username).toBe('ci');
31
+ });
32
+
33
+ test('an empty list is undefined', async () => {
34
+ const fake = fakeArgocd(() => listWith());
35
+ const result = await Effect.runPromise(
36
+ spec.fetchLive({ url: URL_PREFIX }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
37
+ );
38
+ expect(result).toBeUndefined();
39
+ });
40
+
41
+ test('a non-empty list with no matching url is undefined', async () => {
42
+ const fake = fakeArgocd(() => listWith({ type: 'git', url: `${FAKE_BASE}/other/` }));
43
+ const result = await Effect.runPromise(
44
+ spec.fetchLive({ url: URL_PREFIX }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
45
+ );
46
+ expect(result).toBeUndefined();
47
+ });
48
+
49
+ test('a 401 propagates — not folded to absent', async () => {
50
+ const fake = fakeArgocd(() => fakeFailure(401, 'bad token'));
51
+ const failure = await Effect.runPromise(
52
+ Effect.flip(
53
+ spec.fetchLive({ url: URL_PREFIX }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
54
+ ),
55
+ );
56
+ expect(failure._tag).toBe('Unauthorized');
57
+ });
58
+ });
59
+
60
+ describe('Argocd.RepoCreds destroy — idempotent (S11)', () => {
61
+ test('deleting an absent template makes no DELETE call', async () => {
62
+ const fake = fakeArgocd(() => listWith());
63
+ await Effect.runPromise(
64
+ argocdOperations(spec)
65
+ .destroy({ url: URL_PREFIX })
66
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
67
+ );
68
+ expect(fake.seen.every((call) => call.method !== 'DELETE')).toBe(true);
69
+ });
70
+
71
+ test('the DELETE call itself returning 404 (a race with an external removal) still succeeds', async () => {
72
+ // ⚠️ fetchLive's pre-check list finds the template, but something else removes it before
73
+ // this call's own DELETE lands — matching upstream's Hetzner/Certificate.ts `deleteById`.
74
+ const fake = fakeArgocd((method) =>
75
+ method === 'GET' ? listWith({ type: 'git', url: URL_PREFIX }) : fakeFailure(404, 'not found'),
76
+ );
77
+ await Effect.runPromise(
78
+ argocdOperations(spec)
79
+ .destroy({ url: URL_PREFIX })
80
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
81
+ );
82
+ expect(fake.seen.filter((call) => call.method === 'DELETE')).toHaveLength(1);
83
+ });
84
+ });
85
+
86
+ describe('Argocd.RepoCreds spec.upsert — write-only credentials', () => {
87
+ beforeEach(() => {
88
+ delete process.env[ENV_VAR];
89
+ });
90
+ afterEach(() => {
91
+ delete process.env[ENV_VAR];
92
+ });
93
+
94
+ test('refuses with a typed domain error when the declared env var is unset', async () => {
95
+ const fake = fakeArgocd(() => listWith());
96
+ const failure = await Effect.runPromise(
97
+ Effect.flip(
98
+ spec
99
+ .upsert({ credentials: { sshPrivateKey: { fromEnv: ENV_VAR } }, url: URL_PREFIX })
100
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
101
+ ),
102
+ );
103
+ expect(failure._tag).toBe('ArgocdSecretEnvUnsetError');
104
+ expect(fake.seen).toEqual([]);
105
+ });
106
+
107
+ test('resolves the env var and sends it in the body, with upsert=true on the query string', async () => {
108
+ process.env[ENV_VAR] = '-----BEGIN KEY-----fake-----END KEY-----';
109
+ const fake = fakeArgocd((method) =>
110
+ method === 'POST' ? Response.json({ url: URL_PREFIX }) : fakeFailure(404, 'x'),
111
+ );
112
+ await Effect.runPromise(
113
+ spec
114
+ .upsert({ credentials: { sshPrivateKey: { fromEnv: ENV_VAR } }, url: URL_PREFIX })
115
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
116
+ );
117
+ const [create] = fake.seen.filter((call) => call.method === 'POST');
118
+ expect(create?.path).toContain('upsert=true');
119
+ const body = create?.body as Record<string, unknown>;
120
+ expect(body.sshPrivateKey).toBe('-----BEGIN KEY-----fake-----END KEY-----');
121
+ expect(JSON.stringify(body)).not.toContain('fromEnv');
122
+ });
123
+ });