@homeflare/alchemy 0.19.1 → 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 (198) 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 +37 -11
  31. package/dist/linux/index.js.map +10 -10
  32. package/dist/linux/region.d.ts +11 -0
  33. package/dist/linux/region.d.ts.map +1 -1
  34. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  35. package/dist/linux/remote-file-plan.d.ts +22 -0
  36. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  37. package/dist/linux/remote-file.d.ts +4 -1
  38. package/dist/linux/remote-file.d.ts.map +1 -1
  39. package/dist/linux/sudo-listing.d.ts +47 -0
  40. package/dist/linux/sudo-listing.d.ts.map +1 -0
  41. package/dist/linux/unit-handlers.d.ts.map +1 -1
  42. package/dist/linux/unit-preflight.d.ts +10 -1
  43. package/dist/linux/unit-preflight.d.ts.map +1 -1
  44. package/dist/proxmox/credential-literals.d.ts +1 -1
  45. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  46. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  47. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  48. package/dist/proxmox/generated/pbs.d.ts +7 -0
  49. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  50. package/dist/proxmox/generated/pve.d.ts +7 -0
  51. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  52. package/dist/proxmox/index.d.ts +16 -4
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +5 -2
  55. package/dist/proxmox/index.js.map +9 -9
  56. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  57. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  59. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  60. package/dist/release/archive.d.ts +11 -0
  61. package/dist/release/archive.d.ts.map +1 -0
  62. package/dist/release/binary-claim.d.ts +53 -0
  63. package/dist/release/binary-claim.d.ts.map +1 -0
  64. package/dist/release/binary-diff.d.ts +38 -0
  65. package/dist/release/binary-diff.d.ts.map +1 -0
  66. package/dist/release/binary-form.d.ts +118 -0
  67. package/dist/release/binary-form.d.ts.map +1 -0
  68. package/dist/release/binary-lifecycle.d.ts +55 -0
  69. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  70. package/dist/release/binary-read.d.ts +11 -0
  71. package/dist/release/binary-read.d.ts.map +1 -0
  72. package/dist/release/binary.d.ts +68 -0
  73. package/dist/release/binary.d.ts.map +1 -0
  74. package/dist/release/catalog.d.ts +76 -0
  75. package/dist/release/catalog.d.ts.map +1 -0
  76. package/dist/release/checksums.d.ts +22 -0
  77. package/dist/release/checksums.d.ts.map +1 -0
  78. package/dist/release/declared-pins.d.ts +4 -0
  79. package/dist/release/declared-pins.d.ts.map +1 -0
  80. package/dist/release/download.d.ts +44 -0
  81. package/dist/release/download.d.ts.map +1 -0
  82. package/dist/release/fake-deploy.d.ts +48 -0
  83. package/dist/release/fake-deploy.d.ts.map +1 -0
  84. package/dist/release/fake-engine.d.ts +8 -0
  85. package/dist/release/fake-engine.d.ts.map +1 -0
  86. package/dist/release/fake-install.d.ts +39 -0
  87. package/dist/release/fake-install.d.ts.map +1 -0
  88. package/dist/release/fake-provider.d.ts +58 -0
  89. package/dist/release/fake-provider.d.ts.map +1 -0
  90. package/dist/release/fake-release.d.ts +95 -0
  91. package/dist/release/fake-release.d.ts.map +1 -0
  92. package/dist/release/index.d.ts +24 -0
  93. package/dist/release/index.d.ts.map +1 -0
  94. package/dist/release/index.js +1365 -0
  95. package/dist/release/index.js.map +35 -0
  96. package/dist/release/providers.d.ts +19 -0
  97. package/dist/release/providers.d.ts.map +1 -0
  98. package/dist/release/refused.d.ts +39 -0
  99. package/dist/release/refused.d.ts.map +1 -0
  100. package/dist/release/tar.d.ts +14 -0
  101. package/dist/release/tar.d.ts.map +1 -0
  102. package/dist/release/victoria.d.ts +36 -0
  103. package/dist/release/victoria.d.ts.map +1 -0
  104. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  105. package/docs/adopted-deploys.md +13 -0
  106. package/docs/linux-host.md +5 -1
  107. package/docs/provider-standard.md +138 -0
  108. package/docs/release-binary-catalogs.md +113 -0
  109. package/docs/release-binary-measured.md +163 -0
  110. package/docs/release-binary-refusals.md +57 -0
  111. package/docs/release-binary-upstream.md +163 -0
  112. package/docs/release-binary.md +154 -0
  113. package/docs/upstream-conformance.md +164 -0
  114. package/package.json +5 -1
  115. package/src/caddy/access-forward-auth.test.ts +63 -0
  116. package/src/caddy/access-forward-auth.ts +120 -0
  117. package/src/caddy/index.ts +10 -0
  118. package/src/caddy/providers.test.ts +4 -0
  119. package/src/cloudflare/mcp-server-api.ts +294 -0
  120. package/src/cloudflare/mcp-server-form.ts +219 -0
  121. package/src/index.ts +1 -0
  122. package/src/launchd/file-converge.ts +233 -0
  123. package/src/launchd/file-identity.ts +69 -0
  124. package/src/launchd/host-file-alias.test.ts +65 -0
  125. package/src/launchd/host-file-form.ts +2 -1
  126. package/src/launchd/host-file-lifecycle.ts +25 -144
  127. package/src/launchd/host-file.ts +8 -2
  128. package/src/launchd/local-runner.test.ts +12 -1
  129. package/src/launchd/local-runner.ts +2 -0
  130. package/src/launchd/runner.ts +17 -2
  131. package/src/launchd/sudo-runner.ts +11 -0
  132. package/src/linux/region.ts +15 -0
  133. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  134. package/src/linux/remote-file-lifecycle.ts +25 -7
  135. package/src/linux/remote-file-plan.ts +37 -1
  136. package/src/linux/remote-file-region.test.ts +181 -0
  137. package/src/linux/remote-file.ts +4 -1
  138. package/src/linux/sudo-listing.ts +122 -0
  139. package/src/linux/unit-handlers.ts +12 -5
  140. package/src/linux/unit-preflight.ts +20 -4
  141. package/src/linux/unit-rename.test.ts +76 -0
  142. package/src/proxmox/ceph-adopt.test.ts +210 -0
  143. package/src/proxmox/credential-literals.ts +1 -1
  144. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  145. package/src/proxmox/generated/pbs.ts +7 -0
  146. package/src/proxmox/generated/pve.ts +7 -0
  147. package/src/proxmox/index.test.ts +7 -4
  148. package/src/proxmox/index.ts +16 -4
  149. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  150. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  151. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  152. package/src/proxmox/pbs-notification-target.ts +1 -1
  153. package/src/release/adopt-parity.test.ts +106 -0
  154. package/src/release/apply-pins.test.ts +128 -0
  155. package/src/release/archive.ts +94 -0
  156. package/src/release/binary-alias.test.ts +109 -0
  157. package/src/release/binary-claim.ts +80 -0
  158. package/src/release/binary-claims.test.ts +47 -0
  159. package/src/release/binary-diff.ts +73 -0
  160. package/src/release/binary-form.ts +229 -0
  161. package/src/release/binary-lifecycle.test.ts +90 -0
  162. package/src/release/binary-lifecycle.ts +202 -0
  163. package/src/release/binary-read.ts +55 -0
  164. package/src/release/binary-refusals.test.ts +192 -0
  165. package/src/release/binary-sudo.test.ts +93 -0
  166. package/src/release/binary.ts +181 -0
  167. package/src/release/catalog.test.ts +96 -0
  168. package/src/release/catalog.ts +175 -0
  169. package/src/release/checksums.test.ts +35 -0
  170. package/src/release/checksums.ts +43 -0
  171. package/src/release/declared-pins.ts +39 -0
  172. package/src/release/download.test.ts +138 -0
  173. package/src/release/download.ts +110 -0
  174. package/src/release/error-channel.test.ts +69 -0
  175. package/src/release/fake-deploy.ts +87 -0
  176. package/src/release/fake-engine.ts +50 -0
  177. package/src/release/fake-install.ts +59 -0
  178. package/src/release/fake-provider.ts +68 -0
  179. package/src/release/fake-release.ts +185 -0
  180. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  181. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  182. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  183. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  184. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  185. package/src/release/index.ts +41 -0
  186. package/src/release/plan.test.ts +189 -0
  187. package/src/release/provider-refusals.test.ts +48 -0
  188. package/src/release/providers.test.ts +174 -0
  189. package/src/release/providers.ts +24 -0
  190. package/src/release/recovery-read.test.ts +44 -0
  191. package/src/release/refused.ts +40 -0
  192. package/src/release/tar.test.ts +142 -0
  193. package/src/release/tar.ts +195 -0
  194. package/src/release/victoria.test.ts +108 -0
  195. package/src/release/victoria.ts +154 -0
  196. package/src/{proxmox → secrets}/write-only.ts +6 -0
  197. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  198. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * A vendor's pinned releases as DATA, and the one way to turn an entry into Release.Binary props —
3
+ * or into the reasons there is none. Pure: no network, no host.
4
+ *
5
+ * ★ THE DATA SITS BESIDE THE RESOURCE, NOT INSIDE IT. A catalog (victoria.ts is the first) is the
6
+ * reviewed record of what one vendor published: exact asset names, sizes, both digest layers and
7
+ * where each was copied from. The resource takes one pinned archive as props and never reads a
8
+ * catalog, so adding a vendor is a data module with its own walk-down, never a resource change.
9
+ * ★ A VERSION THE CATALOG DOES NOT LIST IS A REFUSAL, NOT A DOWNLOAD ATTEMPT. `catalogBinary()` runs
10
+ * in the stack's own program — which `alchemy plan` evaluates — so an unknown version, a
11
+ * `-enterprise` spelling or a `v` prefix fails the plan before any resource is asked anything,
12
+ * let alone fetches a byte.
13
+ * ⛔ ADD A VENDOR ONLY AFTER ITS OWN WALK-DOWN (docs/release-binary-catalogs.md): its checksum
14
+ * format read byte-exact, and its binary's linkage measured (`otool -L`). Vendor binaries were
15
+ * measured self-contained ONLY for the five Victoria ones; that is not a property of "Go".
16
+ */
17
+ import type { ReleaseArchive, ReleaseBinaryProps } from './binary-form.ts';
18
+ import { BinaryRefused } from './refused.ts';
19
+
20
+ /** One release archive as a catalog records it: the pin, plus where every digest came from. */
21
+ export interface PinnedArchive extends ReleaseArchive {
22
+ /** Archive member → its SHA-256. Every member the vendor's checksum file lists, not just ours. */
23
+ readonly members: Readonly<Record<string, string>>;
24
+ /** The checksum file the digests were copied from, its own SHA-256, and when. Never fetched. */
25
+ readonly checksums: { readonly url: string; readonly sha256: string; readonly recorded: string };
26
+ }
27
+
28
+ /** One package (one archive per version and platform) of a vendor. */
29
+ export interface CatalogPackage {
30
+ /**
31
+ * Installed name → archive member, written out, never derived: a member the vendor adds is
32
+ * installable only once somebody names it here.
33
+ */
34
+ readonly binaries: Readonly<Record<string, string>>;
35
+ /** Version (as the catalog spells it) → platform (the vendor's spelling) → archive. */
36
+ readonly versions: Readonly<Record<string, Readonly<Record<string, PinnedArchive>>>>;
37
+ }
38
+
39
+ /** A vendor's pinned releases. */
40
+ export interface ReleaseCatalog {
41
+ /** Who publishes these, for refusals: `'VictoriaMetrics'`. */
42
+ readonly vendor: string;
43
+ readonly packages: Readonly<Record<string, CatalogPackage>>;
44
+ }
45
+
46
+ /** Which binary a stack asks a catalog for. */
47
+ export type CatalogRequest = {
48
+ readonly package: string;
49
+ readonly version: string;
50
+ readonly platform: string;
51
+ /** The installed name — `vmalert`, not the member `vmalert-prod`. */
52
+ readonly binary: string;
53
+ };
54
+
55
+ /** The pinned half of Release.Binary's props; the stack adds `directory`, `mode` and owner. */
56
+ export type PinnedBinary = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256' | 'name'>;
57
+
58
+ const quoted = (names: Iterable<string>): string => [...names].map((n) => `"${n}"`).join(', ');
59
+
60
+ // ★ A plain boolean, not a type guard: on a `Record<string, …>` a guard narrows the miss to `never`.
61
+ const has = (record: object, key: string): boolean => Object.hasOwn(record, key);
62
+
63
+ /** Everything wrong with a request against a catalog; empty when it resolves. */
64
+ export const catalogProblems = (catalog: ReleaseCatalog, request: CatalogRequest): string[] => {
65
+ const entry = has(catalog.packages, request.package)
66
+ ? catalog.packages[request.package]
67
+ : undefined;
68
+ if (entry === undefined) {
69
+ return [
70
+ `${catalog.vendor} package "${request.package}" is not in the catalog (${quoted(Object.keys(catalog.packages))})`,
71
+ ];
72
+ }
73
+ const found: string[] = [];
74
+ if (!has(entry.binaries, request.binary)) {
75
+ // ⚠️ The vendor spelling is the likeliest mistake: the member is `vmalert-prod`, the binary
76
+ // this installs is `vmalert`.
77
+ const member = Object.values(entry.binaries).includes(request.binary);
78
+ const hint = member ? ' (that is the archive member; name the installed binary)' : '';
79
+ found.push(
80
+ `${request.package} has no binary "${request.binary}"${hint}; it has ${quoted(Object.keys(entry.binaries))}`,
81
+ );
82
+ }
83
+ const platforms = has(entry.versions, request.version)
84
+ ? entry.versions[request.version]
85
+ : undefined;
86
+ if (platforms === undefined) {
87
+ found.push(
88
+ `${request.package} ${request.version} is not in the catalog (it pins ${quoted(Object.keys(entry.versions))}); ` +
89
+ 'a new version is a reviewed change that pins its digests, never a download of whatever is there',
90
+ );
91
+ return found;
92
+ }
93
+ const archive = has(platforms, request.platform) ? platforms[request.platform] : undefined;
94
+ if (archive === undefined) {
95
+ found.push(
96
+ `${request.package} ${request.version} is not pinned for platform "${request.platform}" (${quoted(Object.keys(platforms))})`,
97
+ );
98
+ } else if (found.length === 0) {
99
+ const member = entry.binaries[request.binary] ?? '';
100
+ if (!has(archive.members, member)) {
101
+ found.push(
102
+ `${request.package} ${request.version}'s checksum file lists no member "${member}"`,
103
+ );
104
+ }
105
+ }
106
+ return found;
107
+ };
108
+
109
+ /**
110
+ * The pinned props for one binary out of a catalog. ⛔ Throws BinaryRefused for anything the
111
+ * catalog does not pin — in a stack program, that fails the plan before anything is fetched.
112
+ */
113
+ export const catalogBinary = (catalog: ReleaseCatalog, request: CatalogRequest): PinnedBinary => {
114
+ const problems = catalogProblems(catalog, request);
115
+ // ⚠️ Own keys only: `packages.constructor` is Object's, and would read as a package.
116
+ const own = <V>(record: Readonly<Record<string, V>> | undefined, key: string): V | undefined =>
117
+ record !== undefined && has(record, key) ? record[key] : undefined;
118
+ const entry = own(catalog.packages, request.package);
119
+ const archive = own(own(entry?.versions, request.version), request.platform);
120
+ const member = own(entry?.binaries, request.binary);
121
+ const sha256 = member === undefined ? undefined : own(archive?.members, member);
122
+ if (
123
+ problems.length > 0 ||
124
+ archive === undefined ||
125
+ member === undefined ||
126
+ sha256 === undefined
127
+ ) {
128
+ throw new BinaryRefused({
129
+ message: `Release.Binary ${request.binary}: ${problems.join('; ')}. Nothing was fetched.`,
130
+ });
131
+ }
132
+ const { asset, repo, sha256: archiveSha256, size, tag } = archive;
133
+ return {
134
+ archive: { asset, repo, sha256: archiveSha256, size, tag },
135
+ member,
136
+ name: request.binary,
137
+ sha256,
138
+ };
139
+ };
140
+
141
+ /** `<root>/<package>-<version>`: one directory per version, so an upgrade is a new path. */
142
+ export const catalogDirectory = (
143
+ root: string,
144
+ request: Pick<CatalogRequest, 'package' | 'version'>,
145
+ ): string => `${root}/${request.package}-${request.version}`;
146
+
147
+ /** What a catalog says an installed binary is, found by its SHA-256 alone. */
148
+ export type IdentifiedBinary = CatalogRequest & { readonly vendor: string };
149
+
150
+ /**
151
+ * Name an installed file by its digest — never by running it. ★ IDENTITY BY SHA-256: the running
152
+ * Nix victoria-traces reports an empty version (`vm_app_version{version=""}` on /metrics, measured
153
+ * 2026-09-22 — its build sets no buildinfo), a stamped build prints `-version` to STDERR, not
154
+ * stdout (lib/buildinfo, read, not run), and executing a binary to learn what it is runs the very
155
+ * bytes whose provenance is the question. A digest needs a read, nothing more.
156
+ */
157
+ export const identifyBinary = (
158
+ catalogs: readonly ReleaseCatalog[],
159
+ sha256: string,
160
+ ): IdentifiedBinary | undefined => {
161
+ for (const catalog of catalogs) {
162
+ for (const [pkg, entry] of Object.entries(catalog.packages)) {
163
+ for (const [version, platforms] of Object.entries(entry.versions)) {
164
+ for (const [platform, archive] of Object.entries(platforms)) {
165
+ for (const [binary, member] of Object.entries(entry.binaries)) {
166
+ if (archive.members[member] === sha256) {
167
+ return { binary, package: pkg, platform, vendor: catalog.vendor, version };
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return undefined;
175
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The checksum-file parser, held to the one format it was measured on (`sha256sum` text mode,
3
+ * 2026-09-22): anything else is a vendor change to read before a pin moves, so it throws.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { parseChecksums } from './checksums.ts';
7
+
8
+ const line = `${'a'.repeat(64)} vmagent-prod`;
9
+
10
+ describe('parseChecksums is strict', () => {
11
+ test('the measured shape: name → digest, in file order', () => {
12
+ const text = `${'b'.repeat(64)} vmutils.tar.gz\n${line}\n`;
13
+ expect([...parseChecksums(text).entries()]).toEqual([
14
+ ['vmutils.tar.gz', 'b'.repeat(64)],
15
+ ['vmagent-prod', 'a'.repeat(64)],
16
+ ]);
17
+ });
18
+
19
+ test.each([
20
+ ['CRLF endings', `${line}\r\n`],
21
+ ['a binary-mode marker', `${'a'.repeat(64)} *vmagent-prod\n`],
22
+ ['upper-case hex', `${'A'.repeat(64)} vmagent-prod\n`],
23
+ ['a blank line', `${line}\n\n`],
24
+ ['no trailing newline', line],
25
+ ['a name listed twice', `${line}\n${line}\n`],
26
+ ['a path in a name', `${'a'.repeat(64)} bin/vmagent-prod\n`],
27
+ ['an empty file', '\n'],
28
+ ['a tab for the two spaces', `${'a'.repeat(64)}\tvmagent-prod\n`],
29
+ ['one space', `${'a'.repeat(64)} vmagent-prod\n`],
30
+ ['a trailing space (a name that merely starts with another)', `${line} \n`],
31
+ ['a no-break space in the name', `${line}\u00a0-enterprise\n`],
32
+ ])('refuses %s', (_, text) => {
33
+ expect(() => parseChecksums(text)).toThrow();
34
+ });
35
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A vendor checksum file in `sha256sum` text mode, parsed strictly — the shape every pin in
3
+ * victoria.ts was copied from.
4
+ *
5
+ * ★ WHY PARSE A FILE THE PROVIDER NEVER FETCHES. The pins are the ground truth at apply; this is
6
+ * the ground truth when a pin is WRITTEN. victoria.test.ts feeds it the exact bytes of all four
7
+ * recorded files (fixtures/victoria/, each matching GitHub's digest of that asset AS READ on the
8
+ * recorded date — no test re-reads it) and requires the catalog to equal what it reads, so a pin
9
+ * typed by hand, a line missed, or a member added by the vendor is a failing test rather than a
10
+ * trusted typo.
11
+ * ⛔ STRICT, BECAUSE THE FORMAT IS KNOWN. Measured 2026-09-22 on all four files: `sha256sum` text
12
+ * mode — `<64 lowercase hex><two spaces><name>` per line, LF endings, a trailing newline, the
13
+ * archive on line 1 and one `-prod` member per line after it. Anything else (a `*` binary-mode
14
+ * marker, CRLF, upper-case hex, a blank line, a repeated name) is a refusal: a file that no longer
15
+ * has this shape is a vendor change to read before any pin moves, not something to guess past.
16
+ * ⚠️ ONE FORMAT, MEASURED FOR ONE VENDOR. Other vendors list archives only (Prometheus, vector,
17
+ * pyroscope, OpenBao — the estate census, 2026-09-22) or publish digests in JSON (grafana.com). A
18
+ * catalog for one of them reads ITS format byte-exact first; nothing here is assumed to fit it.
19
+ */
20
+
21
+ const LINE = /^([0-9a-f]{64}) {2}([^\s/]+)$/;
22
+
23
+ /** Name → SHA-256, in file order. Throws, naming the line, on anything but the measured shape. */
24
+ export const parseChecksums = (text: string): ReadonlyMap<string, string> => {
25
+ if (!text.endsWith('\n')) throw new Error('checksum file: no trailing newline');
26
+ const lines = text.slice(0, -1).split('\n');
27
+ const digests = new Map<string, string>();
28
+ for (const [index, line] of lines.entries()) {
29
+ const match = LINE.exec(line);
30
+ if (match === null) {
31
+ throw new Error(
32
+ `checksum file line ${String(index + 1)}: not "<sha256> <name>": ${JSON.stringify(line)}`,
33
+ );
34
+ }
35
+ const [, digest = '', name = ''] = match;
36
+ if (digests.has(name)) {
37
+ throw new Error(`checksum file line ${String(index + 1)}: "${name}" is listed twice`);
38
+ }
39
+ digests.set(name, digest);
40
+ }
41
+ if (digests.size === 0) throw new Error('checksum file: no lines');
42
+ return digests;
43
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The pins as the STACK PROGRAM declared them — before the engine resolved a single Output — checked
3
+ * at apply. The half of "a pin is never computed during the deploy" that a FIRST deploy needs.
4
+ *
5
+ * ⛔ WHY APPLY HAS TO ASK THE STACK, NOT `news`. By the time `reconcile` runs, the engine has
6
+ * resolved every Output in `news`, so a digest wired from another resource (a checksum file read
7
+ * at apply, a lookup, a computed value) arrives as a well-formed plain string, indistinguishable
8
+ * from a reviewed pin. And on a first deploy nothing else looked: Alchemy never diffs a create,
9
+ * and skips the adoption probe while `news` holds an Output (beta.79 Plan.ts). 🔴 MEASURED
10
+ * 2026-09-22 (apply-pins.test.ts): a swapped archive plus the swapped digests, wired in as an
11
+ * Output, installed the swapped bytes and the deploy reported success. binary-diff.ts refused the
12
+ * same declaration only from the SECOND plan on, after the wrong binary was on the host.
13
+ * ★ `Stack.resources[fqn].Props` IS WHAT THE PROGRAM PASSED, Outputs and all: Alchemy's
14
+ * `Resource` records it at registration (beta.79 Resource.ts, `Props: props`), and the same Stack
15
+ * reaches the apply (ownership/adopt.ts reads `Adopt` from the same record for the same reason).
16
+ * pinProblems then sees the Output itself — a function-typed proxy, an Effect or a Config — and
17
+ * refuses it as "not a plain string".
18
+ * ⚠️ NO STACK, NO ANSWER. A handler called outside the engine (the fake-provider.ts harness) has no
19
+ * Stack service and no registration to read, so this says nothing there; the engine always has
20
+ * one. apply-pins.test.ts runs the real Plan and Apply, so an Alchemy upgrade that stops
21
+ * recording `Props` there makes the swapped bytes install again, and fails that test by name.
22
+ */
23
+ import { Stack } from 'alchemy/Stack';
24
+ import * as Effect from 'effect/Effect';
25
+ import * as Option from 'effect/Option';
26
+ import { pinProblems } from './binary-form.ts';
27
+
28
+ /** Everything wrong with the pins of the resource at `fqn` as its stack program declared them. */
29
+ export const declaredPinProblems = (fqn: string): Effect.Effect<string[]> =>
30
+ Effect.gen(function* () {
31
+ const stack = yield* Effect.serviceOption(Stack);
32
+ if (Option.isNone(stack)) return [];
33
+ const declared: unknown = stack.value.resources[fqn]?.Props;
34
+ if (typeof declared !== 'object' && typeof declared !== 'function') return [];
35
+ if (declared === null) return [];
36
+ return pinProblems(declared as Parameters<typeof pinProblems>[0]).map(
37
+ (problem) => `as declared in the stack program, ${problem}`,
38
+ );
39
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The download over a fake `HttpClient` — no network: status first, the body against the pinned
3
+ * size, bounded retries only where another attempt could help, and one download shared by the
4
+ * resources that need the same archive at the same time.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
9
+ import * as HttpClientError from 'effect/unstable/http/HttpClientError';
10
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
11
+ import { type FetchArchive, httpFetchArchive, sharingInFlight } from './download.ts';
12
+ import { bytesOf } from './fake-release.ts';
13
+ import { DownloadFailed } from './refused.ts';
14
+
15
+ const URL_A = 'https://example.invalid/a.tar.gz';
16
+ const FAST = { retries: 2, spacing: '1 millis', timeout: '5 seconds' } as const;
17
+
18
+ /** A client that answers each request with the next scripted reply, recording every URL. */
19
+ const scripted = (...replies: Array<{ status: number; body?: Uint8Array }>) => {
20
+ const urls: string[] = [];
21
+ const client = HttpClient.make((request, url) => {
22
+ urls.push(url.toString());
23
+ const reply = replies[Math.min(urls.length, replies.length) - 1] ?? { status: 599 };
24
+ const body = reply.body === undefined ? null : new Blob([reply.body.slice()]);
25
+ return Effect.succeed(
26
+ HttpClientResponse.fromWeb(request, new Response(body, { status: reply.status })),
27
+ );
28
+ });
29
+ return { fetch: httpFetchArchive(client, FAST), urls };
30
+ };
31
+
32
+ const failure = async (promise: Promise<unknown>): Promise<DownloadFailed> => {
33
+ try {
34
+ await promise;
35
+ } catch (cause) {
36
+ expect(cause).toBeInstanceOf(DownloadFailed);
37
+ return cause as DownloadFailed;
38
+ }
39
+ throw new Error('expected DownloadFailed');
40
+ };
41
+
42
+ describe('downloadPinned', () => {
43
+ test('200 with exactly the pinned size: the bytes', async () => {
44
+ const body = bytesOf('archive');
45
+ const { fetch, urls } = scripted({ body, status: 200 });
46
+ expect(await fetch(URL_A, body.length)).toEqual(body);
47
+ expect(urls).toEqual([URL_A]);
48
+ });
49
+
50
+ test('404 is final: one request, not retried', async () => {
51
+ const { fetch, urls } = scripted({ status: 404 });
52
+ const error = await failure(fetch(URL_A, 7));
53
+ expect(error.message).toContain('HTTP 404');
54
+ expect(error.retryable).toBe(false);
55
+ expect(urls.length).toBe(1);
56
+ });
57
+
58
+ test('a 5xx is retried, twice and no more', async () => {
59
+ const { fetch, urls } = scripted({ status: 503 });
60
+ expect((await failure(fetch(URL_A, 7))).message).toContain('HTTP 503');
61
+ expect(urls.length).toBe(3);
62
+ });
63
+
64
+ test('a 5xx then a good answer succeeds', async () => {
65
+ const body = bytesOf('archive');
66
+ const { fetch, urls } = scripted({ status: 502 }, { body, status: 200 });
67
+ expect(await fetch(URL_A, body.length)).toEqual(body);
68
+ expect(urls.length).toBe(2);
69
+ });
70
+
71
+ test('a body longer than the pin is cut off and refused, not retried', async () => {
72
+ const { fetch, urls } = scripted({ body: bytesOf('x'.repeat(100)), status: 200 });
73
+ expect((await failure(fetch(URL_A, 10))).message).toContain('runs past the pinned 10 bytes');
74
+ expect(urls.length).toBe(1);
75
+ });
76
+
77
+ test('a short body is refused (and retried, as a cut-off transfer)', async () => {
78
+ const { fetch, urls } = scripted({ body: bytesOf('abc'), status: 200 });
79
+ expect((await failure(fetch(URL_A, 10))).message).toContain('3 bytes, the pin says 10');
80
+ expect(urls.length).toBe(3);
81
+ });
82
+
83
+ test('a transport failure is a DownloadFailed with the reason, retried', async () => {
84
+ const urls: string[] = [];
85
+ const client = HttpClient.make((request, url) => {
86
+ urls.push(url.toString());
87
+ const reason = new HttpClientError.TransportError({
88
+ description: 'connection reset',
89
+ request,
90
+ });
91
+ return Effect.fail(new HttpClientError.HttpClientError({ reason }));
92
+ });
93
+ const error = await failure(httpFetchArchive(client, FAST)(URL_A, 7));
94
+ expect(error.message).toContain('connection reset');
95
+ expect(error.retryable).toBe(true);
96
+ expect(urls.length).toBe(3);
97
+ });
98
+
99
+ test('no answer in time is final', async () => {
100
+ const client = HttpClient.make(() => Effect.never);
101
+ const fetch = httpFetchArchive(client, { ...FAST, timeout: '20 millis' });
102
+ expect((await failure(fetch(URL_A, 7))).message).toContain('no complete response in time');
103
+ });
104
+ });
105
+
106
+ describe('sharingInFlight', () => {
107
+ test('two requests for one archive at once share one download; a later one downloads again', async () => {
108
+ let calls = 0;
109
+ let release: (bytes: Uint8Array) => void = () => undefined;
110
+ const slow: FetchArchive = () => {
111
+ calls += 1;
112
+ return new Promise((resolve) => {
113
+ release = resolve;
114
+ });
115
+ };
116
+ const fetch = sharingInFlight(slow);
117
+ const first = fetch(URL_A, 3);
118
+ const second = fetch(URL_A, 3);
119
+ release(bytesOf('abc'));
120
+ expect(await first).toBe(await second);
121
+ expect(calls).toBe(1);
122
+ const third = fetch(URL_A, 3);
123
+ release(bytesOf('abc'));
124
+ await third;
125
+ expect(calls).toBe(2);
126
+ });
127
+
128
+ test('a failed download is not cached', async () => {
129
+ let calls = 0;
130
+ const fetch = sharingInFlight(async () => {
131
+ calls += 1;
132
+ throw new DownloadFailed({ message: 'nope', retryable: false });
133
+ });
134
+ await expect(fetch(URL_A, 1)).rejects.toBeInstanceOf(DownloadFailed);
135
+ await expect(fetch(URL_A, 1)).rejects.toBeInstanceOf(DownloadFailed);
136
+ expect(calls).toBe(2);
137
+ });
138
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * One pinned release archive over Effect's `HttpClient`: status first, then the body read against
3
+ * the pinned size, into memory.
4
+ *
5
+ * ★ `HttpClient`, THE HOUSE TRANSPORT (openbao/bao-http.ts, decided 2026-09-14) and Alchemy's own:
6
+ * a typed error channel, interruption, and a client a test swaps for a fake. releaseProviders()
7
+ * provides `FetchHttpClient.layer` itself, so a host stack that has no HTTP layer yet (the Mac
8
+ * stack's has none) cannot forget it. ⚠️ fetch follows GitHub's 302 to its asset CDN (measured
9
+ * 2026-09-22 with HEAD: a signed, expiring URL), so the status judged here is the CDN's.
10
+ * ★ THE PINNED SIZE BOUNDS MEMORY. A body that runs past it is cut off at that byte, so a
11
+ * misbehaving server or a swapped asset cannot stream an unbounded file into the deploy.
12
+ * ★ NO API CALL. The browser-download URL needs no token and spends no API rate limit; the pins
13
+ * say everything the release API would.
14
+ * ⚠️ IN MEMORY: vmutils is 123.6 MB. Nothing is staged on disk, so no failure leaves a file behind.
15
+ */
16
+ import type * as Duration from 'effect/Duration';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Schedule from 'effect/Schedule';
19
+ import * as Stream from 'effect/Stream';
20
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
21
+ import { DownloadFailed } from './refused.ts';
22
+
23
+ /** The provider's view of a transport: an archive's bytes by URL, never longer than `size`. */
24
+ export type FetchArchive = (url: string, size: number) => Promise<Uint8Array>;
25
+
26
+ /** How patient a download is. ★ Parameters so a test never waits for real. */
27
+ export type DownloadPolicy = {
28
+ /** @default '10 minutes' — 123.6 MB on a slow link, and still a bounded wait. */
29
+ readonly timeout?: Duration.Input;
30
+ /** Retries after the first attempt, transport failures and 5xx only. @default 2 */
31
+ readonly retries?: number;
32
+ /** @default '2 seconds' */
33
+ readonly spacing?: Duration.Input;
34
+ };
35
+
36
+ const failed = (message: string, retryable: boolean) => new DownloadFailed({ message, retryable });
37
+
38
+ const attempt = (url: string, size: number) =>
39
+ Effect.gen(function* () {
40
+ const client = yield* HttpClient.HttpClient;
41
+ const response = yield* client.get(url);
42
+ if (response.status !== 200) {
43
+ return yield* failed(`${url}: HTTP ${String(response.status)}`, response.status >= 500);
44
+ }
45
+ // ★ ONE BUFFER OF THE PINNED SIZE, filled in place: no chunk list and no concatenating copy,
46
+ // so the archive costs its own size in memory and not twice it.
47
+ const archive = new Uint8Array(size);
48
+ let total = 0;
49
+ yield* Stream.runForEach(response.stream, (chunk) => {
50
+ if (total + chunk.length > size) {
51
+ return Effect.fail(failed(`${url}: runs past the pinned ${String(size)} bytes`, false));
52
+ }
53
+ archive.set(chunk, total);
54
+ total += chunk.length;
55
+ return Effect.void;
56
+ });
57
+ if (total !== size) {
58
+ return yield* failed(`${url}: ${String(total)} bytes, the pin says ${String(size)}`, true);
59
+ }
60
+ return archive;
61
+ }).pipe(
62
+ // ⛔ `.message` only: it carries the reason, method and URL, and nothing else goes to the log.
63
+ Effect.catchTag('HttpClientError', (cause) => Effect.fail(failed(cause.message, true))),
64
+ );
65
+
66
+ /** The archive's bytes, exactly `size` of them, or DownloadFailed. */
67
+ export const downloadPinned = (
68
+ url: string,
69
+ size: number,
70
+ policy: DownloadPolicy = {},
71
+ ): Effect.Effect<Uint8Array, DownloadFailed, HttpClient.HttpClient> =>
72
+ attempt(url, size).pipe(
73
+ Effect.timeoutOrElse({
74
+ duration: policy.timeout ?? '10 minutes',
75
+ orElse: () => Effect.fail(failed(`${url}: no complete response in time`, false)),
76
+ }),
77
+ Effect.retry({
78
+ schedule: Schedule.spaced(policy.spacing ?? '2 seconds'),
79
+ times: policy.retries ?? 2,
80
+ while: (error: DownloadFailed) => error.retryable,
81
+ }),
82
+ );
83
+
84
+ /** A FetchArchive over one HttpClient. ⚠️ Rejects with the DownloadFailed itself. */
85
+ export const httpFetchArchive =
86
+ (client: HttpClient.HttpClient, policy: DownloadPolicy = {}): FetchArchive =>
87
+ (url, size) =>
88
+ Effect.runPromise(
89
+ downloadPinned(url, size, policy).pipe(Effect.provideService(HttpClient.HttpClient, client)),
90
+ );
91
+
92
+ /**
93
+ * ★ ONE DOWNLOAD PER ARCHIVE AT A TIME, NOT ONE PER BINARY. vmagent and vmalert are two resources
94
+ * in one 123.6 MB archive, and Alchemy reconciles independent resources concurrently (its fan-out
95
+ * is unbounded, Apply.ts), so two requests for one URL share the download already in flight.
96
+ * The entry goes when it settles: a later request downloads again rather than pinning 123 MB in
97
+ * memory for the rest of the deploy.
98
+ * ⚠️ The buffer is shared, so nothing downstream may write into it (archive.ts only reads).
99
+ */
100
+ export const sharingInFlight = (fetch: FetchArchive): FetchArchive => {
101
+ const inFlight = new Map<string, Promise<Uint8Array>>();
102
+ return (url, size) => {
103
+ const key = `${String(size)} ${url}`;
104
+ const running = inFlight.get(key);
105
+ if (running !== undefined) return running;
106
+ const started = fetch(url, size).finally(() => inFlight.delete(key));
107
+ inFlight.set(key, started);
108
+ return started;
109
+ };
110
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a caller can actually catch from Release.Binary — MEASURED, so docs/release-binary-upstream.md
3
+ * cannot claim more than is true. (The upstream-lens review of 2026-09-22 found it claiming
4
+ * "callers can `catchTag`"; neither path below allows it.)
5
+ *
6
+ * ⚠️ catalogBinary() THROWS. In a stack program (`Effect.gen`) its BinaryRefused is therefore a
7
+ * DEFECT, not a typed failure: `Effect.catchTag('BinaryRefused')` never sees it. The plan still
8
+ * fails before anything is fetched — the house guarantee holds — but the error is not typed.
9
+ * ⚠️ THE PROVIDER'S CHANNEL IS `Error`. Every lifecycle call goes through the house `lift()`
10
+ * (launchd/host-effect.ts), which keeps the tagged instance at runtime but types the channel as
11
+ * plain `Error`, so no caller can name a tag without a cast — which upstream's AGENTS.md forbids.
12
+ * ★ WHEN EITHER BECOMES TYPED, THIS FILE FAILS FIRST: the defect assertions flip, or the
13
+ * `@ts-expect-error` below becomes unused and `tsc` refuses it. Update the upstream page with it.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as Cause from 'effect/Cause';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Exit from 'effect/Exit';
19
+ import { lift } from '../launchd/host-effect.ts';
20
+ import { catalogBinary } from './catalog.ts';
21
+ import { BinaryRefused, ChecksumMismatch } from './refused.ts';
22
+ import { VICTORIA_RELEASES } from './victoria.ts';
23
+
24
+ const UNPINNED = {
25
+ binary: 'vmalert',
26
+ package: 'vmutils',
27
+ platform: 'darwin-arm64',
28
+ version: '1.152.0',
29
+ };
30
+
31
+ describe('the stack program: catalogBinary', () => {
32
+ test('a refusal fails the program as a defect carrying BinaryRefused, not a typed failure', async () => {
33
+ // The shape of a stack program: it yields its resources, and calls catalogBinary() plainly.
34
+ const program = Effect.gen(function* () {
35
+ yield* Effect.void;
36
+ return catalogBinary(VICTORIA_RELEASES, UNPINNED);
37
+ });
38
+ const exit = await Effect.runPromiseExit(program);
39
+ expect(Exit.isFailure(exit)).toBe(true);
40
+ if (!Exit.isFailure(exit)) return;
41
+ expect(Cause.hasFails(exit.cause)).toBe(false);
42
+ expect(Cause.hasDies(exit.cause)).toBe(true);
43
+ expect(Cause.squash(exit.cause)).toBeInstanceOf(BinaryRefused);
44
+ });
45
+ });
46
+
47
+ describe('the provider: lift()', () => {
48
+ const mismatch = new ChecksumMismatch({
49
+ actual: '0'.repeat(64),
50
+ expected: '1'.repeat(64),
51
+ message: 'Release.Binary /x/y: hashes wrong. Nothing was written.',
52
+ subject: 'member "y"',
53
+ });
54
+
55
+ test('the tagged instance survives at runtime, as a failure', async () => {
56
+ const exit = await Effect.runPromiseExit(lift(() => Promise.reject(mismatch)));
57
+ expect(Exit.isFailure(exit)).toBe(true);
58
+ if (!Exit.isFailure(exit)) return;
59
+ expect(Cause.hasFails(exit.cause)).toBe(true);
60
+ expect(Cause.squash(exit.cause)).toBe(mismatch);
61
+ });
62
+
63
+ test('but the channel is typed `Error`, so a tag cannot be named without a cast', () => {
64
+ const lifted = lift(() => Promise.reject(mismatch));
65
+ // @ts-expect-error — lift() erases the tag union to `Error`; see the file header.
66
+ const caught = Effect.catchTag(lifted, 'ChecksumMismatch', () => Effect.succeed('caught'));
67
+ expect(Effect.isEffect(caught)).toBe(true);
68
+ });
69
+ });