@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
|
@@ -6,22 +6,29 @@
|
|
|
6
6
|
* scopes only cap it (<estate>/openbao/src/forgejo-bootstrap.ts).
|
|
7
7
|
*
|
|
8
8
|
* ★ READ OFF THE LIVE 16.0.3 SWAGGER (127.0.0.1:3000/swagger.v1.json, 2026-09-14): GET, PUT and
|
|
9
|
-
* DELETE `/teams/{id}/members/{username}` answer 200, 204 and 204, and 404 when absent.
|
|
10
|
-
*
|
|
9
|
+
* DELETE `/teams/{id}/members/{username}` answer 200, 204 and 204, and 404 when absent. Now
|
|
10
|
+
* `organization.orgListTeamMember` / `orgAddTeamMember` / `orgRemoveTeamMember`; the team is
|
|
11
|
+
* still addressed by numeric id, found by name first via `orgListTeams` — the same seam as
|
|
12
|
+
* org-team.ts.
|
|
11
13
|
*
|
|
12
14
|
* ⚠️ EXISTENCE IS THE WHOLE OBJECT. Nothing about a membership can be updated, so `matches` is
|
|
13
15
|
* always true; a different team or user is a different resource id in alchemy.run.ts.
|
|
14
16
|
*
|
|
15
17
|
* ⚠️ THE TEAM ITSELF IS ONLY LOCATED HERE, NEVER MANAGED. A missing team fails the plan by name
|
|
16
|
-
* instead of folding into "absent"
|
|
18
|
+
* instead of folding into "absent" — `ForgejoTeamNotFoundError` is this family's own domain
|
|
19
|
+
* error, not one of the distilled SDK's typed HTTP errors, so the `catchTag('NotFound', ...)`
|
|
20
|
+
* at the end of `fetchLive` below (see resource.ts's header for why every `fetchLive` folds its
|
|
21
|
+
* own `NotFound` rather than one shared helper) never touches it. A PUT can add a member but
|
|
22
|
+
* cannot create a team.
|
|
17
23
|
*
|
|
18
24
|
* ⛔ TOKEN NEEDS `write:organization` TO ADD OR REMOVE, and an identity allowed to manage the team.
|
|
19
25
|
*/
|
|
20
26
|
import { Resource } from 'alchemy';
|
|
21
27
|
import * as Provider from 'alchemy/Provider';
|
|
28
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
29
|
+
import * as Data from 'effect/Data';
|
|
22
30
|
import * as Effect from 'effect/Effect';
|
|
23
|
-
import {
|
|
24
|
-
import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
|
|
31
|
+
import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
|
|
25
32
|
|
|
26
33
|
export interface TeamMemberProps {
|
|
27
34
|
org: string;
|
|
@@ -47,51 +54,62 @@ export interface ForgejoTeamMember extends Resource<
|
|
|
47
54
|
|
|
48
55
|
export const ForgejoTeamMember = Resource<ForgejoTeamMember>('Forgejo.TeamMember');
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
export class ForgejoTeamNotFoundError extends Data.TaggedError('ForgejoTeamNotFoundError')<{
|
|
58
|
+
readonly message: string;
|
|
59
|
+
}> {}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const id = (Array.isArray(rows) ? rows : []).find((row) => row['name'] === props.team)?.['id'];
|
|
58
|
-
if (typeof id !== 'number') {
|
|
59
|
-
return yield* Effect.fail(
|
|
60
|
-
new ForgejoError(0, 'GET', path, `no team named ${props.team} in org ${props.org}`),
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
return id;
|
|
64
|
-
});
|
|
61
|
+
interface TeamMemberLive {
|
|
62
|
+
readonly teamId: number;
|
|
63
|
+
readonly login: string;
|
|
64
|
+
}
|
|
65
65
|
|
|
66
|
-
const
|
|
67
|
-
|
|
66
|
+
const teamIdOf = (props: TeamMemberProps) =>
|
|
67
|
+
organization.orgListTeams({ org: props.org, limit: 200 }).pipe(
|
|
68
|
+
Effect.flatMap((rows) => {
|
|
69
|
+
const id = rows.find((row) => row.name === props.team)?.id;
|
|
70
|
+
return id === undefined
|
|
71
|
+
? Effect.fail(
|
|
72
|
+
new ForgejoTeamNotFoundError({
|
|
73
|
+
message: `no team named ${props.team} in org ${props.org}`,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
: Effect.succeed(id);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
/** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
|
|
81
|
+
export const spec: ForgejoSpec<
|
|
82
|
+
TeamMemberProps,
|
|
83
|
+
TeamMemberLive,
|
|
84
|
+
TeamMemberAttributes,
|
|
85
|
+
| organization.OrgAddTeamMemberError
|
|
86
|
+
| organization.OrgRemoveTeamMemberError
|
|
87
|
+
| organization.OrgListTeamMemberError
|
|
88
|
+
| organization.OrgListTeamsError
|
|
89
|
+
| ForgejoTeamNotFoundError
|
|
90
|
+
> = {
|
|
70
91
|
attributes: (live, props) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (typeof id !== 'number' || typeof login !== 'string') return undefined;
|
|
74
|
-
if (login.toLowerCase() !== props.username.toLowerCase()) return undefined;
|
|
75
|
-
return { org: props.org, team: props.team, teamId: id, username: props.username };
|
|
92
|
+
if (live.login.toLowerCase() !== props.username.toLowerCase()) return undefined;
|
|
93
|
+
return { org: props.org, team: props.team, teamId: live.teamId, username: props.username };
|
|
76
94
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
destroy: (props, live) =>
|
|
96
|
+
organization.orgRemoveTeamMember({ id: live.teamId, username: props.username }),
|
|
97
|
+
fetchLive: (props) =>
|
|
80
98
|
Effect.gen(function* () {
|
|
81
|
-
const
|
|
82
|
-
const user = yield*
|
|
83
|
-
return
|
|
84
|
-
}),
|
|
99
|
+
const teamId = yield* teamIdOf(props);
|
|
100
|
+
const user = yield* organization.orgListTeamMember({ id: teamId, username: props.username });
|
|
101
|
+
return { login: user.login, teamId };
|
|
102
|
+
}).pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
85
103
|
matches: () => true,
|
|
86
|
-
path: (props) => `orgs/${props.org}/teams/${props.team}/members/${props.username}`,
|
|
87
104
|
upsert: (props, before) =>
|
|
88
105
|
before !== undefined
|
|
89
106
|
? Effect.void
|
|
90
107
|
: Effect.flatMap(teamIdOf(props), (id) =>
|
|
91
|
-
Effect.asVoid(
|
|
108
|
+
Effect.asVoid(organization.orgAddTeamMember({ id, username: props.username })),
|
|
92
109
|
),
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const handlers = forgejoHandlers(spec);
|
|
95
113
|
|
|
96
114
|
export const ForgejoTeamMemberProvider = () =>
|
|
97
115
|
Provider.effect(ForgejoTeamMember, Effect.succeed(ForgejoTeamMember.Provider.of(handlers)));
|
package/src/forgejo/values.ts
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire coercions shared across Forgejo resources.
|
|
3
3
|
*
|
|
4
|
-
* ★ GITEA/
|
|
4
|
+
* ★ GITEA/FORGEJO JSON USES snake_case ON THE WIRE and camelCase in props — each resource
|
|
5
5
|
* translates at the form boundary; these helpers only normalise values for comparison.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `text`/`bool`/`int` FROM THE HAND-ROLLED CLIENT ERA ARE GONE. They coerced values out of an
|
|
8
|
+
* untyped `Record<string, unknown>` response. `@distilled.cloud/forgejo`'s operations decode
|
|
9
|
+
* into typed structs (`Repository`, `Label`, `BranchProtection`, …), so an optional field is
|
|
10
|
+
* already `string | undefined` / `boolean | undefined` — a plain `live.field ?? fallback` at
|
|
11
|
+
* the call site replaces every one of their call sites, and a malformed response now fails the
|
|
12
|
+
* operation's decode instead of silently coercing to a fallback.
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
|
-
export const text = (value: unknown, fallback = '') =>
|
|
9
|
-
typeof value === 'string' ? value : fallback;
|
|
10
|
-
|
|
11
15
|
/** Forgejo returns label colours without `#`; declarations may include it. */
|
|
12
|
-
export const color = (value:
|
|
13
|
-
|
|
14
|
-
export const bool = (value: unknown, fallback = false) =>
|
|
15
|
-
value === undefined || value === null ? fallback : value === true || value === 1 || value === '1';
|
|
16
|
-
|
|
17
|
-
export const int = (value: unknown, fallback = 0) => {
|
|
18
|
-
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
|
|
19
|
-
if (typeof value === 'string' && value.trim() !== '') {
|
|
20
|
-
const parsed = Number.parseInt(value, 10);
|
|
21
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
22
|
-
}
|
|
23
|
-
return fallback;
|
|
24
|
-
};
|
|
16
|
+
export const color = (value: string) => value.replace(/^#/, '').toLowerCase();
|
|
25
17
|
|
|
26
18
|
/** Wire arrays compared in sorted order — Gitea reshuffles nothing but callers should not depend on order. */
|
|
27
19
|
export const stringArray = (value: unknown) =>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercises `@distilled.cloud/proxmox`'s `awaitTask` (the async-task
|
|
3
|
+
* 200-trap — see that package's `src/protocol.ts` header) against a FAKE
|
|
4
|
+
* `HttpClient`, no live PVE node involved.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ THIS PACKAGE IS CONSUMED VIA THE INTERIM-PACKAGE ALIAS
|
|
7
|
+
* (`packages/alchemy/package.json`'s `dependencies`:
|
|
8
|
+
* `"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0"`)
|
|
9
|
+
* — see `packages/alchemy/docs/distilled-interim.md`. Nothing in
|
|
10
|
+
* `packages/alchemy/src/proxmox/*` (the existing 98-resource family) is
|
|
11
|
+
* touched or migrated onto it here; this file is standalone proof the
|
|
12
|
+
* package works end to end, ahead of migrating any hand-written resource
|
|
13
|
+
* onto it (a later PR).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import * as Layer from 'effect/Layer';
|
|
18
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
20
|
+
import * as Proxmox from '@distilled.cloud/proxmox';
|
|
21
|
+
|
|
22
|
+
const credentials = Proxmox.credentials({
|
|
23
|
+
tokenId: 'root@pam!test',
|
|
24
|
+
secret: 'test-secret',
|
|
25
|
+
baseUrl: 'https://pve.test:8006',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A fake PVE that answers `running` (no `exitstatus`) `pendingPolls` times, then `finalBody`. */
|
|
29
|
+
const fakePve = (pendingPolls: number, finalBody: Record<string, unknown>) => {
|
|
30
|
+
let calls = 0;
|
|
31
|
+
return Layer.succeed(
|
|
32
|
+
HttpClient.HttpClient,
|
|
33
|
+
HttpClient.make((request) =>
|
|
34
|
+
Effect.sync(() => {
|
|
35
|
+
calls++;
|
|
36
|
+
const body =
|
|
37
|
+
calls <= pendingPolls
|
|
38
|
+
? {
|
|
39
|
+
status: 'running',
|
|
40
|
+
id: 'qmstart',
|
|
41
|
+
node: 'pve1',
|
|
42
|
+
pid: 100,
|
|
43
|
+
pstart: 1,
|
|
44
|
+
starttime: 1,
|
|
45
|
+
type: 'qmstart',
|
|
46
|
+
upid: 'UPID:pve1:...',
|
|
47
|
+
user: 'root@pam',
|
|
48
|
+
}
|
|
49
|
+
: finalBody;
|
|
50
|
+
return HttpClientResponse.fromWeb(
|
|
51
|
+
request,
|
|
52
|
+
new Response(JSON.stringify({ data: body }), { status: 200 }),
|
|
53
|
+
);
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const runAwaitTask = (pendingPolls: number, finalBody: Record<string, unknown>) =>
|
|
60
|
+
Effect.runPromise(
|
|
61
|
+
Proxmox.awaitTask(
|
|
62
|
+
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
63
|
+
{ pollInterval: '5 millis' },
|
|
64
|
+
).pipe(Effect.provide(Layer.mergeAll(fakePve(pendingPolls, finalBody), credentials))),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const OK_STATUS = {
|
|
68
|
+
status: 'stopped',
|
|
69
|
+
exitstatus: 'OK',
|
|
70
|
+
id: 'qmstart',
|
|
71
|
+
node: 'pve1',
|
|
72
|
+
pid: 100,
|
|
73
|
+
pstart: 1,
|
|
74
|
+
starttime: 1,
|
|
75
|
+
type: 'qmstart',
|
|
76
|
+
upid: 'UPID:pve1:...',
|
|
77
|
+
user: 'root@pam',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe("awaitTask (against a fake PVE, per protocol.ts's async-task trap)", () => {
|
|
81
|
+
test("resolves once exitstatus is exactly OK, after polling through 'running'", async () => {
|
|
82
|
+
const result = await runAwaitTask(2, OK_STATUS);
|
|
83
|
+
expect(result.exitstatus).toBe('OK');
|
|
84
|
+
expect(result.status).toBe('stopped');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('resolves immediately when the task is already done on the first poll', async () => {
|
|
88
|
+
const result = await runAwaitTask(0, OK_STATUS);
|
|
89
|
+
expect(result.exitstatus).toBe('OK');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('fails with ProxmoxTaskFailed for any exitstatus other than exactly OK', async () => {
|
|
93
|
+
const failed = await Effect.runPromise(
|
|
94
|
+
Proxmox.awaitTask(
|
|
95
|
+
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
96
|
+
{ pollInterval: '5 millis' },
|
|
97
|
+
).pipe(
|
|
98
|
+
Effect.provide(
|
|
99
|
+
Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'job errors' }), credentials),
|
|
100
|
+
),
|
|
101
|
+
Effect.flip,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
|
|
105
|
+
expect(failed).toMatchObject({ node: 'pve1', exitstatus: 'job errors' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ⛔ "OK (warnings)" is NOT a success — see task.ts's exact-equality note.
|
|
109
|
+
test("fails (does not swallow) 'OK (warnings)'", async () => {
|
|
110
|
+
const failed = await Effect.runPromise(
|
|
111
|
+
Proxmox.awaitTask(
|
|
112
|
+
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
113
|
+
{ pollInterval: '5 millis' },
|
|
114
|
+
).pipe(
|
|
115
|
+
Effect.provide(
|
|
116
|
+
Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'OK (warnings)' }), credentials),
|
|
117
|
+
),
|
|
118
|
+
Effect.flip,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
|
|
122
|
+
expect(failed).toMatchObject({ exitstatus: 'OK (warnings)' });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Cloudflare REST API as an Effect service, over `cloudflare-typescript`. Codex standard 5:
|
|
3
|
-
* the vendor's SDK, never a hand-rolled client — and never a hand-rolled `fetch` either.
|
|
4
|
-
*
|
|
5
|
-
* ⛔ THE SDK WAS ALREADY IN THE STORE AND DECLARED BY NOBODY. Measured 2026-09-15:
|
|
6
|
-
* `node_modules/.pnpm/cloudflare@4.5.0` exists as a transitive dependency of `alchemy`, and
|
|
7
|
-
* `pnpm ls cloudflare --depth 0` at the root and in every workspace package returns nothing.
|
|
8
|
-
* `<estate>/local/scripts/vendor-sdk-ratchet.ts` row R2 says exactly that — "not declared anywhere
|
|
9
|
-
* in the workspace yet … the row adds the dependency once, then converts the TS callers" — so
|
|
10
|
-
* this package is that row's first half, and the twenty ratcheted offences keep their rows.
|
|
11
|
-
*
|
|
12
|
-
* ⛔ AND IT IS PINNED EXACTLY, NOT `catalog:`. There is no catalog entry for `cloudflare`, and the
|
|
13
|
-
* version that resolves today is the one alchemy pulls in. Pinning `4.5.0` means a future
|
|
14
|
-
* alchemy bump moves alchemy's copy and not this package's contract — the alternative is a
|
|
15
|
-
* provider whose request shape changes because an unrelated dependency was upgraded.
|
|
16
|
-
*
|
|
17
|
-
* ⚠️ `fetch` IS A SEAM, NOT A CONVENIENCE — the same argument `packages/litellm-client` makes. The
|
|
18
|
-
* SDK hands its custom fetch the URL and init it built, so a test double conforming to
|
|
19
|
-
* `Core.Fetch` exercises the real path assembly, the real body serialisation and the real header
|
|
20
|
-
* set. A test that stubbed `client.r2.buckets.locks.update` would prove only that a stub was
|
|
21
|
-
* called, which is precisely the class of check this estate keeps finding green over nothing.
|
|
22
|
-
*/
|
|
23
|
-
import Cloudflare from 'cloudflare';
|
|
24
|
-
import * as Context from 'effect/Context';
|
|
25
|
-
import * as Layer from 'effect/Layer';
|
|
26
|
-
/** What the SDK accepts for `fetch`, restated so tests can name it without importing SDK internals. */
|
|
27
|
-
export type ClientFetch = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
28
|
-
declare const CloudflareApi_base: Context.ServiceClass<CloudflareApi, "homeflare/CloudflareApi", Cloudflare>;
|
|
29
|
-
export declare class CloudflareApi extends CloudflareApi_base {
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* ⚠️ THE FAKE FETCH IS THE ONLY REASON THIS TAKES OPTIONS AT ALL. Everything else the SDK reads
|
|
33
|
-
* from the environment the estate CLI already populates.
|
|
34
|
-
*/
|
|
35
|
-
export declare const makeClient: (options: {
|
|
36
|
-
apiToken: string;
|
|
37
|
-
fetch?: ClientFetch;
|
|
38
|
-
}) => Cloudflare;
|
|
39
|
-
/**
|
|
40
|
-
* ⛔ A LAYER'S ERROR CHANNEL MUST BE `never`, so a ConfigError becomes a defect carrying a sentence
|
|
41
|
-
* rather than a type error pushed up into `Stack`. Same decision, same words, as `LiteLLMLive`.
|
|
42
|
-
*/
|
|
43
|
-
export declare const CloudflareApiLive: Layer.Layer<CloudflareApi, never, never>;
|
|
44
|
-
export {};
|
|
45
|
-
//# sourceMappingURL=client.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/cloudflare/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,UAAU,MAAM,YAAY,CAAC;AAEpC,OAAO,KAAK,OAAO,MAAM,gBAAgB,CAAC;AAE1C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAGtC,uGAAuG;AACvG,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;;AAEjG,qBAAa,aAAc,SAAQ,kBAElC;CAAG;AAEJ;;;GAGG;AACH,eAAO,MAAM,UAAU,YAAa;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,WAAW,CAAA;CAAE,KAAG,UAI5E,CAAC;AAiBL;;;GAGG;AACH,eAAO,MAAM,iBAAiB,0CAU7B,CAAC"}
|
package/dist/forgejo/client.d.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One authenticated call to the Forgejo (Gitea) API at `/api/v1`.
|
|
3
|
-
*
|
|
4
|
-
* ★ PLAIN `fetch`, NO TLS ESCAPE HATCH — your Forgejo host serves a real certificate on :443,
|
|
5
|
-
* and the local daemon on :3000 is loopback. There is no `rejectUnauthorized` switch to leave
|
|
6
|
-
* on by accident elsewhere.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ GITEA DOES NOT WRAP ANSWERS IN `{"data": ...}` THE WAY PVE DOES. MEASURED 2026-09-13 on
|
|
9
|
-
* the live daemon: `GET /api/v1/version` returns `{"version":"16.0.3"}`; `GET /api/v1/user`
|
|
10
|
-
* returns the user object; `GET /api/v1/orgs/HomeFlare/repos` returns a JSON array. A caller
|
|
11
|
-
* that unwraps a non-existent envelope sees `undefined` and calls a healthy object "absent".
|
|
12
|
-
*
|
|
13
|
-
* ⛔ THE TOKEN IS NEVER A PROP. Read `FORGEJO_TOKEN` from the environment at call time only.
|
|
14
|
-
* Alchemy persists attributes unencrypted and this estate's state store is Postgres dumped
|
|
15
|
-
* nightly — nothing stored may be a credential.
|
|
16
|
-
*/
|
|
17
|
-
import * as Effect from 'effect/Effect';
|
|
18
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
-
/**
|
|
20
|
-
* The Forgejo instance, from `FORGEJO_URL`. Trailing slash stripped.
|
|
21
|
-
*
|
|
22
|
-
* ⛔ NO DEFAULT HOSTNAME. This used to fall back to the author's own instance, which is
|
|
23
|
-
* fine inside one estate and wrong in a published package: a consumer who forgets the
|
|
24
|
-
* variable would silently point at someone else's server rather than being told.
|
|
25
|
-
* ⚠️ Read at CALL time, never at module scope — a value captured at import is pinned to
|
|
26
|
-
* whatever the first isolate saw.
|
|
27
|
-
*/
|
|
28
|
-
export declare const apiBase: () => string;
|
|
29
|
-
export declare class ForgejoError extends Error {
|
|
30
|
-
readonly status: number;
|
|
31
|
-
readonly method: string;
|
|
32
|
-
readonly path: string;
|
|
33
|
-
constructor(status: number, method: string, path: string, detail: string);
|
|
34
|
-
}
|
|
35
|
-
/** One JSON call. Returns the parsed body, or `undefined` when the response is empty (DELETE). */
|
|
36
|
-
export declare const forgejo: <T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>) => Effect.Effect<T, ForgejoError, HttpClient.HttpClient>;
|
|
37
|
-
//# sourceMappingURL=client.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/forgejo/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAK9D;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,QAAO,MAQ1B,CAAC;AAkBF,qBAAa,YAAa,SAAQ,KAAK;IAEnC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EAIf;CACF;AAQD,kGAAkG;AAClG,eAAO,MAAM,OAAO,GAAI,CAAC,UACf,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,QAC7C,MAAM,SACL,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,0DAwD5B,CAAC"}
|
package/src/cloudflare/client.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Cloudflare REST API as an Effect service, over `cloudflare-typescript`. Codex standard 5:
|
|
3
|
-
* the vendor's SDK, never a hand-rolled client — and never a hand-rolled `fetch` either.
|
|
4
|
-
*
|
|
5
|
-
* ⛔ THE SDK WAS ALREADY IN THE STORE AND DECLARED BY NOBODY. Measured 2026-09-15:
|
|
6
|
-
* `node_modules/.pnpm/cloudflare@4.5.0` exists as a transitive dependency of `alchemy`, and
|
|
7
|
-
* `pnpm ls cloudflare --depth 0` at the root and in every workspace package returns nothing.
|
|
8
|
-
* `<estate>/local/scripts/vendor-sdk-ratchet.ts` row R2 says exactly that — "not declared anywhere
|
|
9
|
-
* in the workspace yet … the row adds the dependency once, then converts the TS callers" — so
|
|
10
|
-
* this package is that row's first half, and the twenty ratcheted offences keep their rows.
|
|
11
|
-
*
|
|
12
|
-
* ⛔ AND IT IS PINNED EXACTLY, NOT `catalog:`. There is no catalog entry for `cloudflare`, and the
|
|
13
|
-
* version that resolves today is the one alchemy pulls in. Pinning `4.5.0` means a future
|
|
14
|
-
* alchemy bump moves alchemy's copy and not this package's contract — the alternative is a
|
|
15
|
-
* provider whose request shape changes because an unrelated dependency was upgraded.
|
|
16
|
-
*
|
|
17
|
-
* ⚠️ `fetch` IS A SEAM, NOT A CONVENIENCE — the same argument `packages/litellm-client` makes. The
|
|
18
|
-
* SDK hands its custom fetch the URL and init it built, so a test double conforming to
|
|
19
|
-
* `Core.Fetch` exercises the real path assembly, the real body serialisation and the real header
|
|
20
|
-
* set. A test that stubbed `client.r2.buckets.locks.update` would prove only that a stub was
|
|
21
|
-
* called, which is precisely the class of check this estate keeps finding green over nothing.
|
|
22
|
-
*/
|
|
23
|
-
import Cloudflare from 'cloudflare';
|
|
24
|
-
import * as Config from 'effect/Config';
|
|
25
|
-
import * as Context from 'effect/Context';
|
|
26
|
-
import * as Effect from 'effect/Effect';
|
|
27
|
-
import * as Layer from 'effect/Layer';
|
|
28
|
-
import * as Redacted from 'effect/Redacted';
|
|
29
|
-
|
|
30
|
-
/** What the SDK accepts for `fetch`, restated so tests can name it without importing SDK internals. */
|
|
31
|
-
export type ClientFetch = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
32
|
-
|
|
33
|
-
export class CloudflareApi extends Context.Service<CloudflareApi, Cloudflare>()(
|
|
34
|
-
'homeflare/CloudflareApi',
|
|
35
|
-
) {}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* ⚠️ THE FAKE FETCH IS THE ONLY REASON THIS TAKES OPTIONS AT ALL. Everything else the SDK reads
|
|
39
|
-
* from the environment the estate CLI already populates.
|
|
40
|
-
*/
|
|
41
|
-
export const makeClient = (options: { apiToken: string; fetch?: ClientFetch }): Cloudflare =>
|
|
42
|
-
new Cloudflare({
|
|
43
|
-
apiToken: options.apiToken,
|
|
44
|
-
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* ⛔ FAIL CLOSED ON THE VALUE, NEVER ON THE VARIABLE EXISTING. AGENTS.md "the four that cost the
|
|
49
|
-
* most" §4: a denied OpenBao path renders the template EMPTY rather than erroring, and every
|
|
50
|
-
* Cloudflare call then answers 403 exactly like a revoked token — which sends the operator to
|
|
51
|
-
* Cloudflare to rotate a credential that was fine. Your deploy wrapper should write
|
|
52
|
-
* CLOUDFLARE_API_TOKEN from the stack's declared role, short-lived and scoped.
|
|
53
|
-
*/
|
|
54
|
-
const MISSING_TOKEN =
|
|
55
|
-
'CLOUDFLARE_API_TOKEN is not set. Mint a short-lived, scoped token and export it before ' +
|
|
56
|
-
'role named in estate.stack.ts; running `alchemy` directly skips that and there is no default.';
|
|
57
|
-
|
|
58
|
-
const EMPTY_TOKEN =
|
|
59
|
-
'CLOUDFLARE_API_TOKEN is set but EMPTY. That is a denied or missing OpenBao grant on the ' +
|
|
60
|
-
'stack’s role, not a rotated token — re-mint it rather than rotating anything at Cloudflare.';
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* ⛔ A LAYER'S ERROR CHANNEL MUST BE `never`, so a ConfigError becomes a defect carrying a sentence
|
|
64
|
-
* rather than a type error pushed up into `Stack`. Same decision, same words, as `LiteLLMLive`.
|
|
65
|
-
*/
|
|
66
|
-
export const CloudflareApiLive = Layer.effect(
|
|
67
|
-
CloudflareApi,
|
|
68
|
-
Effect.gen(function* () {
|
|
69
|
-
const token = yield* Config.Redacted('CLOUDFLARE_API_TOKEN').pipe(
|
|
70
|
-
Effect.catchTag('ConfigError', () => Effect.die(new Error(MISSING_TOKEN))),
|
|
71
|
-
);
|
|
72
|
-
const value = Redacted.value(token);
|
|
73
|
-
if (value.length === 0) return yield* Effect.die(new Error(EMPTY_TOKEN));
|
|
74
|
-
return makeClient({ apiToken: value });
|
|
75
|
-
}),
|
|
76
|
-
);
|
package/src/forgejo/client.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One authenticated call to the Forgejo (Gitea) API at `/api/v1`.
|
|
3
|
-
*
|
|
4
|
-
* ★ PLAIN `fetch`, NO TLS ESCAPE HATCH — your Forgejo host serves a real certificate on :443,
|
|
5
|
-
* and the local daemon on :3000 is loopback. There is no `rejectUnauthorized` switch to leave
|
|
6
|
-
* on by accident elsewhere.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ GITEA DOES NOT WRAP ANSWERS IN `{"data": ...}` THE WAY PVE DOES. MEASURED 2026-09-13 on
|
|
9
|
-
* the live daemon: `GET /api/v1/version` returns `{"version":"16.0.3"}`; `GET /api/v1/user`
|
|
10
|
-
* returns the user object; `GET /api/v1/orgs/HomeFlare/repos` returns a JSON array. A caller
|
|
11
|
-
* that unwraps a non-existent envelope sees `undefined` and calls a healthy object "absent".
|
|
12
|
-
*
|
|
13
|
-
* ⛔ THE TOKEN IS NEVER A PROP. Read `FORGEJO_TOKEN` from the environment at call time only.
|
|
14
|
-
* Alchemy persists attributes unencrypted and this estate's state store is Postgres dumped
|
|
15
|
-
* nightly — nothing stored may be a credential.
|
|
16
|
-
*/
|
|
17
|
-
import * as Effect from 'effect/Effect';
|
|
18
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
-
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
20
|
-
|
|
21
|
-
const PATH_PREFIX = '/api/v1';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The Forgejo instance, from `FORGEJO_URL`. Trailing slash stripped.
|
|
25
|
-
*
|
|
26
|
-
* ⛔ NO DEFAULT HOSTNAME. This used to fall back to the author's own instance, which is
|
|
27
|
-
* fine inside one estate and wrong in a published package: a consumer who forgets the
|
|
28
|
-
* variable would silently point at someone else's server rather than being told.
|
|
29
|
-
* ⚠️ Read at CALL time, never at module scope — a value captured at import is pinned to
|
|
30
|
-
* whatever the first isolate saw.
|
|
31
|
-
*/
|
|
32
|
-
export const apiBase = (): string => {
|
|
33
|
-
const raw = process.env['FORGEJO_URL'];
|
|
34
|
-
if (raw === undefined || raw.trim() === '') {
|
|
35
|
-
throw new Error(
|
|
36
|
-
'FORGEJO_URL is not set. Point it at your Forgejo instance, e.g. https://git.example.com',
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
return raw.trim().replace(/\/$/, '');
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const token = (): Effect.Effect<string, ForgejoError> =>
|
|
43
|
-
Effect.gen(function* () {
|
|
44
|
-
const raw = process.env['FORGEJO_TOKEN'];
|
|
45
|
-
if (raw === undefined || raw.trim() === '') {
|
|
46
|
-
return yield* Effect.fail(
|
|
47
|
-
new ForgejoError(
|
|
48
|
-
0,
|
|
49
|
-
'GET',
|
|
50
|
-
'',
|
|
51
|
-
'FORGEJO_TOKEN is unset. Mint a scoped token and export it — never store it in Alchemy props.',
|
|
52
|
-
),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return raw.trim();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
export class ForgejoError extends Error {
|
|
59
|
-
constructor(
|
|
60
|
-
readonly status: number,
|
|
61
|
-
readonly method: string,
|
|
62
|
-
readonly path: string,
|
|
63
|
-
detail: string,
|
|
64
|
-
) {
|
|
65
|
-
super(`Forgejo ${method} ${path} -> ${String(status)}: ${detail}`);
|
|
66
|
-
this.name = 'ForgejoError';
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* ⚠️ `Authorization: token <value>`, NOT `Bearer`. Gitea docs and MEASURED behaviour on this
|
|
72
|
-
* estate: Bearer is treated as OAuth2 and rejected; `token` is the documented API-key form.
|
|
73
|
-
*/
|
|
74
|
-
const authorization = (value: string) => ({ Authorization: `token ${value}` });
|
|
75
|
-
|
|
76
|
-
/** One JSON call. Returns the parsed body, or `undefined` when the response is empty (DELETE). */
|
|
77
|
-
export const forgejo = <T>(
|
|
78
|
-
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
79
|
-
path: string,
|
|
80
|
-
body?: Record<string, unknown>,
|
|
81
|
-
) =>
|
|
82
|
-
Effect.gen(function* () {
|
|
83
|
-
const credential = yield* token();
|
|
84
|
-
const url = `${apiBase()}${PATH_PREFIX}/${path.replace(/^\//, '')}`;
|
|
85
|
-
/**
|
|
86
|
-
* ★ THROUGH `HttpClient`, NOT THE GLOBAL `fetch` — the idiom Alchemy's own providers follow
|
|
87
|
-
* (src/Hetzner/Providers.ts builds on `FetchHttpClient.layer`; there is no bare fetch in its
|
|
88
|
-
* Docker, Kubernetes or GitHub providers). It buys a typed error channel, interruption when
|
|
89
|
-
* a plan is cancelled, the runtime's tracing, and a client a test can replace without
|
|
90
|
-
* monkey-patching a global. <estate>/proxmox/src/resource.ts carries the same note.
|
|
91
|
-
*/
|
|
92
|
-
const client = yield* HttpClient.HttpClient;
|
|
93
|
-
/**
|
|
94
|
-
* ⛔ THE CONTENT TYPE GOES ON `bodyText`, NOT IN THE HEADER MAP, AND PUTTING IT IN BOTH IS HOW
|
|
95
|
-
* THIS PACKAGE SHIPPED UNABLE TO WRITE AT ALL.
|
|
96
|
-
*
|
|
97
|
-
* 🔴 THE BUG, MEASURED THE FIRST TIME A WRITE WAS EVER ATTEMPTED. The header map DID carry
|
|
98
|
-
* `Content-Type: application/json` — and `bodyText(s)` with no second argument sets
|
|
99
|
-
* `text/plain` and OVERWRITES it, because it is piped AFTER `setHeaders`. Forgejo answered
|
|
100
|
-
* 422 {"message":"[]: Unsupported Content-Type"}
|
|
101
|
-
* on `PATCH orgs/homeflare/labels/1`. Every create and every update in every family here was
|
|
102
|
-
* broken, identically, from the day the client was written.
|
|
103
|
-
*
|
|
104
|
-
* ⛔ NOTHING CAUGHT IT BECAUSE NOTHING HAD EVER WRITTEN. The only credential on the shelf was
|
|
105
|
-
* `mcp-read`, which answers 403 before a request body is ever parsed — so read, adopt, diff
|
|
106
|
-
* and plan all worked perfectly and the write path had literally never executed. A provider
|
|
107
|
-
* that has never performed its own writes is not a tested provider, whatever the plan says.
|
|
108
|
-
*
|
|
109
|
-
* ⚠️ `bodyJsonUnsafe` WOULD ALSO WORK. `bodyText` with an explicit type is used instead
|
|
110
|
-
* because it makes the content type visible at the call site, which is the thing that was
|
|
111
|
-
* invisible before.
|
|
112
|
-
*/
|
|
113
|
-
const request = HttpClientRequest.make(method)(url).pipe(
|
|
114
|
-
HttpClientRequest.setHeaders({ ...authorization(credential), Accept: 'application/json' }),
|
|
115
|
-
body === undefined
|
|
116
|
-
? (self) => self
|
|
117
|
-
: HttpClientRequest.bodyText(JSON.stringify(body), 'application/json'),
|
|
118
|
-
);
|
|
119
|
-
const response = yield* client
|
|
120
|
-
.execute(request)
|
|
121
|
-
.pipe(Effect.mapError((cause) => new ForgejoError(0, method, path, String(cause))));
|
|
122
|
-
// ⛔ Status first, body once — a Gitea error body is JSON but a proxy's 502 is not.
|
|
123
|
-
if (response.status < 200 || response.status >= 300) {
|
|
124
|
-
const detail = yield* response.text.pipe(Effect.orElseSucceed(() => ''));
|
|
125
|
-
return yield* Effect.fail(
|
|
126
|
-
new ForgejoError(response.status, method, path, detail.slice(0, 300)),
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
// ⚠️ 204 AND AN EMPTY BODY ARE REAL: a label DELETE answers no content, and `json` on an empty
|
|
130
|
-
// body fails. Read text once and parse only when there is something to parse.
|
|
131
|
-
const text = yield* response.text.pipe(
|
|
132
|
-
Effect.mapError((cause) => new ForgejoError(0, method, path, String(cause))),
|
|
133
|
-
);
|
|
134
|
-
// ⚠️ PUT/DELETE on actions secrets and hooks often answer 201/204 with no body — same trap as DELETE.
|
|
135
|
-
return (text === '' ? undefined : JSON.parse(text)) as T;
|
|
136
|
-
});
|