@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,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Release.Binary` — one binary out of a pinned release archive, written onto a host and verified
|
|
3
|
+
* twice: the archive against its pinned SHA-256, then the binary against its own.
|
|
4
|
+
*
|
|
5
|
+
* - reconcile — validate every prop; download the archive by its exact URL; verify it; unpack ONLY
|
|
6
|
+
* the declared member; verify that; write it atomically through the HostRunner; read it back. A
|
|
7
|
+
* file already holding the pinned bytes is never downloaded again.
|
|
8
|
+
* - read — lstat plus the SHA-256 of the bytes; never runs the binary.
|
|
9
|
+
* - diff — the pins, the path, then mode and owner against the live file (binary-diff.ts).
|
|
10
|
+
* - replace — when the path moves (a new version is a new directory): create-before-delete.
|
|
11
|
+
* - delete — removes the file, never the directory.
|
|
12
|
+
*
|
|
13
|
+
* ★ VENDOR-NEUTRAL. The pinned archive arrives as props, usually from a data set beside this
|
|
14
|
+
* (victoria.ts) through `catalogBinary()`; nothing here knows a vendor, a package or a version.
|
|
15
|
+
* ⛔ IT INSTALLS; IT NEVER STARTS. No launchctl, no systemctl, no restart: the job that runs the
|
|
16
|
+
* binary is the stack's own `LaunchdJob`, which puts this resource's `path` in its argv so the
|
|
17
|
+
* engine installs first and restarts the job when the path moves.
|
|
18
|
+
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`, AT PLAN OR AT APPLY: the probe reads a file already at
|
|
19
|
+
* the path as `Unowned` — even one holding exactly the pinned bytes (docs/ownership.md) — and
|
|
20
|
+
* where the probe never ran (a prop was an Output) reconcile refuses it the same way. With
|
|
21
|
+
* `--adopt`, a file holding the pinned bytes is recognised by its digest and taken over without
|
|
22
|
+
* a download. ⛔ A file with OTHER bytes is never adopted, `--adopt` or not: that would be an
|
|
23
|
+
* overwrite in place that the plan prints as `adopted` (binary-claim.ts, both halves).
|
|
24
|
+
*/
|
|
25
|
+
import { Resource } from 'alchemy';
|
|
26
|
+
import * as Provider from 'alchemy/Provider';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
29
|
+
import { lift } from '../launchd/host-effect.ts';
|
|
30
|
+
import { HostRunnerService } from '../launchd/runner.ts';
|
|
31
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
32
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
33
|
+
import { diffBinary } from './binary-diff.ts';
|
|
34
|
+
import {
|
|
35
|
+
type ReleaseBinaryAttributes,
|
|
36
|
+
type ReleaseBinaryProps,
|
|
37
|
+
releaseBinaryPath,
|
|
38
|
+
} from './binary-form.ts';
|
|
39
|
+
import {
|
|
40
|
+
NOTHING,
|
|
41
|
+
deleteBinary,
|
|
42
|
+
reconcileBinary,
|
|
43
|
+
refreshBinary,
|
|
44
|
+
refuse,
|
|
45
|
+
} from './binary-lifecycle.ts';
|
|
46
|
+
import { readWithoutState } from './binary-read.ts';
|
|
47
|
+
import { declaredPinProblems } from './declared-pins.ts';
|
|
48
|
+
import { type DownloadPolicy, httpFetchArchive, sharingInFlight } from './download.ts';
|
|
49
|
+
|
|
50
|
+
export type { ReleaseBinaryAttributes, ReleaseBinaryProps } from './binary-form.ts';
|
|
51
|
+
|
|
52
|
+
export interface ReleaseBinary extends Resource<
|
|
53
|
+
'Release.Binary',
|
|
54
|
+
ReleaseBinaryProps,
|
|
55
|
+
ReleaseBinaryAttributes
|
|
56
|
+
> {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One binary from a pinned release archive, installed onto a host and verified against digests
|
|
60
|
+
* the stack pins in code — the archive's, then the binary's own.
|
|
61
|
+
*
|
|
62
|
+
* ### Installing a binary
|
|
63
|
+
*
|
|
64
|
+
* The directory is declared first and passed by its `path`, which orders the two; the job that
|
|
65
|
+
* runs the binary references the binary's `path`, which orders the job after it.
|
|
66
|
+
*
|
|
67
|
+
* **Example:**
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const vmalertOf = { package: 'vmutils', version: '1.151.0', platform: 'darwin-arm64', binary: 'vmalert' };
|
|
71
|
+
* const dir = yield* HostDirectory('vmutils-1.151.0', {
|
|
72
|
+
* path: catalogDirectory('/opt/example/bin', vmalertOf),
|
|
73
|
+
* mode: 0o755,
|
|
74
|
+
* });
|
|
75
|
+
* const vmalert = yield* ReleaseBinary('vmalert', {
|
|
76
|
+
* ...catalogBinary(VICTORIA_RELEASES, vmalertOf),
|
|
77
|
+
* directory: dir.path,
|
|
78
|
+
* });
|
|
79
|
+
* // LaunchdJob(…, { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'] })
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @resource
|
|
83
|
+
*/
|
|
84
|
+
export const ReleaseBinary = Resource<ReleaseBinary>('Release.Binary');
|
|
85
|
+
|
|
86
|
+
/** Test seams — NOT on the barrel. Every caller outside tests uses the defaults. */
|
|
87
|
+
export type ReleaseBinaryInternals = { readonly download?: DownloadPolicy };
|
|
88
|
+
|
|
89
|
+
export const makeReleaseBinaryProvider = (internals: ReleaseBinaryInternals = {}) =>
|
|
90
|
+
Provider.effect(
|
|
91
|
+
ReleaseBinary,
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const runner = yield* HostRunnerService;
|
|
94
|
+
const client = yield* HttpClient.HttpClient;
|
|
95
|
+
const fetch = sharingInFlight(httpFetchArchive(client, internals.download));
|
|
96
|
+
/**
|
|
97
|
+
* ⛔ ONE PATH, ONE DECLARATION. MEASURED 2026-09-22 (binary-claims.test.ts, before this): two
|
|
98
|
+
* resources installing one path in one deploy both planned `create`, shared one download,
|
|
99
|
+
* and both recorded the file as theirs. Dropping either later — gating vmalert-logs off
|
|
100
|
+
* along with its own ReleaseBinary, say — deleted the file the other still declared, and
|
|
101
|
+
* that deploy reported the survivor `noop`. So a second resource claiming a path in the
|
|
102
|
+
* same run is refused before it touches anything. Two jobs that run one binary share ONE
|
|
103
|
+
* ReleaseBinary. ⚠️ Keyed by spelling: one file under two spellings is not caught here.
|
|
104
|
+
*/
|
|
105
|
+
const claims = new Map<string, string>();
|
|
106
|
+
const claim = (fqn: string, props: ReleaseBinaryProps): void => {
|
|
107
|
+
const path = releaseBinaryPath(props);
|
|
108
|
+
const holder = claims.get(path);
|
|
109
|
+
if (holder !== undefined && holder !== fqn) {
|
|
110
|
+
throw refuse(
|
|
111
|
+
path,
|
|
112
|
+
`is already installed by ${holder} in this deploy; declare each binary path once, ` +
|
|
113
|
+
`and let every job that runs it share that one resource. ${NOTHING}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
claims.set(path, fqn);
|
|
117
|
+
};
|
|
118
|
+
return ReleaseBinary.Provider.of({
|
|
119
|
+
list: () => Effect.succeed([]),
|
|
120
|
+
|
|
121
|
+
// ⛔ The probe (no `output`) validates every prop first, so a malformed pin fails the plan
|
|
122
|
+
// here — before any host call, let alone a download. A recovery read never fails its
|
|
123
|
+
// caller (binary-read.ts): its props are the failed deploy's.
|
|
124
|
+
read: ({ fqn, instanceId, olds, output }) =>
|
|
125
|
+
output === undefined
|
|
126
|
+
? readWithoutState(runner, { fqn, instanceId, olds })
|
|
127
|
+
: lift(() => refreshBinary(runner, output)),
|
|
128
|
+
|
|
129
|
+
diff: ({ instanceId, news, olds, output }) =>
|
|
130
|
+
output === undefined
|
|
131
|
+
? noteUnfinished(instanceId)
|
|
132
|
+
: lift(() => diffBinary(runner, news, olds, output)),
|
|
133
|
+
|
|
134
|
+
// ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
|
|
135
|
+
// ⛔ So do the pins AS DECLARED: `news` is resolved by now, and a first deploy is never
|
|
136
|
+
// diffed, so only the stack's own record can show a digest that was an Output.
|
|
137
|
+
reconcile: ({ fqn, instanceId, news, olds, output, session }) =>
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
const declared = yield* declaredPinProblems(fqn);
|
|
140
|
+
const adopt = yield* adoptsAtApply({ fqn, instanceId, output });
|
|
141
|
+
return yield* lift(async () => {
|
|
142
|
+
claim(fqn, news);
|
|
143
|
+
return reconcileBinary(runner, fetch, news, {
|
|
144
|
+
adopt,
|
|
145
|
+
declared,
|
|
146
|
+
note: (message) => Effect.runPromise(session.note(message)),
|
|
147
|
+
olds,
|
|
148
|
+
output,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}),
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ⛔ NEVER REMOVE A FILE ANOTHER DECLARATION INSTALLED IN THIS DEPLOY. Alchemy runs every
|
|
155
|
+
* delete (Apply.ts collectGarbage, Phase 2) only after every reconcile has succeeded, so a
|
|
156
|
+
* claim by another name means the file is that resource's now, verified as its pin.
|
|
157
|
+
* 🔴 MEASURED 2026-09-22 (adopt-parity.test.ts): a rename under --adopt — the new name
|
|
158
|
+
* claimed the file, the old name's orphan delete removed it, and the deploy succeeded
|
|
159
|
+
* with the binary gone. ★ Left in place rather than removed: a stray file is a tidy-up, a
|
|
160
|
+
* missing binary is an outage. ⚠️ Keyed by spelling, like the claims themselves.
|
|
161
|
+
* ★ A deleted resource gives its claim back, so a provider that outlives one deploy never
|
|
162
|
+
* refuses the next resource declared at that path.
|
|
163
|
+
*/
|
|
164
|
+
delete: ({ fqn, output, session }) =>
|
|
165
|
+
lift(async () => {
|
|
166
|
+
const holder = claims.get(output.path);
|
|
167
|
+
if (holder !== undefined && holder !== fqn) {
|
|
168
|
+
await Effect.runPromise(
|
|
169
|
+
session.note(`${output.path} was installed by ${holder} in this deploy; kept`),
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await deleteBinary(runner, output);
|
|
174
|
+
if (holder === fqn) claims.delete(output.path);
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/** The provider, as a stack uses it. It needs a HostRunnerService and an HttpClient. */
|
|
181
|
+
export const ReleaseBinaryProvider = () => makeReleaseBinaryProvider();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A catalog request → pinned props, or a refusal — pure, over the REAL Victoria data set. Every
|
|
3
|
+
* refusal here happens in the stack's own program, so it fails the plan before any resource runs
|
|
4
|
+
* (plan.test.ts proves that through the engine).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { binaryProblems } from './binary-form.ts';
|
|
8
|
+
import { catalogBinary, catalogDirectory, catalogProblems, identifyBinary } from './catalog.ts';
|
|
9
|
+
import { BinaryRefused } from './refused.ts';
|
|
10
|
+
import { VICTORIA_RELEASES } from './victoria.ts';
|
|
11
|
+
|
|
12
|
+
const VMALERT = {
|
|
13
|
+
binary: 'vmalert',
|
|
14
|
+
package: 'vmutils',
|
|
15
|
+
platform: 'darwin-arm64',
|
|
16
|
+
version: '1.151.0',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('refusing what the catalog does not pin', () => {
|
|
20
|
+
test.each([
|
|
21
|
+
[
|
|
22
|
+
'a version the catalog does not pin',
|
|
23
|
+
{ version: '1.152.0' },
|
|
24
|
+
'vmutils 1.152.0 is not in the catalog',
|
|
25
|
+
],
|
|
26
|
+
['the tag spelling', { version: 'v1.151.0' }, 'is not in the catalog'],
|
|
27
|
+
['an enterprise version', { version: '1.151.0-enterprise' }, 'is not in the catalog'],
|
|
28
|
+
[
|
|
29
|
+
'a cluster package',
|
|
30
|
+
{ package: 'victoria-metrics-cluster' },
|
|
31
|
+
'package "victoria-metrics-cluster"',
|
|
32
|
+
],
|
|
33
|
+
['the vendor member name', { binary: 'vmalert-prod' }, 'that is the archive member'],
|
|
34
|
+
['a binary vmutils does not ship', { binary: 'vminsert' }, 'has no binary "vminsert"'],
|
|
35
|
+
['another platform', { platform: 'linux-amd64' }, 'not pinned for platform "linux-amd64"'],
|
|
36
|
+
['an inherited key', { package: 'constructor' }, 'package "constructor"'],
|
|
37
|
+
])('refuses %s', (_, change, message) => {
|
|
38
|
+
const request = { ...VMALERT, ...change };
|
|
39
|
+
expect(catalogProblems(VICTORIA_RELEASES, request).join('; ')).toContain(message);
|
|
40
|
+
expect(() => catalogBinary(VICTORIA_RELEASES, request)).toThrow(message);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the refusal is typed, names the binary, and says nothing was fetched', () => {
|
|
44
|
+
let caught: unknown;
|
|
45
|
+
try {
|
|
46
|
+
catalogBinary(VICTORIA_RELEASES, { ...VMALERT, version: '1.152.0' });
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
caught = cause;
|
|
49
|
+
}
|
|
50
|
+
expect(caught).toBeInstanceOf(BinaryRefused);
|
|
51
|
+
expect((caught as Error).message).toMatch(
|
|
52
|
+
/^Release\.Binary vmalert: .* Nothing was fetched\.$/,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('what a pinned request becomes', () => {
|
|
58
|
+
test('props that validate once the stack adds a directory', () => {
|
|
59
|
+
const pinned = catalogBinary(VICTORIA_RELEASES, VMALERT);
|
|
60
|
+
const directory = catalogDirectory('/opt/example/bin', VMALERT);
|
|
61
|
+
expect(directory).toBe('/opt/example/bin/vmutils-1.151.0');
|
|
62
|
+
expect(binaryProblems({ ...pinned, directory })).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('every binary of every pinned archive resolves to valid props', () => {
|
|
66
|
+
for (const [pkg, entry] of Object.entries(VICTORIA_RELEASES.packages)) {
|
|
67
|
+
for (const [version, platforms] of Object.entries(entry.versions)) {
|
|
68
|
+
for (const platform of Object.keys(platforms)) {
|
|
69
|
+
for (const binary of Object.keys(entry.binaries)) {
|
|
70
|
+
const request = { binary, package: pkg, platform, version };
|
|
71
|
+
const props = { ...catalogBinary(VICTORIA_RELEASES, request), directory: '/x/y' };
|
|
72
|
+
expect(binaryProblems(props)).toEqual([]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('identifying an installed file', () => {
|
|
81
|
+
test('by its digest alone', () => {
|
|
82
|
+
const pin = '4759ec89a466129b9eefc22244a111e1f489739340747805fd93b58fd0f7b2dd';
|
|
83
|
+
expect(identifyBinary([VICTORIA_RELEASES], pin)).toEqual({
|
|
84
|
+
binary: 'victoria-traces',
|
|
85
|
+
package: 'victoria-traces',
|
|
86
|
+
platform: 'darwin-arm64',
|
|
87
|
+
vendor: 'VictoriaMetrics',
|
|
88
|
+
version: '0.10.0',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an archive's digest is not a binary's: identity is by the member's own line", () => {
|
|
93
|
+
const archive = 'cbe83f1d409cbb85fbf1c890c4a9b7fd34c1d74acec4dce2e0ebe46fcb260e38';
|
|
94
|
+
expect(identifyBinary([VICTORIA_RELEASES], archive)).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A vendor's pinned releases as DATA, and the one way to turn an entry into Release.Binary props —
|
|
3
|
+
* or into the reasons there is none. Pure: no network, no host.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE DATA SITS BESIDE THE RESOURCE, NOT INSIDE IT. A catalog (victoria.ts is the first) is the
|
|
6
|
+
* reviewed record of what one vendor published: exact asset names, sizes, both digest layers and
|
|
7
|
+
* where each was copied from. The resource takes one pinned archive as props and never reads a
|
|
8
|
+
* catalog, so adding a vendor is a data module with its own walk-down, never a resource change.
|
|
9
|
+
* ★ A VERSION THE CATALOG DOES NOT LIST IS A REFUSAL, NOT A DOWNLOAD ATTEMPT. `catalogBinary()` runs
|
|
10
|
+
* in the stack's own program — which `alchemy plan` evaluates — so an unknown version, a
|
|
11
|
+
* `-enterprise` spelling or a `v` prefix fails the plan before any resource is asked anything,
|
|
12
|
+
* let alone fetches a byte.
|
|
13
|
+
* ⛔ ADD A VENDOR ONLY AFTER ITS OWN WALK-DOWN (docs/release-binary-catalogs.md): its checksum
|
|
14
|
+
* format read byte-exact, and its binary's linkage measured (`otool -L`). Vendor binaries were
|
|
15
|
+
* measured self-contained ONLY for the five Victoria ones; that is not a property of "Go".
|
|
16
|
+
*/
|
|
17
|
+
import type { ReleaseArchive, ReleaseBinaryProps } from './binary-form.ts';
|
|
18
|
+
import { BinaryRefused } from './refused.ts';
|
|
19
|
+
|
|
20
|
+
/** One release archive as a catalog records it: the pin, plus where every digest came from. */
|
|
21
|
+
export interface PinnedArchive extends ReleaseArchive {
|
|
22
|
+
/** Archive member → its SHA-256. Every member the vendor's checksum file lists, not just ours. */
|
|
23
|
+
readonly members: Readonly<Record<string, string>>;
|
|
24
|
+
/** The checksum file the digests were copied from, its own SHA-256, and when. Never fetched. */
|
|
25
|
+
readonly checksums: { readonly url: string; readonly sha256: string; readonly recorded: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One package (one archive per version and platform) of a vendor. */
|
|
29
|
+
export interface CatalogPackage {
|
|
30
|
+
/**
|
|
31
|
+
* Installed name → archive member, written out, never derived: a member the vendor adds is
|
|
32
|
+
* installable only once somebody names it here.
|
|
33
|
+
*/
|
|
34
|
+
readonly binaries: Readonly<Record<string, string>>;
|
|
35
|
+
/** Version (as the catalog spells it) → platform (the vendor's spelling) → archive. */
|
|
36
|
+
readonly versions: Readonly<Record<string, Readonly<Record<string, PinnedArchive>>>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A vendor's pinned releases. */
|
|
40
|
+
export interface ReleaseCatalog {
|
|
41
|
+
/** Who publishes these, for refusals: `'VictoriaMetrics'`. */
|
|
42
|
+
readonly vendor: string;
|
|
43
|
+
readonly packages: Readonly<Record<string, CatalogPackage>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which binary a stack asks a catalog for. */
|
|
47
|
+
export type CatalogRequest = {
|
|
48
|
+
readonly package: string;
|
|
49
|
+
readonly version: string;
|
|
50
|
+
readonly platform: string;
|
|
51
|
+
/** The installed name — `vmalert`, not the member `vmalert-prod`. */
|
|
52
|
+
readonly binary: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The pinned half of Release.Binary's props; the stack adds `directory`, `mode` and owner. */
|
|
56
|
+
export type PinnedBinary = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256' | 'name'>;
|
|
57
|
+
|
|
58
|
+
const quoted = (names: Iterable<string>): string => [...names].map((n) => `"${n}"`).join(', ');
|
|
59
|
+
|
|
60
|
+
// ★ A plain boolean, not a type guard: on a `Record<string, …>` a guard narrows the miss to `never`.
|
|
61
|
+
const has = (record: object, key: string): boolean => Object.hasOwn(record, key);
|
|
62
|
+
|
|
63
|
+
/** Everything wrong with a request against a catalog; empty when it resolves. */
|
|
64
|
+
export const catalogProblems = (catalog: ReleaseCatalog, request: CatalogRequest): string[] => {
|
|
65
|
+
const entry = has(catalog.packages, request.package)
|
|
66
|
+
? catalog.packages[request.package]
|
|
67
|
+
: undefined;
|
|
68
|
+
if (entry === undefined) {
|
|
69
|
+
return [
|
|
70
|
+
`${catalog.vendor} package "${request.package}" is not in the catalog (${quoted(Object.keys(catalog.packages))})`,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
const found: string[] = [];
|
|
74
|
+
if (!has(entry.binaries, request.binary)) {
|
|
75
|
+
// ⚠️ The vendor spelling is the likeliest mistake: the member is `vmalert-prod`, the binary
|
|
76
|
+
// this installs is `vmalert`.
|
|
77
|
+
const member = Object.values(entry.binaries).includes(request.binary);
|
|
78
|
+
const hint = member ? ' (that is the archive member; name the installed binary)' : '';
|
|
79
|
+
found.push(
|
|
80
|
+
`${request.package} has no binary "${request.binary}"${hint}; it has ${quoted(Object.keys(entry.binaries))}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const platforms = has(entry.versions, request.version)
|
|
84
|
+
? entry.versions[request.version]
|
|
85
|
+
: undefined;
|
|
86
|
+
if (platforms === undefined) {
|
|
87
|
+
found.push(
|
|
88
|
+
`${request.package} ${request.version} is not in the catalog (it pins ${quoted(Object.keys(entry.versions))}); ` +
|
|
89
|
+
'a new version is a reviewed change that pins its digests, never a download of whatever is there',
|
|
90
|
+
);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
const archive = has(platforms, request.platform) ? platforms[request.platform] : undefined;
|
|
94
|
+
if (archive === undefined) {
|
|
95
|
+
found.push(
|
|
96
|
+
`${request.package} ${request.version} is not pinned for platform "${request.platform}" (${quoted(Object.keys(platforms))})`,
|
|
97
|
+
);
|
|
98
|
+
} else if (found.length === 0) {
|
|
99
|
+
const member = entry.binaries[request.binary] ?? '';
|
|
100
|
+
if (!has(archive.members, member)) {
|
|
101
|
+
found.push(
|
|
102
|
+
`${request.package} ${request.version}'s checksum file lists no member "${member}"`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The pinned props for one binary out of a catalog. ⛔ Throws BinaryRefused for anything the
|
|
111
|
+
* catalog does not pin — in a stack program, that fails the plan before anything is fetched.
|
|
112
|
+
*/
|
|
113
|
+
export const catalogBinary = (catalog: ReleaseCatalog, request: CatalogRequest): PinnedBinary => {
|
|
114
|
+
const problems = catalogProblems(catalog, request);
|
|
115
|
+
// ⚠️ Own keys only: `packages.constructor` is Object's, and would read as a package.
|
|
116
|
+
const own = <V>(record: Readonly<Record<string, V>> | undefined, key: string): V | undefined =>
|
|
117
|
+
record !== undefined && has(record, key) ? record[key] : undefined;
|
|
118
|
+
const entry = own(catalog.packages, request.package);
|
|
119
|
+
const archive = own(own(entry?.versions, request.version), request.platform);
|
|
120
|
+
const member = own(entry?.binaries, request.binary);
|
|
121
|
+
const sha256 = member === undefined ? undefined : own(archive?.members, member);
|
|
122
|
+
if (
|
|
123
|
+
problems.length > 0 ||
|
|
124
|
+
archive === undefined ||
|
|
125
|
+
member === undefined ||
|
|
126
|
+
sha256 === undefined
|
|
127
|
+
) {
|
|
128
|
+
throw new BinaryRefused({
|
|
129
|
+
message: `Release.Binary ${request.binary}: ${problems.join('; ')}. Nothing was fetched.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const { asset, repo, sha256: archiveSha256, size, tag } = archive;
|
|
133
|
+
return {
|
|
134
|
+
archive: { asset, repo, sha256: archiveSha256, size, tag },
|
|
135
|
+
member,
|
|
136
|
+
name: request.binary,
|
|
137
|
+
sha256,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** `<root>/<package>-<version>`: one directory per version, so an upgrade is a new path. */
|
|
142
|
+
export const catalogDirectory = (
|
|
143
|
+
root: string,
|
|
144
|
+
request: Pick<CatalogRequest, 'package' | 'version'>,
|
|
145
|
+
): string => `${root}/${request.package}-${request.version}`;
|
|
146
|
+
|
|
147
|
+
/** What a catalog says an installed binary is, found by its SHA-256 alone. */
|
|
148
|
+
export type IdentifiedBinary = CatalogRequest & { readonly vendor: string };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Name an installed file by its digest — never by running it. ★ IDENTITY BY SHA-256: the running
|
|
152
|
+
* Nix victoria-traces reports an empty version (`vm_app_version{version=""}` on /metrics, measured
|
|
153
|
+
* 2026-09-22 — its build sets no buildinfo), a stamped build prints `-version` to STDERR, not
|
|
154
|
+
* stdout (lib/buildinfo, read, not run), and executing a binary to learn what it is runs the very
|
|
155
|
+
* bytes whose provenance is the question. A digest needs a read, nothing more.
|
|
156
|
+
*/
|
|
157
|
+
export const identifyBinary = (
|
|
158
|
+
catalogs: readonly ReleaseCatalog[],
|
|
159
|
+
sha256: string,
|
|
160
|
+
): IdentifiedBinary | undefined => {
|
|
161
|
+
for (const catalog of catalogs) {
|
|
162
|
+
for (const [pkg, entry] of Object.entries(catalog.packages)) {
|
|
163
|
+
for (const [version, platforms] of Object.entries(entry.versions)) {
|
|
164
|
+
for (const [platform, archive] of Object.entries(platforms)) {
|
|
165
|
+
for (const [binary, member] of Object.entries(entry.binaries)) {
|
|
166
|
+
if (archive.members[member] === sha256) {
|
|
167
|
+
return { binary, package: pkg, platform, vendor: catalog.vendor, version };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checksum-file parser, held to the one format it was measured on (`sha256sum` text mode,
|
|
3
|
+
* 2026-09-22): anything else is a vendor change to read before a pin moves, so it throws.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { parseChecksums } from './checksums.ts';
|
|
7
|
+
|
|
8
|
+
const line = `${'a'.repeat(64)} vmagent-prod`;
|
|
9
|
+
|
|
10
|
+
describe('parseChecksums is strict', () => {
|
|
11
|
+
test('the measured shape: name → digest, in file order', () => {
|
|
12
|
+
const text = `${'b'.repeat(64)} vmutils.tar.gz\n${line}\n`;
|
|
13
|
+
expect([...parseChecksums(text).entries()]).toEqual([
|
|
14
|
+
['vmutils.tar.gz', 'b'.repeat(64)],
|
|
15
|
+
['vmagent-prod', 'a'.repeat(64)],
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test.each([
|
|
20
|
+
['CRLF endings', `${line}\r\n`],
|
|
21
|
+
['a binary-mode marker', `${'a'.repeat(64)} *vmagent-prod\n`],
|
|
22
|
+
['upper-case hex', `${'A'.repeat(64)} vmagent-prod\n`],
|
|
23
|
+
['a blank line', `${line}\n\n`],
|
|
24
|
+
['no trailing newline', line],
|
|
25
|
+
['a name listed twice', `${line}\n${line}\n`],
|
|
26
|
+
['a path in a name', `${'a'.repeat(64)} bin/vmagent-prod\n`],
|
|
27
|
+
['an empty file', '\n'],
|
|
28
|
+
['a tab for the two spaces', `${'a'.repeat(64)}\tvmagent-prod\n`],
|
|
29
|
+
['one space', `${'a'.repeat(64)} vmagent-prod\n`],
|
|
30
|
+
['a trailing space (a name that merely starts with another)', `${line} \n`],
|
|
31
|
+
['a no-break space in the name', `${line}\u00a0-enterprise\n`],
|
|
32
|
+
])('refuses %s', (_, text) => {
|
|
33
|
+
expect(() => parseChecksums(text)).toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A vendor checksum file in `sha256sum` text mode, parsed strictly — the shape every pin in
|
|
3
|
+
* victoria.ts was copied from.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY PARSE A FILE THE PROVIDER NEVER FETCHES. The pins are the ground truth at apply; this is
|
|
6
|
+
* the ground truth when a pin is WRITTEN. victoria.test.ts feeds it the exact bytes of all four
|
|
7
|
+
* recorded files (fixtures/victoria/, each matching GitHub's digest of that asset AS READ on the
|
|
8
|
+
* recorded date — no test re-reads it) and requires the catalog to equal what it reads, so a pin
|
|
9
|
+
* typed by hand, a line missed, or a member added by the vendor is a failing test rather than a
|
|
10
|
+
* trusted typo.
|
|
11
|
+
* ⛔ STRICT, BECAUSE THE FORMAT IS KNOWN. Measured 2026-09-22 on all four files: `sha256sum` text
|
|
12
|
+
* mode — `<64 lowercase hex><two spaces><name>` per line, LF endings, a trailing newline, the
|
|
13
|
+
* archive on line 1 and one `-prod` member per line after it. Anything else (a `*` binary-mode
|
|
14
|
+
* marker, CRLF, upper-case hex, a blank line, a repeated name) is a refusal: a file that no longer
|
|
15
|
+
* has this shape is a vendor change to read before any pin moves, not something to guess past.
|
|
16
|
+
* ⚠️ ONE FORMAT, MEASURED FOR ONE VENDOR. Other vendors list archives only (Prometheus, vector,
|
|
17
|
+
* pyroscope, OpenBao — the estate census, 2026-09-22) or publish digests in JSON (grafana.com). A
|
|
18
|
+
* catalog for one of them reads ITS format byte-exact first; nothing here is assumed to fit it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const LINE = /^([0-9a-f]{64}) {2}([^\s/]+)$/;
|
|
22
|
+
|
|
23
|
+
/** Name → SHA-256, in file order. Throws, naming the line, on anything but the measured shape. */
|
|
24
|
+
export const parseChecksums = (text: string): ReadonlyMap<string, string> => {
|
|
25
|
+
if (!text.endsWith('\n')) throw new Error('checksum file: no trailing newline');
|
|
26
|
+
const lines = text.slice(0, -1).split('\n');
|
|
27
|
+
const digests = new Map<string, string>();
|
|
28
|
+
for (const [index, line] of lines.entries()) {
|
|
29
|
+
const match = LINE.exec(line);
|
|
30
|
+
if (match === null) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`checksum file line ${String(index + 1)}: not "<sha256> <name>": ${JSON.stringify(line)}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const [, digest = '', name = ''] = match;
|
|
36
|
+
if (digests.has(name)) {
|
|
37
|
+
throw new Error(`checksum file line ${String(index + 1)}: "${name}" is listed twice`);
|
|
38
|
+
}
|
|
39
|
+
digests.set(name, digest);
|
|
40
|
+
}
|
|
41
|
+
if (digests.size === 0) throw new Error('checksum file: no lines');
|
|
42
|
+
return digests;
|
|
43
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pins as the STACK PROGRAM declared them — before the engine resolved a single Output — checked
|
|
3
|
+
* at apply. The half of "a pin is never computed during the deploy" that a FIRST deploy needs.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHY APPLY HAS TO ASK THE STACK, NOT `news`. By the time `reconcile` runs, the engine has
|
|
6
|
+
* resolved every Output in `news`, so a digest wired from another resource (a checksum file read
|
|
7
|
+
* at apply, a lookup, a computed value) arrives as a well-formed plain string, indistinguishable
|
|
8
|
+
* from a reviewed pin. And on a first deploy nothing else looked: Alchemy never diffs a create,
|
|
9
|
+
* and skips the adoption probe while `news` holds an Output (beta.79 Plan.ts). 🔴 MEASURED
|
|
10
|
+
* 2026-09-22 (apply-pins.test.ts): a swapped archive plus the swapped digests, wired in as an
|
|
11
|
+
* Output, installed the swapped bytes and the deploy reported success. binary-diff.ts refused the
|
|
12
|
+
* same declaration only from the SECOND plan on, after the wrong binary was on the host.
|
|
13
|
+
* ★ `Stack.resources[fqn].Props` IS WHAT THE PROGRAM PASSED, Outputs and all: Alchemy's
|
|
14
|
+
* `Resource` records it at registration (beta.79 Resource.ts, `Props: props`), and the same Stack
|
|
15
|
+
* reaches the apply (ownership/adopt.ts reads `Adopt` from the same record for the same reason).
|
|
16
|
+
* pinProblems then sees the Output itself — a function-typed proxy, an Effect or a Config — and
|
|
17
|
+
* refuses it as "not a plain string".
|
|
18
|
+
* ⚠️ NO STACK, NO ANSWER. A handler called outside the engine (the fake-provider.ts harness) has no
|
|
19
|
+
* Stack service and no registration to read, so this says nothing there; the engine always has
|
|
20
|
+
* one. apply-pins.test.ts runs the real Plan and Apply, so an Alchemy upgrade that stops
|
|
21
|
+
* recording `Props` there makes the swapped bytes install again, and fails that test by name.
|
|
22
|
+
*/
|
|
23
|
+
import { Stack } from 'alchemy/Stack';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import * as Option from 'effect/Option';
|
|
26
|
+
import { pinProblems } from './binary-form.ts';
|
|
27
|
+
|
|
28
|
+
/** Everything wrong with the pins of the resource at `fqn` as its stack program declared them. */
|
|
29
|
+
export const declaredPinProblems = (fqn: string): Effect.Effect<string[]> =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
32
|
+
if (Option.isNone(stack)) return [];
|
|
33
|
+
const declared: unknown = stack.value.resources[fqn]?.Props;
|
|
34
|
+
if (typeof declared !== 'object' && typeof declared !== 'function') return [];
|
|
35
|
+
if (declared === null) return [];
|
|
36
|
+
return pinProblems(declared as Parameters<typeof pinProblems>[0]).map(
|
|
37
|
+
(problem) => `as declared in the stack program, ${problem}`,
|
|
38
|
+
);
|
|
39
|
+
});
|