@homeflare/alchemy 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +173 -120
- package/dist/launchd/index.js.map +12 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js.map +3 -3
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +1 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +76 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1365 -0
- package/dist/release/index.js.map +35 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/release-binary-catalogs.md +113 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +154 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/sudo-listing.ts +122 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.ts +1 -1
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +96 -0
- package/src/release/catalog.ts +175 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +41 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +174 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release.Binary and HostDirectory deployed through Alchemy's OWN Plan and Apply
|
|
3
|
+
* (openbao/fake-stack.ts, in-memory state), over a fake host (linux/fake-linux-host.ts) and a fake
|
|
4
|
+
* server holding the synthetic vmutils archive at its real URL. `events` is every host change and
|
|
5
|
+
* request, in order; `serving = false` answers every request 404.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY, like fake-release.ts: no provider imports it and it is not on the barrel.
|
|
8
|
+
*/
|
|
9
|
+
import { renamedFrom } from 'alchemy/Rename';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
13
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
14
|
+
import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
|
|
15
|
+
import { HostDirectory, HostDirectoryProvider } from '../linux/directory.ts';
|
|
16
|
+
import { fakeLinuxHost } from '../linux/fake-linux-host.ts';
|
|
17
|
+
import { fakeStack } from '../openbao/fake-stack.ts';
|
|
18
|
+
import type { ReleaseBinaryProps } from './binary-form.ts';
|
|
19
|
+
import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
|
|
20
|
+
import { catalogBinary } from './catalog.ts';
|
|
21
|
+
import { VMALERT_REQUEST, VMUTILS_URL, bytesOf, syntheticRelease } from './fake-release.ts';
|
|
22
|
+
|
|
23
|
+
export const ROOT = '/opt/example/bin';
|
|
24
|
+
export const DIR = `${ROOT}/vmutils-1.151.0`;
|
|
25
|
+
export const PATH = `${DIR}/vmalert`;
|
|
26
|
+
export const THEIRS = bytesOf('#!someone else built this vmalert\n');
|
|
27
|
+
|
|
28
|
+
/** One fake host (with DIR already there when `dirExists`), one fake server, one stack. */
|
|
29
|
+
export const deployHarness = (dirExists: boolean) => {
|
|
30
|
+
const release = syntheticRelease();
|
|
31
|
+
const pins = catalogBinary(release.catalog, VMALERT_REQUEST);
|
|
32
|
+
const fake = fakeLinuxHost({
|
|
33
|
+
dirs: dirExists ? { [DIR]: 0, [ROOT]: 0 } : { [ROOT]: 0 },
|
|
34
|
+
euid: 0,
|
|
35
|
+
});
|
|
36
|
+
const events: string[] = [];
|
|
37
|
+
const server = { serving: true };
|
|
38
|
+
const base = fake.runner;
|
|
39
|
+
const runner: HostRunner = {
|
|
40
|
+
...base,
|
|
41
|
+
exec: (argv) => {
|
|
42
|
+
events.push(argv.filter((arg) => arg !== '--').join(' '));
|
|
43
|
+
return base.exec(argv);
|
|
44
|
+
},
|
|
45
|
+
removeFile: (path) => {
|
|
46
|
+
events.push(`remove ${path}`);
|
|
47
|
+
return base.removeFile(path);
|
|
48
|
+
},
|
|
49
|
+
writeFileAtomic: (path, bytes, options) => {
|
|
50
|
+
events.push(`write ${path}`);
|
|
51
|
+
return base.writeFileAtomic(path, bytes, options);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const client = HttpClient.make((request, url) => {
|
|
55
|
+
events.push(`GET ${url.toString()}`);
|
|
56
|
+
const found = server.serving && url.toString() === VMUTILS_URL;
|
|
57
|
+
const body = found ? new Blob([release.vmutils.slice()]) : null;
|
|
58
|
+
return Effect.succeed(
|
|
59
|
+
HttpClientResponse.fromWeb(request, new Response(body, { status: found ? 200 : 404 })),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
const providers = Layer.mergeAll(makeReleaseBinaryProvider(), HostDirectoryProvider()).pipe(
|
|
63
|
+
Layer.provide(
|
|
64
|
+
Layer.mergeAll(hostRunnerLayer(runner), Layer.succeed(HttpClient.HttpClient, client)),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
const place = (bytes: Uint8Array, mode = 0o755, kind: 'file' | 'symlink' = 'file') =>
|
|
68
|
+
fake.files.set(PATH, { bytes, gid: 0, kind, mode, uid: 0 });
|
|
69
|
+
/** The binary declared with the directory as a literal, so Alchemy probes it. */
|
|
70
|
+
const literal = ReleaseBinary('vmalert', { ...pins, directory: DIR });
|
|
71
|
+
/** The documented shape: the directory is HostDirectory's `path`, an Output at a first plan. */
|
|
72
|
+
const wired = (
|
|
73
|
+
id = 'vmalert',
|
|
74
|
+
dirMode = 0o755,
|
|
75
|
+
formerId?: string,
|
|
76
|
+
more: Partial<ReleaseBinaryProps> = {},
|
|
77
|
+
) =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const dir = yield* HostDirectory('dir', { mode: dirMode, path: DIR });
|
|
80
|
+
const binary = ReleaseBinary(id, { ...pins, ...more, directory: dir.path });
|
|
81
|
+
yield* formerId === undefined ? binary : binary.pipe(renamedFrom(formerId));
|
|
82
|
+
});
|
|
83
|
+
/** The directory alone: the binary's declaration removed. */
|
|
84
|
+
const dirOnly = HostDirectory('dir', { mode: 0o755, path: DIR });
|
|
85
|
+
const stack = fakeStack(providers, {}, 'ReleaseStack');
|
|
86
|
+
return { dirOnly, events, fake, literal, place, server, stack, wired };
|
|
87
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release.Binary through Alchemy's own Plan and Apply (openbao/fake-stack.ts) over the REAL
|
|
3
|
+
* localRunner, for tests that need what a fake host cannot model: two spellings of one file, and
|
|
4
|
+
* the engine's order across resources. The HTTP server is a fake serving the synthetic vmutils.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, like fake-release.ts: no provider imports it and it is not on the barrel. Every
|
|
7
|
+
* directory a test passes must be under its own `mkdtemp`; nothing here chooses a path.
|
|
8
|
+
*/
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
12
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
13
|
+
import { localRunner } from '../launchd/local-runner.ts';
|
|
14
|
+
import { hostRunnerLayer } from '../launchd/runner.ts';
|
|
15
|
+
import { fakeStack } from '../openbao/fake-stack.ts';
|
|
16
|
+
import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
|
|
17
|
+
import { catalogBinary } from './catalog.ts';
|
|
18
|
+
import { BINARY, VMALERT_REQUEST, VMUTILS_URL, syntheticRelease } from './fake-release.ts';
|
|
19
|
+
|
|
20
|
+
/** vmalert's bytes as text, to compare with what a test reads off the disk. */
|
|
21
|
+
export const VMALERT_TEXT = new TextDecoder().decode(BINARY.vmalert);
|
|
22
|
+
|
|
23
|
+
/** One stack over localRunner; `deploy` takes logical id → directory; `gets` counts downloads. */
|
|
24
|
+
export const realEngine = () => {
|
|
25
|
+
const { catalog, vmutils } = syntheticRelease();
|
|
26
|
+
const gets: string[] = [];
|
|
27
|
+
const client = HttpClient.make((request, url) => {
|
|
28
|
+
gets.push(url.toString());
|
|
29
|
+
const body = url.toString() === VMUTILS_URL ? new Blob([vmutils.slice()]) : null;
|
|
30
|
+
return Effect.succeed(
|
|
31
|
+
HttpClientResponse.fromWeb(request, new Response(body, { status: body ? 200 : 404 })),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
const providers = makeReleaseBinaryProvider().pipe(
|
|
35
|
+
Layer.provide(
|
|
36
|
+
Layer.mergeAll(hostRunnerLayer(localRunner()), Layer.succeed(HttpClient.HttpClient, client)),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
const stack = fakeStack(providers, {}, 'RealEngineStack');
|
|
40
|
+
const pins = catalogBinary(catalog, VMALERT_REQUEST);
|
|
41
|
+
const deploy = (binaries: Readonly<Record<string, string>>) =>
|
|
42
|
+
stack.deploy(
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
for (const [id, directory] of Object.entries(binaries)) {
|
|
45
|
+
yield* ReleaseBinary(id, { ...pins, directory });
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
return { deploy, gets };
|
|
50
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install lifecycle's test bench: a fake host, a synthetic release served at the REAL URLs next
|
|
3
|
+
* to an `-enterprise` decoy, and `refused()`, which asserts a refusal left the host exactly as it
|
|
4
|
+
* was and wrote nothing.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, like fake-release.ts: no provider imports it and it is not on the barrel.
|
|
7
|
+
*/
|
|
8
|
+
import { expect } from 'bun:test';
|
|
9
|
+
import type { ReleaseBinaryProps } from './binary-form.ts';
|
|
10
|
+
import { reconcileBinary } from './binary-lifecycle.ts';
|
|
11
|
+
import {
|
|
12
|
+
ENTERPRISE_URL,
|
|
13
|
+
TRACES_URL,
|
|
14
|
+
VMUTILS_DIR,
|
|
15
|
+
VMUTILS_ENTRIES,
|
|
16
|
+
VMUTILS_URL,
|
|
17
|
+
bytesOf,
|
|
18
|
+
fakeTransport,
|
|
19
|
+
gzip,
|
|
20
|
+
pathsOn,
|
|
21
|
+
releaseHost,
|
|
22
|
+
syntheticRelease,
|
|
23
|
+
tarOf,
|
|
24
|
+
vmalertProps,
|
|
25
|
+
} from './fake-release.ts';
|
|
26
|
+
|
|
27
|
+
export const PATH = `${VMUTILS_DIR}/vmalert`;
|
|
28
|
+
export const writes = (fake: ReturnType<typeof releaseHost>) =>
|
|
29
|
+
fake.calls.filter((c) => c[0] === 'write');
|
|
30
|
+
|
|
31
|
+
/** A host, a synthetic release served at the real URLs (plus an enterprise decoy), and a runner. */
|
|
32
|
+
export const setup = (entries = VMUTILS_ENTRIES) => {
|
|
33
|
+
const { catalog, traces, vmutils } = syntheticRelease(entries);
|
|
34
|
+
const decoy = gzip(tarOf([{ bytes: bytesOf('#!enterprise\n'), name: 'vmalert-prod' }]));
|
|
35
|
+
const transport = fakeTransport({
|
|
36
|
+
[ENTERPRISE_URL]: decoy,
|
|
37
|
+
[TRACES_URL]: traces,
|
|
38
|
+
[VMUTILS_URL]: vmutils,
|
|
39
|
+
});
|
|
40
|
+
const fake = releaseHost();
|
|
41
|
+
const props = (more: Partial<ReleaseBinaryProps> = {}) => vmalertProps(catalog, more);
|
|
42
|
+
const install = (declared = props(), options = {}) =>
|
|
43
|
+
reconcileBinary(fake.runner, transport.fetch, declared, options);
|
|
44
|
+
return { fake, install, props, transport };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Run a refusal; return it, having checked the host is exactly as it was and nothing was written. */
|
|
48
|
+
export const refused = async (s: ReturnType<typeof setup>, run: () => Promise<unknown>) => {
|
|
49
|
+
const before = pathsOn(s.fake);
|
|
50
|
+
const error = await run().then(
|
|
51
|
+
() => {
|
|
52
|
+
throw new Error('expected a refusal');
|
|
53
|
+
},
|
|
54
|
+
(cause: unknown) => cause,
|
|
55
|
+
);
|
|
56
|
+
expect(pathsOn(s.fake)).toEqual(before);
|
|
57
|
+
expect(writes(s.fake)).toEqual([]);
|
|
58
|
+
return error as Error;
|
|
59
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Release.Binary provider over one fake host and one fake HTTP server, for the handler tests
|
|
3
|
+
* beside it: `requests` counts every URL asked, `notes` every `session.note`.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, like fake-release.ts: no provider imports it and it is not on the barrel.
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
10
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
11
|
+
import { hostRunnerLayer } from '../launchd/runner.ts';
|
|
12
|
+
import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
|
|
13
|
+
import {
|
|
14
|
+
VMUTILS_DIR,
|
|
15
|
+
VMUTILS_URL,
|
|
16
|
+
releaseHost,
|
|
17
|
+
syntheticRelease,
|
|
18
|
+
vmalertProps,
|
|
19
|
+
} from './fake-release.ts';
|
|
20
|
+
|
|
21
|
+
export const ids = { fqn: 'stack/vmalert', id: 'vmalert', instanceId: 'i-1' };
|
|
22
|
+
export const call = { bindings: [] as never, newBindings: [] as never, oldBindings: [] as never };
|
|
23
|
+
export const PATH = `${VMUTILS_DIR}/vmalert`;
|
|
24
|
+
|
|
25
|
+
export type Service = Effect.Success<typeof ReleaseBinary.Provider>;
|
|
26
|
+
|
|
27
|
+
/** One provider over one fake host and one fake HTTP server; `requests` counts every URL asked. */
|
|
28
|
+
export const harness = () => {
|
|
29
|
+
const release = syntheticRelease();
|
|
30
|
+
const VMALERT = vmalertProps(release.catalog);
|
|
31
|
+
const fake = releaseHost();
|
|
32
|
+
const requests: string[] = [];
|
|
33
|
+
const notes: string[] = [];
|
|
34
|
+
const client = HttpClient.make((request, url) => {
|
|
35
|
+
requests.push(url.toString());
|
|
36
|
+
const body = url.toString() === VMUTILS_URL ? new Blob([release.vmutils.slice()]) : null;
|
|
37
|
+
return Effect.succeed(
|
|
38
|
+
HttpClientResponse.fromWeb(request, new Response(body, { status: body ? 200 : 404 })),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
const layer = makeReleaseBinaryProvider().pipe(
|
|
42
|
+
Layer.provide(
|
|
43
|
+
Layer.mergeAll(hostRunnerLayer(fake.runner), Layer.succeed(HttpClient.HttpClient, client)),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
const session = { note: (m: string) => Effect.sync(() => void notes.push(m)) } as never;
|
|
47
|
+
const run = <A>(use: (p: Service) => Effect.Effect<A, unknown>) =>
|
|
48
|
+
Effect.runPromise(
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
return yield* use(yield* ReleaseBinary.Provider);
|
|
51
|
+
}).pipe(Effect.provide(layer)) as Effect.Effect<A>,
|
|
52
|
+
);
|
|
53
|
+
const reconcile = (p: Service, news: object, output?: unknown, olds?: object) =>
|
|
54
|
+
p.reconcile({
|
|
55
|
+
...ids,
|
|
56
|
+
...call,
|
|
57
|
+
news: news as never,
|
|
58
|
+
olds: olds as never,
|
|
59
|
+
output: output as never,
|
|
60
|
+
session,
|
|
61
|
+
});
|
|
62
|
+
return { VMALERT, fake, notes, reconcile, release, requests, run };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const must = <F>(fn: F | undefined): F => {
|
|
66
|
+
if (fn === undefined) throw new Error('provider has no such handler');
|
|
67
|
+
return fn;
|
|
68
|
+
};
|