@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.
- package/README.md +15 -1
- package/dist/litellm/client.d.ts +76 -0
- package/dist/litellm/client.d.ts.map +1 -0
- package/dist/litellm/credentials.d.ts +48 -0
- package/dist/litellm/credentials.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +36 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -0
- package/dist/litellm/fake-stack.d.ts +30 -0
- package/dist/litellm/fake-stack.d.ts.map +1 -0
- package/dist/litellm/generated/pass-through.d.ts +71 -0
- package/dist/litellm/generated/pass-through.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +13 -0
- package/dist/litellm/index.d.ts.map +1 -0
- package/dist/litellm/index.js +270 -0
- package/dist/litellm/index.js.map +14 -0
- package/dist/litellm/pass-through-endpoint.d.ts +94 -0
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +40 -0
- package/dist/litellm/pass-through-form.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +16 -0
- package/dist/litellm/providers.d.ts.map +1 -0
- package/dist/postgres/connection.d.ts +50 -0
- package/dist/postgres/connection.d.ts.map +1 -0
- package/dist/postgres/database-attrs.d.ts +79 -0
- package/dist/postgres/database-attrs.d.ts.map +1 -0
- package/dist/postgres/database-sql.d.ts +73 -0
- package/dist/postgres/database-sql.d.ts.map +1 -0
- package/dist/postgres/database.d.ts +63 -0
- package/dist/postgres/database.d.ts.map +1 -0
- package/dist/postgres/errors.d.ts +86 -0
- package/dist/postgres/errors.d.ts.map +1 -0
- package/dist/postgres/fake-sql.d.ts +22 -0
- package/dist/postgres/fake-sql.d.ts.map +1 -0
- package/dist/postgres/index.d.ts +16 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +238 -0
- package/dist/postgres/index.js.map +15 -0
- package/dist/postgres/providers.d.ts +14 -0
- package/dist/postgres/providers.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts +12 -1
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +8 -3
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +9 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +17 -9
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
- package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +48 -7
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts +52 -22
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/archive.d.ts +6 -2
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/binary-form.d.ts +14 -9
- package/dist/release/binary-form.d.ts.map +1 -1
- package/dist/release/binary-pins.d.ts +17 -0
- package/dist/release/binary-pins.d.ts.map +1 -0
- package/dist/release/catalog.d.ts.map +1 -1
- package/dist/release/index.js +90 -40
- package/dist/release/index.js.map +8 -7
- package/dist/release/tar.d.ts +9 -2
- package/dist/release/tar.d.ts.map +1 -1
- package/docs/adopted-deploys.md +14 -0
- package/docs/litellm.md +110 -0
- package/docs/peers.md +8 -1
- package/docs/postgres.md +125 -0
- package/docs/release-binary-catalogs.md +40 -4
- package/docs/release-binary-refusals.md +22 -8
- package/docs/release-binary-upstream.md +33 -15
- package/package.json +10 -1
- package/src/litellm/client.ts +195 -0
- package/src/litellm/credentials.ts +73 -0
- package/src/litellm/fake-litellm.ts +114 -0
- package/src/litellm/fake-stack.ts +100 -0
- package/src/litellm/generated/pass-through.ts +73 -0
- package/src/litellm/index.ts +42 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
- package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
- package/src/litellm/pass-through-endpoint.test.ts +208 -0
- package/src/litellm/pass-through-endpoint.ts +258 -0
- package/src/litellm/pass-through-form.test.ts +128 -0
- package/src/litellm/pass-through-form.ts +136 -0
- package/src/litellm/providers.ts +19 -0
- package/src/postgres/connection.ts +62 -0
- package/src/postgres/database-attrs.ts +87 -0
- package/src/postgres/database-bigint-serialization.test.ts +140 -0
- package/src/postgres/database-lifecycle.test.ts +113 -0
- package/src/postgres/database-provenance.test.ts +118 -0
- package/src/postgres/database-refusals.test.ts +135 -0
- package/src/postgres/database-sql.ts +174 -0
- package/src/postgres/database.ts +193 -0
- package/src/postgres/errors.ts +131 -0
- package/src/postgres/fake-sql.ts +123 -0
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
- package/src/postgres/index.ts +30 -0
- package/src/postgres/providers.ts +16 -0
- package/src/proxmox/api-token-adopt.test.ts +138 -0
- package/src/proxmox/api-token-form.ts +19 -2
- package/src/proxmox/api-token.ts +8 -3
- package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
- package/src/proxmox/index.test.ts +5 -2
- package/src/proxmox/index.ts +9 -2
- package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
- package/src/proxmox/zfs-pool-spec.ts +76 -0
- package/src/proxmox/zfs-pool-write.ts +63 -9
- package/src/proxmox/zfs-pool.ts +55 -97
- package/src/release/archive.ts +10 -4
- package/src/release/binary-form.ts +24 -76
- package/src/release/binary-pins.ts +95 -0
- package/src/release/binary-root.test.ts +122 -0
- package/src/release/catalog.ts +10 -2
- package/src/release/tar-root.test.ts +114 -0
- package/src/release/tar.ts +78 -12
package/src/proxmox/zfs-pool.ts
CHANGED
|
@@ -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
|
-
* ⛔
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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`.
|
|
55
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
raidlevel
|
|
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
|
/**
|
package/src/release/archive.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/release/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|