@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.
- 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 +2 -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/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
package/dist/forgejo/values.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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
|
-
export declare const text: (value: unknown, fallback?: string) => string;
|
|
8
14
|
/** Forgejo returns label colours without `#`; declarations may include it. */
|
|
9
|
-
export declare const color: (value:
|
|
10
|
-
export declare const bool: (value: unknown, fallback?: boolean) => boolean;
|
|
11
|
-
export declare const int: (value: unknown, fallback?: number) => number;
|
|
15
|
+
export declare const color: (value: string) => string;
|
|
12
16
|
/** Wire arrays compared in sorted order — Gitea reshuffles nothing but callers should not depend on order. */
|
|
13
17
|
export declare const stringArray: (value: unknown) => string[];
|
|
14
18
|
export declare const stringRecord: (value: unknown) => Record<string, string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../../src/forgejo/values.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../../src/forgejo/values.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,8EAA8E;AAC9E,eAAO,MAAM,KAAK,UAAW,MAAM,WAA0C,CAAC;AAE9E,8GAA8G;AAC9G,eAAO,MAAM,WAAW,UAAW,OAAO,aAGlC,CAAC;AAET,eAAO,MAAM,YAAY,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOlE,CAAC;AAEF,eAAO,MAAM,WAAW,SAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,YAKtF,CAAC;AAEF,wFAAwF;AACxF,eAAO,MAAM,gBAAgB,UAAW,OAAO;;CAI9C,CAAC;AAEF,+FAA+F;AAC/F,eAAO,MAAM,eAAe,QAAS,MAAM,QAAQ,MAAM,WACyB,CAAC;AAEnF,6FAA6F;AAC7F,eAAO,MAAM,gBAAgB,UAAW,MAAM,QAAQ,MAAM,QAAQ,MAAM,WACmB,CAAC"}
|
package/docs/credentials.md
CHANGED
|
@@ -13,3 +13,9 @@ like a bad credential.
|
|
|
13
13
|
|
|
14
14
|
The Proxmox subpath mints every call's token from an OpenBao mount instead; see
|
|
15
15
|
[proxmox.md](./proxmox.md).
|
|
16
|
+
|
|
17
|
+
The Forgejo subpath (2026-09-23, moved onto `@distilled.cloud/forgejo`) resolves credentials
|
|
18
|
+
through the package's own `CredentialsFromEnv` layer, which reads `FORGEJO_URL` / `FORGEJO_TOKEN`
|
|
19
|
+
— the same two variable names the retired hand-rolled client read, still resolved on the calling
|
|
20
|
+
fiber per request, never captured at module scope. `Authorization: token <value>` (Forgejo's
|
|
21
|
+
access-token scheme, not `Bearer`) is built by the package's protocol layer, not by this package.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# The distilled interim-package route
|
|
2
|
+
|
|
3
|
+
How a `@distilled.cloud/<vendor>` SDK reaches the kit before it exists on
|
|
4
|
+
npm — established by `packages/distilled-netbox` (the first one); every
|
|
5
|
+
later vendor (PVE, PBS, paperless, …) follows this exactly.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
★ **Upstream Alchemy only accepts a provider that calls `@distilled.cloud/<vendor>`
|
|
10
|
+
operations and `catchTag`s their typed errors** (alchemy PR 1425: "We don't
|
|
11
|
+
accept providers without this"). The kit wants to dogfood and test on real
|
|
12
|
+
`@distilled.cloud/*` packages now, but `alchemy-run/distilled` is upstream —
|
|
13
|
+
⛔ **the kit never pushes to it.** Decision 42: build inside the kit first,
|
|
14
|
+
fork `alchemy-run/distilled` and go upstream only with explicit later
|
|
15
|
+
sign-off. Until a vendor's package is merged and released there, it does not
|
|
16
|
+
exist on npm under `@distilled.cloud/<vendor>` — so there is nothing to
|
|
17
|
+
`bun add`.
|
|
18
|
+
|
|
19
|
+
★ **The fix is one alias.** `package.json` can point a dependency name at any
|
|
20
|
+
tarball, including one on npm under a different name:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.1.0"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Everything importing `@distilled.cloud/netbox` resolves to the interim
|
|
27
|
+
package's code, unmodified. When the real package ships, the alias's target
|
|
28
|
+
changes and nothing else does.
|
|
29
|
+
|
|
30
|
+
## The five steps
|
|
31
|
+
|
|
32
|
+
### 1. Build the SDK the distilled way, in its own worktree
|
|
33
|
+
|
|
34
|
+
Follow `.agents/skills/distilled-sdk/SKILL.md` in the local distilled clone —
|
|
35
|
+
Steps 1–8 (never Step 9, which pushes). One worktree per vendor, branch
|
|
36
|
+
`homeflare/<vendor>`, off `homeflare/base`. Spec sourcing, `patches/`,
|
|
37
|
+
`convert.ts`/`generate.ts`, typecheck, `pnpm format`, `pnpm specs:check`.
|
|
38
|
+
Commit in the local clone only — ⛔ **never push it anywhere; no forks, no
|
|
39
|
+
PRs, no issues or comments on `alchemy-run/distilled` from this route.**
|
|
40
|
+
|
|
41
|
+
### 2. Copy into a kit workspace package
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
mkdir -p packages/distilled-<vendor>/src
|
|
45
|
+
cp -R <distilled-clone>/packages/<vendor>/src/. packages/distilled-<vendor>/src/
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
⛔ **`src/` is copied, never hand-edited in the kit.** A bug found here (the
|
|
49
|
+
netbox package's own history: a doubled `/api/api/` prefix, caught by its
|
|
50
|
+
`scripts/smoke.ts`, not by typecheck) is fixed in the distilled clone,
|
|
51
|
+
regenerated, and copied back — never patched in place in the kit. The
|
|
52
|
+
package's own README says so; keep saying so for the next vendor too.
|
|
53
|
+
|
|
54
|
+
`package.json` (write by hand, once, then keep almost untouched across
|
|
55
|
+
regenerations):
|
|
56
|
+
|
|
57
|
+
- `"name"`: `"@homeflare/distilled-<vendor>"`
|
|
58
|
+
- `"dependencies"`: `{ "@distilled.cloud/core": "1.0.0-rc.12" }` — the
|
|
59
|
+
**exact** version, a real npm dependency (`@distilled.cloud/core` is
|
|
60
|
+
already published; only the vendor package is missing). Never
|
|
61
|
+
`"workspace:*"` — this package is not part of the distilled workspace.
|
|
62
|
+
- `"peerDependencies"`: `{ "effect": "4.0.0-rc.115" }` — the kit's own
|
|
63
|
+
`effect` override (root `package.json` → `overrides`), pinned literally
|
|
64
|
+
(matching `packages/alchemy`'s own peer on `effect`, not the `catalog:`
|
|
65
|
+
protocol — a peerDependency is resolved by the _consumer's_ installer, and
|
|
66
|
+
`catalog:` only expands inside this workspace).
|
|
67
|
+
- `"license"`: `"Apache-2.0"`, **not** the kit's usual MIT — the content is a
|
|
68
|
+
redistribution of `alchemy-run/distilled`'s own Apache-2.0 output. Ship its
|
|
69
|
+
`LICENSE` file (copied from the distilled clone root) alongside.
|
|
70
|
+
- `"scripts"`: `build` (`tsc -p tsconfig.json`), `types` (same, `--noEmit`),
|
|
71
|
+
`test` (`bun test`), `smoke` (`bun run scripts/smoke.ts`) — the four every
|
|
72
|
+
kit package answers to `bun run --filter '*' <script>`.
|
|
73
|
+
|
|
74
|
+
`tsconfig.json`: **self-contained**, not `packages/config`'s
|
|
75
|
+
`tsconfig.lib.json` preset. That preset requires `isolatedDeclarations`,
|
|
76
|
+
which the distilled generator's own output does not satisfy (its
|
|
77
|
+
`as any as S.Schema<X>` cast pattern) and was never written to satisfy —
|
|
78
|
+
forcing it through a different lib preset on every copy is exactly the
|
|
79
|
+
friction this route exists to avoid. Copy `packages/distilled-netbox/tsconfig.json`
|
|
80
|
+
and change nothing but the package name in its comment.
|
|
81
|
+
|
|
82
|
+
**Formatting/linting are excluded, on purpose.** `.oxfmtrc.json` and
|
|
83
|
+
`.oxlintrc.json` both ignore `packages/distilled-*/src/**` at the repo root —
|
|
84
|
+
already wired for every future vendor, not just netbox. Reformatting a
|
|
85
|
+
generated SDK to house style would fight the source of truth on every
|
|
86
|
+
regeneration; the hand-written files alongside it (`package.json`,
|
|
87
|
+
`tsconfig.json`, `scripts/smoke.ts`, `README.md`) are NOT excluded and are
|
|
88
|
+
checked normally.
|
|
89
|
+
|
|
90
|
+
### 3. Prove it before shipping
|
|
91
|
+
|
|
92
|
+
Two checks, both real, neither skippable:
|
|
93
|
+
|
|
94
|
+
1. **`bun run --filter '@homeflare/distilled-<vendor>' types`** — typechecks
|
|
95
|
+
inside the KIT's own dependency graph (real npm `@distilled.cloud/core`,
|
|
96
|
+
not the distilled workspace's local one). A green typecheck in the
|
|
97
|
+
distilled clone does not by itself prove this; the netbox package
|
|
98
|
+
typechecked in both places, which is the point of checking both.
|
|
99
|
+
2. **`scripts/smoke.ts`** — packs the tarball, installs it into a scratch
|
|
100
|
+
dir under its OWN published name (never the alias — the alias is the
|
|
101
|
+
_consumer's_ concern), and builds a real request for one operation
|
|
102
|
+
against a fake `HttpClient` (`effect/unstable/http/HttpClient.make`,
|
|
103
|
+
capturing the request instead of sending it). Assert on method, URL and
|
|
104
|
+
headers, and decode a canned response. This is what caught the `/api/api/`
|
|
105
|
+
bug — a clean typecheck did not, because the bug was in a runtime string,
|
|
106
|
+
not a type.
|
|
107
|
+
|
|
108
|
+
### 4. Publish through the kit's own changesets release
|
|
109
|
+
|
|
110
|
+
No special-casing: add a changeset (`bun changeset`, minor — new package),
|
|
111
|
+
push, merge. `scripts/publish.ts` packs with `bun pm pack` (resolves
|
|
112
|
+
`workspace:`/`catalog:` the way `npm pack` cannot) and publishes with `npm
|
|
113
|
+
publish --provenance` from CI. `README.md`/`LICENSE`/`scripts/smoke.ts` ship
|
|
114
|
+
in `files`; nothing about the release pipeline needs to know this package is
|
|
115
|
+
an interim stand-in.
|
|
116
|
+
|
|
117
|
+
### 5. Alias it in, then cut over later
|
|
118
|
+
|
|
119
|
+
Once the interim package is on npm (`npm view @homeflare/distilled-<vendor>
|
|
120
|
+
version` — **npm's CDN lags a few minutes after release**, so don't chase an
|
|
121
|
+
empty read as a failure), the FIRST consuming PR adds the alias to whichever
|
|
122
|
+
kit package needs the vendor's operations:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
"@distilled.cloud/<vendor>": "npm:@homeflare/distilled-<vendor>@<version>"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
in `dependencies` (not `peerDependencies` — the kit package needs it
|
|
129
|
+
resolved, not left to whoever installs the kit package). Everything in that
|
|
130
|
+
PR imports `@distilled.cloud/<vendor>`, never `@homeflare/distilled-<vendor>`
|
|
131
|
+
directly — ⛔ **that is what makes the eventual cutover a one-line change.**
|
|
132
|
+
|
|
133
|
+
When `@distilled.cloud/<vendor>` is genuinely published upstream: replace the
|
|
134
|
+
alias's target version with the real one (or drop the alias for a plain
|
|
135
|
+
`"@distilled.cloud/<vendor>": "<version>"`), delete `packages/distilled-<vendor>`,
|
|
136
|
+
run `bun install`. No import in the kit changes. **STATE MUST NOT MOVE** —
|
|
137
|
+
this is a transport swap, not a redesign; an adopted resource's plan must
|
|
138
|
+
stay a no-op across the cutover, the same invariant the distilled walk-down
|
|
139
|
+
itself holds everywhere else.
|
|
140
|
+
|
|
141
|
+
## What NOT to do
|
|
142
|
+
|
|
143
|
+
- ⛔ Don't move existing hand-written resources (e.g. the kit's own
|
|
144
|
+
`packages/alchemy/src/netbox/*`) onto the interim package in the same PR
|
|
145
|
+
that introduces it. The alias can only resolve once the package is
|
|
146
|
+
actually published — that migration is its own PR, after `npm view`
|
|
147
|
+
confirms the version is live.
|
|
148
|
+
- ⛔ Don't add the alias speculatively before the interim package is
|
|
149
|
+
published — `bun install` would fail to resolve it.
|
|
150
|
+
- ⛔ Don't hand-fix a bug inside `packages/distilled-<vendor>/src/`. Fix it
|
|
151
|
+
upstream (the distilled clone), regenerate, copy again.
|
package/docs/peers.md
CHANGED
|
@@ -6,8 +6,9 @@ The install line for `@homeflare/alchemy`, and why every peer on it and every en
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
|
|
9
|
-
@effect/platform-node@4.0.0-rc.115
|
|
10
|
-
@distilled.cloud/cloudflare@1.0.0-rc.12 @
|
|
9
|
+
@effect/platform-node@4.0.0-rc.115 mime@4.1.0 \
|
|
10
|
+
@distilled.cloud/cloudflare@1.0.0-rc.12 @distilled.cloud/forgejo@1.0.0-rc.12 \
|
|
11
|
+
@effect/sql-pg@4.0.0-rc.115
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
|
|
@@ -33,10 +34,18 @@ import the Proxmox subpath.
|
|
|
33
34
|
declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
|
|
34
35
|
that followed the 78 README does not drop a required line.
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
subpath would degrade without it — measured 2026-09-16,
|
|
38
|
-
`Cannot find package 'cloudflare'
|
|
39
|
-
|
|
37
|
+
★ `cloudflare` **is gone, not merely tolerated.** It was a required peer (marked optional in
|
|
38
|
+
0.1.1, which claimed the `/cloudflare` subpath would degrade without it — measured 2026-09-16,
|
|
39
|
+
the subpath did not load at all: `Cannot find package 'cloudflare'`) until 2026-09-23, when
|
|
40
|
+
`R2BucketLock` moved off the `cloudflare` npm SDK onto `@distilled.cloud/cloudflare/r2`, the same
|
|
41
|
+
one `MeshNode` already called. Nothing else in this package imported it, so the peer — and the
|
|
42
|
+
`client.ts` wrapper it existed for — were dropped rather than left declared and unused.
|
|
43
|
+
|
|
44
|
+
⚠️ **`@distilled.cloud/forgejo` (added 2026-09-23, moving `/forgejo` off a hand-rolled
|
|
45
|
+
`HttpClient` client) is required for the same reason `@distilled.cloud/cloudflare` is: it is
|
|
46
|
+
a plain dependency of this package, not something a hoisting installer can hide from a strict
|
|
47
|
+
one.** Pinned to `1.0.0-rc.12` — the distilled release's own lockstep version, not alchemy's,
|
|
48
|
+
since alchemy does not bundle a Forgejo SDK the way it bundles Cloudflare's.
|
|
40
49
|
|
|
41
50
|
⚠️ **`@effect/sql-pg` (added for `/postgres`, 2026-09-23) is required too, for the same
|
|
42
51
|
reason, not because every subpath imports it.** This package has one flat peer set for the
|
|
@@ -11,30 +11,54 @@ A decision marked **maintainer** is not an agent's to make.
|
|
|
11
11
|
Findings are ranked by what they can break: runtime safety first, then contributability,
|
|
12
12
|
then tidiness.
|
|
13
13
|
|
|
14
|
-
1. **`cloudflare/R2BucketLock` diverges on
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
1. **`cloudflare/R2BucketLock` diverges on three rules.**
|
|
15
|
+
|
|
16
|
+
✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-r2-bucket-lock`, an agent task
|
|
17
|
+
scoped to exactly this SDK swap): it now calls `@distilled.cloud/cloudflare/r2`'s
|
|
18
|
+
`getBucketLock`/`putBucketLock`, the same package `MeshNode` uses, with
|
|
19
|
+
`catchTag('NoSuchBucket', …)` in place of the old `instanceof NotFoundError` status
|
|
20
|
+
check. The `cloudflare` peer and `client.ts` are gone from the package — nothing else
|
|
21
|
+
imported either. As a side effect this also cleared the `Effect.promise` calls in
|
|
22
|
+
`reconcileLock`/`deleteLock` (S19): distilled's operations are already Effects, so there
|
|
23
|
+
is nothing left to promise-wrap.
|
|
24
|
+
|
|
25
|
+
Still open, and out of scope for that task (props/attributes were required to stay
|
|
26
|
+
byte-identical, so neither touches the wire contract):
|
|
27
|
+
- `readLock`, and now `reconcileLock`/`deleteLock` too, end in `Effect.orDie` for the
|
|
28
|
+
error channel distilled leaves after `NoSuchBucket` (S20). A full fix means a typed
|
|
29
|
+
refusal error for `InvalidRoute`/`CloudflareRateLimited`/`CloudflareError`, which is a
|
|
30
|
+
separate, larger change than the transport swap.
|
|
31
|
+
- `reconcileLock` still skips the PUT whenever `output.rules` equals the declaration,
|
|
32
|
+
without reading the live lock (S10). Neither `--force` nor `drift --repair` can
|
|
33
|
+
restore lock rules removed out of band. ⚠️ Reasoned from the source, not measured live.
|
|
34
|
+
- `accountId` is still a prop (S15).
|
|
35
|
+
|
|
36
|
+
Upstream `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is
|
|
37
|
+
still a real gap. **Remaining fix:** observe before the PUT, and resolve the account
|
|
38
|
+
from `CloudflareEnvironment` — the same shape change `MeshNode` uses instead of a prop.
|
|
39
|
+
That makes it contributable as `lockRules` on `R2.Bucket`, or as
|
|
28
40
|
`Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
|
|
29
41
|
|
|
30
|
-
2. **`forgejo/*` duplicates an upstream building block.**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
2. **`forgejo/*` duplicates an upstream building block.**
|
|
43
|
+
- ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-forgejo`, PR #180 — Tim, decision
|
|
44
|
+
42, "build inside the kit first, dogfood and test"; an agent task scoped to exactly this
|
|
45
|
+
SDK swap). Every call now goes through
|
|
46
|
+
`@distilled.cloud/forgejo@1.0.0-rc.12`'s typed operations, `catchTag('NotFound', ...)`
|
|
47
|
+
replaced the status-carrying `ForgejoError`, and `client.ts` is deleted. Verified
|
|
48
|
+
operation by operation against the package before relying on it (every operation this
|
|
49
|
+
family calls exists, every error it handles has a tag) — nothing was missing, so no
|
|
50
|
+
distilled patch was needed. State did not move: every prop and attribute stays
|
|
51
|
+
byte-identical, proven by keeping the family's existing tests unchanged plus new tests
|
|
52
|
+
against a fake Forgejo exercising the real distilled protocol. No stack in this estate
|
|
53
|
+
currently imports `@homeflare/alchemy/forgejo` (measured 2026-09-23 across
|
|
54
|
+
homeflare-landscape and the house monorepo), so there is no live plan to re-run —
|
|
55
|
+
`house/forgejo` in the house monorepo declares the same seven resource types but against
|
|
56
|
+
its OWN independent hand-rolled copy under `house/forgejo/src/`, not this package; this
|
|
57
|
+
PR does not touch it.
|
|
58
|
+
- ⛔ **`read` still never answers `Unowned` (H1), open.** Credentials still come from
|
|
59
|
+
`FORGEJO_TOKEN` at call time rather than an `alchemy/Auth` provider (S24). Both need a
|
|
60
|
+
maintainer decision on the ownership-check shape before a distilled-backed
|
|
61
|
+
implementation is worth writing — the client swap did not attempt either.
|
|
38
62
|
3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
|
|
39
63
|
deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
|
|
40
64
|
connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.2",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
},
|
|
96
96
|
"peerDependencies": {
|
|
97
97
|
"@distilled.cloud/cloudflare": "1.0.0-rc.12",
|
|
98
|
+
"@distilled.cloud/forgejo": "1.0.0-rc.12",
|
|
98
99
|
"@effect/platform-node": "4.0.0-rc.115",
|
|
99
100
|
"@effect/sql-pg": "4.0.0-rc.115",
|
|
100
101
|
"alchemy": "2.0.0-beta.79",
|
|
101
|
-
"cloudflare": ">=4.5.0",
|
|
102
102
|
"effect": "4.0.0-rc.115",
|
|
103
103
|
"mime": "4.1.0"
|
|
104
104
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake Cloudflare R2 bucket-lock API for r2-bucket-lock.test.ts — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real FetchHttpClient through its `Fetch` reference, so the tests exercise distilled's
|
|
4
|
+
* real path assembly, header encoding, envelope decoding and error matching. Same idiom as
|
|
5
|
+
* fake-mesh.ts, for the route `GET`/`PUT /accounts/<FAKE_ACCOUNT>/r2/buckets/<name>/lock`.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
8
|
+
* `api.example.com`, and any request outside that route throws, so a mis-wired test fails
|
|
9
|
+
* instead of reaching a real API.
|
|
10
|
+
* ★ `existingBuckets` MODELS THE VENDOR'S OWN DISTINCTION. A bucket not in the set answers
|
|
11
|
+
* `NoSuchBucket` (code 10006) on both verbs, matching the 404 the doc comment in
|
|
12
|
+
* r2-bucket-lock.ts measured. A bucket in the set with no rules answers `{}` on GET, matching
|
|
13
|
+
* "never locked" — both collapse to "absent" in `readLock`, but the fake keeps them distinct so
|
|
14
|
+
* a test can put a bucket in either state on purpose.
|
|
15
|
+
* ★ `onRoute` OVERRIDES ONE REQUEST. A test uses it to answer a request with an arbitrary
|
|
16
|
+
* `Response` — a non-404 status carrying code 10006 (proves matching is by code, not status), or
|
|
17
|
+
* a different tagged error entirely (`InvalidRoute`, code 7003) to prove it does NOT fall into
|
|
18
|
+
* `readLock`'s `NoSuchBucket` catch.
|
|
19
|
+
*/
|
|
20
|
+
import { type Credentials, fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
|
|
21
|
+
import * as Layer from 'effect/Layer';
|
|
22
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
23
|
+
import type { R2LockRule } from './lock-rules.ts';
|
|
24
|
+
|
|
25
|
+
export const FAKE_BASE = 'https://api.example.com/client/v4';
|
|
26
|
+
export const FAKE_ACCOUNT = '00000000000000000000000000000002';
|
|
27
|
+
/** ⚠️ A placeholder, not a credential. */
|
|
28
|
+
export const FAKE_API_TOKEN = 'placeholder-api-token';
|
|
29
|
+
|
|
30
|
+
export type Seen = {
|
|
31
|
+
readonly method: string;
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly headers: Record<string, string>;
|
|
34
|
+
readonly body: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ok = (result: unknown) => Response.json({ success: true, errors: [], messages: [], result });
|
|
38
|
+
|
|
39
|
+
export const fakeFailure = (status: number, code: number, message: string) =>
|
|
40
|
+
Response.json(
|
|
41
|
+
{ success: false, errors: [{ code, message }], messages: [], result: null },
|
|
42
|
+
{ status },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** The response `NoSuchBucket` matches: code 10006, conventionally at 404. */
|
|
46
|
+
export const noSuchBucket = (status = 404) =>
|
|
47
|
+
fakeFailure(status, 10006, 'The specified bucket does not exist.');
|
|
48
|
+
|
|
49
|
+
export type FakeOptions = {
|
|
50
|
+
/** Buckets the account has. Any other name is `NoSuchBucket` on both GET and PUT. */
|
|
51
|
+
readonly existingBuckets?: readonly string[];
|
|
52
|
+
/** Override one request's response. `undefined` falls through to the default route. */
|
|
53
|
+
readonly onRoute?: (method: string, bucketName: string) => Response | undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const fakeR2Lock = (options: FakeOptions = {}) => {
|
|
57
|
+
const existing = new Set(options.existingBuckets ?? []);
|
|
58
|
+
const locks = new Map<string, readonly R2LockRule[]>();
|
|
59
|
+
const seen: Seen[] = [];
|
|
60
|
+
|
|
61
|
+
const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
|
|
62
|
+
const prefix = `/client/v4/accounts/${FAKE_ACCOUNT}/r2/buckets/`;
|
|
63
|
+
if (url.host !== 'api.example.com' || !url.pathname.startsWith(prefix)) {
|
|
64
|
+
throw new Error(`fake-r2-lock: unexpected request ${method} ${url.href}`);
|
|
65
|
+
}
|
|
66
|
+
const rest = url.pathname.slice(prefix.length).split('/');
|
|
67
|
+
const bucketName = rest[0];
|
|
68
|
+
if (bucketName === undefined || rest[1] !== 'lock' || rest.length !== 2) {
|
|
69
|
+
throw new Error(`fake-r2-lock: unexpected path ${url.pathname}`);
|
|
70
|
+
}
|
|
71
|
+
const override = options.onRoute?.(method, bucketName);
|
|
72
|
+
if (override !== undefined) return override;
|
|
73
|
+
if (!existing.has(bucketName)) return noSuchBucket();
|
|
74
|
+
if (method === 'GET') return ok({ rules: locks.get(bucketName) ?? [] });
|
|
75
|
+
if (method === 'PUT') {
|
|
76
|
+
const rules = (body['rules'] as readonly R2LockRule[] | undefined) ?? [];
|
|
77
|
+
if (rules.length === 0) locks.delete(bucketName);
|
|
78
|
+
else locks.set(bucketName, rules);
|
|
79
|
+
return ok({});
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`fake-r2-lock: unexpected ${method} ${url.pathname}`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
85
|
+
const request =
|
|
86
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
87
|
+
const url = new URL(request.url);
|
|
88
|
+
const text = await request.text();
|
|
89
|
+
const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
|
|
90
|
+
seen.push({
|
|
91
|
+
method: request.method,
|
|
92
|
+
path: `${url.pathname}${url.search}`,
|
|
93
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
94
|
+
body,
|
|
95
|
+
});
|
|
96
|
+
return route(request.method, url, body);
|
|
97
|
+
}) as typeof globalThis.fetch;
|
|
98
|
+
|
|
99
|
+
return { existing, fetch, locks, seen };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type FakeR2Lock = ReturnType<typeof fakeR2Lock>;
|
|
103
|
+
|
|
104
|
+
/** distilled Credentials + the real FetchHttpClient over the fake — `CloudflareOpContext`. */
|
|
105
|
+
export const fakeR2LockLayer = (
|
|
106
|
+
fake: FakeR2Lock,
|
|
107
|
+
credentials: Layer.Layer<Credentials> = fromApiToken({
|
|
108
|
+
apiToken: FAKE_API_TOKEN,
|
|
109
|
+
apiBaseUrl: FAKE_BASE,
|
|
110
|
+
}),
|
|
111
|
+
) =>
|
|
112
|
+
Layer.mergeAll(
|
|
113
|
+
credentials,
|
|
114
|
+
FetchHttpClient.layer,
|
|
115
|
+
Layer.succeed(FetchHttpClient.Fetch, fake.fetch),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/** The writes the fake saw, in order: `['PUT', 'GET', …]`. */
|
|
119
|
+
export const writes = (fake: FakeR2Lock) =>
|
|
120
|
+
fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
|
|
3
3
|
*
|
|
4
|
-
* ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock`
|
|
5
|
-
* SDK
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
* there is no path string this package invents.
|
|
4
|
+
* ★ Built on vendor SDKs, not hand-rolled HTTP. Both `R2BucketLock` and `MeshNode` call
|
|
5
|
+
* `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers use — R2BucketLock
|
|
6
|
+
* over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over `/zero-trust`, because
|
|
7
|
+
* `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Neither invents a path string.
|
|
9
8
|
*/
|
|
10
9
|
export type { R2LockRule } from './lock-rules.ts';
|
|
11
10
|
export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* `GET …/warp_connector/{id}/token` (developers.cloudflare.com/mesh/get-started, read
|
|
7
7
|
* 2026-09-21). There is no separate "mesh node" endpoint.
|
|
8
8
|
*
|
|
9
|
-
* ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE,
|
|
10
|
-
*
|
|
9
|
+
* ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE — and, as
|
|
10
|
+
* of 2026-09-23, the same one `R2BucketLock` calls too (r2-bucket-lock.ts), not the `cloudflare`
|
|
11
|
+
* npm SDK either used before that. Measured 2026-09-21: `cloudflare@4.5.0`'s
|
|
11
12
|
* `WARPConnectorCreateParams` has only `account_id` and `name`, so it cannot create an HA node.
|
|
12
13
|
* distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins) has `ha?: boolean` on
|
|
13
14
|
* `CreateTunnelWarpConnectorRequest`, typed errors (`TunnelNotFound` code 1002 / 404,
|
|
@@ -1,41 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The HomeFlare Cloudflare provider collection, in the shape `LiteLLM.providers()` has.
|
|
3
3
|
*
|
|
4
|
-
* ★ ONE LAYER, BUILT ONCE, so the single `Config.Redacted('CLOUDFLARE_API_TOKEN')` read is shared
|
|
5
|
-
* by every resource in the stack rather than repeated per resource.
|
|
6
|
-
*
|
|
7
4
|
* ⚠️ THIS IS NOT `alchemy/Cloudflare`'s COLLECTION AND DOES NOT REPLACE IT. A stack that declares
|
|
8
5
|
* both a `Cloudflare.R2.Bucket` and this lock merges the two:
|
|
9
6
|
* `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
|
|
10
7
|
* a consuming stack names for one talking to more than one account.
|
|
11
8
|
*
|
|
12
|
-
* ★
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
9
|
+
* ★ ONE CREDENTIAL PATH NOW, NOT TWO. Until 2026-09-23 `R2BucketLock` ran over the `cloudflare`
|
|
10
|
+
* npm SDK with its own `CLOUDFLARE_API_TOKEN` read (`client.ts`, since deleted) while `MeshNode`
|
|
11
|
+
* ran over `@distilled.cloud/cloudflare` through `Cloudflare.CloudflareApiLive()`. Both
|
|
12
|
+
* providers now call distilled, so both resolve credentials and the account the same way
|
|
13
|
+
* `Cloudflare.providers()` does (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
|
|
14
|
+
* `CLOUDFLARE_ACCOUNT_ID` in CI) — a lock and the Gateway rules that reference the same bucket
|
|
15
|
+
* land in the same account.
|
|
16
|
+
* ⚠️ THE LAYER IS BUILT TWICE ON PURPOSE, once per provider, even when the stack also merges
|
|
17
|
+
* `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: each feeds its own provider
|
|
18
|
+
* only and adds nothing to the stack's context.
|
|
20
19
|
*/
|
|
21
20
|
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
22
21
|
import * as Provider from 'alchemy/Provider';
|
|
23
22
|
import * as Layer from 'effect/Layer';
|
|
24
|
-
import { CloudflareApiLive } from './client.ts';
|
|
25
23
|
import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
|
|
26
24
|
import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
|
|
27
25
|
|
|
28
26
|
export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
|
|
29
27
|
|
|
28
|
+
// ⛔ `orDie` ON BOTH, as `Cloudflare.providers()` ends with: a stack's providers layer must not
|
|
29
|
+
// fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
|
|
30
30
|
export const providers = () =>
|
|
31
31
|
Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
|
|
32
32
|
Layer.provide(
|
|
33
33
|
Layer.mergeAll(
|
|
34
|
-
R2BucketLockProvider(),
|
|
35
|
-
// ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
|
|
36
|
-
// fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
|
|
34
|
+
R2BucketLockProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
37
35
|
MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
38
36
|
),
|
|
39
37
|
),
|
|
40
|
-
Layer.provideMerge(CloudflareApiLive),
|
|
41
38
|
);
|