@homeflare/alchemy 0.19.0 → 0.20.0

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 (39) hide show
  1. package/dist/linux/index.js +140 -93
  2. package/dist/linux/index.js.map +11 -10
  3. package/dist/linux/region.d.ts +11 -0
  4. package/dist/linux/region.d.ts.map +1 -1
  5. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  6. package/dist/linux/remote-file-plan.d.ts +22 -0
  7. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  8. package/dist/linux/remote-file.d.ts +4 -1
  9. package/dist/linux/remote-file.d.ts.map +1 -1
  10. package/dist/linux/unit-handlers.d.ts.map +1 -1
  11. package/dist/linux/unit-lifecycle.d.ts +1 -5
  12. package/dist/linux/unit-lifecycle.d.ts.map +1 -1
  13. package/dist/linux/unit-preflight.d.ts +50 -0
  14. package/dist/linux/unit-preflight.d.ts.map +1 -0
  15. package/dist/proxmox/index.d.ts +15 -3
  16. package/dist/proxmox/index.d.ts.map +1 -1
  17. package/dist/proxmox/index.js +4 -1
  18. package/dist/proxmox/index.js.map +1 -1
  19. package/docs/adopted-deploys.md +13 -0
  20. package/docs/linux-host.md +11 -1
  21. package/docs/provider-standard.md +138 -0
  22. package/docs/upstream-conformance.md +164 -0
  23. package/package.json +1 -1
  24. package/src/linux/region.ts +15 -0
  25. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  26. package/src/linux/remote-file-lifecycle.ts +25 -7
  27. package/src/linux/remote-file-plan.ts +37 -1
  28. package/src/linux/remote-file-region.test.ts +181 -0
  29. package/src/linux/remote-file.ts +4 -1
  30. package/src/linux/unit-handlers.ts +12 -5
  31. package/src/linux/unit-lifecycle.ts +12 -30
  32. package/src/linux/unit-preflight.test.ts +128 -0
  33. package/src/linux/unit-preflight.ts +116 -0
  34. package/src/linux/unit-rename.test.ts +76 -0
  35. package/src/proxmox/ceph-adopt.test.ts +210 -0
  36. package/src/proxmox/constraints-forms.test.ts +14 -6
  37. package/src/proxmox/constraints-node-forms.test.ts +12 -3
  38. package/src/proxmox/index.test.ts +7 -4
  39. package/src/proxmox/index.ts +15 -3
@@ -0,0 +1,138 @@
1
+ # The provider standard
2
+
3
+ Status: in force. Verified 2026-09-22 against `alchemy@2.0.0-beta.79` (tag
4
+ `v2.0.0-beta.79`, commit `473c3959`) and the distilled commit that tag pins (`c2a78002`).
5
+
6
+ This page is the kit-side statement of the house standard for a custom Alchemy provider.
7
+ The full rule set is the `alchemy-provider-standard` skill in the estate's workflow plugin.
8
+ It has 46 numbered rules (S1–S46) and 14 house differences (H1–H14), and every rule cites
9
+ `alchemy-run/alchemy` as `<path>@v2.0.0-beta.79#<section>`. This page keeps the rule numbers
10
+ so that a review comment can name the rule a change breaks. How each family measures up is
11
+ in [upstream-conformance.md](./upstream-conformance.md).
12
+
13
+ ★ **Why this package holds itself to upstream's bar.** Everything in `@homeflare/alchemy`
14
+ should be good enough to contribute to Alchemy. So the kit builds only what upstream lacks,
15
+ and builds it the way upstream would. A house re-implementation of something Alchemy
16
+ already ships is a finding, even when it works.
17
+
18
+ ## Three questions before any code
19
+
20
+ 1. **Does upstream ship the resource?** Look under `packages/alchemy/src/<Cloud>/` at the
21
+ pinned tag. If it does, use it. `github/` does exactly this: it composes
22
+ `GitHub.Repository` and `GitHub.Ruleset`. `website.ts` does it too, over
23
+ `Website.Astro` and `Website.Vite`. A missing prop means an upstream PR, never a fork
24
+ (S1).
25
+ 2. **Does `@distilled.cloud/<vendor>` exist?** If it does, every call goes through it,
26
+ with its typed error unions and Credentials layer. A second SDK or a hand-rolled client
27
+ is a finding. On 2026-09-22 npm had distilled packages for Forgejo, GitHub, Kubernetes
28
+ and Docker, among others, and none for Proxmox, OpenBao, Caddy, Talos, NetBox or UniFi
29
+ (S23).
30
+ 3. **Does Alchemy ship the helper?** Hashing (`alchemy/Util/sha256`), polling
31
+ (`alchemy/Util/poll`), atomic writes (`alchemy/Util/AtomicFile`), equality
32
+ (`deepEqual` in `alchemy/Diff`), names (`alchemy/PhysicalName`), tags (`alchemy/Tags`),
33
+ ownership (`alchemy/AdoptPolicy`), credentials (`alchemy/Auth`) and the test harness
34
+ (`alchemy/Test/Bun`) all exist. Use them.
35
+
36
+ ## The contract, in brief
37
+
38
+ - **Shape (S2, S3).** Declare the interface as
39
+ `Resource<"Vendor.Service.Type", Props, Attrs, never, Providers>`, and the constructor as
40
+ `const X = Resource<X>(…)`. The type string starts with the vendor's own name. The
41
+ contract and its provider share a file, and helpers go in sibling files that `index.ts`
42
+ does not export.
43
+ - **Props (S5).** Props are plain types, never `Input<T>`, and every field has JSDoc.
44
+ - **Diff (S6).** `diff` narrows with `isResolved`. It returns `undefined` for an ordinary
45
+ update and `replace` only for identity changes.
46
+ - **Read (S7, S8).** `read` works with `output: undefined`. It answers `undefined`, plain
47
+ attributes or `Unowned(attrs)`. This package answers `Unowned` for any live object this
48
+ stack cannot prove it made (H1; [ownership.md](./ownership.md)).
49
+ - **Reconcile (S9, S10).** `reconcile` is one flow: observe, ensure, sync, return. It
50
+ never trusts `output` as proof, and it makes zero writes when nothing drifted.
51
+ - **Delete (S11, S14).** `delete` is idempotent and bounded. A family whose deletion
52
+ breaks its consumers defaults to `retain`, and still implements `delete` in full.
53
+ - **List (S12).** `list` enumerates every object of the type in its scope, or returns `[]`
54
+ with the reason written down. A family whose objects carry ownership marks filters to
55
+ owned ones, as upstream's third-party providers do; the core contract does not ask for it. `nuke` is declared wherever nuke must never reach the object.
56
+ - **Scope (S15).** The account, zone and region are resolved inside each operation. They
57
+ are never props.
58
+
59
+ ## Code, errors, credentials
60
+
61
+ - **Effect only (S19).** Provider code, helpers and tests use `FileSystem`, `Path`,
62
+ `HttpClient` and `ChildProcessSpawner`. They never use `async`/`await`, a raw `Promise`,
63
+ `node:fs`, `node:fs/promises`, `node:os`, `node:path` or bare `fetch`. A synchronous,
64
+ CPU-only Node call (`node:crypto` `createHash`, `Buffer`) goes inside `Effect.sync`, which
65
+ is upstream's own example. When a promise cannot be avoided, use `Effect.tryPromise`,
66
+ never `Effect.promise`.
67
+ - **No defects (S20).** Lifecycle operations contain no `Effect.orDie` and no
68
+ `Effect.die`. A refusal is a typed error.
69
+ - **Typed errors (S21, S22).** Handle errors with `Effect.catchTag` over the SDK's union.
70
+ Where the kit generates its own client from a vendor schema, the status-to-tag mapping
71
+ lives in that client, once, and never in a resource.
72
+ - **Credentials (S24).** Credentials come from an `alchemy/Auth` provider and a lazy
73
+ Credentials service. Short-lived credentials are minted per call, never reused, and never
74
+ taken as props.
75
+ - **Secrets (S25).** A secret value is never a prop or an attribute. Alchemy persists
76
+ `Redacted` values in plaintext, and only one state store encrypts them.
77
+ - **Bounded waits (S26).** A retry stops after at most 8 to 10 attempts and under about a
78
+ minute. A poll never runs past about 90 seconds.
79
+
80
+ ## The Bun line for this package (S42–S45)
81
+
82
+ ★ This package is Bun to author and runtime-neutral to consume. It builds with
83
+ `bun build --target node`, and consumers run `dist/` on Node.
84
+
85
+ - **`src/**` provider code** (resources, providers, clients, and helpers a provider calls)
86
+ never calls `Bun.*` and never imports `bun:*` or the `node:*` modules S19 names. A Node
87
+ consumer calling an exported helper that uses `Bun.YAML` fails with
88
+ `ReferenceError: Bun is not defined` at the call site. `node:crypto`, `node:http` and
89
+ `node:child_process` all load on Node. Where they break the standard, they break the
90
+ Effect-only rule, not the runtime.
91
+ - **Tests, fakes, scripts and codegen** are Bun-native. That means `bun:test` (never
92
+ `node:test`, never vitest), `Bun.serve` fakes on loopback, `Bun.YAML`, `Bun.file()`,
93
+ `Bun.Glob` and `Bun.CryptoHasher`. Upstream runs its scripts and its test runner on Bun
94
+ too.
95
+ - **A module that truly needs Bun** is named for Bun, sits behind its own subpath, or
96
+ guards on `typeof globalThis.Bun`. The package root never reaches it.
97
+
98
+ The files currently on the wrong side of the line are listed in
99
+ [upstream-conformance.md](./upstream-conformance.md#the-bun-line).
100
+
101
+ ## Versions (S37–S41)
102
+
103
+ - **One aligned set.** `effect`, `@distilled.cloud/*` and `@effect/platform-*` are pinned
104
+ exactly to what the pinned `alchemy` release was built and tested against. They move only
105
+ in the same PR as the `alchemy` bump.
106
+ - **Every family records the vendor version it was walked against, in code.**
107
+ - For a generated client, the record is the schema manifest entry and the generated
108
+ header. `codegen/manifest.json` holds the product, version, sha256, fetch date and
109
+ source role for PVE, PBS, UniFi and NetBox.
110
+ - For a distilled-backed family, it is the chain: `alchemy` version, distilled package
111
+ version, distilled commit, spec-mirror commit and date, and Smithy service version.
112
+ - For a CLI or OS-service family, it is the binary or OS version, the command that
113
+ reports it, and the date, in that family's docs page.
114
+ - **And in its changeset.** A provider change names the vendor version it was walked
115
+ against. A pin move names every link, old and new.
116
+
117
+ | family | vendor version walked against | recorded in |
118
+ | ------------------- | -------------------------------------- | --------------------------------- |
119
+ | `proxmox` (PVE) | pve-manager 9.2.11 | `codegen/manifest.json` + headers |
120
+ | `proxmox` (PBS) | proxmox-backup-server 4.2.6-1 | `codegen/manifest.json` + headers |
121
+ | `netbox` | NetBox 4.7.0 OpenAPI | `codegen/manifest.json` + headers |
122
+ | `cloudflare` | distilled 1.0.0-rc.12 (API v4) | `package.json` pins only |
123
+ | `openbao` | OpenBao 2.6.2 | source comments only ⚠️ |
124
+ | `caddy` | Caddy 2.11.4 | source comments only ⚠️ |
125
+ | `forgejo` | Forgejo 16.0.3 | source comments only ⚠️ |
126
+ | `talos` | Talos v1.13 docs (never measured live) | source comments only ⚠️ |
127
+ | `launchd` / `linux` | macOS 27 / systemd 257 | source comments only ⚠️ |
128
+
129
+ ⚠️ These rows record a version only in prose, so no test catches a drift. `schemas/manifest.json`
130
+ also repeats the PVE, PBS and UniFi entries, which means there are two manifests for one
131
+ fact.
132
+
133
+ ## Where this package differs from upstream on purpose
134
+
135
+ Each difference is written down with its reason. H1 is strict `Unowned`. H2 is the
136
+ apply-time ownership re-check in `ownership/`. H4 is `retain` by default. H5 is `adopt(true)`
137
+ on each resource. H6 is `hf-adopt-verify`. [upstream-conformance.md](./upstream-conformance.md)
138
+ marks each one justified or a gap. Anything not listed there is a gap by definition.
@@ -0,0 +1,164 @@
1
+ # Upstream conformance, family by family
2
+
3
+ Status: open ledger. Verified 2026-09-22 by reading `origin/main` `925454b` against
4
+ [provider-standard.md](./provider-standard.md) (`alchemy@2.0.0-beta.79`). Nothing in any
5
+ family was changed by this audit. Each row names the rule it breaks and the upstream
6
+ replacement or fix. A row is crossed off in the PR that fixes it, with that PR's evidence.
7
+ A decision marked **maintainer** is not an agent's to make.
8
+
9
+ ## Ranked findings
10
+
11
+ Findings are ranked by what they can break: runtime safety first, then contributability,
12
+ then tidiness.
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
28
+ `Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
29
+
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.
38
+ 3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
39
+ deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
40
+ connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
41
+ type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
42
+ **Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
43
+ or keep the house resource as a recorded divergence. **Decision:** maintainer.
44
+ 4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
45
+ (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
46
+ under Node, which is the runtime this package promises its consumers. The `node:*` paths
47
+ load on Node but break the Effect-only rule.
48
+ 5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
49
+ `CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
50
+ and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
51
+ reason is that a consumer implements plain async functions. Upstream meets the same need
52
+ by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
53
+ layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
54
+ ⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
55
+ `chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
56
+ default flags and mode, `chmod`s afterwards, and never `chown`s.
57
+ **Decision:** maintainer. These families are also host-specific, so "house-only" is a
58
+ valid answer.
59
+ 6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
60
+ `HttpClient`, strict `Unowned`, retain). It diverges in three ways:
61
+ - 30 `Effect.die` sites in non-test source, several of them on `diff` and
62
+ `reconcile` paths (S20);
63
+ - 42 test files on `node:test` (S43);
64
+ - `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
65
+
66
+ No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
67
+ distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
68
+ a distilled package.
69
+
70
+ 7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
71
+ vendor schema, and a manifest records the version and sha256. It diverges in four ways:
72
+ - the generic factory's `read` returns plain attributes, so every family except `Lxc`
73
+ adopts silently, against the house's own H1;
74
+ - 27 `Effect.die` sites in non-test source (S20);
75
+ - 7 test files on `node:test`;
76
+ - `Pbs.*` type strings (H14).
77
+ 8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
78
+ provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
79
+ `Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
80
+ kubeconfig it writes is meant to be consumed through upstream's
81
+ `Kubernetes.KubeConfig({ path })`.
82
+ 9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
83
+ OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
84
+ It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
85
+ 10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
86
+ `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
87
+ (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
88
+ where nuke must never reach the object.
89
+ 11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
90
+ `**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
91
+ the house's glyph rationale into `//` comments.
92
+ 12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
93
+ fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
94
+
95
+ ## Conforms
96
+
97
+ - **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
98
+ `GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
99
+ without prior output, so a first deploy creates a duplicate
100
+ ([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
101
+ `Unowned`.
102
+ - **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
103
+ `Website.Vite`.
104
+ - **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
105
+ apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
106
+ an Output. That fix also belongs in the engine upstream.
107
+ - **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
108
+ labels every cold adoption as an update (H6).
109
+
110
+ ## The Bun line
111
+
112
+ These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
113
+ marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
114
+ files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
115
+ S42's scope:
116
+
117
+ - four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
118
+ `proxmox/fake-pve-lxc.ts`), which are test-side under S44;
119
+ - `proxmox/provision-cli-fake.ts`, which is used only by tests;
120
+ - `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
121
+
122
+ That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
123
+ out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
124
+ reaches them.
125
+ ⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
126
+ ⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
127
+ `proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
128
+ `node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
129
+ (`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
130
+ among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
131
+ `node:*` ban that upstream does not have.
132
+
133
+ | file | API | exp | portable replacement |
134
+ | ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
135
+ | `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
136
+ | `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
137
+ | `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
138
+ | `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
139
+ | `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
140
+ | `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
141
+ | `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
142
+ | `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
143
+ | `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
144
+ | `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
145
+ | `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
146
+ | `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
147
+ | `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
148
+ | `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
149
+ | `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
150
+ | `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
151
+ | `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
152
+
153
+ The ⚠️ rows:
154
+
155
+ - `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
156
+ hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
157
+ `Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
158
+ error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
159
+ - `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
160
+ replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
161
+ state, and it would change the persisted seal format.
162
+
163
+ Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
164
+ talos 1), and 126 import `bun:test`. S43 says `bun:test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",
@@ -34,6 +34,21 @@ const tokens = (spec: RegionSpec) => {
34
34
  };
35
35
  };
36
36
 
37
+ /**
38
+ * Whether two declarations name the SAME block.
39
+ *
40
+ * ⛔ THE COMMENT TOKEN IS PART OF THE IDENTITY, not a formatting preference. The markers are
41
+ * matched as literal whole lines, so `# BEGIN pf` and `// BEGIN pf` are two different blocks:
42
+ * changing the token leaves the old block invisible to every later read, exactly as renaming
43
+ * does. A resource that treated either as a mere content change would splice a second block in
44
+ * and never be able to find the first one again.
45
+ * ★ Whole-file mode is its own identity: `undefined` equals only `undefined`.
46
+ */
47
+ export const sameRegion = (a: RegionSpec | undefined, b: RegionSpec | undefined): boolean =>
48
+ a === undefined || b === undefined
49
+ ? a === undefined && b === undefined
50
+ : a.name === b.name && (a.comment ?? DEFAULT_COMMENT) === (b.comment ?? DEFAULT_COMMENT);
51
+
37
52
  export const regionProblems = (spec: RegionSpec): string[] => {
38
53
  const found: string[] = [];
39
54
  const comment = spec.comment ?? DEFAULT_COMMENT;
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
6
  import { fakeLinuxHost } from './fake-linux-host.ts';
7
- import { fileProblems } from './remote-file-form.ts';
8
7
  import {
9
8
  deleteFile,
10
9
  diffFile,
@@ -89,93 +88,6 @@ describe('whole-file mode', () => {
89
88
  });
90
89
  });
91
90
 
92
- describe('region mode', () => {
93
- const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
94
- const region = { name: 'homeflare' };
95
- const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
96
-
97
- const withVendor = () => {
98
- const fake = host();
99
- fake.files.set('/etc/vendor.conf', {
100
- bytes: new TextEncoder().encode(VENDOR),
101
- gid: 0,
102
- kind: 'file',
103
- mode: 0o600,
104
- uid: 0,
105
- });
106
- return fake;
107
- };
108
-
109
- test('editing the block leaves the rest of the file byte-identical', async () => {
110
- const fake = withVendor();
111
- const output = await reconcileFile(fake.runner, block);
112
- const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
113
- expect(after.startsWith(VENDOR)).toBe(true);
114
- await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
115
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
116
- });
117
-
118
- test("the other owner's mode and uid are preserved, never re-declared", async () => {
119
- const fake = withVendor();
120
- await reconcileFile(fake.runner, block);
121
- expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
122
- });
123
-
124
- test('a change anywhere else in the file is not this resource’s drift', async () => {
125
- const fake = withVendor();
126
- const output = await reconcileFile(fake.runner, block);
127
- const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
128
- fake.files.set('/etc/vendor.conf', {
129
- bytes: new TextEncoder().encode(edited),
130
- gid: 0,
131
- kind: 'file',
132
- mode: 0o600,
133
- uid: 0,
134
- });
135
- expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
136
- });
137
-
138
- test('delete removes only the block', async () => {
139
- const fake = withVendor();
140
- const output = await reconcileFile(fake.runner, block);
141
- await deleteFile(fake.runner, output);
142
- expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
143
- });
144
-
145
- test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
146
- const fake = host();
147
- await expect(
148
- reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
149
- ).rejects.toThrow(/does not create the file it lives in/);
150
- const created = await reconcileFile(fake.runner, {
151
- ...block,
152
- create: true,
153
- path: '/etc/example/absent.conf',
154
- });
155
- expect(created.region).toEqual(region);
156
- });
157
-
158
- test('mode and owner without create are refused at validation', () => {
159
- expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
160
- expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
161
- });
162
-
163
- test('a block already there with other content needs --adopt', async () => {
164
- const fake = withVendor();
165
- fake.files.set('/etc/vendor.conf', {
166
- bytes: new TextEncoder().encode(
167
- `${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
168
- ),
169
- gid: 0,
170
- kind: 'file',
171
- mode: 0o600,
172
- uid: 0,
173
- });
174
- await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
175
- await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
176
- });
177
- });
178
-
179
91
  describe('a failed read refuses', () => {
180
92
  test('a stat that throws is never read as “nothing is there”', async () => {
181
93
  const fake = host();
@@ -28,6 +28,7 @@ import {
28
28
  planWrite,
29
29
  readFileAttributes,
30
30
  refuse,
31
+ relocation,
31
32
  } from './remote-file-plan.ts';
32
33
 
33
34
  export { readFileAttributes } from './remote-file-plan.ts';
@@ -42,7 +43,8 @@ export const diffFile = async (
42
43
  output: RemoteFileAttributes,
43
44
  ): Promise<Diff> => {
44
45
  const want = await identity(runner, news);
45
- if (news.path !== output.path) {
46
+ // ⛔ A mode flip throws HERE, at plan time, before anything is written (remote-file-plan.ts).
47
+ if (relocation(news, output) === 'path') {
46
48
  // ★ Create-before-delete: two paths hold two files at once, so nothing forces deleteFirst.
47
49
  await runner.checkWrite?.(news.path, { mode: news.mode ?? DEFAULT_MODE, ...want });
48
50
  return { action: 'replace' };
@@ -80,8 +82,13 @@ export const reconcileFile = async (
80
82
  'a root destination or a privileged HostRunner. This provider never calls sudo itself.',
81
83
  );
82
84
  }
83
- const moved = output !== undefined && output.path !== props.path;
84
- const prior = moved ? undefined : output;
85
+ /**
86
+ * ⛔ WHAT WE OWNED LAST TIME MAY NOT BE WHAT WE OWN NOW. A new path, or a new region name or
87
+ * comment token, means the old block or file is still out there and must be taken back — and
88
+ * a flip between the two modes is refused outright (remote-file-plan.ts relocation).
89
+ */
90
+ const move = output === undefined ? 'none' : relocation(props, output);
91
+ const prior = move === 'none' ? output : undefined;
85
92
  const planned = await planWrite(runner, props, want);
86
93
  const live = ownedDigest(planned.before, props.region);
87
94
  const declared = digestOf(props.content);
@@ -127,10 +134,21 @@ export const reconcileFile = async (
127
134
  'the write returned but the file on disk does not match the declaration',
128
135
  );
129
136
  }
130
- // ★ Create-before-delete, as the replace would have been: the old path goes once the new one is
131
- // written and verified.
132
- if (moved && output !== undefined) await deleteFile(runner, output);
133
- return after;
137
+ // ★ Create-before-delete, as the replace would have been: the old path — or, for a rename inside
138
+ // one file, the old block — goes once the new one is written and verified.
139
+ if (move === 'none' || output === undefined) return after;
140
+ await deleteFile(runner, output);
141
+ if (move === 'path') return after;
142
+ /**
143
+ * ⛔ A REGION RENAME TOOK THE OLD BLOCK OUT OF THIS SAME FILE, so `after` — read before that
144
+ * removal — describes bytes that no longer exist. Storing it would make the whole-file digest
145
+ * wrong from the moment it was written and every later plan would read it as drift.
146
+ */
147
+ const settled = await readFileAttributes(runner, props.path, props.region);
148
+ if (settled === undefined || settled.contentSha256 !== declared) {
149
+ throw refuse(props.path, 'removing the previous managed block did not leave ours intact');
150
+ }
151
+ return settled;
134
152
  };
135
153
 
136
154
  /**
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { sha256Hex } from '../launchd/job-form.ts';
10
10
  import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
11
- import { type RegionSpec, readRegion, spliceRegion } from './region.ts';
11
+ import { type RegionSpec, readRegion, sameRegion, spliceRegion } from './region.ts';
12
12
  import {
13
13
  DEFAULT_MODE,
14
14
  type RemoteFileAttributes,
@@ -46,6 +46,42 @@ export const identity = async (runner: HostRunner, props: RemoteFileProps): Prom
46
46
 
47
47
  export const digestOf = (text: string): string => sha256Hex(encoder.encode(text));
48
48
 
49
+ /** What this declaration now owns, compared with what the last deploy owned. */
50
+ export type Move = 'none' | 'path' | 'region';
51
+
52
+ /**
53
+ * Has the thing this resource owns MOVED since the last deploy — and if so, how?
54
+ *
55
+ * 🔴 WHY THIS EXISTS (found by review, reproduced against the fake host, 2026-09-22). Without it,
56
+ * only `path` was identity and both of these read as a routine `update`:
57
+ * - RENAMING THE REGION left the old block in the file FOREVER. The new markers were spliced in,
58
+ * the old ones were never touched, and `delete` — which can only look for the name in state,
59
+ * now the new one — could never remove them. Two `anchor` lines in a packet filter, two
60
+ * entries in a host table, and nothing in the stack able to take either back.
61
+ * - DROPPING `region` TOOK OVER THE WHOLE FILE. The plan said `update`; the apply replaced every
62
+ * byte of a file belonging to a vendor package with this resource's four lines. That is the
63
+ * one thing the managed-region design exists to make impossible.
64
+ * ⛔ A FLIP BETWEEN THE TWO MODES IS A REFUSAL, NOT A MOVE. Neither order is safe: writing the
65
+ * whole file first destroys the other owner's bytes before anything can be undone, and removing
66
+ * the block first destroys our own claim and then refuses. The operator destroys the resource
67
+ * and declares a new one, which is the only sequence with a reviewable diff at each step.
68
+ * ★ A region-to-region rename IS safe as a move, because both blocks can exist at once: write the
69
+ * new one, verify it, then take the old one out (remote-file-lifecycle.ts reconcileFile).
70
+ */
71
+ export const relocation = (props: RemoteFileProps, output: RemoteFileAttributes): Move => {
72
+ if (props.path !== output.path) return 'path';
73
+ if (sameRegion(props.region, output.region)) return 'none';
74
+ if (props.region === undefined || output.region === undefined) {
75
+ throw refuse(
76
+ props.path,
77
+ `changes between owning the whole file and owning the managed region ` +
78
+ `${JSON.stringify((props.region ?? output.region)?.name ?? '')} at the same path. Those ` +
79
+ 'are two different claims on one file: destroy this resource and declare a new one.',
80
+ );
81
+ }
82
+ return 'region';
83
+ };
84
+
49
85
  /** The digest of the part this resource owns, or `undefined` when that part is not there. */
50
86
  export const ownedDigest = (
51
87
  file: string | undefined,