@homeflare/alchemy 0.27.1 → 0.27.3
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.
- package/README.md +3 -2
- package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
- package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +4 -5
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +79 -103
- package/dist/cloudflare/index.js.map +6 -7
- package/dist/cloudflare/mesh-node-api.d.ts +3 -2
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +16 -5
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +34 -3
- package/dist/forgejo/branch-protection.d.ts.map +1 -1
- package/dist/forgejo/fake-forgejo.d.ts +41 -0
- package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
- package/dist/forgejo/index.js +220 -284
- package/dist/forgejo/index.js.map +14 -15
- package/dist/forgejo/org-actions-secrets.d.ts +42 -12
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
- package/dist/forgejo/org-label.d.ts +30 -3
- package/dist/forgejo/org-label.d.ts.map +1 -1
- package/dist/forgejo/org-team.d.ts +37 -4
- package/dist/forgejo/org-team.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook-form.d.ts +9 -18
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook.d.ts +35 -3
- package/dist/forgejo/repo-webhook.d.ts.map +1 -1
- package/dist/forgejo/repository.d.ts +47 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +37 -37
- package/dist/forgejo/resource.d.ts.map +1 -1
- package/dist/forgejo/team-member.d.ts +48 -4
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/forgejo/values.d.ts +9 -5
- package/dist/forgejo/values.d.ts.map +1 -1
- package/docs/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +5 -2
- package/src/cloudflare/fake-r2-lock.ts +120 -0
- package/src/cloudflare/index.ts +4 -5
- package/src/cloudflare/mesh-node-api.ts +3 -2
- package/src/cloudflare/providers.ts +13 -16
- package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
- package/src/cloudflare/r2-bucket-lock.ts +59 -51
- package/src/forgejo/branch-protection-form.ts +12 -5
- package/src/forgejo/branch-protection.ts +58 -26
- package/src/forgejo/fake-forgejo.ts +54 -0
- package/src/forgejo/org-actions-secrets.ts +44 -36
- package/src/forgejo/org-label.test.ts +100 -0
- package/src/forgejo/org-label.ts +46 -36
- package/src/forgejo/org-team.ts +40 -27
- package/src/forgejo/repo-webhook-form.ts +10 -27
- package/src/forgejo/repo-webhook.ts +66 -35
- package/src/forgejo/repository.test.ts +125 -0
- package/src/forgejo/repository.ts +72 -52
- package/src/forgejo/resource.ts +80 -79
- package/src/forgejo/team-member.test.ts +97 -0
- package/src/forgejo/team-member.ts +56 -38
- package/src/forgejo/values.ts +9 -17
- package/src/proxmox/distilled-task-await.test.ts +124 -0
- package/dist/cloudflare/client.d.ts +0 -45
- package/dist/cloudflare/client.d.ts.map +0 -1
- package/dist/forgejo/client.d.ts +0 -37
- package/dist/forgejo/client.d.ts.map +0 -1
- package/src/cloudflare/client.ts +0 -76
- 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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
35
|
-
import {
|
|
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
|
|
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.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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*
|
|
159
|
-
|
|
160
|
-
|
|
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*
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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(
|
|
222
|
+
return yield* run(readLock(props, output !== undefined));
|
|
215
223
|
}),
|
|
216
|
-
reconcile: Effect.fn(({ news, output }) => reconcileLock(
|
|
217
|
-
delete: Effect.fn(({ olds, output }) => deleteLock(
|
|
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/
|
|
2
|
+
* Create/edit request bodies for `Forgejo.BranchProtection` — extracted for the 250-line cap.
|
|
3
3
|
*
|
|
4
|
-
* ★
|
|
5
|
-
* `
|
|
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
|
-
|
|
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}`.
|
|
6
|
-
* `
|
|
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 {
|
|
17
|
-
import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
|
|
18
|
-
import {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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));
|