@homeflare/alchemy 0.20.0 → 0.22.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 (191) hide show
  1. package/README.md +27 -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.d.ts +2 -0
  24. package/dist/launchd/index.d.ts.map +1 -1
  25. package/dist/launchd/index.js +225 -120
  26. package/dist/launchd/index.js.map +13 -10
  27. package/dist/launchd/local-runner.d.ts.map +1 -1
  28. package/dist/launchd/port-claims.d.ts +31 -0
  29. package/dist/launchd/port-claims.d.ts.map +1 -0
  30. package/dist/launchd/runner.d.ts +17 -2
  31. package/dist/launchd/runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-runner.d.ts +11 -0
  33. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  34. package/dist/linux/index.js.map +3 -3
  35. package/dist/linux/sudo-listing.d.ts +47 -0
  36. package/dist/linux/sudo-listing.d.ts.map +1 -0
  37. package/dist/proxmox/credential-literals.d.ts +1 -1
  38. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  40. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  41. package/dist/proxmox/generated/pbs.d.ts +7 -0
  42. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  43. package/dist/proxmox/generated/pve.d.ts +7 -0
  44. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  45. package/dist/proxmox/index.d.ts +1 -1
  46. package/dist/proxmox/index.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +2 -2
  48. package/dist/proxmox/index.js.map +9 -9
  49. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  50. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  52. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  53. package/dist/release/archive.d.ts +11 -0
  54. package/dist/release/archive.d.ts.map +1 -0
  55. package/dist/release/binary-claim.d.ts +53 -0
  56. package/dist/release/binary-claim.d.ts.map +1 -0
  57. package/dist/release/binary-diff.d.ts +38 -0
  58. package/dist/release/binary-diff.d.ts.map +1 -0
  59. package/dist/release/binary-form.d.ts +118 -0
  60. package/dist/release/binary-form.d.ts.map +1 -0
  61. package/dist/release/binary-lifecycle.d.ts +55 -0
  62. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  63. package/dist/release/binary-read.d.ts +11 -0
  64. package/dist/release/binary-read.d.ts.map +1 -0
  65. package/dist/release/binary.d.ts +68 -0
  66. package/dist/release/binary.d.ts.map +1 -0
  67. package/dist/release/catalog.d.ts +113 -0
  68. package/dist/release/catalog.d.ts.map +1 -0
  69. package/dist/release/checksums.d.ts +22 -0
  70. package/dist/release/checksums.d.ts.map +1 -0
  71. package/dist/release/declared-pins.d.ts +4 -0
  72. package/dist/release/declared-pins.d.ts.map +1 -0
  73. package/dist/release/download.d.ts +44 -0
  74. package/dist/release/download.d.ts.map +1 -0
  75. package/dist/release/fake-deploy.d.ts +48 -0
  76. package/dist/release/fake-deploy.d.ts.map +1 -0
  77. package/dist/release/fake-engine.d.ts +8 -0
  78. package/dist/release/fake-engine.d.ts.map +1 -0
  79. package/dist/release/fake-install.d.ts +39 -0
  80. package/dist/release/fake-install.d.ts.map +1 -0
  81. package/dist/release/fake-provider.d.ts +58 -0
  82. package/dist/release/fake-provider.d.ts.map +1 -0
  83. package/dist/release/fake-release.d.ts +95 -0
  84. package/dist/release/fake-release.d.ts.map +1 -0
  85. package/dist/release/index.d.ts +25 -0
  86. package/dist/release/index.d.ts.map +1 -0
  87. package/dist/release/index.js +1425 -0
  88. package/dist/release/index.js.map +36 -0
  89. package/dist/release/openbao.d.ts +35 -0
  90. package/dist/release/openbao.d.ts.map +1 -0
  91. package/dist/release/providers.d.ts +19 -0
  92. package/dist/release/providers.d.ts.map +1 -0
  93. package/dist/release/refused.d.ts +39 -0
  94. package/dist/release/refused.d.ts.map +1 -0
  95. package/dist/release/tar.d.ts +14 -0
  96. package/dist/release/tar.d.ts.map +1 -0
  97. package/dist/release/victoria.d.ts +36 -0
  98. package/dist/release/victoria.d.ts.map +1 -0
  99. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  100. package/docs/launchd-ports.md +109 -0
  101. package/docs/launchd.md +5 -0
  102. package/docs/release-binary-catalogs.md +138 -0
  103. package/docs/release-binary-measured.md +163 -0
  104. package/docs/release-binary-openbao.md +151 -0
  105. package/docs/release-binary-refusals.md +57 -0
  106. package/docs/release-binary-upstream.md +163 -0
  107. package/docs/release-binary.md +165 -0
  108. package/package.json +5 -1
  109. package/src/caddy/access-forward-auth.test.ts +63 -0
  110. package/src/caddy/access-forward-auth.ts +120 -0
  111. package/src/caddy/index.ts +10 -0
  112. package/src/caddy/providers.test.ts +4 -0
  113. package/src/cloudflare/mcp-server-api.ts +294 -0
  114. package/src/cloudflare/mcp-server-form.ts +219 -0
  115. package/src/index.ts +1 -0
  116. package/src/launchd/file-converge.ts +233 -0
  117. package/src/launchd/file-identity.ts +69 -0
  118. package/src/launchd/host-file-alias.test.ts +65 -0
  119. package/src/launchd/host-file-form.ts +2 -1
  120. package/src/launchd/host-file-lifecycle.ts +25 -144
  121. package/src/launchd/host-file.ts +8 -2
  122. package/src/launchd/index.ts +2 -0
  123. package/src/launchd/local-runner.test.ts +12 -1
  124. package/src/launchd/local-runner.ts +2 -0
  125. package/src/launchd/port-claims.test.ts +124 -0
  126. package/src/launchd/port-claims.ts +102 -0
  127. package/src/launchd/providers.test.ts +3 -0
  128. package/src/launchd/runner.ts +17 -2
  129. package/src/launchd/sudo-runner.ts +11 -0
  130. package/src/linux/sudo-listing.ts +122 -0
  131. package/src/proxmox/credential-literals.ts +1 -1
  132. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  133. package/src/proxmox/generated/pbs.ts +7 -0
  134. package/src/proxmox/generated/pve.ts +7 -0
  135. package/src/proxmox/index.ts +1 -1
  136. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  137. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  138. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  139. package/src/proxmox/pbs-notification-target.ts +1 -1
  140. package/src/release/adopt-parity.test.ts +106 -0
  141. package/src/release/apply-pins.test.ts +128 -0
  142. package/src/release/archive.ts +94 -0
  143. package/src/release/binary-alias.test.ts +109 -0
  144. package/src/release/binary-claim.ts +80 -0
  145. package/src/release/binary-claims.test.ts +47 -0
  146. package/src/release/binary-diff.ts +73 -0
  147. package/src/release/binary-form.ts +229 -0
  148. package/src/release/binary-lifecycle.test.ts +90 -0
  149. package/src/release/binary-lifecycle.ts +202 -0
  150. package/src/release/binary-read.ts +55 -0
  151. package/src/release/binary-refusals.test.ts +192 -0
  152. package/src/release/binary-sudo.test.ts +93 -0
  153. package/src/release/binary.ts +181 -0
  154. package/src/release/catalog.test.ts +167 -0
  155. package/src/release/catalog.ts +238 -0
  156. package/src/release/checksums.test.ts +35 -0
  157. package/src/release/checksums.ts +43 -0
  158. package/src/release/declared-pins.ts +39 -0
  159. package/src/release/download.test.ts +138 -0
  160. package/src/release/download.ts +110 -0
  161. package/src/release/error-channel.test.ts +69 -0
  162. package/src/release/fake-deploy.ts +87 -0
  163. package/src/release/fake-engine.ts +50 -0
  164. package/src/release/fake-install.ts +59 -0
  165. package/src/release/fake-provider.ts +68 -0
  166. package/src/release/fake-release.ts +185 -0
  167. package/src/release/fixtures/openbao/checksums.txt +83 -0
  168. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  169. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  170. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  171. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  172. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  173. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  174. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  175. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  176. package/src/release/index.ts +44 -0
  177. package/src/release/openbao.test.ts +120 -0
  178. package/src/release/openbao.ts +86 -0
  179. package/src/release/plan.test.ts +189 -0
  180. package/src/release/provider-refusals.test.ts +48 -0
  181. package/src/release/providers.test.ts +175 -0
  182. package/src/release/providers.ts +24 -0
  183. package/src/release/recovery-read.test.ts +44 -0
  184. package/src/release/refused.ts +40 -0
  185. package/src/release/tar.test.ts +142 -0
  186. package/src/release/tar.ts +195 -0
  187. package/src/release/victoria.test.ts +108 -0
  188. package/src/release/victoria.ts +154 -0
  189. package/src/{proxmox → secrets}/write-only.ts +6 -0
  190. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  191. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Release.Binary's props, attributes and validation — the pure half of the provider.
3
+ *
4
+ * ★ THE PINNED ARCHIVE IS A PROP, NOT A LOOKUP. Repository, tag, exact asset name, size, the
5
+ * archive's SHA-256, the member and the member's SHA-256 all arrive as props — usually spread from
6
+ * a vendor data set (victoria.ts) by `catalogBinary()`. So the resource knows no vendor at all: a
7
+ * second vendor is a second data module, never a change here, and the resource is the shape
8
+ * Alchemy could accept (docs/release-binary-upstream.md).
9
+ * ⛔ NOTHING ESTATE-SPECIFIC IS A CONSTANT HERE. The install directory, owner, group and mode are
10
+ * the consuming stack's props. A stack that keeps binaries under `/opt/<estate>/obs/bin` says so
11
+ * in its own code.
12
+ * ⛔ EVERY PIN IS REQUIRED, AND WELL-FORMED OR REFUSED. There is no "latest", no optional digest and
13
+ * no fallback to a checksum file fetched at apply — see victoria.ts for why a pin beats a fetch.
14
+ */
15
+ import type { HostFileAttributes } from '../launchd/host-file-form.ts';
16
+ import { identityProblems, pathProblems } from '../launchd/host-file-form.ts';
17
+
18
+ /** One release archive, pinned: where it is published, and the bytes it must be. */
19
+ export interface ReleaseArchive {
20
+ /** The GitHub repository that publishes it, `owner/name`: `'VictoriaMetrics/VictoriaMetrics'`. */
21
+ readonly repo: string;
22
+ /** The release tag, exactly as published: `'v1.151.0'`. */
23
+ readonly tag: string;
24
+ /**
25
+ * The asset's file name, whole: `'vmutils-darwin-arm64-v1.151.0.tar.gz'`. ⛔ Never a prefix or a
26
+ * pattern — `-enterprise` and `-cluster` siblings share every prefix with it.
27
+ */
28
+ readonly asset: string;
29
+ /** Bytes, per the release API. A download that runs past this is cut off and refused. */
30
+ readonly size: number;
31
+ /** The archive's SHA-256, lower-case hex, as the vendor's checksum file lists it. */
32
+ readonly sha256: string;
33
+ }
34
+
35
+ export interface ReleaseBinaryProps {
36
+ /** The pinned archive the binary comes out of. `catalogBinary()` fills it from a data set. */
37
+ archive: ReleaseArchive;
38
+ /** The archive member to extract, by its exact name: `'vmalert-prod'`. Nothing else is unpacked. */
39
+ member: string;
40
+ /** The member's own SHA-256 — what the installed file must hash to. */
41
+ sha256: string;
42
+ /**
43
+ * The installed file name: `'vmalert'`. ★ Separate from `member` on purpose — VictoriaMetrics
44
+ * names every member `<binary>-prod`, and a job's argv[0] should not carry that.
45
+ */
46
+ name: string;
47
+ /**
48
+ * The directory the binary is written into. ⛔ It must already exist — declare it with
49
+ * `HostDirectory` and pass that resource's `path`, which also orders it first. ★ Give each
50
+ * version its own directory (`catalogDirectory()`): an upgrade is then a new path — a
51
+ * create-before-delete replace, never an overwrite under a running daemon — and a rollback is a
52
+ * path. A new pin at the SAME path is refused (binary-diff.ts).
53
+ */
54
+ directory: string;
55
+ /**
56
+ * Permission bits. ⛔ Owner-readable and -executable, never group- or world-writable, never
57
+ * setuid, setgid or sticky — a writable binary is whoever-can-write's code, run as whoever runs
58
+ * the daemon. Its directory must not be group- or world-writable either (binary-lifecycle.ts).
59
+ * @default 0o755
60
+ */
61
+ mode?: number;
62
+ /** User name or numeric uid. Omitted: whoever the runner writes as. */
63
+ owner?: string | number;
64
+ /** Group name or numeric gid. Omitted: the directory's default group. */
65
+ group?: string | number;
66
+ }
67
+
68
+ export interface ReleaseBinaryAttributes extends HostFileAttributes {
69
+ /** The archive the bytes came from, whole. */
70
+ url: string;
71
+ /** The archive member installed, by its exact name: `vmalert-prod`. */
72
+ member: string;
73
+ }
74
+
75
+ /** Everything a download and its two checks need: one archive, one member, both digests. */
76
+ export type PinnedDownload = {
77
+ readonly url: string;
78
+ readonly size: number;
79
+ /** The archive's pinned SHA-256. */
80
+ readonly sha256: string;
81
+ /** The archive member to extract, by exact name. */
82
+ readonly member: string;
83
+ /** The member's pinned SHA-256. */
84
+ readonly memberSha256: string;
85
+ };
86
+
87
+ export const DEFAULT_BINARY_MODE = 0o755;
88
+
89
+ /**
90
+ * The release-asset URL. ★ GITHUB'S DOWNLOAD PATH, NOT THE API: it needs no token and spends no rate
91
+ * limit (it redirects to a signed CDN URL — measured 2026-09-22 with HEAD). Every consumer the
92
+ * estate census found publishes on GitHub except Grafana (grafana.com), which is not a
93
+ * single-binary consumer anyway (docs/release-binary-catalogs.md). A second source kind arrives
94
+ * with its first consumer, not before.
95
+ */
96
+ export const releaseUrl = (repo: string, tag: string, asset: string): string =>
97
+ `https://github.com/${repo}/releases/download/${tag}/${asset}`;
98
+
99
+ /** Where a declaration puts its binary: `<directory>/<name>`. */
100
+ export const releaseBinaryPath = (props: {
101
+ readonly directory: string;
102
+ readonly name: string;
103
+ }): string => `${props.directory}/${props.name}`;
104
+
105
+ /** The download a declaration means. ⚠️ Validate first (binaryProblems): this trusts its input. */
106
+ export const pinnedDownload = (props: ReleaseBinaryProps): PinnedDownload => ({
107
+ member: props.member,
108
+ memberSha256: props.sha256,
109
+ sha256: props.archive.sha256,
110
+ size: props.archive.size,
111
+ url: releaseUrl(props.archive.repo, props.archive.tag, props.archive.asset),
112
+ });
113
+
114
+ export const modeProblems = (mode: number): string[] => {
115
+ if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) return ['mode must be 0–0o7777'];
116
+ const found: string[] = [];
117
+ if ((mode & 0o7000) !== 0) found.push('mode must not set setuid, setgid or sticky');
118
+ if ((mode & 0o022) !== 0) found.push('mode must not be group- or world-writable');
119
+ if ((mode & 0o100) === 0) found.push('mode must let the owner execute it');
120
+ // ⛔ AND READ IT: every plan re-hashes the binary, and a write is read back. MEASURED 2026-09-22,
121
+ // an execute-only 0111 written by a non-root operator failed its read-back with EACCES.
122
+ if ((mode & 0o400) === 0) found.push('mode must let the owner read it (it is re-hashed)');
123
+ return found;
124
+ };
125
+
126
+ const HEX64 = /^[0-9a-f]{64}$/;
127
+ export const MAX_ARCHIVE = 1 << 30;
128
+ // ⛔ Strict on purpose: each is written into a URL or a path. GitHub's own owner and name alphabet.
129
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
130
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
131
+ // ★ ONLY WHAT THE READER HANDLES: a gzipped tar. A `.zip` or a bare binary is a new capability
132
+ // with its own walk-down, not a name this should accept and then fail to unpack.
133
+ const TARBALL = /\.(tar\.gz|tgz)$/;
134
+
135
+ /** A single path segment that cannot climb, hide or name the directory itself. */
136
+ const segmentProblem = (what: string, value: string): string | undefined =>
137
+ !SEGMENT.test(value) || value === '.' || value === '..'
138
+ ? `${what} must be one path segment of letters, digits, '.', '_' or '-': ${JSON.stringify(value)}`
139
+ : undefined;
140
+
141
+ /** Everything wrong with the pins alone — plain strings in any declaration, resolved or not. */
142
+ export const pinProblems = (props: {
143
+ readonly archive?: Partial<ReleaseArchive>;
144
+ readonly member?: unknown;
145
+ readonly sha256?: unknown;
146
+ readonly name?: unknown;
147
+ }): string[] => {
148
+ const archive = props.archive ?? {};
149
+ const found: string[] = [];
150
+ const text = (what: string, value: unknown): string | undefined => {
151
+ if (typeof value === 'string') return value;
152
+ found.push(`${what} is required, as a plain string`);
153
+ return undefined;
154
+ };
155
+ const repo = text('archive.repo', archive.repo);
156
+ // ⚠️ `owner/..` passes the alphabet, and a URL parser normalises it away: the request would go
157
+ // to `github.com/releases/…`, somewhere nobody pinned. Refused by name.
158
+ const repoName = repo?.slice(repo.indexOf('/') + 1);
159
+ if (repo !== undefined && (!REPO.test(repo) || repoName === '.' || repoName === '..'))
160
+ found.push(`archive.repo must be "owner/name": ${JSON.stringify(repo)}`);
161
+ for (const [what, value] of [
162
+ ['archive.tag', archive.tag],
163
+ ['name', props.name],
164
+ ] as const) {
165
+ const given = text(what, value);
166
+ const problem = given === undefined ? undefined : segmentProblem(what, given);
167
+ if (problem !== undefined) found.push(problem);
168
+ }
169
+ const asset = text('archive.asset', archive.asset);
170
+ if (asset !== undefined) {
171
+ const problem = segmentProblem('archive.asset', asset);
172
+ if (problem !== undefined) found.push(problem);
173
+ else if (!TARBALL.test(asset))
174
+ found.push(`archive.asset must be a .tar.gz or .tgz: "${asset}"`);
175
+ }
176
+ // ⛔ THE SIZE IS ALLOCATED UP FRONT (download.ts fills one buffer of exactly this many bytes), so
177
+ // a typo'd size is a memory bomb in the deploying process. vmutils, the largest archive pinned,
178
+ // is 123.6 MB; a gibibyte is a ceiling nothing single-binary should reach.
179
+ const size = archive.size;
180
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ARCHIVE) {
181
+ found.push(`archive.size must be the asset's byte count, 1 to ${String(MAX_ARCHIVE)}`);
182
+ }
183
+ for (const [what, value] of [
184
+ ['archive.sha256', archive.sha256],
185
+ ['sha256', props.sha256],
186
+ ] as const) {
187
+ const given = text(what, value);
188
+ if (given !== undefined && !HEX64.test(given))
189
+ found.push(`${what} must be 64 lower-case hex digits`);
190
+ }
191
+ const member = text('member', props.member);
192
+ // ⛔ The tar reader refuses these entries anyway; asking for one is a mistake to name at plan.
193
+ if (
194
+ member !== undefined &&
195
+ (member === '' || member.startsWith('/') || member.split('/').includes('..'))
196
+ ) {
197
+ found.push(`member must be a relative name inside the archive: ${JSON.stringify(member)}`);
198
+ }
199
+ return found;
200
+ };
201
+
202
+ const ARCHIVE_KEYS = ['repo', 'tag', 'asset', 'size', 'sha256'] as const;
203
+
204
+ type Pins = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256'>;
205
+
206
+ /** Whether two declarations pin different bytes: another archive, member or member digest. */
207
+ export const pinsMoved = (olds: Pins, news: Pins): boolean =>
208
+ olds.member !== news.member ||
209
+ olds.sha256 !== news.sha256 ||
210
+ ARCHIVE_KEYS.some((key) => olds.archive[key] !== news.archive[key]);
211
+
212
+ /**
213
+ * ⛔ A NEW PIN IS A NEW PATH. The same path with other bytes would overwrite a binary under the
214
+ * daemon running it, and leave no rollback but a re-download — so it is refused, at plan when the
215
+ * path is resolved (binary-diff.ts) and at apply otherwise (binary-lifecycle.ts).
216
+ */
217
+ export const inPlaceRefusal = (olds: Pins, news: Pins): string =>
218
+ `a new pin (${news.archive.tag} ${news.member} ${news.sha256}) at the path of the installed ` +
219
+ `one (${olds.archive.tag} ${olds.member} ${olds.sha256}) would overwrite it in place; give each ` +
220
+ 'version its own directory (catalogDirectory), so an upgrade is a new path';
221
+
222
+ /** Everything wrong with a declaration, before any host or network is asked anything. */
223
+ export const binaryProblems = (props: ReleaseBinaryProps): string[] => [
224
+ ...pinProblems(props),
225
+ ...pathProblems(props.directory).map((problem) => `directory: ${problem}`),
226
+ ...modeProblems(props.mode ?? DEFAULT_BINARY_MODE),
227
+ ...identityProblems('owner', props.owner),
228
+ ...identityProblems('group', props.group),
229
+ ];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The install lifecycle over a fake host (launchd/fake-runner.ts) and a fake transport — no network,
3
+ * no real filesystem: what an install writes, what a matching host costs (nothing), and read and
4
+ * delete. Every refusal: binary-refusals.test.ts.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { sha256Hex } from '../launchd/job-form.ts';
8
+ import { deleteBinary, readBinary } from './binary-lifecycle.ts';
9
+ import { PATH, setup, writes } from './fake-install.ts';
10
+ import { BINARY, ENTERPRISE_URL, VMUTILS_URL, bytesOf, pathsOn } from './fake-release.ts';
11
+
12
+ describe('installing', () => {
13
+ test('the declared member, from the plain archive only, verified, at <directory>/<name>', async () => {
14
+ const s = setup();
15
+ const notes: string[] = [];
16
+ const attrs = await s.install(s.props(), { note: async (m: string) => void notes.push(m) });
17
+ // ⛔ The enterprise sibling was served too, under its real name; it was never asked for.
18
+ expect(s.transport.requests).toEqual([VMUTILS_URL]);
19
+ expect(s.transport.requests).not.toContain(ENTERPRISE_URL);
20
+ expect(s.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
21
+ expect(attrs).toMatchObject({
22
+ member: 'vmalert-prod',
23
+ mode: 0o755,
24
+ path: PATH,
25
+ url: VMUTILS_URL,
26
+ });
27
+ expect(attrs.sha256).toBe(sha256Hex(BINARY.vmalert));
28
+ expect(notes[0]).toContain('downloading');
29
+ });
30
+
31
+ test('⛔ an undeclared member is never written: vmagent-prod and vmauth-prod stay in the archive', async () => {
32
+ const s = setup();
33
+ await s.install();
34
+ expect(pathsOn(s.fake)).toEqual([PATH]);
35
+ expect(writes(s.fake).map((c) => c[1])).toEqual([PATH]);
36
+ });
37
+
38
+ test('already installed and matching: no download, no write', async () => {
39
+ const s = setup();
40
+ const output = await s.install();
41
+ s.transport.requests.length = 0;
42
+ s.fake.calls.length = 0;
43
+ await s.install(s.props(), { olds: s.props(), output });
44
+ expect([s.transport.requests, writes(s.fake)]).toEqual([[], []]);
45
+ });
46
+
47
+ test('the right bytes with the wrong mode: re-written from disk, not downloaded', async () => {
48
+ const s = setup();
49
+ const output = await s.install();
50
+ s.transport.requests.length = 0;
51
+ const fixed = await s.install(s.props({ mode: 0o555 }), { olds: s.props(), output });
52
+ expect(fixed.mode).toBe(0o555);
53
+ expect(s.transport.requests).toEqual([]);
54
+ });
55
+
56
+ // ⚠️ Only under --adopt (binary-claim.ts); without it, binary-refusals.test.ts refuses the same file.
57
+ test('unprobed, the pinned bytes at the path, under --adopt: kept, not fetched', async () => {
58
+ const s = setup();
59
+ s.fake.files.set(PATH, { bytes: BINARY.vmalert, gid: 0, kind: 'file', mode: 0o755, uid: 0 });
60
+ expect((await s.install(s.props(), { adopt: true })).sha256).toBe(sha256Hex(BINARY.vmalert));
61
+ expect([s.transport.requests, writes(s.fake)]).toEqual([[], []]);
62
+ });
63
+
64
+ test('a new version is a new path: written, then the old one removed', async () => {
65
+ const s = setup();
66
+ const old = { ...(await s.install()), path: '/opt/example/bin/vmutils-1.150.0/vmalert' };
67
+ s.fake.files.set(old.path, {
68
+ bytes: bytesOf('old'),
69
+ gid: 0,
70
+ kind: 'file',
71
+ mode: 0o755,
72
+ uid: 0,
73
+ });
74
+ await s.install(s.props(), { output: old });
75
+ expect(s.fake.files.has(old.path)).toBe(false);
76
+ expect(s.fake.files.has(PATH)).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe('read and delete', () => {
81
+ test('read reports the file at the path by its digest; delete removes it, twice', async () => {
82
+ const s = setup();
83
+ expect(await readBinary(s.fake.runner, s.props())).toBeUndefined();
84
+ const output = await s.install();
85
+ expect((await readBinary(s.fake.runner, s.props()))?.sha256).toBe(output.sha256);
86
+ await deleteBinary(s.fake.runner, output);
87
+ await deleteBinary(s.fake.runner, output);
88
+ expect(s.fake.files.has(PATH)).toBe(false);
89
+ });
90
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Release.Binary's read / reconcile / delete as plain async functions over a HostRunner and a
3
+ * FetchArchive, so the lifecycle runs against fakes of both in tests. (diff: binary-diff.ts.)
4
+ *
5
+ * ⛔ ONE WRITER. The verified bytes reach the host through file-converge.ts — the path Host.File's
6
+ * bytes take — and so through `HostRunner.writeFileAtomic` alone: a temp file in the same
7
+ * directory, mode and owner set, rename. Under sudoRunner that is its staged `install -S`.
8
+ * Nothing here opens a file, spawns `tar`, or stages a download on disk.
9
+ * ★ REFUSE BEFORE TOUCHING ANYTHING. Validation runs before the first host call, the host checks
10
+ * run before the first download, and the digest checks run before the one write — so every
11
+ * refusal but a failed read-back can say "Nothing was written."
12
+ * ★ AN INSTALLED BINARY IS RECOGNISED BY ITS DIGEST. A file whose SHA-256 is the pin needs no
13
+ * download to confirm, and a mode or owner fix re-writes the bytes already on disk.
14
+ */
15
+ import {
16
+ type FileTarget,
17
+ convergeFile,
18
+ readFileAttributes,
19
+ removeWholeFile,
20
+ resolveIds,
21
+ } from '../launchd/file-converge.ts';
22
+ import type { HostFileAttributes } from '../launchd/host-file-form.ts';
23
+ import { sha256Hex } from '../launchd/job-form.ts';
24
+ import type { HostRunner } from '../launchd/runner.ts';
25
+ import { verifiedMember } from './archive.ts';
26
+ import { claimProblem } from './binary-claim.ts';
27
+ import {
28
+ DEFAULT_BINARY_MODE,
29
+ type PinnedDownload,
30
+ type ReleaseBinaryAttributes,
31
+ type ReleaseBinaryProps,
32
+ binaryProblems,
33
+ inPlaceRefusal,
34
+ pinnedDownload,
35
+ pinsMoved,
36
+ releaseBinaryPath,
37
+ } from './binary-form.ts';
38
+ import type { FetchArchive } from './download.ts';
39
+ import { ArchiveRefused, BinaryRefused, ChecksumMismatch, DownloadFailed } from './refused.ts';
40
+
41
+ export const NOTHING = 'Nothing was written.';
42
+
43
+ export const refuse = (path: string, message: string): BinaryRefused =>
44
+ new BinaryRefused({ message: `Release.Binary ${path}: ${message}` });
45
+
46
+ /** Re-say a download or archive failure as this resource's, keeping its tag. */
47
+ const restated = (path: string, cause: unknown): unknown => {
48
+ const say = (message: string) => `Release.Binary ${path}: ${message}. ${NOTHING}`;
49
+ if (cause instanceof ChecksumMismatch) {
50
+ const { actual, expected, subject } = cause;
51
+ return new ChecksumMismatch({ actual, expected, message: say(cause.message), subject });
52
+ }
53
+ if (cause instanceof ArchiveRefused) return new ArchiveRefused({ message: say(cause.message) });
54
+ if (cause instanceof DownloadFailed) {
55
+ return new DownloadFailed({ message: say(cause.message), retryable: cause.retryable });
56
+ }
57
+ return cause;
58
+ };
59
+
60
+ export type Desired = { readonly want: FileTarget; readonly download: PinnedDownload };
61
+
62
+ /** Validate every prop, then resolve owner and group through the runner. */
63
+ export const desiredBinary = async (
64
+ runner: HostRunner,
65
+ props: ReleaseBinaryProps,
66
+ ): Promise<Desired> => {
67
+ const path = releaseBinaryPath(props);
68
+ const problems = binaryProblems(props);
69
+ if (problems.length > 0) throw refuse(path, `${problems.join('; ')}. ${NOTHING}`);
70
+ const ids = await resolveIds(runner, props, path, refuse);
71
+ const mode = props.mode ?? DEFAULT_BINARY_MODE;
72
+ return { download: pinnedDownload(props), want: { mode, path, sha256: props.sha256, ...ids } };
73
+ };
74
+
75
+ const withSource = (
76
+ found: HostFileAttributes,
77
+ download: PinnedDownload,
78
+ ): ReleaseBinaryAttributes => ({ ...found, member: download.member, url: download.url });
79
+
80
+ /**
81
+ * The file at the declaration's path, or undefined when absent — the adoption probe's answer.
82
+ * ★ Its `sha256` is the digest of the bytes on disk, so a caller sees at once whether it is the
83
+ * pinned binary (equal to `props.sha256`) without anything being executed.
84
+ */
85
+ export const readBinary = async (
86
+ runner: HostRunner,
87
+ props: ReleaseBinaryProps,
88
+ ): Promise<ReleaseBinaryAttributes | undefined> => {
89
+ const { download, want } = await desiredBinary(runner, props);
90
+ const found = await readFileAttributes(runner, want.path);
91
+ return found === undefined ? undefined : withSource(found, download);
92
+ };
93
+
94
+ /** An owned binary as it is now, at the path state recorded — no pins needed to find it. */
95
+ export const refreshBinary = async (
96
+ runner: HostRunner,
97
+ output: ReleaseBinaryAttributes,
98
+ ): Promise<ReleaseBinaryAttributes | undefined> => {
99
+ const found = await readFileAttributes(runner, output.path);
100
+ return found === undefined ? undefined : { ...found, member: output.member, url: output.url };
101
+ };
102
+
103
+ /**
104
+ * ⛔ THE DIRECTORY IS DECLARED, NEVER INVENTED. Checked before any download: a missing directory
105
+ * would otherwise cost 123 MB and then fail at the write. A symlink is refused as well — the
106
+ * write would land wherever it points, under an owner nobody declared.
107
+ */
108
+ const directoryReady = async (runner: HostRunner, props: ReleaseBinaryProps): Promise<void> => {
109
+ const stat = await runner.stat(props.directory);
110
+ /**
111
+ * ⛔ AND ONE ONLY ITS OWNER MAY WRITE. The mode rule (binary-form.ts modeProblems) refuses a
112
+ * group- or world-writable binary because its writers could change the code a daemon runs. A
113
+ * directory they may write gives them the same power by another call: rename(2) their own file
114
+ * over the binary, and the next launchd start runs theirs. The sticky bit is no excuse — a
115
+ * binary directory has no business being shared.
116
+ */
117
+ if (stat?.kind === 'directory' && (stat.mode & 0o022) !== 0) {
118
+ throw refuse(
119
+ releaseBinaryPath(props),
120
+ `directory ${props.directory} is writable by group or other (mode ` +
121
+ `${stat.mode.toString(8).padStart(4, '0')}), so whoever may write it may replace the ` +
122
+ `binary; declare it 0755 or narrower. ${NOTHING}`,
123
+ );
124
+ }
125
+ if (stat?.kind === 'directory') return;
126
+ const why = stat === undefined ? 'does not exist' : `is a ${stat.kind}, not a directory`;
127
+ throw refuse(
128
+ releaseBinaryPath(props),
129
+ `directory ${props.directory} ${why}; declare it (HostDirectory) and pass its path. ${NOTHING}`,
130
+ );
131
+ };
132
+
133
+ export type ReconcileOptions = {
134
+ /** The attributes state holds; undefined on a create. */
135
+ readonly output?: ReleaseBinaryAttributes | undefined;
136
+ /** The props state holds; undefined on a create and on an adoption. */
137
+ readonly olds?: ReleaseBinaryProps | undefined;
138
+ /** adoptsAtApply's answer. What it may take over is convergeFile's ⛔. @default false */
139
+ readonly adopt?: boolean;
140
+ /** Progress for the deploy log — the engine's `session.note`. */
141
+ readonly note?: (message: string) => Promise<void>;
142
+ /**
143
+ * What is wrong with the pins as the stack program DECLARED them (declared-pins.ts) — an Output
144
+ * there resolves to a plausible plain string by the time it reaches `props`. @default []
145
+ */
146
+ readonly declared?: readonly string[];
147
+ };
148
+
149
+ /** Install the declared binary unless it is already there, and return it as read back. */
150
+ export const reconcileBinary = async (
151
+ runner: HostRunner,
152
+ fetch: FetchArchive,
153
+ props: ReleaseBinaryProps,
154
+ options: ReconcileOptions = {},
155
+ ): Promise<ReleaseBinaryAttributes> => {
156
+ const { adopt = false, declared = [], note = async () => undefined } = options;
157
+ // ⛔ FIRST, before any host call: a digest the deploy computed is a fetch by another name.
158
+ if (declared.length > 0) {
159
+ throw refuse(
160
+ releaseBinaryPath(props),
161
+ `${declared.join('; ')}. A pin is copied into reviewed code, never computed during the ` +
162
+ `deploy (declared-pins.ts). ${NOTHING}`,
163
+ );
164
+ }
165
+ const { download, want } = await desiredBinary(runner, props);
166
+ // ⛔ The apply-time half of binary-diff.ts's in-place refusal, for an update whose `olds` say so.
167
+ const olds = options.olds;
168
+ if (olds !== undefined && releaseBinaryPath(olds) === want.path && pinsMoved(olds, props)) {
169
+ throw refuse(want.path, `${inPlaceRefusal(olds, props)}. ${NOTHING}`);
170
+ }
171
+ await directoryReady(runner, props);
172
+ // ⛔ WITH NO STATE, ONLY THE PINNED BINARY UNDER --adopt (binary-claim.ts).
173
+ // ⚠️ NOT an update across a move (`output` at another path): --adopt can never speak for one, so
174
+ // refusing identical bytes there would strand the resume of our own interrupted move with no
175
+ // remedy but a hand removal. Other bytes there stay refused (file-converge.ts).
176
+ if (options.output === undefined) {
177
+ const problem = await claimProblem(runner, want, adopt);
178
+ if (problem !== undefined) throw refuse(want.path, `${problem}. ${NOTHING}`);
179
+ }
180
+ const bytesFor = async (before: HostFileAttributes | undefined): Promise<Uint8Array> => {
181
+ // ★ The right bytes with the wrong mode or owner: re-write what is there, re-hashed, rather
182
+ // than download 123 MB to get the same bytes back.
183
+ if (before?.sha256 === want.sha256) {
184
+ const local = await runner.readFile(want.path);
185
+ if (local !== undefined && sha256Hex(local) === want.sha256) return local;
186
+ }
187
+ await note(`downloading ${download.url} (${String(download.size)} bytes)`);
188
+ try {
189
+ const bytes = await verifiedMember(await fetch(download.url, download.size), download);
190
+ await note(`verified ${download.member} ${download.memberSha256}`);
191
+ return bytes;
192
+ } catch (cause) {
193
+ throw restated(want.path, cause);
194
+ }
195
+ };
196
+ const spec = { bytesFor, owner: props.owner, refuse, want };
197
+ return withSource(await convergeFile(runner, spec, options.output, adopt), download);
198
+ };
199
+
200
+ /** Remove the binary. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
201
+ export const deleteBinary = (runner: HostRunner, output: ReleaseBinaryAttributes): Promise<void> =>
202
+ removeWholeFile(runner, output, refuse);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Release.Binary's `read` with no attributes. Alchemy asks it twice over (ownership/probe.ts): as
3
+ * the ADOPTION PROBE (no row; `olds` is the declaration, `instanceId` fresh) and as the RECOVERY
4
+ * READ of a row whose create never recorded attributes (`olds` and `instanceId` are the row's —
5
+ * Plan.ts for a `creating` row, Apply.ts for such a row it must delete).
6
+ *
7
+ * ⛔ THE PROBE VALIDATES; THE RECOVERY READ NEVER FAILS ITS CALLER. The probe's `olds` is today's
8
+ * declaration, so a malformed prop fails the plan there, before anything is touched. The recovery
9
+ * read's `olds` is the FAILED deploy's props, and with `directory: dir.path` — the documented
10
+ * usage — a first deploy commits its `creating` row while the directory is still an Output, so
11
+ * the row has no `directory` at all. 🔴 MEASURED 2026-09-22 (adopt-parity.test.ts): after ANY
12
+ * failed first install — a 404, a checksum mismatch, a mode refused at apply — every later plan
13
+ * died with `TypeError: undefined is not an object (evaluating 'path.startsWith')`, the fixed
14
+ * declaration included, and removing the declaration failed too (DestroyError: the orphan's
15
+ * recovery read crashed the same way). The stage was bricked until a hand `alchemy state delete`.
16
+ * ownership/probe.ts states the rule this broke: the recovery read "NEVER FAILS THE CALLER".
17
+ * ★ "NOTHING RECOVERED" IS THE SAFE ANSWER, NOT `Unowned`. The plan then re-drives the create, and
18
+ * reconcile looks at the path itself with every prop resolved: a file there is refused unless
19
+ * `--adopt` (binary-claim.ts), exactly as ownership.md says a create interrupted while a prop was
20
+ * an Output resumes. An orphan's row is dropped and its path left alone — a leak at worst, never
21
+ * a delete of something unproven.
22
+ * ★ THE ROW, NOT THE SHAPE OF `olds`, SAYS WHICH READ THIS IS: a probe's instance id is minted for
23
+ * the probe, so no row holds it (ownership/rows.ts). Without a Stack or State in context a row
24
+ * reads as absent, and this validates as the probe does — the behaviour before this file.
25
+ */
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import * as Effect from 'effect/Effect';
28
+ import { lift } from '../launchd/host-effect.ts';
29
+ import type { HostRunner } from '../launchd/runner.ts';
30
+ import { recordedGeneration } from '../ownership/rows.ts';
31
+ import type { ReleaseBinaryProps } from './binary-form.ts';
32
+ import { readBinary } from './binary-lifecycle.ts';
33
+
34
+ type Ask = { readonly fqn: string; readonly instanceId: string; readonly olds: unknown };
35
+
36
+ /** The file a declaration (or a row) names, as `Unowned` — or undefined when there is none. */
37
+ export const readWithoutState = (runner: HostRunner, ask: Ask) =>
38
+ Effect.gen(function* () {
39
+ const recovering = (yield* recordedGeneration(ask.fqn, ask.instanceId)) !== undefined;
40
+ const read = lift(() => readBinary(runner, ask.olds as ReleaseBinaryProps));
41
+ const found = recovering
42
+ ? yield* read.pipe(
43
+ Effect.catch((cause) =>
44
+ Effect.as(
45
+ Effect.logWarning(
46
+ `${ask.fqn}: the interrupted create's row cannot locate its binary (${cause.message}); ` +
47
+ 'nothing is recovered, and the create is re-driven with the declaration as it is now',
48
+ ),
49
+ undefined,
50
+ ),
51
+ ),
52
+ )
53
+ : yield* read;
54
+ return found === undefined ? undefined : Unowned(found);
55
+ });