@homeflare/alchemy 0.35.0 → 0.37.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 (127) 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/grafana/index.d.ts +5 -4
  27. package/dist/grafana/index.d.ts.map +1 -1
  28. package/dist/grafana/index.js +204 -90
  29. package/dist/grafana/index.js.map +7 -4
  30. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  31. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  32. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  33. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  34. package/dist/grafana/notification-policy-model.d.ts +53 -0
  35. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  36. package/dist/grafana/notification-policy.d.ts +57 -0
  37. package/dist/grafana/notification-policy.d.ts.map +1 -0
  38. package/dist/grafana/providers.d.ts +1 -1
  39. package/dist/grafana/providers.d.ts.map +1 -1
  40. package/dist/litellm/index.js.map +2 -2
  41. package/dist/litellm/operations.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  43. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
  45. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  46. package/dist/proxmox/ceph-daemon.d.ts +35 -0
  47. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
  49. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  51. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  52. package/dist/proxmox/ceph-fs.d.ts +31 -6
  53. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  54. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  55. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  56. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  57. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  58. package/dist/proxmox/ceph-osd.d.ts +6 -0
  59. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  60. package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
  61. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  62. package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
  63. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  64. package/dist/proxmox/ceph-pool.d.ts +10 -5
  65. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  66. package/dist/proxmox/group-wire.d.ts +1 -1
  67. package/dist/proxmox/group-wire.d.ts.map +1 -1
  68. package/dist/proxmox/index.js +611 -477
  69. package/dist/proxmox/index.js.map +15 -12
  70. package/dist/proxmox/storage-wire.d.ts +8 -1
  71. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +1 -1
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/docs/argocd-kubernetes.md +100 -0
  75. package/docs/argocd.md +139 -0
  76. package/docs/credentials.md +9 -0
  77. package/docs/grafana-alerting.md +3 -2
  78. package/docs/grafana-notification-policy.md +127 -0
  79. package/docs/grafana.md +12 -8
  80. package/docs/litellm.md +17 -0
  81. package/docs/peers.md +7 -0
  82. package/docs/unifi.md +45 -0
  83. package/docs/upstream-conformance.md +19 -5
  84. package/package.json +8 -3
  85. package/src/argocd/cluster.test.ts +120 -0
  86. package/src/argocd/cluster.ts +222 -0
  87. package/src/argocd/credentials.ts +38 -0
  88. package/src/argocd/fake-argocd.ts +60 -0
  89. package/src/argocd/git-credentials.ts +71 -0
  90. package/src/argocd/index.ts +40 -0
  91. package/src/argocd/providers.ts +17 -0
  92. package/src/argocd/repo-creds.test.ts +123 -0
  93. package/src/argocd/repo-creds.ts +115 -0
  94. package/src/argocd/repository.test.ts +167 -0
  95. package/src/argocd/repository.ts +165 -0
  96. package/src/argocd/resource.ts +150 -0
  97. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  98. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  99. package/src/grafana/index.ts +11 -4
  100. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  101. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  102. package/src/grafana/notification-policy-fixtures.ts +27 -0
  103. package/src/grafana/notification-policy-model.test.ts +104 -0
  104. package/src/grafana/notification-policy-model.ts +100 -0
  105. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  106. package/src/grafana/notification-policy.test.ts +86 -0
  107. package/src/grafana/notification-policy.ts +191 -0
  108. package/src/grafana/providers.ts +2 -0
  109. package/src/litellm/operations.ts +8 -4
  110. package/src/proxmox/ceph-daemon-form.ts +7 -8
  111. package/src/proxmox/ceph-daemon-wire.ts +115 -0
  112. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  113. package/src/proxmox/ceph-daemon.ts +65 -47
  114. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  115. package/src/proxmox/ceph-fs-distilled.ts +102 -0
  116. package/src/proxmox/ceph-fs-wire.ts +17 -31
  117. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  118. package/src/proxmox/ceph-fs.ts +66 -69
  119. package/src/proxmox/ceph-osd-tree.ts +14 -5
  120. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  121. package/src/proxmox/ceph-osd-write.ts +33 -6
  122. package/src/proxmox/ceph-osd.ts +6 -0
  123. package/src/proxmox/ceph-pool-settle.ts +13 -7
  124. package/src/proxmox/ceph-pool-wire.ts +110 -0
  125. package/src/proxmox/ceph-pool-write.test.ts +145 -0
  126. package/src/proxmox/ceph-pool.ts +87 -98
  127. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -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
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `Argocd.RepoCreds` — a credential TEMPLATE that Argo CD applies to any repository whose URL
3
+ * starts with `url` (`/api/v1/repocreds`) — distinct from `Argocd.Repository`, which registers one
4
+ * specific repository. REST-managed, not a CRD; see `docs/argocd.md`.
5
+ *
6
+ * ⚠️ NO `GetRepoCredsService` OPERATION EXISTS — measured against the operation list in
7
+ * `services/argocd.ts` (distilled homeflare/base): Create/Update/Delete/List only, no single-item
8
+ * Get. `fetchLive` below lists and finds by `url`, the same shape
9
+ * `discord/guild-application-command.ts#fetchByName` and
10
+ * `forgejo/org-actions-secrets.ts#fetchLive` use for the same reason.
11
+ *
12
+ * ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE (2026-09-24) — see `docs/argocd.md`.
13
+ */
14
+ import { Resource } from 'alchemy';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as argocd from '@distilled.cloud/argocd/argocd';
17
+ import * as Effect from 'effect/Effect';
18
+ import {
19
+ type ArgocdSecretEnvUnsetError,
20
+ type GitCredentialRefs,
21
+ resolveGitCredentials,
22
+ } from './git-credentials.ts';
23
+ import { type ArgocdRequirements, type ArgocdSpec, argocdHandlers, present } from './resource.ts';
24
+
25
+ export interface RepoCredsProps {
26
+ /** The URL prefix this template matches — identity key; changing it is a replace. */
27
+ url: string;
28
+ /** "git" (default), "helm" or "oci". */
29
+ type?: string;
30
+ username?: string;
31
+ /** The client certificate itself — public, unlike its key. */
32
+ tlsClientCertData?: string;
33
+ /** Write-only — see `git-credentials.ts`. Never a value here, only env var names. */
34
+ credentials?: GitCredentialRefs;
35
+ }
36
+
37
+ export interface RepoCredsAttributes {
38
+ url: string;
39
+ type: string | undefined;
40
+ username: string | undefined;
41
+ tlsClientCertData: string | undefined;
42
+ }
43
+
44
+ export interface ArgocdRepoCreds extends Resource<
45
+ 'Argocd.RepoCreds',
46
+ RepoCredsProps,
47
+ RepoCredsAttributes,
48
+ never,
49
+ ArgocdRequirements
50
+ > {}
51
+
52
+ export const ArgocdRepoCreds = Resource<ArgocdRepoCreds>('Argocd.RepoCreds');
53
+
54
+ const attributesOf = (live: argocd.V1alpha1RepoCreds): RepoCredsAttributes => ({
55
+ tlsClientCertData: live.tlsClientCertData,
56
+ type: live.type,
57
+ url: live.url ?? '',
58
+ username: live.username,
59
+ });
60
+
61
+ /** Only the plain fields — see `git-credentials.ts` for why the secret fields are excluded. */
62
+ const plainMatches = (attrs: RepoCredsAttributes, props: RepoCredsProps): boolean =>
63
+ (props.type ?? 'git') === (attrs.type ?? 'git') &&
64
+ (props.username ?? undefined) === (attrs.username ?? undefined) &&
65
+ (props.tlsClientCertData ?? undefined) === (attrs.tlsClientCertData ?? undefined);
66
+
67
+ type RepoCredsError =
68
+ | argocd.ListRepoCredsServiceRepositoryCredentialsError
69
+ | argocd.CreateRepoCredsServiceRepositoryCredentialsError
70
+ | argocd.DeleteRepoCredsServiceRepositoryCredentialsError
71
+ | ArgocdSecretEnvUnsetError;
72
+
73
+ /** ★ EXPORTED for direct testing against `fake-argocd.ts` — see `repo-creds.test.ts`. */
74
+ export const spec: ArgocdSpec<
75
+ RepoCredsProps,
76
+ argocd.V1alpha1RepoCreds,
77
+ RepoCredsAttributes,
78
+ RepoCredsError
79
+ > = {
80
+ attributes: (live) => attributesOf(live),
81
+ // ★ Catches the DELETE call's own NotFound too — closes the TOCTOU race between the pre-check
82
+ // `fetchLive` above and this call. Matches upstream's Hetzner/Certificate.ts `deleteById`.
83
+ destroy: (props) =>
84
+ argocd
85
+ .deleteRepoCredsServiceRepositoryCredentials({ url: props.url })
86
+ .pipe(Effect.catchTag('NotFound', () => Effect.void)),
87
+ identityOfAttributes: (attrs) => attrs.url,
88
+ identityOfProps: (props) => props.url,
89
+ fetchLive: (props) =>
90
+ argocd.listRepoCredsServiceRepositoryCredentials({ url: props.url }).pipe(
91
+ Effect.map((page) => (page.items ?? []).find((row) => row.url === props.url)),
92
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
93
+ ),
94
+ matches: plainMatches,
95
+ upsert: (props) =>
96
+ resolveGitCredentials(props.credentials ?? {}).pipe(
97
+ Effect.flatMap((creds) =>
98
+ argocd.createRepoCredsServiceRepositoryCredentials(
99
+ present({
100
+ upsert: true,
101
+ tlsClientCertData: props.tlsClientCertData,
102
+ type: props.type,
103
+ url: props.url,
104
+ username: props.username,
105
+ ...creds,
106
+ }),
107
+ ),
108
+ ),
109
+ ),
110
+ };
111
+
112
+ export const handlers = argocdHandlers(spec);
113
+
114
+ export const ArgocdRepoCredsProvider = () =>
115
+ Provider.effect(ArgocdRepoCreds, Effect.succeed(ArgocdRepoCreds.Provider.of(handlers)));
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `Argocd.Repository`'s `spec` against a fake Argo CD — proves the doctrine-required shapes:
3
+ *
4
+ * - `fetchLive` folds a real distilled `NotFound` (404) to `undefined`, and does NOT fold a
5
+ * 401 — the same `catchTag('NotFound', ...)` idiom `forgejo/repository.test.ts` proves.
6
+ * - `spec.destroy`-via-`argocdOperations#destroy` is idempotent: a delete of an already-absent
7
+ * repository makes no request at all (S11).
8
+ * - `spec.upsert` sends `upsert: true` and resolves write-only credentials from the environment,
9
+ * never a literal value on a stack file — and refuses (a typed domain error, not a distilled
10
+ * one) when the declared env var is unset.
11
+ *
12
+ * No live Argo CD instance exists on the estate (2026-09-24) — every assertion here runs against
13
+ * `fake-argocd.ts`, never a real server.
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
+ import * as Effect from 'effect/Effect';
17
+ import { FAKE_BASE, fakeArgocd, fakeArgocdLayer, fakeFailure } from './fake-argocd.ts';
18
+ import { argocdOperations } from './resource.ts';
19
+ import { spec } from './repository.ts';
20
+
21
+ const REPO = `${FAKE_BASE}/homeflare/kit.git`;
22
+ const ENV_VAR = 'ARGOCD_REPO_KIT_PASSWORD';
23
+
24
+ const liveRepo = (overrides: Record<string, unknown> = {}) =>
25
+ Response.json({ enableLfs: true, repo: REPO, type: 'git', ...overrides });
26
+
27
+ describe('Argocd.Repository spec.fetchLive', () => {
28
+ test('a live repository decodes into attributes', async () => {
29
+ const fake = fakeArgocd((method) =>
30
+ method === 'GET' ? liveRepo() : fakeFailure(404, 'not found'),
31
+ );
32
+ const live = await Effect.runPromise(
33
+ spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
34
+ );
35
+ expect(live?.repo).toBe(REPO);
36
+ expect(live?.enableLfs).toBe(true);
37
+ });
38
+
39
+ test('a 404 folds to undefined', async () => {
40
+ const fake = fakeArgocd(() => fakeFailure(404, 'repository not found'));
41
+ const result = await Effect.runPromise(
42
+ spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
43
+ );
44
+ expect(result).toBeUndefined();
45
+ });
46
+
47
+ test('a 401 propagates — not folded to absent', async () => {
48
+ const fake = fakeArgocd(() => fakeFailure(401, 'bad token'));
49
+ const failure = await Effect.runPromise(
50
+ Effect.flip(spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch)))),
51
+ );
52
+ expect(failure._tag).toBe('Unauthorized');
53
+ });
54
+ });
55
+
56
+ describe('Argocd.Repository destroy — idempotent (S11)', () => {
57
+ test('deleting an already-absent repository makes no delete call', async () => {
58
+ const fake = fakeArgocd(() => fakeFailure(404, 'repository not found'));
59
+ await Effect.runPromise(
60
+ argocdOperations(spec)
61
+ .destroy({ repo: REPO })
62
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
63
+ );
64
+ // Only the read (fetchLive) happened — never a DELETE against an object that was never there.
65
+ expect(fake.seen.every((call) => call.method !== 'DELETE')).toBe(true);
66
+ });
67
+
68
+ test('deleting a live repository calls DELETE once', async () => {
69
+ const fake = fakeArgocd((method) => (method === 'GET' ? liveRepo() : Response.json({})));
70
+ await Effect.runPromise(
71
+ argocdOperations(spec)
72
+ .destroy({ repo: REPO })
73
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
74
+ );
75
+ expect(fake.seen.filter((call) => call.method === 'DELETE')).toHaveLength(1);
76
+ });
77
+
78
+ test('the DELETE call itself returning 404 (a race with an external removal) still succeeds', async () => {
79
+ // ⚠️ Different from the "already-absent" case above: fetchLive's pre-check finds the object
80
+ // live, but something else deletes it before this call's own DELETE lands — matching
81
+ // upstream's Hetzner/Certificate.ts `deleteById` pattern (catchTag on the delete itself).
82
+ const fake = fakeArgocd((method) =>
83
+ method === 'GET' ? liveRepo() : fakeFailure(404, 'repository not found'),
84
+ );
85
+ await Effect.runPromise(
86
+ argocdOperations(spec)
87
+ .destroy({ repo: REPO })
88
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
89
+ );
90
+ expect(fake.seen.filter((call) => call.method === 'DELETE')).toHaveLength(1);
91
+ });
92
+ });
93
+
94
+ describe('Argocd.Repository spec.upsert — write-only credentials', () => {
95
+ beforeEach(() => {
96
+ delete process.env[ENV_VAR];
97
+ });
98
+ afterEach(() => {
99
+ delete process.env[ENV_VAR];
100
+ });
101
+
102
+ test('refuses with a typed domain error when the declared env var is unset', async () => {
103
+ const fake = fakeArgocd(() => fakeFailure(404, 'unused'));
104
+ const failure = await Effect.runPromise(
105
+ Effect.flip(
106
+ spec
107
+ .upsert({ credentials: { password: { fromEnv: ENV_VAR } }, repo: REPO, username: 'ci' })
108
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
109
+ ),
110
+ );
111
+ expect(failure._tag).toBe('ArgocdSecretEnvUnsetError');
112
+ expect(fake.seen).toEqual([]);
113
+ });
114
+
115
+ test('resolves the env var and sends it — never the FromEnv reference itself', async () => {
116
+ process.env[ENV_VAR] = 'hunter2';
117
+ const fake = fakeArgocd((method) =>
118
+ method === 'POST' ? Response.json({ repo: REPO }) : fakeFailure(404, 'x'),
119
+ );
120
+ await Effect.runPromise(
121
+ spec
122
+ .upsert({ credentials: { password: { fromEnv: ENV_VAR } }, repo: REPO, username: 'ci' })
123
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
124
+ );
125
+ const [create] = fake.seen.filter((call) => call.method === 'POST');
126
+ // `upsert` is a query-string trait on this operation (`T.Query()`), not a body field —
127
+ // measured against `CreateRepositoryServiceRepositoryRequest` in services/argocd.ts.
128
+ expect(create?.path).toContain('upsert=true');
129
+ const body = create?.body as Record<string, unknown>;
130
+ expect(body.password).toBe('hunter2');
131
+ expect(body.username).toBe('ci');
132
+ expect(JSON.stringify(body)).not.toContain('fromEnv');
133
+ });
134
+ });
135
+
136
+ describe('Argocd.Repository diff — a changed identity key is a replace, never an update', () => {
137
+ // ⛔ REGRESSION: adversarial review 2026-09-24 found `diff` could only ever return
138
+ // noop/update, so a changed `repo` (Argo CD indexes by it) silently created a new
139
+ // registration at the new URL and left the old, still-credentialed one orphaned — no state
140
+ // pointed at it, nothing would ever delete it. Fixed via `identityOfProps`/
141
+ // `identityOfAttributes` in resource.ts.
142
+ const RENAMED_REPO = `${FAKE_BASE}/homeflare/kit-renamed.git`;
143
+ const oldOutput = spec.attributes({ enableLfs: true, repo: REPO, type: 'git' }, { repo: REPO });
144
+
145
+ test('a changed repo URL is a replace, and no read of the new URL happens first', async () => {
146
+ const fake = fakeArgocd(() => fakeFailure(500, 'must not be called — identity differs'));
147
+ const result = await Effect.runPromise(
148
+ argocdOperations(spec)
149
+ .diff({ repo: RENAMED_REPO }, oldOutput)
150
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
151
+ );
152
+ expect(result).toEqual({ action: 'replace' });
153
+ expect(fake.seen).toEqual([]);
154
+ });
155
+
156
+ test('the SAME repo URL with a plain-field change is an update, not a replace', async () => {
157
+ const fake = fakeArgocd((method) =>
158
+ method === 'GET' ? liveRepo({ enableLfs: false }) : fakeFailure(404, 'x'),
159
+ );
160
+ const result = await Effect.runPromise(
161
+ argocdOperations(spec)
162
+ .diff({ enableLfs: true, repo: REPO }, oldOutput)
163
+ .pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
164
+ );
165
+ expect(result).toEqual({ action: 'update' });
166
+ });
167
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `Argocd.Repository` — a git/helm/OCI source registered with an Argo CD instance
3
+ * (`/api/v1/repositories`). REST-managed, NOT a Kubernetes CRD — see `docs/argocd.md` for the
4
+ * measured split between this family and `Kubernetes.Manifest` (upstream Alchemy's family, for
5
+ * `Application`/`AppProject`, which genuinely are CRDs).
6
+ *
7
+ * ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE (2026-09-24). Built ahead of the Talos-on-PVE
8
+ * cluster this targets; every test runs against `fake-argocd.ts`. See `docs/argocd.md`.
9
+ *
10
+ * ⛔ `defaultRemovalPolicy: 'retain'` — deregistering a repository Argo CD applications still
11
+ * reference breaks their sync, the same reasoning `forgejo/repository.ts` gives for git repos.
12
+ * `delete` below is fully implemented; opt in with `.pipe(RemovalPolicy.destroy())`.
13
+ */
14
+ import { Resource } from 'alchemy';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as argocd from '@distilled.cloud/argocd/argocd';
17
+ import * as Effect from 'effect/Effect';
18
+ import {
19
+ type ArgocdSecretEnvUnsetError,
20
+ type GitCredentialRefs,
21
+ resolveGitCredentials,
22
+ } from './git-credentials.ts';
23
+ import { type ArgocdRequirements, type ArgocdSpec, argocdHandlers, present } from './resource.ts';
24
+
25
+ export interface RepositoryProps {
26
+ /** The repository URL — identity key; changing it is a replace, not an update. */
27
+ repo: string;
28
+ /** "git" (default), "helm" or "oci". */
29
+ type?: string;
30
+ /** Required for "helm" repositories (Argo CD indexes helm repos by name, not URL). */
31
+ name?: string;
32
+ /** Restricts this repository to one Argo CD project; omit for every project. */
33
+ project?: string;
34
+ username?: string;
35
+ insecure?: boolean;
36
+ insecureIgnoreHostKey?: boolean;
37
+ enableLfs?: boolean;
38
+ enableOCI?: boolean;
39
+ forceHttpBasicAuth?: boolean;
40
+ proxy?: string;
41
+ noProxy?: string;
42
+ /** The client certificate itself — public, unlike its key. */
43
+ tlsClientCertData?: string;
44
+ /** Write-only — see `git-credentials.ts`. Never a value here, only env var names. */
45
+ credentials?: GitCredentialRefs;
46
+ }
47
+
48
+ export interface RepositoryAttributes {
49
+ repo: string;
50
+ type: string | undefined;
51
+ name: string | undefined;
52
+ project: string | undefined;
53
+ username: string | undefined;
54
+ insecure: boolean | undefined;
55
+ insecureIgnoreHostKey: boolean | undefined;
56
+ enableLfs: boolean | undefined;
57
+ enableOCI: boolean | undefined;
58
+ forceHttpBasicAuth: boolean | undefined;
59
+ proxy: string | undefined;
60
+ noProxy: string | undefined;
61
+ tlsClientCertData: string | undefined;
62
+ }
63
+
64
+ export interface ArgocdRepository extends Resource<
65
+ 'Argocd.Repository',
66
+ RepositoryProps,
67
+ RepositoryAttributes,
68
+ never,
69
+ ArgocdRequirements
70
+ > {}
71
+
72
+ export const ArgocdRepository = Resource<ArgocdRepository>('Argocd.Repository', {
73
+ defaultRemovalPolicy: 'retain',
74
+ });
75
+
76
+ const attributesOf = (live: argocd.V1alpha1Repository): RepositoryAttributes => ({
77
+ enableLfs: live.enableLfs,
78
+ enableOCI: live.enableOCI,
79
+ forceHttpBasicAuth: live.forceHttpBasicAuth,
80
+ insecure: live.insecure,
81
+ insecureIgnoreHostKey: live.insecureIgnoreHostKey,
82
+ name: live.name,
83
+ noProxy: live.noProxy,
84
+ project: live.project,
85
+ proxy: live.proxy,
86
+ repo: live.repo ?? '',
87
+ tlsClientCertData: live.tlsClientCertData,
88
+ type: live.type,
89
+ username: live.username,
90
+ });
91
+
92
+ /** Only the plain fields — see `git-credentials.ts` for why the secret fields are excluded. */
93
+ const plainMatches = (attrs: RepositoryAttributes, props: RepositoryProps): boolean =>
94
+ (props.type ?? 'git') === (attrs.type ?? 'git') &&
95
+ (props.name ?? undefined) === (attrs.name ?? undefined) &&
96
+ (props.project ?? undefined) === (attrs.project ?? undefined) &&
97
+ (props.username ?? undefined) === (attrs.username ?? undefined) &&
98
+ Boolean(props.insecure) === Boolean(attrs.insecure) &&
99
+ Boolean(props.insecureIgnoreHostKey) === Boolean(attrs.insecureIgnoreHostKey) &&
100
+ Boolean(props.enableLfs) === Boolean(attrs.enableLfs) &&
101
+ Boolean(props.enableOCI) === Boolean(attrs.enableOCI) &&
102
+ Boolean(props.forceHttpBasicAuth) === Boolean(attrs.forceHttpBasicAuth) &&
103
+ (props.proxy ?? undefined) === (attrs.proxy ?? undefined) &&
104
+ (props.noProxy ?? undefined) === (attrs.noProxy ?? undefined) &&
105
+ (props.tlsClientCertData ?? undefined) === (attrs.tlsClientCertData ?? undefined);
106
+
107
+ type RepositoryError =
108
+ | argocd.GetRepositoryServiceError
109
+ | argocd.CreateRepositoryServiceRepositoryError
110
+ | argocd.DeleteRepositoryServiceRepositoryError
111
+ | ArgocdSecretEnvUnsetError;
112
+
113
+ /** ★ EXPORTED for direct testing against `fake-argocd.ts` — see `repository.test.ts`. */
114
+ export const spec: ArgocdSpec<
115
+ RepositoryProps,
116
+ argocd.V1alpha1Repository,
117
+ RepositoryAttributes,
118
+ RepositoryError
119
+ > = {
120
+ attributes: (live) => attributesOf(live),
121
+ // ★ Catches the DELETE call's own NotFound too, not just the pre-check `fetchLive` fold above
122
+ // — closes the TOCTOU race between that read and this call (the object could be removed by
123
+ // something else in between). Matches upstream's own pattern: Hetzner/Certificate.ts's
124
+ // `deleteById` (`.pipe(Effect.catchTag('NotFound', () => Effect.void))`).
125
+ destroy: (props) =>
126
+ argocd
127
+ .deleteRepositoryServiceRepository({ repo: props.repo })
128
+ .pipe(Effect.catchTag('NotFound', () => Effect.void)),
129
+ identityOfAttributes: (attrs) => attrs.repo,
130
+ identityOfProps: (props) => props.repo,
131
+ fetchLive: (props) =>
132
+ argocd
133
+ .getRepositoryService({ repo: props.repo })
134
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
135
+ matches: plainMatches,
136
+ upsert: (props) =>
137
+ resolveGitCredentials(props.credentials ?? {}).pipe(
138
+ Effect.flatMap((creds) =>
139
+ argocd.createRepositoryServiceRepository(
140
+ present({
141
+ upsert: true,
142
+ enableLfs: props.enableLfs,
143
+ enableOCI: props.enableOCI,
144
+ forceHttpBasicAuth: props.forceHttpBasicAuth,
145
+ insecure: props.insecure,
146
+ insecureIgnoreHostKey: props.insecureIgnoreHostKey,
147
+ name: props.name,
148
+ noProxy: props.noProxy,
149
+ project: props.project,
150
+ proxy: props.proxy,
151
+ repo: props.repo,
152
+ tlsClientCertData: props.tlsClientCertData,
153
+ type: props.type,
154
+ username: props.username,
155
+ ...creds,
156
+ }),
157
+ ),
158
+ ),
159
+ ),
160
+ };
161
+
162
+ export const handlers = argocdHandlers(spec);
163
+
164
+ export const ArgocdRepositoryProvider = () =>
165
+ Provider.effect(ArgocdRepository, Effect.succeed(ArgocdRepository.Provider.of(handlers)));