@homeflare/alchemy 0.20.0 → 0.21.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 (172) hide show
  1. package/README.md +25 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js.map +3 -3
  31. package/dist/linux/sudo-listing.d.ts +47 -0
  32. package/dist/linux/sudo-listing.d.ts.map +1 -0
  33. package/dist/proxmox/credential-literals.d.ts +1 -1
  34. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  35. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  36. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  37. package/dist/proxmox/generated/pbs.d.ts +7 -0
  38. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pve.d.ts +7 -0
  40. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  41. package/dist/proxmox/index.d.ts +1 -1
  42. package/dist/proxmox/index.d.ts.map +1 -1
  43. package/dist/proxmox/index.js +2 -2
  44. package/dist/proxmox/index.js.map +9 -9
  45. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  46. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  48. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  49. package/dist/release/archive.d.ts +11 -0
  50. package/dist/release/archive.d.ts.map +1 -0
  51. package/dist/release/binary-claim.d.ts +53 -0
  52. package/dist/release/binary-claim.d.ts.map +1 -0
  53. package/dist/release/binary-diff.d.ts +38 -0
  54. package/dist/release/binary-diff.d.ts.map +1 -0
  55. package/dist/release/binary-form.d.ts +118 -0
  56. package/dist/release/binary-form.d.ts.map +1 -0
  57. package/dist/release/binary-lifecycle.d.ts +55 -0
  58. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  59. package/dist/release/binary-read.d.ts +11 -0
  60. package/dist/release/binary-read.d.ts.map +1 -0
  61. package/dist/release/binary.d.ts +68 -0
  62. package/dist/release/binary.d.ts.map +1 -0
  63. package/dist/release/catalog.d.ts +76 -0
  64. package/dist/release/catalog.d.ts.map +1 -0
  65. package/dist/release/checksums.d.ts +22 -0
  66. package/dist/release/checksums.d.ts.map +1 -0
  67. package/dist/release/declared-pins.d.ts +4 -0
  68. package/dist/release/declared-pins.d.ts.map +1 -0
  69. package/dist/release/download.d.ts +44 -0
  70. package/dist/release/download.d.ts.map +1 -0
  71. package/dist/release/fake-deploy.d.ts +48 -0
  72. package/dist/release/fake-deploy.d.ts.map +1 -0
  73. package/dist/release/fake-engine.d.ts +8 -0
  74. package/dist/release/fake-engine.d.ts.map +1 -0
  75. package/dist/release/fake-install.d.ts +39 -0
  76. package/dist/release/fake-install.d.ts.map +1 -0
  77. package/dist/release/fake-provider.d.ts +58 -0
  78. package/dist/release/fake-provider.d.ts.map +1 -0
  79. package/dist/release/fake-release.d.ts +95 -0
  80. package/dist/release/fake-release.d.ts.map +1 -0
  81. package/dist/release/index.d.ts +24 -0
  82. package/dist/release/index.d.ts.map +1 -0
  83. package/dist/release/index.js +1365 -0
  84. package/dist/release/index.js.map +35 -0
  85. package/dist/release/providers.d.ts +19 -0
  86. package/dist/release/providers.d.ts.map +1 -0
  87. package/dist/release/refused.d.ts +39 -0
  88. package/dist/release/refused.d.ts.map +1 -0
  89. package/dist/release/tar.d.ts +14 -0
  90. package/dist/release/tar.d.ts.map +1 -0
  91. package/dist/release/victoria.d.ts +36 -0
  92. package/dist/release/victoria.d.ts.map +1 -0
  93. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  94. package/docs/release-binary-catalogs.md +113 -0
  95. package/docs/release-binary-measured.md +163 -0
  96. package/docs/release-binary-refusals.md +57 -0
  97. package/docs/release-binary-upstream.md +163 -0
  98. package/docs/release-binary.md +154 -0
  99. package/package.json +5 -1
  100. package/src/caddy/access-forward-auth.test.ts +63 -0
  101. package/src/caddy/access-forward-auth.ts +120 -0
  102. package/src/caddy/index.ts +10 -0
  103. package/src/caddy/providers.test.ts +4 -0
  104. package/src/cloudflare/mcp-server-api.ts +294 -0
  105. package/src/cloudflare/mcp-server-form.ts +219 -0
  106. package/src/index.ts +1 -0
  107. package/src/launchd/file-converge.ts +233 -0
  108. package/src/launchd/file-identity.ts +69 -0
  109. package/src/launchd/host-file-alias.test.ts +65 -0
  110. package/src/launchd/host-file-form.ts +2 -1
  111. package/src/launchd/host-file-lifecycle.ts +25 -144
  112. package/src/launchd/host-file.ts +8 -2
  113. package/src/launchd/local-runner.test.ts +12 -1
  114. package/src/launchd/local-runner.ts +2 -0
  115. package/src/launchd/runner.ts +17 -2
  116. package/src/launchd/sudo-runner.ts +11 -0
  117. package/src/linux/sudo-listing.ts +122 -0
  118. package/src/proxmox/credential-literals.ts +1 -1
  119. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  120. package/src/proxmox/generated/pbs.ts +7 -0
  121. package/src/proxmox/generated/pve.ts +7 -0
  122. package/src/proxmox/index.ts +1 -1
  123. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  124. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  125. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  126. package/src/proxmox/pbs-notification-target.ts +1 -1
  127. package/src/release/adopt-parity.test.ts +106 -0
  128. package/src/release/apply-pins.test.ts +128 -0
  129. package/src/release/archive.ts +94 -0
  130. package/src/release/binary-alias.test.ts +109 -0
  131. package/src/release/binary-claim.ts +80 -0
  132. package/src/release/binary-claims.test.ts +47 -0
  133. package/src/release/binary-diff.ts +73 -0
  134. package/src/release/binary-form.ts +229 -0
  135. package/src/release/binary-lifecycle.test.ts +90 -0
  136. package/src/release/binary-lifecycle.ts +202 -0
  137. package/src/release/binary-read.ts +55 -0
  138. package/src/release/binary-refusals.test.ts +192 -0
  139. package/src/release/binary-sudo.test.ts +93 -0
  140. package/src/release/binary.ts +181 -0
  141. package/src/release/catalog.test.ts +96 -0
  142. package/src/release/catalog.ts +175 -0
  143. package/src/release/checksums.test.ts +35 -0
  144. package/src/release/checksums.ts +43 -0
  145. package/src/release/declared-pins.ts +39 -0
  146. package/src/release/download.test.ts +138 -0
  147. package/src/release/download.ts +110 -0
  148. package/src/release/error-channel.test.ts +69 -0
  149. package/src/release/fake-deploy.ts +87 -0
  150. package/src/release/fake-engine.ts +50 -0
  151. package/src/release/fake-install.ts +59 -0
  152. package/src/release/fake-provider.ts +68 -0
  153. package/src/release/fake-release.ts +185 -0
  154. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  155. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  156. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  157. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  158. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  159. package/src/release/index.ts +41 -0
  160. package/src/release/plan.test.ts +189 -0
  161. package/src/release/provider-refusals.test.ts +48 -0
  162. package/src/release/providers.test.ts +174 -0
  163. package/src/release/providers.ts +24 -0
  164. package/src/release/recovery-read.test.ts +44 -0
  165. package/src/release/refused.ts +40 -0
  166. package/src/release/tar.test.ts +142 -0
  167. package/src/release/tar.ts +195 -0
  168. package/src/release/victoria.test.ts +108 -0
  169. package/src/release/victoria.ts +154 -0
  170. package/src/{proxmox → secrets}/write-only.ts +6 -0
  171. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  172. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The one path a declared file's bytes take onto a host, shared by every resource that owns ONE
3
+ * whole file: `Host.File` (text it was handed) and `Release.Binary` (bytes it downloaded and
4
+ * verified). Read, diff, converge and delete, as plain async functions over a HostRunner.
5
+ *
6
+ * ★ EXTRACTED FROM host-file-lifecycle.ts, NOT COPIED (2026-09-22). The ownership rules below — a
7
+ * file this resource does not own is never overwritten, a move onto an occupied path stays
8
+ * refused, a failed create is rolled back — were red-teamed on Host.File (docs/ownership.md). A
9
+ * second resource that writes a whole file gets them by calling this, so a fix lands in both.
10
+ * ★ THE BYTES ARE ASKED FOR ONLY WHEN A WRITE IS DUE (`bytesFor`). Host.File has them already; a
11
+ * binary has only a pinned digest until it downloads, and a host whose file already matches
12
+ * must not cost a 120 MB download to prove it.
13
+ * ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED. rename(2) over a symlink replaces the LINK,
14
+ * so a path that is a symlink (nix-darwin's /etc entries point into /nix/store) would be quietly
15
+ * taken from the tool that owns it — which puts it back, or refuses its next activation. Remove
16
+ * the path from its owner first, then declare it here.
17
+ */
18
+ import type { Diff } from 'alchemy/Diff';
19
+ import { leaveOldPath, oneFile } from './file-identity.ts';
20
+ import type { HostFileAttributes } from './host-file-form.ts';
21
+ import { sha256Hex } from './job-form.ts';
22
+ import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
23
+
24
+ /** What a whole file must be: where, which bytes (by digest), which mode and owner. */
25
+ export type FileTarget = {
26
+ readonly path: string;
27
+ readonly sha256: string;
28
+ readonly mode: number;
29
+ readonly uid?: number;
30
+ readonly gid?: number;
31
+ };
32
+
33
+ /** A family's refusal: `<Type> <path>: <message>`, so a deploy log names the resource that said no. */
34
+ export type Refuse = (path: string, message: string) => Error;
35
+
36
+ const numericId = (value: string | number): number | undefined =>
37
+ typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
38
+
39
+ /** Resolve a declared owner and group (names or ids) to ids through the runner. */
40
+ export const resolveIds = async (
41
+ runner: HostRunner,
42
+ declared: { readonly owner?: string | number; readonly group?: string | number },
43
+ path: string,
44
+ refuse: Refuse,
45
+ ): Promise<{ uid?: number; gid?: number }> => {
46
+ let uid: number | undefined;
47
+ let gid: number | undefined;
48
+ if (declared.owner !== undefined) {
49
+ uid = numericId(declared.owner) ?? (await runner.lookupUser(String(declared.owner)))?.uid;
50
+ if (uid === undefined) throw refuse(path, `no user ${String(declared.owner)} on this host`);
51
+ }
52
+ if (declared.group !== undefined) {
53
+ gid = numericId(declared.group) ?? (await runner.lookupGroup(String(declared.group)));
54
+ if (gid === undefined) throw refuse(path, `no group ${String(declared.group)} on this host`);
55
+ }
56
+ return { ...(uid === undefined ? {} : { uid }), ...(gid === undefined ? {} : { gid }) };
57
+ };
58
+
59
+ /** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
60
+ export const readFileAttributes = async (
61
+ runner: HostRunner,
62
+ path: string,
63
+ ): Promise<HostFileAttributes | undefined> => {
64
+ const stat = await runner.stat(path);
65
+ if (stat === undefined) return undefined;
66
+ const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
67
+ return {
68
+ gid: stat.gid,
69
+ mode: stat.mode,
70
+ path,
71
+ sha256: bytes === undefined ? '' : sha256Hex(bytes),
72
+ size: stat.size,
73
+ uid: stat.uid,
74
+ };
75
+ };
76
+
77
+ export const writeOptionsOf = (want: FileTarget): WriteOptions => ({
78
+ mode: want.mode,
79
+ ...(want.uid === undefined ? {} : { uid: want.uid }),
80
+ ...(want.gid === undefined ? {} : { gid: want.gid }),
81
+ });
82
+
83
+ export const fileMatches = (live: HostFileAttributes, want: FileTarget): boolean =>
84
+ live.sha256 === want.sha256 &&
85
+ live.mode === want.mode &&
86
+ (want.uid === undefined || live.uid === want.uid) &&
87
+ (want.gid === undefined || live.gid === want.gid);
88
+
89
+ /**
90
+ * ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
91
+ * refusals — a setuid or group-writable root file, a directory under a prefix another user may
92
+ * change — then fail the plan, before any resource is applied, instead of halfway through it.
93
+ */
94
+ export const diffTarget = async (
95
+ runner: HostRunner,
96
+ want: FileTarget,
97
+ output: HostFileAttributes,
98
+ ): Promise<Diff> => {
99
+ const writes = async (diff: Diff): Promise<Diff> => {
100
+ await runner.checkWrite?.(want.path, writeOptionsOf(want));
101
+ return diff;
102
+ };
103
+ // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
104
+ // ⛔ …unless the two paths are ONE file (file-identity.ts). That is a respelling, not a move: a
105
+ // replace would let Phase 2 delete the old generation's path, which is the new one's file. An
106
+ // update records the new spelling, and convergeFile keeps the file.
107
+ if (want.path !== output.path) {
108
+ const same = await oneFile(runner, want.path, output.path);
109
+ return writes({ action: same === true ? 'update' : 'replace' });
110
+ }
111
+ if (want.sha256 !== output.sha256) return writes({ action: 'update' });
112
+ const live = await readFileAttributes(runner, want.path);
113
+ return live !== undefined && fileMatches(live, want)
114
+ ? { action: 'noop' }
115
+ : writes({ action: 'update' });
116
+ };
117
+
118
+ export type Converge = {
119
+ readonly want: FileTarget;
120
+ /** The owner as declared (a name or an id), for the chown refusal's wording. */
121
+ readonly owner?: string | number | undefined;
122
+ /** The bytes to write, asked for only when a write is due; `before` is what is there now. */
123
+ readonly bytesFor: (before: HostFileAttributes | undefined) => Promise<Uint8Array>;
124
+ readonly refuse: Refuse;
125
+ };
126
+
127
+ /**
128
+ * Make the file at `want.path` match `want`, and return it as read back.
129
+ *
130
+ * `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
131
+ * own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
132
+ * path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
133
+ * path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
134
+ * below), and a fresh replace's new generation, which the caller never marks adoptable (its
135
+ * `output` is undefined too, so this function cannot tell it from a create by itself).
136
+ */
137
+ export const convergeFile = async (
138
+ runner: HostRunner,
139
+ spec: Converge,
140
+ output?: HostFileAttributes,
141
+ adopt = false,
142
+ ): Promise<HostFileAttributes> => {
143
+ const { want, refuse } = spec;
144
+ const path = want.path;
145
+ // ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
146
+ if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
147
+ throw refuse(
148
+ path,
149
+ `owner ${String(spec.owner)} is not the deploying user; only root may chown. Deploy as root ` +
150
+ 'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
151
+ 'provider never calls sudo itself.',
152
+ );
153
+ }
154
+ const stat = await runner.stat(path);
155
+ if (stat !== undefined && stat.kind !== 'file') {
156
+ throw refuse(
157
+ path,
158
+ `is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
159
+ );
160
+ }
161
+ const before = stat === undefined ? undefined : await readFileAttributes(runner, path);
162
+ // ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
163
+ // when diff could not see the new path (host-file.ts). Finish it the way a replace would.
164
+ // ★ A respelling of ONE file (file-identity.ts) still counts as moved here, so a file there
165
+ // that does not match is refused below — never rewritten in place under the old spelling's pins.
166
+ const moved = output !== undefined && output.path !== path;
167
+ const prior = moved ? undefined : output;
168
+ /**
169
+ * ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
170
+ * the engine's adoption probe has already refused anything it found — except where it never
171
+ * looked: the new path of a replace, and a create whose props still held an Output at plan
172
+ * time. A path typo there would otherwise overwrite, say, a system file that a fresh
173
+ * declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
174
+ * create takes it over, as the probe would have let it (decided 2026-09-21).
175
+ */
176
+ const takeOver = adopt && output === undefined;
177
+ if (prior === undefined && before !== undefined && !fileMatches(before, want) && !takeOver) {
178
+ throw refuse(
179
+ path,
180
+ 'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
181
+ 'deploy with --adopt.',
182
+ );
183
+ }
184
+ if (before === undefined || !fileMatches(before, want)) {
185
+ const bytes = await spec.bytesFor(before);
186
+ // ⛔ THE ONE WRITER NEVER WRITES BYTES THAT ARE NOT THE DECLARED ONES. Host.File's always are;
187
+ // a supplier that verifies elsewhere (a download) is held to the same digest here, last.
188
+ if (sha256Hex(bytes) !== want.sha256) {
189
+ throw refuse(path, `the bytes to write do not hash to ${want.sha256}. Nothing was written.`);
190
+ }
191
+ await runner.writeFileAtomic(path, bytes, writeOptionsOf(want));
192
+ }
193
+ // ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
194
+ // up here as a refusal instead of as a forever-`update`.
195
+ // ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no state,
196
+ // reports it `Unowned`, and every later deploy demands --adopt for our own file. ⛔ Also when
197
+ // the read-back THROWS: MEASURED 2026-09-22, a 0111 binary written as a non-root operator read
198
+ // back EACCES and stayed, and every later plan's probe failed with the same EACCES.
199
+ const rollBack = async () => {
200
+ if (before === undefined) await runner.removeFile(path).catch(() => undefined);
201
+ };
202
+ const after = await readFileAttributes(runner, path).catch(async (cause: unknown) => {
203
+ await rollBack();
204
+ throw refuse(path, `the write returned but reading it back failed: ${String(cause)}`);
205
+ });
206
+ if (after === undefined || !fileMatches(after, want)) {
207
+ await rollBack();
208
+ throw refuse(path, 'the write returned but the file on disk does not match the declaration');
209
+ }
210
+ // ★ Create-before-delete, as the replace would have been: the old path goes only once the new
211
+ // one is written and verified — and ⛔ never when the old path IS this file (file-identity.ts).
212
+ if (moved)
213
+ await leaveOldPath(
214
+ runner,
215
+ output.path,
216
+ path,
217
+ () => removeWholeFile(runner, output, refuse),
218
+ refuse,
219
+ );
220
+ return after;
221
+ };
222
+
223
+ /** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
224
+ export const removeWholeFile = async (
225
+ runner: HostRunner,
226
+ output: HostFileAttributes,
227
+ refuse: Refuse,
228
+ ): Promise<void> => {
229
+ const stat = await runner.stat(output.path);
230
+ if (stat === undefined) return;
231
+ if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
232
+ await runner.removeFile(output.path);
233
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Whether two paths name ONE file — the question a move must answer before it deletes its old
3
+ * path, and a plan before it answers `replace`.
4
+ *
5
+ * 🔴 MEASURED 2026-09-22 (release/binary-alias.test.ts, localRunner over a real temp directory,
6
+ * through Alchemy's own Plan and Apply): a Release.Binary whose directory was respelled to
7
+ * another path for the SAME file — through a symlinked parent (`/tmp` → `/private/tmp`, `/etc`
8
+ * → `/private/etc`), or by case alone on case-insensitive APFS, macOS's default — planned a
9
+ * `replace`. The new generation took the file already there as its own (same bytes, same mode:
10
+ * file-converge.ts's resume rule), then Phase 2 deleted the old generation's path, which was that
11
+ * same file. The deploy SUCCEEDED and the binary was gone. The `update` route lost it in one step:
12
+ * convergeFile's move removed the old path after "writing" the new one. Host.File shares both.
13
+ * ★ IDENTITY, NOT SPELLING. Case-folding the strings would be wrong on a case-sensitive volume and
14
+ * blind to a symlinked parent; device and inode from lstat are what the kernel itself compares.
15
+ * ⚠️ A HARD LINK also shares an inode. At the old path of a move it is therefore kept, not removed:
16
+ * one stray link left behind, rather than the risk of deleting the file just verified.
17
+ */
18
+ import type { FileStat, HostRunner } from './runner.ts';
19
+
20
+ /**
21
+ * `true` when both entries exist and are one file; `false` when either is absent or they differ;
22
+ * `undefined` when the runner reported no identity, so nobody can tell (fakes, custom runners).
23
+ */
24
+ export const sameEntry = (
25
+ a: FileStat | undefined,
26
+ b: FileStat | undefined,
27
+ ): boolean | undefined => {
28
+ if (a === undefined || b === undefined) return false;
29
+ if (a.dev === undefined || a.ino === undefined || b.dev === undefined || b.ino === undefined) {
30
+ return undefined;
31
+ }
32
+ return a.dev === b.dev && a.ino === b.ino;
33
+ };
34
+
35
+ /** Whether `a` and `b` are one file on this host right now. Equal strings always are. */
36
+ export const oneFile = async (
37
+ runner: HostRunner,
38
+ a: string,
39
+ b: string,
40
+ ): Promise<boolean | undefined> => {
41
+ if (a === b) return true;
42
+ const [left, right] = await Promise.all([runner.stat(a), runner.stat(b)]);
43
+ return sameEntry(left, right);
44
+ };
45
+
46
+ /**
47
+ * The last step of a move: `remove` the old path unless it is the file just verified at `path`.
48
+ * ⚠️ A runner that reports no identity cannot be asked beforehand, so `path` is checked after: a
49
+ * file lost to a respelling then fails the deploy instead of reporting success.
50
+ */
51
+ export const leaveOldPath = async (
52
+ runner: HostRunner,
53
+ oldPath: string,
54
+ path: string,
55
+ remove: () => Promise<void>,
56
+ refuse: (path: string, message: string) => Error,
57
+ ): Promise<void> => {
58
+ const same = await oneFile(runner, oldPath, path);
59
+ if (same === true) return;
60
+ await remove();
61
+ if (same === undefined && (await runner.stat(path)) === undefined) {
62
+ throw refuse(
63
+ path,
64
+ `removing the old path ${oldPath} removed this file too: they are one file under two ` +
65
+ 'spellings (a symlinked parent, or case on a case-insensitive volume). Deploy again to ' +
66
+ 'reinstall it, and keep one spelling.',
67
+ );
68
+ }
69
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Host.File's diff while `content` is still an Output, over the REAL localRunner in a fresh temp
3
+ * directory: a new path is a replace, unless it is the old file under another spelling
4
+ * (file-identity.ts), where a replace's Phase 2 would delete the file the new generation took.
5
+ *
6
+ * 🔴 The respelled case answered `replace` before the fix (2026-09-22). Nothing outside `mkdtemp`
7
+ * is touched; the diff only reads.
8
+ */
9
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
10
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import * as Effect from 'effect/Effect';
14
+ import * as Layer from 'effect/Layer';
15
+ import { HostFile, HostFileProvider } from './host-file.ts';
16
+ import { localRunner } from './local-runner.ts';
17
+ import { hostRunnerLayer } from './runner.ts';
18
+
19
+ let tmp = '';
20
+ beforeEach(async () => {
21
+ tmp = await mkdtemp(join(tmpdir(), 'hf-file-alias-'));
22
+ await mkdir(join(tmp, 'real'));
23
+ await writeFile(join(tmp, 'real', 'a.conf'), 'x');
24
+ await symlink(join(tmp, 'real'), join(tmp, 'link'));
25
+ });
26
+ afterEach(async () => {
27
+ await rm(tmp, { force: true, recursive: true });
28
+ });
29
+
30
+ const diffTo = (path: string) => {
31
+ const output = {
32
+ gid: 0,
33
+ mode: 0o644,
34
+ path: join(tmp, 'real', 'a.conf'),
35
+ sha256: '',
36
+ size: 1,
37
+ uid: 0,
38
+ };
39
+ const news = { content: Effect.succeed('x'), path } as never;
40
+ return Effect.runPromise(
41
+ Effect.gen(function* () {
42
+ const diff = (yield* HostFile.Provider).diff;
43
+ if (diff === undefined) throw new Error('Host.File has no diff');
44
+ return yield* diff({
45
+ bindings: [] as never,
46
+ fqn: 'stack/conf',
47
+ id: 'conf',
48
+ instanceId: 'i-1',
49
+ newBindings: [] as never,
50
+ news,
51
+ oldBindings: [] as never,
52
+ olds: { content: 'x', path: output.path },
53
+ output,
54
+ } as never);
55
+ }).pipe(Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(localRunner()))))),
56
+ );
57
+ };
58
+
59
+ test('another file is still a replace', async () => {
60
+ expect(await diffTo(join(tmp, 'real', 'b.conf'))).toEqual({ action: 'replace' });
61
+ });
62
+
63
+ test('⛔ the same file through a symlinked parent is left to the engine (an update)', async () => {
64
+ expect(await diffTo(join(tmp, 'link', 'a.conf'))).toBeUndefined();
65
+ });
@@ -34,7 +34,8 @@ export interface HostFileAttributes {
34
34
 
35
35
  export const DEFAULT_MODE = 0o644;
36
36
 
37
- const identityProblems = (name: string, value: string | number | undefined): string[] => {
37
+ /** A user or group as declared: a non-negative id, or a name the host could have. */
38
+ export const identityProblems = (name: string, value: string | number | undefined): string[] => {
38
39
  if (value === undefined) return [];
39
40
  if (typeof value === 'number') {
40
41
  return Number.isSafeInteger(value) && value >= 0
@@ -2,12 +2,19 @@
2
2
  * HostFile's read / diff / reconcile / delete as plain async functions over a HostRunner, so the
3
3
  * lifecycle runs against fake-runner.ts in tests.
4
4
  *
5
- * ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED. rename(2) over a symlink replaces the LINK,
6
- * so a path that is a symlink (nix-darwin's /etc entries point into /nix/store) would be quietly
7
- * taken from the tool that owns it — which puts it back, or refuses its next activation. Remove
8
- * the path from its owner first, then declare it here.
5
+ * ★ THE CONVERGENCE ITSELF IS file-converge.ts, shared with every resource that owns one whole file
6
+ * (Release.Binary is the second). What stays here is what makes a HOST FILE: text content, its
7
+ * validation (never a secret — host-file-form.ts), and the refusal wording `Host.File <path>: …`.
8
+ * ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED — see file-converge.ts for why.
9
9
  */
10
10
  import type { Diff } from 'alchemy/Diff';
11
+ import {
12
+ type FileTarget,
13
+ convergeFile,
14
+ diffTarget,
15
+ removeWholeFile,
16
+ resolveIds,
17
+ } from './file-converge.ts';
11
18
  import {
12
19
  DEFAULT_MODE,
13
20
  type HostFileAttributes,
@@ -15,106 +22,38 @@ import {
15
22
  fileProblems,
16
23
  } from './host-file-form.ts';
17
24
  import { sha256Hex } from './job-form.ts';
18
- import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
25
+ import type { HostRunner } from './runner.ts';
19
26
 
20
- type Desired = {
21
- readonly bytes: Uint8Array;
22
- readonly sha256: string;
23
- readonly mode: number;
24
- readonly uid?: number;
25
- readonly gid?: number;
26
- };
27
+ export { readFileAttributes } from './file-converge.ts';
27
28
 
28
- const refuse = (path: string, message: string): Error => new Error(`Host.File ${path}: ${message}`);
29
+ type Desired = FileTarget & { readonly bytes: Uint8Array };
29
30
 
30
- const numericId = (value: string | number): number | undefined =>
31
- typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
31
+ const refuse = (path: string, message: string): Error => new Error(`Host.File ${path}: ${message}`);
32
32
 
33
33
  /** Validate, then resolve owner/group names to ids through the runner. */
34
34
  export const desiredFile = async (runner: HostRunner, props: HostFileProps): Promise<Desired> => {
35
35
  const found = fileProblems(props);
36
36
  if (found.length > 0) throw refuse(props.path, found.join('; '));
37
37
  const bytes = new TextEncoder().encode(props.content);
38
- let uid: number | undefined;
39
- let gid: number | undefined;
40
- if (props.owner !== undefined) {
41
- uid = numericId(props.owner) ?? (await runner.lookupUser(String(props.owner)))?.uid;
42
- if (uid === undefined) throw refuse(props.path, `no user ${String(props.owner)} on this host`);
43
- }
44
- if (props.group !== undefined) {
45
- gid = numericId(props.group) ?? (await runner.lookupGroup(String(props.group)));
46
- if (gid === undefined) throw refuse(props.path, `no group ${String(props.group)} on this host`);
47
- }
48
38
  return {
49
39
  bytes,
50
40
  mode: props.mode ?? DEFAULT_MODE,
41
+ path: props.path,
51
42
  sha256: sha256Hex(bytes),
52
- ...(uid === undefined ? {} : { uid }),
53
- ...(gid === undefined ? {} : { gid }),
54
- };
55
- };
56
-
57
- /** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
58
- export const readFileAttributes = async (
59
- runner: HostRunner,
60
- path: string,
61
- ): Promise<HostFileAttributes | undefined> => {
62
- const stat = await runner.stat(path);
63
- if (stat === undefined) return undefined;
64
- const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
65
- return {
66
- gid: stat.gid,
67
- mode: stat.mode,
68
- path,
69
- sha256: bytes === undefined ? '' : sha256Hex(bytes),
70
- size: stat.size,
71
- uid: stat.uid,
43
+ ...(await resolveIds(runner, props, props.path, refuse)),
72
44
  };
73
45
  };
74
46
 
75
- const writeOptionsOf = (want: Desired): WriteOptions => ({
76
- mode: want.mode,
77
- ...(want.uid === undefined ? {} : { uid: want.uid }),
78
- ...(want.gid === undefined ? {} : { gid: want.gid }),
79
- });
80
-
81
- const matches = (live: HostFileAttributes, want: Desired): boolean =>
82
- live.sha256 === want.sha256 &&
83
- live.mode === want.mode &&
84
- (want.uid === undefined || live.uid === want.uid) &&
85
- (want.gid === undefined || live.gid === want.gid);
86
-
87
- /**
88
- * ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
89
- * refusals — a setuid or group-writable root file, a directory under a prefix another user may
90
- * change — then fail the plan, before any resource is applied, instead of halfway through it.
91
- */
47
+ /** Declared digest, mode and owner against state and the live file — see diffTarget. */
92
48
  export const diffFile = async (
93
49
  runner: HostRunner,
94
50
  news: HostFileProps,
95
51
  output: HostFileAttributes,
96
- ): Promise<Diff> => {
97
- const want = await desiredFile(runner, news);
98
- const writes = async (diff: Diff): Promise<Diff> => {
99
- await runner.checkWrite?.(news.path, writeOptionsOf(want));
100
- return diff;
101
- };
102
- // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
103
- if (news.path !== output.path) return writes({ action: 'replace' });
104
- if (want.sha256 !== output.sha256) return writes({ action: 'update' });
105
- const live = await readFileAttributes(runner, news.path);
106
- return live !== undefined && matches(live, want)
107
- ? { action: 'noop' }
108
- : writes({ action: 'update' });
109
- };
52
+ ): Promise<Diff> => diffTarget(runner, await desiredFile(runner, news), output);
110
53
 
111
54
  /**
112
- * `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
113
- * own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
114
- * path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
115
- * path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
116
- * below), and a fresh replace's new generation, which the caller never marks adoptable (its
117
- * `output` is undefined too, so this function cannot tell it from a create by itself).
55
+ * Write the declared text, unless the file already matches. `adopt` is adoptsAtApply's answer;
56
+ * what it may and may not take over is convergeFile's ⛔.
118
57
  */
119
58
  export const reconcileFile = async (
120
59
  runner: HostRunner,
@@ -123,68 +62,10 @@ export const reconcileFile = async (
123
62
  adopt = false,
124
63
  ): Promise<HostFileAttributes> => {
125
64
  const want = await desiredFile(runner, props);
126
- // ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
127
- if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
128
- throw refuse(
129
- props.path,
130
- `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
131
- 'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
132
- 'provider never calls sudo itself.',
133
- );
134
- }
135
- const stat = await runner.stat(props.path);
136
- if (stat !== undefined && stat.kind !== 'file') {
137
- throw refuse(
138
- props.path,
139
- `is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
140
- );
141
- }
142
- const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
143
- // ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
144
- // when diff could not see the new path (host-file.ts). Finish it the way a replace would.
145
- const moved = output !== undefined && output.path !== props.path;
146
- const prior = moved ? undefined : output;
147
- /**
148
- * ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
149
- * the engine's adoption probe has already refused anything it found — except where it never
150
- * looked: the new path of a replace, and a create whose props still held an Output at plan
151
- * time. A path typo there would otherwise overwrite, say, a system file that a fresh
152
- * declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
153
- * create takes it over, as the probe would have let it (decided 2026-09-21).
154
- */
155
- const takeOver = adopt && output === undefined;
156
- if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
157
- throw refuse(
158
- props.path,
159
- 'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
160
- 'deploy with --adopt.',
161
- );
162
- }
163
- if (before === undefined || !matches(before, want)) {
164
- await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
165
- }
166
- // ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
167
- // up here as a refusal instead of as a forever-`update`.
168
- const after = await readFileAttributes(runner, props.path);
169
- if (after === undefined || !matches(after, want)) {
170
- // ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no
171
- // state, reports it `Unowned`, and every later deploy demands --adopt for our own file.
172
- if (before === undefined) await runner.removeFile(props.path).catch(() => undefined);
173
- throw refuse(
174
- props.path,
175
- 'the write returned but the file on disk does not match the declaration',
176
- );
177
- }
178
- // ★ Create-before-delete, as the replace would have been: the old path goes only once the new
179
- // one is written and verified.
180
- if (moved) await deleteFile(runner, output);
181
- return after;
65
+ const spec = { bytesFor: async () => want.bytes, owner: props.owner, refuse, want };
66
+ return convergeFile(runner, spec, output, adopt);
182
67
  };
183
68
 
184
69
  /** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
185
- export const deleteFile = async (runner: HostRunner, output: HostFileAttributes): Promise<void> => {
186
- const stat = await runner.stat(output.path);
187
- if (stat === undefined) return;
188
- if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
189
- await runner.removeFile(output.path);
190
- };
70
+ export const deleteFile = (runner: HostRunner, output: HostFileAttributes): Promise<void> =>
71
+ removeWholeFile(runner, output, refuse);
@@ -21,6 +21,7 @@ import * as Provider from 'alchemy/Provider';
21
21
  import * as Effect from 'effect/Effect';
22
22
  import { adoptsAtApply } from '../ownership/adopt.ts';
23
23
  import { noteUnfinished } from '../ownership/resume.ts';
24
+ import { oneFile } from './file-identity.ts';
24
25
  import { lift, resolvedString } from './host-effect.ts';
25
26
  import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
26
27
  import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
@@ -54,9 +55,14 @@ export const HostFileProvider = () =>
54
55
  // ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
55
56
  // the engine's default would be an update, which writes the new path and never
56
57
  // removes the old one. reconcileFile also defends, for a path that is an Output.
58
+ // ⛔ Unless the new path is the old FILE under another spelling (file-identity.ts):
59
+ // a replace's Phase 2 would delete it. The update then records the new spelling.
57
60
  const path = resolvedString(news, 'path');
58
- return Effect.succeed(
59
- path !== undefined && path !== output.path ? { action: 'replace' as const } : undefined,
61
+ if (path === undefined || path === output.path) return Effect.succeed(undefined);
62
+ return lift(async () =>
63
+ (await oneFile(runner, path, output.path)) === true
64
+ ? undefined
65
+ : { action: 'replace' as const },
60
66
  );
61
67
  },
62
68
 
@@ -3,7 +3,7 @@
3
3
  * outside `mkdtemp`; the only programs it spawns are echo/false and a read-only user lookup.
4
4
  */
5
5
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
6
- import { mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
6
+ import { mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { localRunner } from './local-runner.ts';
@@ -80,6 +80,17 @@ describe('read, stat, remove', () => {
80
80
  expect((await runner.stat(dir))?.kind).toBe('directory');
81
81
  });
82
82
 
83
+ test('stat carries identity: one file under two spellings has one dev and ino', async () => {
84
+ await mkdir(join(dir, 'real'));
85
+ await writeFile(join(dir, 'real', 'f'), 'x');
86
+ await symlink(join(dir, 'real'), join(dir, 'link'));
87
+ const [a, b] = await Promise.all(
88
+ [join(dir, 'real', 'f'), join(dir, 'link', 'f')].map((p) => runner.stat(p)),
89
+ );
90
+ expect(a?.ino).toBeNumber();
91
+ expect([b?.dev, b?.ino]).toEqual([a?.dev, a?.ino]);
92
+ });
93
+
83
94
  test('readFile returns the bytes; removeFile is idempotent', async () => {
84
95
  await writeFile(join(dir, 'f'), 'hello');
85
96
  expect(new TextDecoder().decode(await runner.readFile(join(dir, 'f')))).toBe('hello');
@@ -130,7 +130,9 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
130
130
  try {
131
131
  const stats = await lstat(path);
132
132
  return {
133
+ dev: stats.dev,
133
134
  gid: stats.gid,
135
+ ino: stats.ino,
134
136
  kind: kindOf(stats),
135
137
  mode: stats.mode & 0o7777,
136
138
  size: stats.size,