@homeflare/alchemy 0.27.1 → 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 (71) 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/docs/credentials.md +6 -0
  41. package/docs/distilled-interim.md +151 -0
  42. package/docs/peers.md +15 -6
  43. package/docs/upstream-conformance.md +46 -22
  44. package/package.json +2 -2
  45. package/src/cloudflare/fake-r2-lock.ts +120 -0
  46. package/src/cloudflare/index.ts +4 -5
  47. package/src/cloudflare/mesh-node-api.ts +3 -2
  48. package/src/cloudflare/providers.ts +13 -16
  49. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  50. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  51. package/src/forgejo/branch-protection-form.ts +12 -5
  52. package/src/forgejo/branch-protection.ts +58 -26
  53. package/src/forgejo/fake-forgejo.ts +54 -0
  54. package/src/forgejo/org-actions-secrets.ts +44 -36
  55. package/src/forgejo/org-label.test.ts +100 -0
  56. package/src/forgejo/org-label.ts +46 -36
  57. package/src/forgejo/org-team.ts +40 -27
  58. package/src/forgejo/repo-webhook-form.ts +10 -27
  59. package/src/forgejo/repo-webhook.ts +66 -35
  60. package/src/forgejo/repository.test.ts +125 -0
  61. package/src/forgejo/repository.ts +72 -52
  62. package/src/forgejo/resource.ts +80 -79
  63. package/src/forgejo/team-member.test.ts +97 -0
  64. package/src/forgejo/team-member.ts +56 -38
  65. package/src/forgejo/values.ts +9 -17
  66. package/dist/cloudflare/client.d.ts +0 -45
  67. package/dist/cloudflare/client.d.ts.map +0 -1
  68. package/dist/forgejo/client.d.ts +0 -37
  69. package/dist/forgejo/client.d.ts.map +0 -1
  70. package/src/cloudflare/client.ts +0 -76
  71. package/src/forgejo/client.ts +0 -136
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `readLock` / `reconcileLock` / `deleteLock` against the fake Cloudflare in fake-r2-lock.ts, the
3
+ * same direct style mesh-node-token.test.ts uses for `fetchMeshNodeToken`: run the plain Effect
4
+ * under `fakeR2LockLayer`, no Provider or engine harness needed.
5
+ *
6
+ * ★ THE POINT OF THIS FILE IS THE TAGGED-ERROR PATHS, per the transport swap onto
7
+ * `@distilled.cloud/cloudflare/r2`: `NoSuchBucket` is matched by Cloudflare's own error code
8
+ * (10006), never by HTTP status, and only `readLock` treats it as "nothing to adopt" —
9
+ * `reconcileLock`/`deleteLock` let it die like any other tagged error.
10
+ */
11
+ import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
12
+ import { Unowned } from 'alchemy/AdoptPolicy';
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Cause from 'effect/Cause';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as Exit from 'effect/Exit';
17
+ import {
18
+ FAKE_ACCOUNT,
19
+ type FakeR2Lock,
20
+ fakeFailure,
21
+ fakeR2Lock,
22
+ fakeR2LockLayer,
23
+ writes,
24
+ } from './fake-r2-lock.ts';
25
+ import type { R2LockRule } from './lock-rules.ts';
26
+ import { type R2BucketLockProps, deleteLock, readLock, reconcileLock } from './r2-bucket-lock.ts';
27
+
28
+ const run = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
29
+ Effect.runPromise(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
30
+
31
+ const dies = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
32
+ Effect.runPromiseExit(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
33
+
34
+ const rule: R2LockRule = {
35
+ id: 'keep-30d',
36
+ enabled: true,
37
+ condition: { type: 'Age', maxAgeSeconds: 2_592_000 },
38
+ };
39
+
40
+ const props = (over: Partial<R2BucketLockProps> = {}): R2BucketLockProps => ({
41
+ accountId: FAKE_ACCOUNT,
42
+ bucketName: 'my-backups',
43
+ rules: [rule],
44
+ ...over,
45
+ });
46
+
47
+ describe('readLock', () => {
48
+ test('a bucket that does not exist (NoSuchBucket, code 10006) reads as absent', async () => {
49
+ const fake = fakeR2Lock();
50
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
51
+ });
52
+
53
+ test('a bucket that exists but was never locked reads as absent', async () => {
54
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
55
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
56
+ });
57
+
58
+ test('a locked bucket, written by this stack, reads as plain attributes', async () => {
59
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
60
+ await run(fake, reconcileLock(props(), undefined));
61
+ const attrs = await run(fake, readLock(props(), true));
62
+ expect(Unowned.is(attrs)).toBe(false);
63
+ expect(attrs).toEqual({ bucketName: 'my-backups', jurisdiction: 'default', rules: [rule] });
64
+ });
65
+
66
+ test('a locked bucket, not yet written by this stack, reads Unowned', async () => {
67
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
68
+ await run(fake, reconcileLock(props(), undefined));
69
+ const attrs = await run(fake, readLock(props(), false));
70
+ expect(Unowned.is(attrs)).toBe(true);
71
+ });
72
+
73
+ test('NoSuchBucket is matched by code, not by HTTP status', async () => {
74
+ // ⛔ THE PROOF THAT THIS IS catchTag, NOT A STATUS CHECK: code 10006 at a non-404 status is
75
+ // still caught. The old `cloudflare` SDK's `instanceof NotFoundError` could only ever match
76
+ // a 404; distilled's error matcher does not look at status at all for this tag.
77
+ const fake = fakeR2Lock({
78
+ onRoute: () => fakeFailure(400, 10006, 'The specified bucket does not exist.'),
79
+ });
80
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
81
+ });
82
+
83
+ test('a different tagged error at 404 is NOT swallowed as NoSuchBucket', async () => {
84
+ // ⛔ code 7003 is `InvalidRoute`, not `NoSuchBucket` — even at the same 404 status a plain
85
+ // status check would have treated as "no bucket". `catchTag('NoSuchBucket', …)` leaves it
86
+ // alone, and `Effect.orDie` turns it into a defect.
87
+ const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
88
+ const exit = await dies(fake, readLock(props(), false));
89
+ expect(Exit.isFailure(exit)).toBe(true);
90
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('reconcileLock', () => {
95
+ test('writes nothing when the declared rules already match', async () => {
96
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
97
+ const output = await run(fake, reconcileLock(props(), undefined));
98
+ fake.seen.length = 0;
99
+ const again = await run(fake, reconcileLock(props(), output));
100
+ expect(again).toEqual(output);
101
+ expect(writes(fake)).toEqual([]);
102
+ });
103
+
104
+ test('PUTs the whole rule set when it differs from output', async () => {
105
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
106
+ await run(fake, reconcileLock(props(), undefined));
107
+ expect(writes(fake)).toEqual(['PUT']);
108
+ expect(fake.locks.get('my-backups')).toEqual([rule]);
109
+ });
110
+
111
+ test('NoSuchBucket is NOT caught here: a genuinely missing bucket dies', async () => {
112
+ const fake = fakeR2Lock();
113
+ const exit = await dies(fake, reconcileLock(props(), undefined));
114
+ expect(Exit.isFailure(exit)).toBe(true);
115
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('deleteLock', () => {
120
+ test('PUTs an empty rule set, unlocking the bucket', async () => {
121
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
122
+ const output = await run(fake, reconcileLock(props(), undefined));
123
+ fake.seen.length = 0;
124
+ await run(fake, deleteLock(output, FAKE_ACCOUNT));
125
+ expect(writes(fake)).toEqual(['PUT']);
126
+ expect(fake.locks.has('my-backups')).toBe(false);
127
+ });
128
+
129
+ test('NoSuchBucket is NOT caught here either: a genuinely missing bucket dies', async () => {
130
+ const fake = fakeR2Lock();
131
+ const output = await run(
132
+ fakeR2Lock({ existingBuckets: ['my-backups'] }),
133
+ reconcileLock(props(), undefined),
134
+ );
135
+ const exit = await dies(fake, deleteLock(output, FAKE_ACCOUNT));
136
+ expect(Exit.isFailure(exit)).toBe(true);
137
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
138
+ });
139
+ });
@@ -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));