@homeflare/alchemy 0.27.1 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +3 -2
  2. package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
  3. package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
  4. package/dist/cloudflare/index.d.ts +4 -5
  5. package/dist/cloudflare/index.d.ts.map +1 -1
  6. package/dist/cloudflare/index.js +79 -103
  7. package/dist/cloudflare/index.js.map +6 -7
  8. package/dist/cloudflare/mesh-node-api.d.ts +3 -2
  9. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
  10. package/dist/cloudflare/providers.d.ts +1 -1
  11. package/dist/cloudflare/providers.d.ts.map +1 -1
  12. package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
  13. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  14. package/dist/forgejo/branch-protection-form.d.ts +16 -5
  15. package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
  16. package/dist/forgejo/branch-protection.d.ts +34 -3
  17. package/dist/forgejo/branch-protection.d.ts.map +1 -1
  18. package/dist/forgejo/fake-forgejo.d.ts +41 -0
  19. package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
  20. package/dist/forgejo/index.js +220 -284
  21. package/dist/forgejo/index.js.map +14 -15
  22. package/dist/forgejo/org-actions-secrets.d.ts +42 -12
  23. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
  24. package/dist/forgejo/org-label.d.ts +30 -3
  25. package/dist/forgejo/org-label.d.ts.map +1 -1
  26. package/dist/forgejo/org-team.d.ts +37 -4
  27. package/dist/forgejo/org-team.d.ts.map +1 -1
  28. package/dist/forgejo/repo-webhook-form.d.ts +9 -18
  29. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
  30. package/dist/forgejo/repo-webhook.d.ts +35 -3
  31. package/dist/forgejo/repo-webhook.d.ts.map +1 -1
  32. package/dist/forgejo/repository.d.ts +47 -2
  33. package/dist/forgejo/repository.d.ts.map +1 -1
  34. package/dist/forgejo/resource.d.ts +37 -37
  35. package/dist/forgejo/resource.d.ts.map +1 -1
  36. package/dist/forgejo/team-member.d.ts +48 -4
  37. package/dist/forgejo/team-member.d.ts.map +1 -1
  38. package/dist/forgejo/values.d.ts +9 -5
  39. package/dist/forgejo/values.d.ts.map +1 -1
  40. package/docs/credentials.md +6 -0
  41. package/docs/distilled-interim.md +151 -0
  42. package/docs/peers.md +15 -6
  43. package/docs/upstream-conformance.md +46 -22
  44. package/package.json +2 -2
  45. package/src/cloudflare/fake-r2-lock.ts +120 -0
  46. package/src/cloudflare/index.ts +4 -5
  47. package/src/cloudflare/mesh-node-api.ts +3 -2
  48. package/src/cloudflare/providers.ts +13 -16
  49. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  50. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  51. package/src/forgejo/branch-protection-form.ts +12 -5
  52. package/src/forgejo/branch-protection.ts +58 -26
  53. package/src/forgejo/fake-forgejo.ts +54 -0
  54. package/src/forgejo/org-actions-secrets.ts +44 -36
  55. package/src/forgejo/org-label.test.ts +100 -0
  56. package/src/forgejo/org-label.ts +46 -36
  57. package/src/forgejo/org-team.ts +40 -27
  58. package/src/forgejo/repo-webhook-form.ts +10 -27
  59. package/src/forgejo/repo-webhook.ts +66 -35
  60. package/src/forgejo/repository.test.ts +125 -0
  61. package/src/forgejo/repository.ts +72 -52
  62. package/src/forgejo/resource.ts +80 -79
  63. package/src/forgejo/team-member.test.ts +97 -0
  64. package/src/forgejo/team-member.ts +56 -38
  65. package/src/forgejo/values.ts +9 -17
  66. package/dist/cloudflare/client.d.ts +0 -45
  67. package/dist/cloudflare/client.d.ts.map +0 -1
  68. package/dist/forgejo/client.d.ts +0 -37
  69. package/dist/forgejo/client.d.ts.map +0 -1
  70. package/src/cloudflare/client.ts +0 -76
  71. package/src/forgejo/client.ts +0 -136
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Wire coercions shared across Forgejo resources.
3
3
  *
4
- * ★ GITEA/ FORGEJO JSON USES snake_case ON THE WIRE and camelCase in props — each resource
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: unknown) => string;
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;;;;;GAKG;AAEH,eAAO,MAAM,IAAI,UAAW,OAAO,8BACW,CAAC;AAE/C,8EAA8E;AAC9E,eAAO,MAAM,KAAK,UAAW,OAAO,WAAgD,CAAC;AAErF,eAAO,MAAM,IAAI,UAAW,OAAO,gCACgE,CAAC;AAEpG,eAAO,MAAM,GAAG,UAAW,OAAO,8BAOjC,CAAC;AAEF,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"}
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"}
@@ -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 cloudflare@4.5.0 mime@4.1.0 \
10
- @distilled.cloud/cloudflare@1.0.0-rc.12 @effect/sql-pg@4.0.0-rc.115
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
- ⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
37
- subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
38
- `Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
39
- that an import fails.
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 four rules.**
15
- - `readLock` ends in `Effect.orDie` (S20).
16
- - `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
17
- - `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
18
- reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
19
- lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
20
- live.
21
- - `accountId` is a prop (S15).
22
-
23
- It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
24
- `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
25
- **Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
26
- observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
27
- the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
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.** `client.ts` is a hand-rolled
31
- Effect `HttpClient` client with a status-carrying `ForgejoError`, while
32
- `@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
33
- 16.0.3, the version this family measured (S21, S23). Credentials come from
34
- `FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
35
- `Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
36
- Untyped errors become distilled patches. **Decision:** maintainer, because it changes
37
- the error surface.
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.1",
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);
@@ -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` uses the official `cloudflare`
5
- * SDK, which already has `r2.buckets.locks.update/get` over the documented endpoints.
6
- * `MeshNode` uses `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers
7
- * use, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Either way
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, not the
10
- * `cloudflare` npm SDK the R2 lock uses. Measured 2026-09-21: `cloudflare@4.5.0`'s
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
- * ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
13
- * `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
14
- * and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
15
- * source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
16
- * `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
17
- * ⚠️ That second layer is built again here even when the stack also merges
18
- * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
19
- * nothing to the stack's context.
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
  );