@homeflare/alchemy 0.27.0 → 0.27.2

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 (76) hide show
  1. package/README.md +3 -2
  2. package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
  3. package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
  4. package/dist/cloudflare/index.d.ts +4 -5
  5. package/dist/cloudflare/index.d.ts.map +1 -1
  6. package/dist/cloudflare/index.js +79 -103
  7. package/dist/cloudflare/index.js.map +6 -7
  8. package/dist/cloudflare/mesh-node-api.d.ts +3 -2
  9. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
  10. package/dist/cloudflare/providers.d.ts +1 -1
  11. package/dist/cloudflare/providers.d.ts.map +1 -1
  12. package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
  13. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  14. package/dist/forgejo/branch-protection-form.d.ts +16 -5
  15. package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
  16. package/dist/forgejo/branch-protection.d.ts +34 -3
  17. package/dist/forgejo/branch-protection.d.ts.map +1 -1
  18. package/dist/forgejo/fake-forgejo.d.ts +41 -0
  19. package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
  20. package/dist/forgejo/index.js +220 -284
  21. package/dist/forgejo/index.js.map +14 -15
  22. package/dist/forgejo/org-actions-secrets.d.ts +42 -12
  23. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
  24. package/dist/forgejo/org-label.d.ts +30 -3
  25. package/dist/forgejo/org-label.d.ts.map +1 -1
  26. package/dist/forgejo/org-team.d.ts +37 -4
  27. package/dist/forgejo/org-team.d.ts.map +1 -1
  28. package/dist/forgejo/repo-webhook-form.d.ts +9 -18
  29. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
  30. package/dist/forgejo/repo-webhook.d.ts +35 -3
  31. package/dist/forgejo/repo-webhook.d.ts.map +1 -1
  32. package/dist/forgejo/repository.d.ts +47 -2
  33. package/dist/forgejo/repository.d.ts.map +1 -1
  34. package/dist/forgejo/resource.d.ts +37 -37
  35. package/dist/forgejo/resource.d.ts.map +1 -1
  36. package/dist/forgejo/team-member.d.ts +48 -4
  37. package/dist/forgejo/team-member.d.ts.map +1 -1
  38. package/dist/forgejo/values.d.ts +9 -5
  39. package/dist/forgejo/values.d.ts.map +1 -1
  40. package/dist/launchd/index.js.map +2 -2
  41. package/dist/launchd/port-claims.d.ts +2 -2
  42. package/dist/launchd/port-claims.d.ts.map +1 -1
  43. package/docs/credentials.md +6 -0
  44. package/docs/distilled-interim.md +151 -0
  45. package/docs/launchd-ports.md +14 -17
  46. package/docs/peers.md +15 -6
  47. package/docs/upstream-conformance.md +46 -22
  48. package/package.json +2 -2
  49. package/src/cloudflare/fake-r2-lock.ts +120 -0
  50. package/src/cloudflare/index.ts +4 -5
  51. package/src/cloudflare/mesh-node-api.ts +3 -2
  52. package/src/cloudflare/providers.ts +13 -16
  53. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  54. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  55. package/src/forgejo/branch-protection-form.ts +12 -5
  56. package/src/forgejo/branch-protection.ts +58 -26
  57. package/src/forgejo/fake-forgejo.ts +54 -0
  58. package/src/forgejo/org-actions-secrets.ts +44 -36
  59. package/src/forgejo/org-label.test.ts +100 -0
  60. package/src/forgejo/org-label.ts +46 -36
  61. package/src/forgejo/org-team.ts +40 -27
  62. package/src/forgejo/repo-webhook-form.ts +10 -27
  63. package/src/forgejo/repo-webhook.ts +66 -35
  64. package/src/forgejo/repository.test.ts +125 -0
  65. package/src/forgejo/repository.ts +72 -52
  66. package/src/forgejo/resource.ts +80 -79
  67. package/src/forgejo/team-member.test.ts +97 -0
  68. package/src/forgejo/team-member.ts +56 -38
  69. package/src/forgejo/values.ts +9 -17
  70. package/src/launchd/port-claims.ts +8 -9
  71. package/dist/cloudflare/client.d.ts +0 -45
  72. package/dist/cloudflare/client.d.ts.map +0 -1
  73. package/dist/forgejo/client.d.ts +0 -37
  74. package/dist/forgejo/client.d.ts.map +0 -1
  75. package/src/cloudflare/client.ts +0 -76
  76. package/src/forgejo/client.ts +0 -136
@@ -14,16 +14,17 @@ import { Resource, type ResourceClass } from 'alchemy/Resource';
14
14
  * every gate completely green. The operator's rule (2026-09-15) is to cover such a gap with a
15
15
  * custom resource over the vendor's SDK, which is what this is.
16
16
  *
17
- * ⛔ THE SDK ALREADY HAD IT, AND THAT IS WORTH SAYING BECAUSE IT DECIDED THE SHAPE. Measured
18
- * 2026-09-15 in `cloudflare@4.5.0`, `resources/r2/buckets/locks.d.ts`:
19
- *
20
- * update(bucketName: string, params: LockUpdateParams, options?): APIPromise<…>
21
- * get(bucketName: string, params: LockGetParams, options?): APIPromise<LockGetResponse>
22
- *
23
- * over `PUT`/`GET /accounts/{account_id}/r2/buckets/{bucket_name}/lock` with the jurisdiction
24
- * carried as the `cf-r2-jurisdiction` header (`locks.mjs:17` and `:41`). So there is no raw
25
- * `client.put` fallback here and no path string this package invented — the fallback the rule
26
- * allows was not needed.
17
+ * ★ OVER `@distilled.cloud/cloudflare/r2`, THE SAME SDK `Cloudflare.MeshNode` USES
18
+ * (mesh-node-api.ts), NOT THE `cloudflare` NPM SDK THIS RESOURCE USED TO CALL. Measured
19
+ * 2026-09-23 in distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins),
20
+ * `packages/cloudflare/src/services/r2.ts`: `getBucketLock` / `putBucketLock` cover the same
21
+ * `GET`/`PUT /accounts/{account_id}/r2/buckets/{bucket_name}/lock` route the old SDK's
22
+ * `r2.buckets.locks.get`/`.update` did — same `rules[]` body, the `cf-r2-jurisdiction` header
23
+ * spelled as the `jurisdiction` field — but typed as `NoSuchBucket | InvalidRoute |
24
+ * CloudflareRateLimited | CloudflareError` instead of an opaque HTTP status, so the "bucket
25
+ * does not exist yet" case below is `catchTag('NoSuchBucket', …)`, never a status check. This
26
+ * is also the swap that lets `client.ts` (the hand-rolled `cloudflare@4.5.0` wrapper) go away:
27
+ * nothing else in the kit imported it.
27
28
  *
28
29
  * ⚠️ THE RULE SET IS A REPLACE, NOT A MERGE. `PUT` takes the WHOLE list, exactly like R2's
29
30
  * lifecycle rules — which is the trap a consuming stack records for
@@ -31,10 +32,12 @@ import { Resource, type ResourceClass } from 'alchemy/Resource';
31
32
  * drop them, so `read` fetches them first and `reconcile` refuses to write when they already
32
33
  * match.
33
34
  */
34
- import type Cloudflare from 'cloudflare';
35
- import { NotFoundError } from 'cloudflare';
35
+ import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
36
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
37
+ import * as r2 from '@distilled.cloud/cloudflare/r2';
38
+ import * as Context from 'effect/Context';
36
39
  import * as Effect from 'effect/Effect';
37
- import { CloudflareApi } from './client.ts';
40
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
38
41
  import {
39
42
  type Jurisdiction,
40
43
  type R2LockRule,
@@ -95,10 +98,12 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
95
98
  * exist yet: `404 {"success":false,"errors":[{"code":10006,"message":"The specified bucket does
96
99
  * not exist."}]}` — raised from `read`, during PLANNING, before anything is created. Alchemy
97
100
  * calls `read` for a resource with no prior state, so on every first deploy the lock is asked
98
- * about a bucket the same plan is about to create. Letting that throw makes the resource
99
- * impossible to plan; swallowing it with `catchAll` would also hide a 403 from a mis-scoped
100
- * token, which is the failure this estate spends the most time on. So: 404 → absent, everything
101
- * else propagates.
101
+ * about a bucket the same plan is about to create. Code 10006 is exactly distilled's
102
+ * `NoSuchBucket` tag (r2.ts's error matcher, `[{ code: 10006 }]`), so `catchTag` on it is the
103
+ * same case the status check used to cover — but by the vendor's own error identity, not by
104
+ * reading a number off the response. Letting it propagate would make the resource impossible to
105
+ * plan; swallowing every failure would also hide a 403 from a mis-scoped token, which is the
106
+ * failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else dies.
102
107
  *
103
108
  * ⛔ AN EMPTY LIVE RULE SET IS "NO LOCK", NOT "A LOCK WITH NOTHING IN IT", so it reads as absent
104
109
  * and the resource CREATES. The API answers `{}` for a bucket that has never been locked and
@@ -110,26 +115,17 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
110
115
  * all, because `reconcile` below returns before writing.
111
116
  */
112
117
  export const readLock = (
113
- client: Cloudflare,
114
118
  props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },
115
119
  written: boolean,
116
- ): Effect.Effect<R2BucketLockAttributes | undefined> =>
120
+ ): Effect.Effect<R2BucketLockAttributes | undefined, never, CloudflareOpContext> =>
117
121
  Effect.gen(function* () {
118
122
  const jurisdiction = jurisdictionOf(props);
119
- const live = yield* Effect.tryPromise({
120
- try: () =>
121
- client.r2.buckets.locks.get(props.bucketName, {
122
- account_id: props.accountId,
123
- jurisdiction,
124
- }),
125
- catch: (cause) => cause,
126
- }).pipe(
127
- Effect.catchIf(
128
- (cause) => cause instanceof NotFoundError,
129
- () => Effect.succeed(undefined),
130
- ),
131
- Effect.orDie,
132
- );
123
+ const live = yield* r2
124
+ .getBucketLock({ accountId: props.accountId, bucketName: props.bucketName, jurisdiction })
125
+ .pipe(
126
+ Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)),
127
+ Effect.orDie,
128
+ );
133
129
  if (live === undefined) return undefined;
134
130
  const rules = (live.rules ?? []) as readonly R2LockRule[];
135
131
  if (rules.length === 0) return undefined;
@@ -146,22 +142,25 @@ export const readLock = (
146
142
  * rule set; re-sending an identical one on every deploy is a write against the only thing
147
143
  * standing between this repository and a delete, for no change. `output` is what the last deploy
148
144
  * persisted (or what `read` just adopted), so the comparison costs nothing either.
145
+ * ⛔ `NoSuchBucket` IS NOT CAUGHT HERE. Unlike `read`, a `reconcile` that hits it means the bucket
146
+ * this declaration names is genuinely gone — that is a real failure to surface, not a case to
147
+ * paper over, so it dies like every other tagged error `putBucketLock` can raise.
149
148
  */
150
149
  export const reconcileLock = (
151
- client: Cloudflare,
152
150
  news: R2BucketLockProps,
153
151
  output: R2BucketLockAttributes | undefined,
154
- ): Effect.Effect<R2BucketLockAttributes> =>
152
+ ): Effect.Effect<R2BucketLockAttributes, never, CloudflareOpContext> =>
155
153
  Effect.gen(function* () {
156
154
  const jurisdiction = jurisdictionOf(news);
157
155
  if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;
158
- yield* Effect.promise(() =>
159
- client.r2.buckets.locks.update(news.bucketName, {
160
- account_id: news.accountId,
156
+ yield* r2
157
+ .putBucketLock({
158
+ accountId: news.accountId,
159
+ bucketName: news.bucketName,
161
160
  jurisdiction,
162
161
  ...toBody(news.rules),
163
- }),
164
- );
162
+ })
163
+ .pipe(Effect.orDie);
165
164
  return { bucketName: news.bucketName, jurisdiction, rules: news.rules };
166
165
  });
167
166
 
@@ -173,25 +172,34 @@ export const reconcileLock = (
173
172
  * no ordinary deploy should ever reach it.
174
173
  */
175
174
  export const deleteLock = (
176
- client: Cloudflare,
177
175
  output: R2BucketLockAttributes,
178
176
  accountId: string,
179
- ): Effect.Effect<void> =>
177
+ ): Effect.Effect<void, never, CloudflareOpContext> =>
180
178
  Effect.gen(function* () {
181
- yield* Effect.promise(() =>
182
- client.r2.buckets.locks.update(output.bucketName, {
183
- account_id: accountId,
179
+ yield* r2
180
+ .putBucketLock({
181
+ accountId,
182
+ bucketName: output.bucketName,
184
183
  jurisdiction: output.jurisdiction,
185
184
  rules: [],
186
- }),
187
- );
185
+ })
186
+ .pipe(Effect.orDie);
188
187
  });
189
188
 
190
189
  export const R2BucketLockProvider = () =>
191
190
  Provider.effect(
192
191
  R2BucketLock,
193
192
  Effect.gen(function* () {
194
- const client = yield* CloudflareApi;
193
+ // ★ CAPTURED AT BUILD, LIKE `MeshNodeProvider`. `Credentials` and `HttpClient.HttpClient`
194
+ // are the whole of distilled's `CloudflareOpContext` (r2.ts re-exports the type); pinning
195
+ // them here rather than reading ambient context inside each handler keeps this provider
196
+ // correct whether or not the stack also merges `Cloudflare.providers()`.
197
+ const services = Context.make(Credentials, yield* Credentials).pipe(
198
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
199
+ );
200
+ const run = <A, E>(effect: Effect.Effect<A, E, CloudflareOpContext>) =>
201
+ Effect.provideContext(effect, services);
202
+
195
203
  return R2BucketLock.Provider.of({
196
204
  /**
197
205
  * ⛔ EMPTY, AND ALCHEMY'S OWN CONTRACT ASKS FOR EXACTLY THAT. `ProviderService.list`:
@@ -211,10 +219,10 @@ export const R2BucketLockProvider = () =>
211
219
  read: Effect.fn(function* ({ olds, output }) {
212
220
  const props = olds ?? undefined;
213
221
  if (props === undefined) return undefined;
214
- return yield* readLock(client, props, output !== undefined);
222
+ return yield* run(readLock(props, output !== undefined));
215
223
  }),
216
- reconcile: Effect.fn(({ news, output }) => reconcileLock(client, news, output)),
217
- delete: Effect.fn(({ olds, output }) => deleteLock(client, output, olds.accountId)),
224
+ reconcile: Effect.fn(({ news, output }) => run(reconcileLock(news, output))),
225
+ delete: Effect.fn(({ olds, output }) => run(deleteLock(output, olds.accountId))),
218
226
  });
219
227
  }),
220
228
  );
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Create/PATCH bodies for `Forgejo.BranchProtection` — extracted for the 250-line cap.
2
+ * Create/edit request bodies for `Forgejo.BranchProtection` — extracted for the 250-line cap.
3
3
  *
4
- * ★ READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json` — create posts
5
- * `CreateBranchProtectionOption`; update sends `EditBranchProtectionOption` fields only.
4
+ * ★ `repoCreateBranchProtection`'s request carries `branch_name`; `repoEditBranchProtection`'s
5
+ * carries `name` (the rule-name path segment) instead — both are covered by the two exports
6
+ * below, so branch-protection.ts never builds the field list twice.
6
7
  */
7
8
  import type { BranchProtectionProps } from './branch-protection.ts';
8
9
 
9
- export const branchProtectionForm = (props: BranchProtectionProps) => ({
10
+ const shared = (props: BranchProtectionProps) => ({
10
11
  ...(props.applyToAdmins === undefined ? {} : { apply_to_admins: props.applyToAdmins }),
11
12
  ...(props.dismissStaleApprovals === undefined
12
13
  ? {}
@@ -25,5 +26,11 @@ export const branchProtectionForm = (props: BranchProtectionProps) => ({
25
26
  ...(props.statusCheckContexts === undefined
26
27
  ? {}
27
28
  : { status_check_contexts: props.statusCheckContexts }),
28
- rule_name: props.branchName,
29
29
  });
30
+
31
+ export const createBranchProtectionForm = (props: BranchProtectionProps) => ({
32
+ ...shared(props),
33
+ branch_name: props.branchName,
34
+ });
35
+
36
+ export const editBranchProtectionForm = (props: BranchProtectionProps) => shared(props);
@@ -2,8 +2,13 @@
2
2
  * `Forgejo.BranchProtection` — one rule at `/repos/{owner}/{repo}/branch_protections/{name}`.
3
3
  *
4
4
  * ★ READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json`: create is POST collection;
5
- * read/update/delete use the branch/rule name in the path segment `{name}`. Response carries
6
- * `rule_name` (preferred) and deprecated `branch_name`.
5
+ * read/update/delete use the branch/rule name in the path segment `{name}`. Now through
6
+ * `@distilled.cloud/forgejo`'s `repository.repoCreateBranchProtection` /
7
+ * `repoGetBranchProtection` / `repoEditBranchProtection` / `repoDeleteBranchProtection`.
8
+ *
9
+ * ⚠️ `BranchProtection.rule_name` IS A REQUIRED STRING IN THE PACKAGE'S SCHEMA — the deprecated
10
+ * `branch_name` fallback the hand-rolled client carried is gone; a response missing `rule_name`
11
+ * now fails the operation's own decode instead of this family silently treating it as absent.
7
12
  *
8
13
  * ⛔ `defaultRemovalPolicy: 'retain'` — dropping a protection rule from the stack must not silently
9
14
  * unprotect a branch; opt into `.pipe(RemovalPolicy.destroy())` to DELETE.
@@ -12,10 +17,11 @@
12
17
  */
13
18
  import { Resource } from 'alchemy';
14
19
  import * as Provider from 'alchemy/Provider';
20
+ import * as repository from '@distilled.cloud/forgejo/repository';
15
21
  import * as Effect from 'effect/Effect';
16
- import { branchProtectionForm } from './branch-protection-form.ts';
17
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
18
- import { bool, int, stringArray, text } from './values.ts';
22
+ import { createBranchProtectionForm, editBranchProtectionForm } from './branch-protection-form.ts';
23
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
24
+ import { stringArray } from './values.ts';
19
25
 
20
26
  export interface BranchProtectionProps {
21
27
  owner: string;
@@ -59,26 +65,45 @@ export const ForgejoBranchProtection = Resource<ForgejoBranchProtection>(
59
65
  { defaultRemovalPolicy: 'retain' },
60
66
  );
61
67
 
62
- const handlers = forgejoHandlers<BranchProtectionProps, BranchProtectionAttributes>({
63
- attributes: (live, props) => {
64
- const ruleName = text(live['rule_name'], text(live['branch_name']));
65
- if (ruleName === '') return undefined;
66
- return {
67
- applyToAdmins: bool(live['apply_to_admins']),
68
- branchName: props.branchName,
69
- dismissStaleApprovals: bool(live['dismiss_stale_approvals']),
70
- enablePush: bool(live['enable_push'], true),
71
- enableStatusCheck: bool(live['enable_status_check']),
72
- ignoreStaleApprovals: bool(live['ignore_stale_approvals']),
68
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
69
+ export const spec: ForgejoSpec<
70
+ BranchProtectionProps,
71
+ repository.BranchProtection,
72
+ BranchProtectionAttributes,
73
+ | repository.RepoCreateBranchProtectionError
74
+ | repository.RepoGetBranchProtectionError
75
+ | repository.RepoEditBranchProtectionError
76
+ | repository.RepoDeleteBranchProtectionError
77
+ > = {
78
+ attributes: (live, props) => ({
79
+ applyToAdmins: live.apply_to_admins ?? false,
80
+ branchName: props.branchName,
81
+ dismissStaleApprovals: live.dismiss_stale_approvals ?? false,
82
+ enablePush: live.enable_push ?? true,
83
+ enableStatusCheck: live.enable_status_check ?? false,
84
+ ignoreStaleApprovals: live.ignore_stale_approvals ?? false,
85
+ owner: props.owner,
86
+ repo: props.repo,
87
+ requireSignedCommits: live.require_signed_commits ?? false,
88
+ requiredApprovals: live.required_approvals ?? 0,
89
+ statusCheckContexts: stringArray(live.status_check_contexts ?? []),
90
+ }),
91
+ create: (props) =>
92
+ repository.repoCreateBranchProtection({
93
+ owner: props.owner,
94
+ repo: props.repo,
95
+ ...createBranchProtectionForm(props),
96
+ }),
97
+ destroy: (props) =>
98
+ repository.repoDeleteBranchProtection({
73
99
  owner: props.owner,
74
100
  repo: props.repo,
75
- requireSignedCommits: bool(live['require_signed_commits']),
76
- requiredApprovals: int(live['required_approvals']),
77
- statusCheckContexts: stringArray(live['status_check_contexts']),
78
- };
79
- },
80
- collection: (props) => `repos/${props.owner}/${props.repo}/branch_protections`,
81
- createForm: branchProtectionForm,
101
+ name: props.branchName,
102
+ }),
103
+ fetchLive: (props) =>
104
+ repository
105
+ .repoGetBranchProtection({ owner: props.owner, repo: props.repo, name: props.branchName })
106
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
82
107
  matches: (attributes, props) =>
83
108
  attributes.requiredApprovals === (props.requiredApprovals ?? 0) &&
84
109
  attributes.enablePush === (props.enablePush ?? true) &&
@@ -90,9 +115,16 @@ const handlers = forgejoHandlers<BranchProtectionProps, BranchProtectionAttribut
90
115
  (props.statusCheckContexts === undefined ||
91
116
  stringArray(props.statusCheckContexts).join('\0') ===
92
117
  attributes.statusCheckContexts.join('\0')),
93
- path: (props) => `repos/${props.owner}/${props.repo}/branch_protections/${props.branchName}`,
94
- updateForm: branchProtectionForm,
95
- });
118
+ update: (props) =>
119
+ repository.repoEditBranchProtection({
120
+ owner: props.owner,
121
+ repo: props.repo,
122
+ name: props.branchName,
123
+ ...editBranchProtectionForm(props),
124
+ }),
125
+ };
126
+
127
+ export const handlers = forgejoHandlers(spec);
96
128
 
97
129
  export const ForgejoBranchProtectionProvider = () =>
98
130
  Provider.effect(
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A minimal in-memory Forgejo v1 API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `cloudflare/fake-mesh.ts`. Tests exercise distilled's REAL path assembly, JSON encode/decode
5
+ * and status→typed-error matching (`404` → `NotFound`, `403` → `Forbidden`, …) — nothing about
6
+ * that matching 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
+ * `forgejo.example.com`, and the token is a placeholder.
10
+ */
11
+ import { type Credentials, credentials } from '@distilled.cloud/forgejo/Credentials';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+
15
+ export const FAKE_BASE = 'https://forgejo.example.com';
16
+ export const FAKE_TOKEN = 'placeholder-forgejo-token';
17
+
18
+ export type Seen = { readonly method: string; readonly path: string };
19
+
20
+ /** Forgejo's failure envelope — `{ message, url }` — matching protocol.ts's `errorEnvelope`. */
21
+ export const fakeFailure = (status: number, message: string) =>
22
+ Response.json({ message, url: 'https://forgejo.example.com/api/v1/swagger' }, { status });
23
+
24
+ /**
25
+ * `route` decides every response; this wrapper only records what was sent and adapts to
26
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
27
+ */
28
+ export const fakeForgejo = (route: (method: string, url: URL) => Response) => {
29
+ const seen: Seen[] = [];
30
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
35
+ return route(request.method, url);
36
+ }) as typeof globalThis.fetch;
37
+ return { fetch, seen };
38
+ };
39
+
40
+ /**
41
+ * distilled Credentials + the real FetchHttpClient over the fake — `ForgejoOpContext`, what every
42
+ * resource file's exported `spec.fetchLive` needs.
43
+ *
44
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `forgejoHandlers(spec)` export). `handlers`
45
+ * bakes in `CredentialsFromEnv`, which resolves `FORGEJO_URL` / `FORGEJO_TOKEN` through Effect's
46
+ * `Config` — whose default provider snapshots `process.env` once and never re-reads it (measured
47
+ * 2026-09-23: mutating `process.env` mid-test-run had no effect). So a test cannot point
48
+ * `handlers` at a fake server; it calls `spec.fetchLive`/`spec.attributes` directly instead, with
49
+ * this explicit layer, exercising exactly the same production code without that env seam.
50
+ */
51
+ export const fakeForgejoLayer = (
52
+ fetchFn: typeof globalThis.fetch,
53
+ creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
54
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -1,36 +1,32 @@
1
1
  /**
2
2
  * `Forgejo.OrgSecret` — org-level Actions secret metadata at `/orgs/{org}/actions/secrets/{name}`.
3
3
  *
4
- * ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED. The OpenAPI in
5
- * `<estate>/mcp-servers/docs/api/upstream/forgejo.json` shows `Secret` returns only `name` and
6
- * `created_at` — never the `data` field — so attributes stay metadata-only. Create/update is PUT
7
- * with body `{ data }`, which does not fit POST/PATCH; this family uses the `upsert` seam on
8
- * `ForgejoSpec` instead of hand-rolling provider handlers.
4
+ * ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED. `organization.Secret` (the
5
+ * package's typed output) has only `name` and `created_at` — never `data` — so attributes stay
6
+ * metadata-only. Create/update is `updateOrgSecret`, a PUT with `{ data }`, which does not fit
7
+ * POST/PATCH; this family uses the `upsert` seam on `ForgejoSpec` instead of a plain create.
9
8
  *
10
9
  * ⛔ THE SECRET VALUE IS NEVER A PROP OR AN ATTRIBUTE. It is read from `FORGEJO_ORG_SECRET_{org}_{name}`
11
10
  * (see orgSecretEnvKey) at PUT time when the name is absent from the list. Once present, reconcile
12
11
  * noops — the API cannot confirm the value matches, and re-PUT on every deploy would rotate
13
12
  * unintentionally. Rotate by destroy-then-create or a manual PUT outside Alchemy.
14
13
  *
15
- * ⚠️ PUT ANSWERS 201/204 WITH NO BODY — client.ts treats empty responses as `undefined`; read-back
16
- * uses the list endpoint, not the PUT response.
17
- *
18
14
  * ⛔ TOKEN NEEDS `write:organization` TO PUT — list needs `read:organization`.
19
15
  *
20
16
  * ⛔ THE FILE NAME IS PLURAL ON PURPOSE AND MUST STAY THAT WAY. `.gitignore:198` carries
21
17
  * `*-secret.*` — the rule that stops `client-secret.json` and friends being committed — and it
22
18
  * matches a SOURCE file called `org-secret.ts` just as happily. This family was first authored
23
19
  * under that name: it existed on disk, typechecked, passed oxlint, was imported by alchemy.run.ts
24
- * and appeared in NO `git status`. It would have been committed as a broken import for everyone
25
- * else, with the local tree green the whole time. `scripts/husky/ignored-imports.ts` is the gate
26
- * that now catches the class; this comment is for whoever is tempted to rename it back.
20
+ * and appeared in NO `git status`. `scripts/husky/ignored-imports.ts` is the gate that now
21
+ * catches the class; this comment is for whoever is tempted to rename it back.
27
22
  */
28
23
  import { Resource } from 'alchemy';
29
24
  import * as Provider from 'alchemy/Provider';
25
+ import * as organization from '@distilled.cloud/forgejo/organization';
26
+ import * as Data from 'effect/Data';
30
27
  import * as Effect from 'effect/Effect';
31
- import { ForgejoError, forgejo } from './client.ts';
32
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
33
- import { orgSecretEnvKey, text } from './values.ts';
28
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
29
+ import { orgSecretEnvKey } from './values.ts';
34
30
 
35
31
  export interface OrgSecretProps {
36
32
  org: string;
@@ -54,46 +50,58 @@ export interface ForgejoOrgSecret extends Resource<
54
50
 
55
51
  export const ForgejoOrgSecret = Resource<ForgejoOrgSecret>('Forgejo.OrgSecret');
56
52
 
53
+ /** Domain refusal, not a distilled error — the env var this family reads the secret value from is unset. */
54
+ export class ForgejoSecretEnvUnsetError extends Data.TaggedError('ForgejoSecretEnvUnsetError')<{
55
+ readonly message: string;
56
+ }> {}
57
+
57
58
  const secretData = (org: string, name: string) =>
58
59
  Effect.gen(function* () {
59
60
  const key = orgSecretEnvKey(org, name);
60
61
  const raw = process.env[key];
61
62
  if (raw === undefined || raw.trim() === '') {
62
63
  return yield* Effect.fail(
63
- new ForgejoError(
64
- 0,
65
- 'PUT',
66
- `orgs/${org}/actions/secrets/${name}`,
67
- `${key} is unset. Export the secret value there — never as an Alchemy prop.`,
68
- ),
64
+ new ForgejoSecretEnvUnsetError({
65
+ message: `${key} is unset. Export the secret value there — never as an Alchemy prop.`,
66
+ }),
69
67
  );
70
68
  }
71
69
  return raw.trim();
72
70
  });
73
71
 
74
- const handlers = forgejoHandlers<OrgSecretProps, OrgSecretAttributes>({
72
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
73
+ export const spec: ForgejoSpec<
74
+ OrgSecretProps,
75
+ organization.Secret,
76
+ OrgSecretAttributes,
77
+ | organization.OrgListActionsSecretsError
78
+ | organization.UpdateOrgSecretError
79
+ | organization.DeleteOrgSecretError
80
+ | ForgejoSecretEnvUnsetError
81
+ > = {
75
82
  attributes: (live, props) => ({
76
- createdAt: text(live['created_at']),
83
+ createdAt: live.created_at,
77
84
  name: props.name,
78
85
  org: props.org,
79
86
  }),
80
- collection: (props) => `orgs/${props.org}/actions/secrets/${props.name}`,
81
- createForm: () => ({}),
82
- locate: (props) =>
83
- forgejo<Record<string, unknown>[]>('GET', `orgs/${props.org}/actions/secrets?limit=200`).pipe(
84
- Effect.map((rows) =>
85
- Array.isArray(rows) ? rows.find((row) => row['name'] === props.name) : undefined,
86
- ),
87
+ destroy: (props) => organization.deleteOrgSecret({ org: props.org, secretname: props.name }),
88
+ fetchLive: (props) =>
89
+ organization.orgListActionsSecrets({ org: props.org, limit: 200 }).pipe(
90
+ Effect.map((rows) => rows.find((row) => row.name === props.name)),
91
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
87
92
  ),
88
93
  matches: () => true,
89
- path: (props) => `orgs/${props.org}/actions/secrets/${props.name}`,
90
94
  upsert: (props, before) =>
91
- Effect.gen(function* () {
92
- if (before !== undefined) return;
93
- const data = yield* secretData(props.org, props.name);
94
- yield* forgejo('PUT', `orgs/${props.org}/actions/secrets/${props.name}`, { data });
95
- }),
96
- });
95
+ before !== undefined
96
+ ? Effect.void
97
+ : Effect.flatMap(secretData(props.org, props.name), (data) =>
98
+ Effect.asVoid(
99
+ organization.updateOrgSecret({ data, org: props.org, secretname: props.name }),
100
+ ),
101
+ ),
102
+ };
103
+
104
+ export const handlers = forgejoHandlers(spec);
97
105
 
98
106
  export const ForgejoOrgSecretProvider = () =>
99
107
  Provider.effect(ForgejoOrgSecret, Effect.succeed(ForgejoOrgSecret.Provider.of(handlers)));
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `Forgejo.OrgLabel`'s `fetchLive` against a fake Forgejo — the "list, then find by name" pattern
3
+ * (labels, teams, hooks and org secrets all share it, since Gitea addresses them by numeric id,
4
+ * not name). Two absent cases fold to `undefined` the same way, for different reasons:
5
+ *
6
+ * - the org itself is gone: `orgListLabels` answers a real distilled `NotFound` (404), caught by
7
+ * the same `catchTag('NotFound', ...)` idiom every `fetchLive` in this family ends with.
8
+ * - the org exists but has no label by that name: the list call succeeds, and `.find()` returns
9
+ * `undefined` on its own — no error, let alone a typed one, is involved.
10
+ *
11
+ * A 401 on the list still propagates unfolded, same as repository.test.ts.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as organization from '@distilled.cloud/forgejo/organization';
15
+ import * as Effect from 'effect/Effect';
16
+ import { fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
17
+ import { spec } from './org-label.ts';
18
+
19
+ const LABELS_PATH = '/api/v1/orgs/homeflare/labels';
20
+
21
+ const fetchLabel = (fetchFn: typeof globalThis.fetch, name: string) =>
22
+ organization.orgListLabels({ limit: 200, org: 'homeflare' }).pipe(
23
+ Effect.map((rows) => rows.find((row) => row.name === name)),
24
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
25
+ Effect.provide(fakeForgejoLayer(fetchFn)),
26
+ );
27
+
28
+ describe('Forgejo.OrgLabel fetchLive', () => {
29
+ test('a live label matched by name decodes into the typed row', async () => {
30
+ const fake = fakeForgejo((method, url) =>
31
+ method === 'GET' && url.pathname === LABELS_PATH
32
+ ? Response.json([{ color: 'ff0000', id: 7, name: 'bug' }])
33
+ : fakeFailure(404, 'not found'),
34
+ );
35
+ const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
36
+ expect(live?.id).toBe(7);
37
+ });
38
+
39
+ test('a name absent from a successful list is undefined — no error at all', async () => {
40
+ const fake = fakeForgejo(() => Response.json([{ color: 'ff0000', id: 7, name: 'bug' }]));
41
+ const live = await Effect.runPromise(fetchLabel(fake.fetch, 'enhancement'));
42
+ expect(live).toBeUndefined();
43
+ });
44
+
45
+ test('the org itself missing (404 on the list) folds to undefined too', async () => {
46
+ const fake = fakeForgejo(() => fakeFailure(404, 'org does not exist'));
47
+ const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
48
+ expect(live).toBeUndefined();
49
+ });
50
+
51
+ test('a 401 on the list propagates — never folded to absent', async () => {
52
+ // ⚠️ 401 rather than 403: `orgListLabels` only declares `NotFound` beyond the shared default
53
+ // channel — Unauthorized is in that shared set for every operation regardless.
54
+ const fake = fakeForgejo(() => fakeFailure(401, 'bad token'));
55
+ const failure = await Effect.runPromise(Effect.flip(fetchLabel(fake.fetch, 'bug')));
56
+ expect(failure._tag).toBe('Unauthorized');
57
+ });
58
+ });
59
+
60
+ describe('Forgejo.OrgLabel spec — the actual exported production functions', () => {
61
+ // ⛔ Proves the real `fetchLive` + `attributes` in org-label.ts, not a parallel
62
+ // reimplementation — see the same note in repository.test.ts.
63
+ const props = { color: 'ff0000', name: 'bug', org: 'homeflare' };
64
+
65
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
66
+ Effect.runPromise(
67
+ spec.fetchLive(props).pipe(
68
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
69
+ Effect.provide(fakeForgejoLayer(fetchFn)),
70
+ ),
71
+ );
72
+
73
+ test('spec.fetchLive + spec.attributes match the old field mapping', async () => {
74
+ const fake = fakeForgejo((method, url) =>
75
+ method === 'GET' && url.pathname === LABELS_PATH
76
+ ? Response.json([
77
+ { color: 'FF0000', description: 'something is broken', id: 7, name: 'bug' },
78
+ ])
79
+ : fakeFailure(404, 'not found'),
80
+ );
81
+ // ⚠️ `color()` lowercases — the wire value `FF0000` must compare equal to the declared `ff0000`.
82
+ expect(await readThrough(fake.fetch)).toEqual({
83
+ color: 'ff0000',
84
+ description: 'something is broken',
85
+ exclusive: false,
86
+ labelId: 7,
87
+ name: 'bug',
88
+ org: 'homeflare',
89
+ });
90
+ });
91
+
92
+ test('a label absent from the list through spec.fetchLive is undefined', async () => {
93
+ const fake = fakeForgejo((method, url) =>
94
+ method === 'GET' && url.pathname === LABELS_PATH
95
+ ? Response.json([{ color: '00ff00', id: 9, name: 'enhancement' }])
96
+ : fakeFailure(404, 'not found'),
97
+ );
98
+ expect(await readThrough(fake.fetch)).toBeUndefined();
99
+ });
100
+ });