@homeflare/alchemy 0.22.0 → 0.24.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 (118) hide show
  1. package/README.md +15 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/postgres/connection.d.ts +50 -0
  23. package/dist/postgres/connection.d.ts.map +1 -0
  24. package/dist/postgres/database-attrs.d.ts +79 -0
  25. package/dist/postgres/database-attrs.d.ts.map +1 -0
  26. package/dist/postgres/database-sql.d.ts +73 -0
  27. package/dist/postgres/database-sql.d.ts.map +1 -0
  28. package/dist/postgres/database.d.ts +63 -0
  29. package/dist/postgres/database.d.ts.map +1 -0
  30. package/dist/postgres/errors.d.ts +86 -0
  31. package/dist/postgres/errors.d.ts.map +1 -0
  32. package/dist/postgres/fake-sql.d.ts +22 -0
  33. package/dist/postgres/fake-sql.d.ts.map +1 -0
  34. package/dist/postgres/index.d.ts +16 -0
  35. package/dist/postgres/index.d.ts.map +1 -0
  36. package/dist/postgres/index.js +238 -0
  37. package/dist/postgres/index.js.map +15 -0
  38. package/dist/postgres/providers.d.ts +14 -0
  39. package/dist/postgres/providers.d.ts.map +1 -0
  40. package/dist/proxmox/api-token-form.d.ts +12 -1
  41. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  42. package/dist/proxmox/api-token.d.ts +8 -3
  43. package/dist/proxmox/api-token.d.ts.map +1 -1
  44. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
  45. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
  46. package/dist/proxmox/index.d.ts +9 -2
  47. package/dist/proxmox/index.d.ts.map +1 -1
  48. package/dist/proxmox/index.js +17 -9
  49. package/dist/proxmox/index.js.map +8 -7
  50. package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
  51. package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
  52. package/dist/proxmox/zfs-pool-write.d.ts +48 -7
  53. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  54. package/dist/proxmox/zfs-pool.d.ts +52 -22
  55. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  56. package/dist/release/archive.d.ts +6 -2
  57. package/dist/release/archive.d.ts.map +1 -1
  58. package/dist/release/binary-form.d.ts +14 -9
  59. package/dist/release/binary-form.d.ts.map +1 -1
  60. package/dist/release/binary-pins.d.ts +17 -0
  61. package/dist/release/binary-pins.d.ts.map +1 -0
  62. package/dist/release/catalog.d.ts.map +1 -1
  63. package/dist/release/index.js +90 -40
  64. package/dist/release/index.js.map +8 -7
  65. package/dist/release/tar.d.ts +9 -2
  66. package/dist/release/tar.d.ts.map +1 -1
  67. package/docs/adopted-deploys.md +14 -0
  68. package/docs/litellm.md +110 -0
  69. package/docs/peers.md +8 -1
  70. package/docs/postgres.md +125 -0
  71. package/docs/release-binary-catalogs.md +40 -4
  72. package/docs/release-binary-refusals.md +22 -8
  73. package/docs/release-binary-upstream.md +33 -15
  74. package/package.json +10 -1
  75. package/src/litellm/client.ts +195 -0
  76. package/src/litellm/credentials.ts +73 -0
  77. package/src/litellm/fake-litellm.ts +114 -0
  78. package/src/litellm/fake-stack.ts +100 -0
  79. package/src/litellm/generated/pass-through.ts +73 -0
  80. package/src/litellm/index.ts +42 -0
  81. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  82. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  83. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  84. package/src/litellm/pass-through-endpoint.ts +258 -0
  85. package/src/litellm/pass-through-form.test.ts +128 -0
  86. package/src/litellm/pass-through-form.ts +136 -0
  87. package/src/litellm/providers.ts +19 -0
  88. package/src/postgres/connection.ts +62 -0
  89. package/src/postgres/database-attrs.ts +87 -0
  90. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  91. package/src/postgres/database-lifecycle.test.ts +113 -0
  92. package/src/postgres/database-provenance.test.ts +118 -0
  93. package/src/postgres/database-refusals.test.ts +135 -0
  94. package/src/postgres/database-sql.ts +174 -0
  95. package/src/postgres/database.ts +193 -0
  96. package/src/postgres/errors.ts +131 -0
  97. package/src/postgres/fake-sql.ts +123 -0
  98. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  99. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  100. package/src/postgres/index.ts +30 -0
  101. package/src/postgres/providers.ts +16 -0
  102. package/src/proxmox/api-token-adopt.test.ts +138 -0
  103. package/src/proxmox/api-token-form.ts +19 -2
  104. package/src/proxmox/api-token.ts +8 -3
  105. package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
  106. package/src/proxmox/index.test.ts +5 -2
  107. package/src/proxmox/index.ts +9 -2
  108. package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
  109. package/src/proxmox/zfs-pool-spec.ts +76 -0
  110. package/src/proxmox/zfs-pool-write.ts +63 -9
  111. package/src/proxmox/zfs-pool.ts +55 -97
  112. package/src/release/archive.ts +10 -4
  113. package/src/release/binary-form.ts +24 -76
  114. package/src/release/binary-pins.ts +95 -0
  115. package/src/release/binary-root.test.ts +122 -0
  116. package/src/release/catalog.ts +10 -2
  117. package/src/release/tar-root.test.ts +114 -0
  118. package/src/release/tar.ts +78 -12
@@ -6,20 +6,22 @@
6
6
  * moment the worker runs. Every other decision in this file follows from that one sentence, and
7
7
  * each of them is written out below rather than left to be inferred.
8
8
  *
9
- * ⛔ DESTROY IS REFUSED. `delete` MAKES NO API CALL — removing this resource from a stack leaves
10
- * the zpool and its data exactly where they are, and Alchemy simply forgets about them. The
11
- * asymmetry is the whole argument: an ORPHANED pool is recoverable (declare it again, or destroy
12
- * it by hand once a human has looked at which disks are in it), a DESTROYED pool is not. Alchemy
13
- * deletes for reasons that have nothing to do with anybody intending it — a line removed from a
14
- * stack file, a resource renamed so the old id is dropped, a whole stack torn down by the wrong
15
- * command, a replace triggered by a prop nobody meant to touch. Handing `zpool destroy` to any
16
- * of those is not a feature. MEASURED in the node's own source: the DELETE handler forks
17
- * `zfsremove`, which runs `zpool destroy <name>` unconditionally, and with `cleanup-disks` then
18
- * calls `wipe_blockdev` on every member. Neither flag is offered as a prop, because there is no
19
- * call here for them to reach.
20
- * ★ THE OPERATOR'S PATH IS `pvesh delete /nodes/<node>/disks/zfs/<name>`, typed by a person who
21
- * has just looked at the pool. Do not "complete the CRUD" by wiring `delete` to `ops.destroy`:
22
- * that one line turns a text edit into an unrecoverable one.
9
+ * ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "DESTROY IS REFUSED" AND "`delete`
10
+ * MAKES NO API CALL". THAT WAS FALSE: MEASURED against this file's own `handlers.delete` below,
11
+ * which calls `destroyPool`, which sends `DELETE /nodes/{node}/disks/zfs/{name}` — the node's
12
+ * own source forks `zfsremove`, which runs `zpool destroy <name>` unconditionally, and with
13
+ * `cleanup-disks` then calls `wipe_blockdev` on every member (neither flag is offered as a prop,
14
+ * because there is no call here for them to reach). `delete` is FULLY IMPLEMENTED, per S14
15
+ * (alchemy-provider-standard): a `delete` that silently does nothing lies to whoever reads the
16
+ * plan. What actually keeps an ordinary removed declaration from reaching it is
17
+ * `defaultRemovalPolicy: 'retain'` below — Alchemy SKIPS `provider.delete` entirely for an
18
+ * orphaned or destroyed resource under `retain`, so the state row drops and the zpool and its
19
+ * data are left exactly where they are. The asymmetry is still the whole argument: an ORPHANED
20
+ * pool is recoverable (declare it again, or destroy it by hand once a human has looked at which
21
+ * disks are in it), a DESTROYED pool is not — which is why `retain` is the default rather than a
22
+ * documented caution, and why `delete` runs only for a caller who opts in by name with
23
+ * `.pipe(RemovalPolicy.destroy())`. See the ★ on `defaultRemovalPolicy` in resource-spec.ts, and
24
+ * zfs-pool-adopt.test.ts's mutation row, which pins exactly one DELETE reaching the fake cluster.
23
25
  *
24
26
  * ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
25
27
  * (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
@@ -47,39 +49,42 @@
47
49
  * ⚠️ TWO HANDLERS ARE OVERRIDDEN AND THE OTHER THREE COME FROM THE FACTORY — see the ★ above
48
50
  * `handlers`. `delete` is the ⛔ above. `reconcile` is because the create is a FORKED WORKER, so
49
51
  * the factory's immediate read-back would report a successful create as a failure; the measured
50
- * detail is in the ⛔ on `createPool` in zfs-pool-create.ts.
52
+ * detail, and the adopt-only refusal decision 9 adds, are both in `createPool` in
53
+ * zfs-pool-write.ts.
51
54
  *
52
55
  * ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
53
56
  * Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
54
- * needed, because `add_storage` is not offered — see the ⛔ on `ZfsPoolProps`. `delete` needs
55
- * nothing at all, since it calls nothing.
57
+ * needed, because `add_storage` is not offered — see the ⛔ on `ZfsPoolProps`.
58
+ * ★ CORRECTED 2026-09-23, ALONGSIDE THE HEADER ABOVE: `delete` needs `Sys.Modify` on `/` too,
59
+ * for the DELETE it now sends under `.pipe(RemovalPolicy.destroy())` — the whole `disks/zfs`
60
+ * family checks it, not only the POST. MEASURED the same day as the rest of this paragraph:
61
+ * `ceph-osd.ts`'s own privilege comparison names `disks/zfs` as one of the sibling families
62
+ * whose write verbs DO carry a `Sys.Modify` check, unlike `ceph/osd`'s create and delete, which
63
+ * carry no permissions block at all and 403 for any identity but `root@pam`. This line
64
+ * previously said delete "needs nothing at all, since it calls nothing", which repeated the same
65
+ * mistake the header above corrects: `delete` calls the DELETE endpoint, so it needs whatever
66
+ * that endpoint checks.
56
67
  */
57
68
  import { Resource } from 'alchemy';
58
69
  import * as Provider from 'alchemy/Provider';
59
70
  import * as Effect from 'effect/Effect';
60
- import {
61
- type PveRequirements,
62
- type PveSpec,
63
- type WithTarget,
64
- pveHandlers,
65
- pveOperations,
66
- } from './resource.ts';
67
- import { text } from './values.ts';
68
- import { ZFS_POOL_ENDPOINT, createForm, createPool, destroyPool } from './zfs-pool-write.ts';
71
+ import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
72
+ import { type PveRequirements, type WithTarget, pveHandlers, pveOperations } from './resource.ts';
73
+ import { spec } from './zfs-pool-spec.ts';
74
+ import { createPool, destroyPool } from './zfs-pool-write.ts';
69
75
 
70
- /** PVE's layouts. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an even one. */
71
- export type ZfsRaidLevel =
72
- | 'single'
73
- | 'mirror'
74
- | 'raid10'
75
- | 'raidz'
76
- | 'raidz2'
77
- | 'raidz3'
78
- | 'draid'
79
- | 'draid2'
80
- | 'draid3';
76
+ /**
77
+ * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
78
+ * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
79
+ * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
80
+ * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
81
+ * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
82
+ * declaration with no `raidlevel`, never as a full one.
83
+ */
84
+ export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
81
85
 
82
- export type ZfsCompression = 'on' | 'off' | 'gzip' | 'lz4' | 'lzjb' | 'zle' | 'zstd';
86
+ /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
87
+ export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
83
88
 
84
89
  /**
85
90
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
@@ -112,9 +117,19 @@ export interface ZfsPoolProps extends WithTarget {
112
117
  * `csv` sorts, and sorting this list silently re-pairs the mirrors into a different pool.
113
118
  * ⚠️ PVE's only guard is `assert_disk_unused`, i.e. its own `disk_is_used` over the same `used`
114
119
  * column the Disks view shows. A blank disk it considers free is wiped with no confirmation.
120
+ *
121
+ * ⛔ OPTIONAL SINCE DECISION 9 (2026-09-23), AND OMITTING IT MEANS SOMETHING SPECIFIC: leaving
122
+ * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
123
+ * read and report but never build. That is the only shape available for a pool PVE's own POST
124
+ * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
125
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
126
+ * both keys when they are absent, and `createPool` refuses before any POST if the pool it
127
+ * would need to build is not already there — an adopt-only declaration never creates. Declare
128
+ * both fields together for a pool this resource should build from scratch; declaring only one
129
+ * of the two is refused the same way, since PVE's POST requires both.
115
130
  */
116
- devices: readonly string[];
117
- raidlevel: ZfsRaidLevel;
131
+ devices?: readonly string[];
132
+ raidlevel?: ZfsRaidLevel;
118
133
  /**
119
134
  * Sector size exponent, 9–16, PVE default 12.
120
135
  *
@@ -174,63 +189,6 @@ export const ProxmoxZfsPool = Resource<ProxmoxZfsPool>('Proxmox.ZfsPool', {
174
189
  defaultRemovalPolicy: 'retain',
175
190
  });
176
191
 
177
- /**
178
- * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
179
- *
180
- * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
181
- * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
182
- * equally well; recursing on `children` needs no second field to be present and is what makes
183
- * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
184
- */
185
- const leafDevices = (children: unknown): string[] =>
186
- Array.isArray(children)
187
- ? children.flatMap((entry: unknown) => {
188
- const vdev = entry as { children?: unknown; name?: unknown };
189
- const nested = leafDevices(vdev.children);
190
- return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
191
- })
192
- : [];
193
-
194
- const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes> = {
195
- /**
196
- * ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory
197
- * strings ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and
198
- * faults come and go — so keeping them would rewrite this resource's state row for reasons no
199
- * declaration caused. Same reasoning as `digest` in storage.ts.
200
- */
201
- attributes: (live, props) => {
202
- // ⚠️ A detail answer with no `name` is not a pool. `name` is non-optional in PVE's schema, so
203
- // this is a shape check rather than a default — and "absent" is the honest reading of a
204
- // reply that does not describe the object that was asked for.
205
- if (text(live['name']) === '') return undefined;
206
- return {
207
- devices: leafDevices(live['children']).join(','),
208
- errors: text(live['errors'], 'unknown'),
209
- name: props.name,
210
- node: props.node,
211
- state: text(live['state'], 'unknown'),
212
- };
213
- },
214
- collection: (props) => `nodes/${props.node}/disks/zfs`,
215
- createForm,
216
- endpoint: ZFS_POOL_ENDPOINT,
217
- /**
218
- * ⛔ IT ALWAYS ANSWERS TRUE, AND THAT IS THE POINT OF THIS FILE. A pool that is there under the
219
- * declared name on the declared node IS the declaration, because there is nothing else the two
220
- * can be compared on: every create parameter is write-only (see the ⛔ in the header) and
221
- * everything the read does return is telemetry that moves by itself. So declaring what is live
222
- * plans as `noop` — the only honest answer available, and the one the live cluster needs:
223
- * `rpool` exists on node-b, node-c and node-d with nothing about its construction readable.
224
- * ⛔ ANYTHING ADDED HERE IS A REPLACE, NOT AN UPDATE. `updateForm` is omitted because PVE has
225
- * no PUT, so `resource.ts` turns a false into `{action:'replace'}` — delete then create — on
226
- * a family whose delete is `zpool destroy`. A comparison added here would be one plan away
227
- * from destroying a pool over a field it cannot even read back. If a future PVE grows a PUT,
228
- * add `updateForm` FIRST and only then consider comparing what that PUT accepts.
229
- */
230
- matches: () => true,
231
- path: (props) => `nodes/${props.node}/disks/zfs/${props.name}`,
232
- };
233
-
234
192
  const ops = pveOperations(spec);
235
193
 
236
194
  /**
@@ -24,12 +24,17 @@ import { type TarContents, tarReader } from './tar.ts';
24
24
  /** How much gzip is handed to the inflater at a time. */
25
25
  const SLICE = 1 << 20;
26
26
 
27
- /** Gunzip and read a tar, keeping only `wanted`. Throws ArchiveRefused. */
27
+ /**
28
+ * Gunzip and read a tar, keeping only `wanted`. Throws ArchiveRefused.
29
+ * @param root The one declared leading directory to strip (tar.ts) — a directory-wrapped vendor
30
+ * archive (the Prometheus family, measured 2026-09-23) without it, or one that carries no root.
31
+ */
28
32
  export const extractMembers = async (
29
33
  gzipped: Uint8Array,
30
34
  wanted: ReadonlySet<string>,
35
+ root?: string,
31
36
  ): Promise<TarContents> => {
32
- const tar = tarReader(wanted);
37
+ const tar = tarReader(wanted, root);
33
38
  // ⚠️ FED IN SLICES, ON DEMAND. Enqueued whole, the 123.6 MB vmutils archive is inflated in one
34
39
  // transform step and all 264 MB of output queue up before the first read: MEASURED 2026-09-22,
35
40
  // a peak RSS of 1116 MB for vmalert alone under bun 1.4.0. `pull` hands over the next slice only
@@ -79,11 +84,12 @@ export const verifiedMember = async (
79
84
  ): Promise<Uint8Array> => {
80
85
  const archiveSha = sha256Hex(archive);
81
86
  if (archiveSha !== release.sha256) throw mismatch(release.url, release.sha256, archiveSha);
82
- const { members, names } = await extractMembers(archive, new Set([release.member]));
87
+ const { members, names } = await extractMembers(archive, new Set([release.member]), release.root);
83
88
  const bytes = members.get(release.member);
84
89
  if (bytes === undefined) {
90
+ const under = release.root === undefined ? '' : ` under root "${release.root}/"`;
85
91
  throw new ArchiveRefused({
86
- message: `${release.url} has no member named exactly "${release.member}" (it has ${names.map((n) => `"${n}"`).join(', ') || 'none'})`,
92
+ message: `${release.url} has no member named exactly "${release.member}"${under} (it has ${names.map((n) => `"${n}"`).join(', ') || 'none'})`,
87
93
  });
88
94
  }
89
95
  const memberSha = sha256Hex(bytes);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { HostFileAttributes } from '../launchd/host-file-form.ts';
16
16
  import { identityProblems, pathProblems } from '../launchd/host-file-form.ts';
17
+ import { pinProblems } from './binary-pins.ts';
17
18
 
18
19
  /** One release archive, pinned: where it is published, and the bytes it must be. */
19
20
  export interface ReleaseArchive {
@@ -30,6 +31,17 @@ export interface ReleaseArchive {
30
31
  readonly size: number;
31
32
  /** The archive's SHA-256, lower-case hex, as the vendor's checksum file lists it. */
32
33
  readonly sha256: string;
34
+ /**
35
+ * The one directory the vendor wraps every entry in, exactly as its own archive names it —
36
+ * `'alertmanager-0.33.1.darwin-arm64'`, never `'alertmanager-0.33.1.darwin-arm64/'` (no trailing
37
+ * slash). Pinned from the walk-down, never derived from `asset` or `tag`: the vendor spells its
38
+ * wrapper however it likes (`darwin_arm64`, an `-enterprise` suffix, no version at all), so a
39
+ * computed name would drift onto a sibling the moment one vendor's naming changes. Omit it for a
40
+ * flat archive (Victoria, OpenBao); the tar reader then refuses a directory entry exactly as it
41
+ * always has (tar.ts).
42
+ * @default undefined — no wrapping directory; the archive is flat.
43
+ */
44
+ readonly root?: string;
33
45
  }
34
46
 
35
47
  export interface ReleaseBinaryProps {
@@ -82,6 +94,8 @@ export type PinnedDownload = {
82
94
  readonly member: string;
83
95
  /** The member's pinned SHA-256. */
84
96
  readonly memberSha256: string;
97
+ /** The one declared leading directory to strip, if the archive has one — `archive.root`. */
98
+ readonly root?: string;
85
99
  };
86
100
 
87
101
  export const DEFAULT_BINARY_MODE = 0o755;
@@ -109,6 +123,8 @@ export const pinnedDownload = (props: ReleaseBinaryProps): PinnedDownload => ({
109
123
  sha256: props.archive.sha256,
110
124
  size: props.archive.size,
111
125
  url: releaseUrl(props.archive.repo, props.archive.tag, props.archive.asset),
126
+ // ⚠️ exactOptionalPropertyTypes: never spell out `root: undefined`; leave the key off entirely.
127
+ ...(props.archive.root === undefined ? {} : { root: props.archive.root }),
112
128
  });
113
129
 
114
130
  export const modeProblems = (mode: number): string[] => {
@@ -123,83 +139,15 @@ export const modeProblems = (mode: number): string[] => {
123
139
  return found;
124
140
  };
125
141
 
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
- };
142
+ // `pinProblems` (imported above, for `binaryProblems` below) and the constant it needs,
143
+ // `MAX_ARCHIVE`, moved to binary-pins.ts, split out to stay under the file-size cap; re-exported
144
+ // here so every importer of './binary-form.ts' is unchanged.
145
+ export { MAX_ARCHIVE, pinProblems } from './binary-pins.ts';
201
146
 
202
- const ARCHIVE_KEYS = ['repo', 'tag', 'asset', 'size', 'sha256'] as const;
147
+ // ★ 'root' JOINED THIS LIST 2026-09-23. A state row from before this unit has no `root` at all
148
+ // (`undefined`), and `undefined !== undefined` is false, so an old row with no root and a new
149
+ // declaration that still has none does not move; declaring — or changing — a root does.
150
+ const ARCHIVE_KEYS = ['repo', 'tag', 'asset', 'size', 'sha256', 'root'] as const;
203
151
 
204
152
  type Pins = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256'>;
205
153
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `pinProblems` — everything wrong with a declaration's pins alone, as plain strings, checked
3
+ * before a plan resolves a directory or a host is asked anything. Split out of binary-form.ts
4
+ * 2026-09-23 to keep that file under the house's 250-line cap (it was 229, heading for ~250 once
5
+ * `archive.root` needed its own validation and JSDoc). binary-form.ts re-exports `pinProblems` and
6
+ * `MAX_ARCHIVE`, so nothing that imported them moves.
7
+ */
8
+ import type { ReleaseArchive } from './binary-form.ts';
9
+
10
+ const HEX64 = /^[0-9a-f]{64}$/;
11
+ export const MAX_ARCHIVE = 1 << 30;
12
+ // ⛔ Strict on purpose: each is written into a URL or a path. GitHub's own owner and name alphabet.
13
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
14
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
15
+ // ★ ONLY WHAT THE READER HANDLES: a gzipped tar. A `.zip` or a bare binary is a new capability
16
+ // with its own walk-down, not a name this should accept and then fail to unpack.
17
+ const TARBALL = /\.(tar\.gz|tgz)$/;
18
+
19
+ /** A single path segment that cannot climb, hide or name the directory itself. */
20
+ const segmentProblem = (what: string, value: string): string | undefined =>
21
+ !SEGMENT.test(value) || value === '.' || value === '..'
22
+ ? `${what} must be one path segment of letters, digits, '.', '_' or '-': ${JSON.stringify(value)}`
23
+ : undefined;
24
+
25
+ /** Everything wrong with the pins alone — plain strings in any declaration, resolved or not. */
26
+ export const pinProblems = (props: {
27
+ readonly archive?: Partial<ReleaseArchive>;
28
+ readonly member?: unknown;
29
+ readonly sha256?: unknown;
30
+ readonly name?: unknown;
31
+ }): string[] => {
32
+ const archive = props.archive ?? {};
33
+ const found: string[] = [];
34
+ const text = (what: string, value: unknown): string | undefined => {
35
+ if (typeof value === 'string') return value;
36
+ found.push(`${what} is required, as a plain string`);
37
+ return undefined;
38
+ };
39
+ const repo = text('archive.repo', archive.repo);
40
+ // ⚠️ `owner/..` passes the alphabet, and a URL parser normalises it away: the request would go
41
+ // to `github.com/releases/…`, somewhere nobody pinned. Refused by name.
42
+ const repoName = repo?.slice(repo.indexOf('/') + 1);
43
+ if (repo !== undefined && (!REPO.test(repo) || repoName === '.' || repoName === '..'))
44
+ found.push(`archive.repo must be "owner/name": ${JSON.stringify(repo)}`);
45
+ for (const [what, value] of [
46
+ ['archive.tag', archive.tag],
47
+ ['name', props.name],
48
+ ] as const) {
49
+ const given = text(what, value);
50
+ const problem = given === undefined ? undefined : segmentProblem(what, given);
51
+ if (problem !== undefined) found.push(problem);
52
+ }
53
+ const asset = text('archive.asset', archive.asset);
54
+ if (asset !== undefined) {
55
+ const problem = segmentProblem('archive.asset', asset);
56
+ if (problem !== undefined) found.push(problem);
57
+ else if (!TARBALL.test(asset))
58
+ found.push(`archive.asset must be a .tar.gz or .tgz: "${asset}"`);
59
+ }
60
+ // ★ ONE DECLARED DIRECTORY, NEVER DERIVED. Optional: most archives (Victoria, OpenBao) are flat.
61
+ // Where given, it is the vendor's own wrapper (measured, not guessed — tar.ts's header comment)
62
+ // and must be exactly one safe segment, the same alphabet as a tag or an asset name.
63
+ if (archive.root !== undefined) {
64
+ if (typeof archive.root !== 'string') {
65
+ found.push('archive.root must be a plain string when given');
66
+ } else {
67
+ const problem = segmentProblem('archive.root', archive.root);
68
+ if (problem !== undefined) found.push(problem);
69
+ }
70
+ }
71
+ // ⛔ THE SIZE IS ALLOCATED UP FRONT (download.ts fills one buffer of exactly this many bytes), so
72
+ // a typo'd size is a memory bomb in the deploying process. vmutils, the largest archive pinned,
73
+ // is 123.6 MB; a gibibyte is a ceiling nothing single-binary should reach.
74
+ const size = archive.size;
75
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ARCHIVE) {
76
+ found.push(`archive.size must be the asset's byte count, 1 to ${String(MAX_ARCHIVE)}`);
77
+ }
78
+ for (const [what, value] of [
79
+ ['archive.sha256', archive.sha256],
80
+ ['sha256', props.sha256],
81
+ ] as const) {
82
+ const given = text(what, value);
83
+ if (given !== undefined && !HEX64.test(given))
84
+ found.push(`${what} must be 64 lower-case hex digits`);
85
+ }
86
+ const member = text('member', props.member);
87
+ // ⛔ The tar reader refuses these entries anyway; asking for one is a mistake to name at plan.
88
+ if (
89
+ member !== undefined &&
90
+ (member === '' || member.startsWith('/') || member.split('/').includes('..'))
91
+ ) {
92
+ found.push(`member must be a relative name inside the archive: ${JSON.stringify(member)}`);
93
+ }
94
+ return found;
95
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `Release.Binary` end to end, through Alchemy's own Plan and Apply (openbao/fake-stack.ts), for a
3
+ * synthetic archive shaped like the Prometheus family: every entry wrapped in one directory
4
+ * (tar.ts's header comment, measured 2026-09-23). `archive.root` installs it; the same declaration
5
+ * without `root` refuses, exactly as tar-root.test.ts shows at the reader alone.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
11
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
12
+ import { sha256Hex } from '../launchd/job-form.ts';
13
+ import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
14
+ import { HostDirectory, HostDirectoryProvider } from '../linux/directory.ts';
15
+ import { fakeLinuxHost } from '../linux/fake-linux-host.ts';
16
+ import { fakeStack } from '../openbao/fake-stack.ts';
17
+ import { releaseUrl } from './binary-form.ts';
18
+ import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
19
+ import type { PinnedBinary } from './catalog.ts';
20
+ import { bytesOf, gzip, tarOf } from './fake-release.ts';
21
+
22
+ const ROOT = '/opt/example/bin';
23
+ const DIR = `${ROOT}/alertmanager-0.33.1`;
24
+ const WRAPPED = 'alertmanager-0.33.1.darwin-arm64';
25
+ const BYTES = bytesOf('#!fake alertmanager 0.33.1\n');
26
+ const ARCHIVE = gzip(
27
+ tarOf([
28
+ { name: `${WRAPPED}/`, type: '5' },
29
+ { bytes: BYTES, name: `${WRAPPED}/alertmanager` },
30
+ ]),
31
+ );
32
+ const ASSET = 'alertmanager-0.33.1.darwin-arm64.tar.gz';
33
+ const REPO = 'prometheus/alertmanager';
34
+ const TAG = 'v0.33.1';
35
+ const URL = releaseUrl(REPO, TAG, ASSET);
36
+ const PATH = `${DIR}/alertmanager`;
37
+
38
+ /** The pinned props, with or without the declared root — everything else identical. */
39
+ const pins = (root?: string): PinnedBinary => ({
40
+ archive: {
41
+ asset: ASSET,
42
+ repo: REPO,
43
+ sha256: sha256Hex(ARCHIVE),
44
+ size: ARCHIVE.length,
45
+ tag: TAG,
46
+ ...(root === undefined ? {} : { root }),
47
+ },
48
+ member: 'alertmanager',
49
+ name: 'alertmanager',
50
+ sha256: sha256Hex(BYTES),
51
+ });
52
+
53
+ /** One fake host and one fake server; `events` is every host write and request, in order. */
54
+ const harness = () => {
55
+ // ★ SEED THE PARENT, NOT `DIR` ITSELF: `HostDirectory` below creates `DIR` fresh, as
56
+ // plan.test.ts's harness does — a directory pre-seeded at the declared path would instead be
57
+ // an adoption question, which is not what this file is testing.
58
+ const fake = fakeLinuxHost({ dirs: { [ROOT]: 0 }, euid: 0 });
59
+ const events: string[] = [];
60
+ const runner: HostRunner = {
61
+ ...fake.runner,
62
+ writeFileAtomic: (path, bytes, options) => {
63
+ events.push(`write ${path}`);
64
+ return fake.runner.writeFileAtomic(path, bytes, options);
65
+ },
66
+ };
67
+ const client = HttpClient.make((request, url) => {
68
+ events.push(`GET ${url.toString()}`);
69
+ const body = url.toString() === URL ? new Blob([ARCHIVE.slice()]) : null;
70
+ return Effect.succeed(
71
+ HttpClientResponse.fromWeb(request, new Response(body, { status: body ? 200 : 404 })),
72
+ );
73
+ });
74
+ const providers = Layer.mergeAll(makeReleaseBinaryProvider(), HostDirectoryProvider()).pipe(
75
+ Layer.provide(
76
+ Layer.mergeAll(hostRunnerLayer(runner), Layer.succeed(HttpClient.HttpClient, client)),
77
+ ),
78
+ );
79
+ const stack = fakeStack(providers, {}, 'RootBinaryStack');
80
+ const declare = (binary: PinnedBinary) =>
81
+ Effect.gen(function* () {
82
+ const dir = yield* HostDirectory('alertmanager-dir', { mode: 0o755, path: DIR });
83
+ yield* ReleaseBinary('alertmanager', { ...binary, directory: dir.path });
84
+ });
85
+ return { declare, events, fake, stack };
86
+ };
87
+
88
+ describe('a directory-wrapped archive, declared with root', () => {
89
+ test('installs, reads back and re-hashes to the pinned member digest', async () => {
90
+ const h = harness();
91
+ const planned = await h.stack.deploy(h.declare(pins(WRAPPED)));
92
+ expect(planned).toEqual({ alertmanager: 'create', 'alertmanager-dir': 'create' });
93
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BYTES);
94
+ expect(sha256Hex(h.fake.files.get(PATH)?.bytes ?? new Uint8Array())).toBe(sha256Hex(BYTES));
95
+ });
96
+
97
+ test('a second deploy of the same declaration reads the file back; nothing downloaded again', async () => {
98
+ const h = harness();
99
+ await h.stack.deploy(h.declare(pins(WRAPPED)));
100
+ h.events.length = 0;
101
+ await h.stack.deploy(h.declare(pins(WRAPPED)));
102
+ expect(h.events).toEqual([]);
103
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BYTES);
104
+ });
105
+
106
+ test('the same declaration without root refuses, writes nothing, leaves no file at the path', async () => {
107
+ const h = harness();
108
+ await expect(h.stack.deploy(h.declare(pins()))).rejects.toThrow('is a directory');
109
+ expect(h.fake.files.has(PATH)).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('pinProblems refuses an unsafe root, before any host write or network call', () => {
114
+ test.each(['', '.', '..', 'a/b', './a'])('root %j', async (root) => {
115
+ const h = harness();
116
+ await expect(h.stack.deploy(h.declare(pins(root)))).rejects.toThrow('archive.root must be');
117
+ expect(h.fake.files.has(PATH)).toBe(false);
118
+ // ★ mkdir is not instrumented (it goes through `exec`, not `writeFileAtomic`), so HostDirectory
119
+ // creating DIR is expected here; nothing about ReleaseBinary's own pin is.
120
+ expect(h.events.filter((e) => e.startsWith('GET') || e.startsWith('write'))).toEqual([]);
121
+ });
122
+ });
@@ -191,9 +191,17 @@ export const catalogBinary = (catalog: ReleaseCatalog, request: CatalogRequest):
191
191
  message: `Release.Binary ${request.binary}: ${problems.join('; ')}. Nothing was fetched.`,
192
192
  });
193
193
  }
194
- const { asset, repo, sha256: archiveSha256, size, tag } = archive;
194
+ const { asset, repo, root, sha256: archiveSha256, size, tag } = archive;
195
195
  return {
196
- archive: { asset, repo, sha256: archiveSha256, size, tag },
196
+ // ⚠️ exactOptionalPropertyTypes: a conditional spread, never `root: undefined` spelled out.
197
+ archive: {
198
+ asset,
199
+ repo,
200
+ sha256: archiveSha256,
201
+ size,
202
+ tag,
203
+ ...(root === undefined ? {} : { root }),
204
+ },
197
205
  member,
198
206
  name: request.binary,
199
207
  sha256,