@tsdoctor/bundle 0.1.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/BundleFetch.js ADDED
@@ -0,0 +1,348 @@
1
+ import { readBundle } from "./Bundle.js";
2
+ import { discoverBundle } from "./BundleDiscovery.js";
3
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
4
+ import { GitHubRelease, Repo, RepoRef } from "@effected/github";
5
+ import { NpmRegistry, PackageTarball, PublishedVersion } from "@effected/npm";
6
+ import { Cache } from "@effected/store";
7
+ import { AppDirs } from "@effected/xdg";
8
+
9
+ //#region src/BundleFetch.ts
10
+ /**
11
+ * Raised when a remote bundle cannot be fetched into the local cache.
12
+ *
13
+ * @remarks
14
+ * Fetch-plane failures speak in remote terms (`ref` names the coordinate the
15
+ * caller asked for, never a temp directory). Post-fetch READ failures — a
16
+ * fetched artifact whose layer files are malformed — surface as the same
17
+ * typed errors local reads produce (`BundleLayerError`,
18
+ * `BundleManifestError`), so a consumer handles one vocabulary for both.
19
+ *
20
+ * @public
21
+ */
22
+ var BundleFetchError = class extends Schema.TaggedError()("BundleFetchError", {
23
+ /** Which fetcher failed. */
24
+ source: Schema.Literals(["npm", "github"]),
25
+ /** What went wrong, structurally. */
26
+ reason: Schema.Literals([
27
+ "invalidRef",
28
+ "versionNotFound",
29
+ "releaseNotFound",
30
+ "assetNotFound",
31
+ "assetAmbiguous",
32
+ "download",
33
+ "notABundle",
34
+ "cache"
35
+ ]),
36
+ /** The remote coordinate: `name@version` or `owner/repo@tag#asset`. */
37
+ ref: Schema.String,
38
+ /** Human context for the failure. */
39
+ detail: Schema.optionalKey(Schema.String),
40
+ /** The underlying failure, when one exists, preserved structurally. */
41
+ cause: Schema.optionalKey(Schema.Defect())
42
+ }) {
43
+ get message() {
44
+ const detailPart = this.detail !== void 0 ? `: ${this.detail}` : "";
45
+ return `Bundle fetch failed (${this.reason}) for ${this.source} ${this.ref}${detailPart}`;
46
+ }
47
+ };
48
+ /** The default asset suffix for the GitHub release bundle variant. */
49
+ const META_TGZ_SUFFIX = ".npm.meta.tgz";
50
+ /** Cache-record schema: which layer files a cached bundle dir holds. */
51
+ const CachedBundleRecord = Schema.Struct({ files: Schema.Array(Schema.String) });
52
+ /**
53
+ * One path segment safe to join into the cache tree: no separators, no
54
+ * traversal, no whitespace. Mirrors the registry's cache-key discipline —
55
+ * lenient about npm's historical malformations, strict enough that a
56
+ * coordinate can never escape its cache directory.
57
+ */
58
+ const SAFE_SEGMENT = /^(?!\.{1,2}$)[^/\\\s]+$/;
59
+ const utf8Encode = (text) => new TextEncoder().encode(text);
60
+ const utf8Decode = (bytes) => new TextDecoder().decode(bytes);
61
+ /** Validate that every cache-path segment is traversal-safe. */
62
+ function validateSegments(source, ref, segments) {
63
+ for (const segment of segments) if (!SAFE_SEGMENT.test(segment)) return Effect.fail(new BundleFetchError({
64
+ source,
65
+ reason: "invalidRef",
66
+ ref,
67
+ detail: `unsafe path segment "${segment}"`
68
+ }));
69
+ return Effect.void;
70
+ }
71
+ /** The durable cache directory for one remote bundle coordinate. */
72
+ function bundleCacheDir(segments) {
73
+ return Effect.gen(function* () {
74
+ const appDirs = yield* AppDirs;
75
+ const path = yield* Path.Path;
76
+ const cacheRoot = yield* appDirs.ensureCache.pipe(Effect.orDie);
77
+ return path.join(cacheRoot, "bundles", ...segments);
78
+ });
79
+ }
80
+ /**
81
+ * The cache-hit probe: a metadata record exists AND every file it lists is
82
+ * still on disk. Any inconsistency — missing record, undecodable record,
83
+ * missing file, cache-read failure — reads as a miss, so a damaged cache
84
+ * self-heals by refetching.
85
+ */
86
+ function cachedBundle(key, dir) {
87
+ return Effect.gen(function* () {
88
+ const cache = yield* Cache;
89
+ const fs = yield* FileSystem.FileSystem;
90
+ const path = yield* Path.Path;
91
+ const entry = yield* cache.get(key).pipe(Effect.orElseSucceed(() => Option.none()));
92
+ if (Option.isNone(entry)) return Option.none();
93
+ const record = yield* Schema.decodeUnknownEffect(CachedBundleRecord)(yield* Effect.try(() => JSON.parse(utf8Decode(entry.value.value))).pipe(Effect.orElseSucceed(() => null))).pipe(Effect.option);
94
+ if (Option.isNone(record)) return Option.none();
95
+ for (const file of record.value.files) if (!(yield* fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)))) return Option.none();
96
+ return yield* Effect.flatMap(discoverBundle(dir), readBundle).pipe(Effect.option);
97
+ });
98
+ }
99
+ /**
100
+ * Persist a fetched bundle's layer files from the (scoped, about-to-vanish)
101
+ * extraction directory into the durable cache dir, and record them in the
102
+ * metadata cache. The returned Bundle's descriptor points into the CACHE
103
+ * directory, which outlives the extraction scope.
104
+ */
105
+ function persistAndRead(source, ref, key, extractedDir, cacheDir) {
106
+ return Effect.gen(function* () {
107
+ const cache = yield* Cache;
108
+ const fs = yield* FileSystem.FileSystem;
109
+ const path = yield* Path.Path;
110
+ const discovered = yield* discoverBundle(extractedDir).pipe(Effect.mapError((cause) => new BundleFetchError({
111
+ source,
112
+ reason: "notABundle",
113
+ ref,
114
+ detail: "the fetched artifact does not contain a readable bundle (no *.api.json model)",
115
+ cause
116
+ })));
117
+ const layerPaths = [
118
+ discovered.modelPath,
119
+ ...discovered.packageJsonPath !== void 0 ? [discovered.packageJsonPath] : [],
120
+ ...discovered.tsconfigPath !== void 0 ? [discovered.tsconfigPath] : [],
121
+ ...discovered.manifestPath !== void 0 ? [discovered.manifestPath] : []
122
+ ];
123
+ const failCache = (cause) => new BundleFetchError({
124
+ source,
125
+ reason: "cache",
126
+ ref,
127
+ cause
128
+ });
129
+ yield* fs.remove(cacheDir, { recursive: true }).pipe(Effect.ignore);
130
+ yield* fs.makeDirectory(cacheDir, { recursive: true }).pipe(Effect.mapError(failCache));
131
+ const files = [];
132
+ for (const layerPath of layerPaths) {
133
+ const fileName = path.basename(layerPath);
134
+ yield* fs.copyFile(layerPath, path.join(cacheDir, fileName)).pipe(Effect.mapError(failCache));
135
+ files.push(fileName);
136
+ }
137
+ yield* cache.set({
138
+ key,
139
+ value: utf8Encode(JSON.stringify({ files })),
140
+ contentType: "application/json"
141
+ }).pipe(Effect.mapError(failCache));
142
+ return yield* Effect.flatMap(discoverBundle(cacheDir), readBundle).pipe(Effect.catchTag("BundleDiscoveryError", (cause) => Effect.fail(failCache(cause))));
143
+ });
144
+ }
145
+ /**
146
+ * Fetch one published npm package version as a bundle, through the durable
147
+ * XDG cache.
148
+ *
149
+ * @remarks
150
+ * Works against any npm-protocol-family registry via `target` (the
151
+ * `type: "npm"` semantics of the manifest's registries block). The tarball is
152
+ * downloaded, integrity-verified and extracted by `@effected/npm`'s
153
+ * `PackageTarball`, its bundle layer files are copied into
154
+ * `<xdg-cache>/tsdoctor/bundles/npm/<name>/<version>/`, and the returned
155
+ * {@link Bundle}'s descriptor points at that durable directory. A published
156
+ * npm version is immutable, so a cache hit skips the network entirely;
157
+ * `refresh: true` forces a refetch.
158
+ *
159
+ * Provide the `NpmRegistry`, `PackageTarball`, `Cache` and `AppDirs`
160
+ * services (plus `FileSystem`/`Path`) at the application boundary.
161
+ *
162
+ * @public
163
+ */
164
+ function fetchNpmBundle(options) {
165
+ const ref = `${options.name}@${options.version}`;
166
+ return Effect.gen(function* () {
167
+ const segments = [
168
+ "npm",
169
+ ...options.name.split("/"),
170
+ options.version
171
+ ];
172
+ yield* validateSegments("npm", ref, segments.slice(1));
173
+ const cacheDir = yield* bundleCacheDir(segments);
174
+ const key = `bundle:v1:npm:${ref}`;
175
+ if (options.refresh !== true) {
176
+ const hit = yield* cachedBundle(key, cacheDir);
177
+ if (Option.isSome(hit)) return hit.value;
178
+ }
179
+ const registry = yield* NpmRegistry;
180
+ const tarball = yield* PackageTarball;
181
+ const published = yield* registry.version(options.name, options.version, options.target).pipe(Effect.mapError((cause) => new BundleFetchError({
182
+ source: "npm",
183
+ reason: "download",
184
+ ref,
185
+ cause
186
+ })));
187
+ if (Option.isNone(published)) return yield* Effect.fail(new BundleFetchError({
188
+ source: "npm",
189
+ reason: "versionNotFound",
190
+ ref,
191
+ detail: "the registry reports no such published version (exact versions and dist-tags only; ranges are not resolved here)"
192
+ }));
193
+ return yield* Effect.gen(function* () {
194
+ const extractedDir = yield* tarball.extract(published.value).pipe(Effect.mapError((cause) => new BundleFetchError({
195
+ source: "npm",
196
+ reason: cause.reason === "notFound" ? "versionNotFound" : "download",
197
+ ref,
198
+ cause
199
+ })));
200
+ return yield* persistAndRead("npm", ref, key, extractedDir, cacheDir);
201
+ }).pipe(Effect.scoped);
202
+ });
203
+ }
204
+ /**
205
+ * Locate the bundle root inside an extracted release archive. npm-style
206
+ * archives unpack to `package/`; the real `*.npm.meta.tgz` release variant
207
+ * unpacks to `meta/` (verified against vitest-agent's published assets); a
208
+ * hand-rolled archive may put its files at the root. The first candidate
209
+ * containing a `*.api.json` wins: `package/`, the archive root, then each
210
+ * top-level subdirectory.
211
+ */
212
+ function locateBundleRoot(extractedPackageDir) {
213
+ return Effect.gen(function* () {
214
+ const fs = yield* FileSystem.FileSystem;
215
+ const path = yield* Path.Path;
216
+ const archiveRoot = path.dirname(extractedPackageDir);
217
+ const candidates = [
218
+ extractedPackageDir,
219
+ archiveRoot,
220
+ ...(yield* fs.readDirectory(archiveRoot).pipe(Effect.orElseSucceed(() => []))).filter((entry) => entry !== path.basename(extractedPackageDir)).map((entry) => path.join(archiveRoot, entry))
221
+ ];
222
+ for (const candidate of candidates) if ((yield* fs.readDirectory(candidate).pipe(Effect.orElseSucceed(() => []))).some((name) => name.endsWith(".api.json"))) return Option.some(candidate);
223
+ return Option.none();
224
+ });
225
+ }
226
+ /** Pick the release asset: an exact name, or the single `*.npm.meta.tgz`. */
227
+ function pickAsset(ref, assets, wanted) {
228
+ if (wanted !== void 0) {
229
+ const found = assets.find((asset) => asset.name === wanted);
230
+ return found !== void 0 ? Effect.succeed(found) : Effect.fail(new BundleFetchError({
231
+ source: "github",
232
+ reason: "assetNotFound",
233
+ ref,
234
+ detail: `no asset named "${wanted}" (available: ${assets.map((asset) => asset.name).join(", ") || "none"})`
235
+ }));
236
+ }
237
+ const candidates = assets.filter((asset) => asset.name.endsWith(META_TGZ_SUFFIX));
238
+ if (candidates.length === 1) return Effect.succeed(candidates[0]);
239
+ if (candidates.length === 0) return Effect.fail(new BundleFetchError({
240
+ source: "github",
241
+ reason: "assetNotFound",
242
+ ref,
243
+ detail: `no *${META_TGZ_SUFFIX} asset on the release; pass an explicit asset name`
244
+ }));
245
+ return Effect.fail(new BundleFetchError({
246
+ source: "github",
247
+ reason: "assetAmbiguous",
248
+ ref,
249
+ detail: `multiple *${META_TGZ_SUFFIX} assets (${candidates.map((asset) => asset.name).join(", ")}); pass an explicit asset name`
250
+ }));
251
+ }
252
+ /**
253
+ * Fetch a bundle attached to a GitHub release as a `*.npm.meta.tgz`-style
254
+ * asset, through the durable XDG cache.
255
+ *
256
+ * @remarks
257
+ * The release is looked up by tag via `@effected/github`'s `GitHubRelease`
258
+ * (the `Repo` context is provided internally from `owner`/`repo`), the chosen
259
+ * asset is downloaded and extracted through the same verified
260
+ * `PackageTarball` path the npm fetcher uses (no integrity is available for
261
+ * release assets, so the download is unverified — the extractor logs this),
262
+ * and the layer files land in
263
+ * `<xdg-cache>/tsdoctor/bundles/github/<owner>/<repo>/<tag>/<asset>/`.
264
+ *
265
+ * A tarball with an npm-style `package/` root and one with its files at the
266
+ * archive root are both accepted. Git tags CAN move; the cache treats them as
267
+ * immutable and `refresh: true` is the escape hatch. Private-repo assets are
268
+ * not supported yet: the asset's browser download URL is fetched directly,
269
+ * which works for public releases only.
270
+ *
271
+ * @public
272
+ */
273
+ function fetchGitHubReleaseBundle(options) {
274
+ const ref = `${options.owner}/${options.repo}@${options.tag}${options.asset !== void 0 ? `#${options.asset}` : ""}`;
275
+ return Effect.gen(function* () {
276
+ yield* validateSegments("github", ref, [
277
+ options.owner,
278
+ options.repo,
279
+ options.tag,
280
+ ...options.asset !== void 0 ? [options.asset] : []
281
+ ]);
282
+ const cacheCoordinate = (assetName) => ({
283
+ segments: [
284
+ "github",
285
+ options.owner,
286
+ options.repo,
287
+ options.tag,
288
+ assetName
289
+ ],
290
+ key: `bundle:v1:github:${options.owner}/${options.repo}@${options.tag}#${assetName}`
291
+ });
292
+ if (options.asset !== void 0 && options.refresh !== true) {
293
+ const coordinate = cacheCoordinate(options.asset);
294
+ const earlyHit = yield* cachedBundle(coordinate.key, yield* bundleCacheDir(coordinate.segments));
295
+ if (Option.isSome(earlyHit)) return earlyHit.value;
296
+ }
297
+ const releases = yield* GitHubRelease;
298
+ const repoRef = RepoRef.make({
299
+ owner: options.owner,
300
+ repo: options.repo
301
+ });
302
+ const withRepo = (effect) => Effect.provideService(effect, Repo, repoRef);
303
+ const failDownload = (cause) => new BundleFetchError({
304
+ source: "github",
305
+ reason: "download",
306
+ ref,
307
+ cause
308
+ });
309
+ const release = yield* withRepo(releases.getByTagOption(options.tag)).pipe(Effect.mapError(failDownload));
310
+ if (Option.isNone(release)) return yield* Effect.fail(new BundleFetchError({
311
+ source: "github",
312
+ reason: "releaseNotFound",
313
+ ref,
314
+ detail: "no release with that tag"
315
+ }));
316
+ const assets = yield* withRepo(releases.listAssets(release.value.id)).pipe(Effect.mapError(failDownload));
317
+ const asset = yield* pickAsset(ref, assets, options.asset);
318
+ const { segments, key } = cacheCoordinate(asset.name);
319
+ const cacheDir = yield* bundleCacheDir(segments);
320
+ if (options.refresh !== true && options.asset === void 0) {
321
+ const hit = yield* cachedBundle(key, cacheDir);
322
+ if (Option.isSome(hit)) return hit.value;
323
+ }
324
+ const tarball = yield* PackageTarball;
325
+ return yield* Effect.gen(function* () {
326
+ const bundleRoot = yield* locateBundleRoot(yield* tarball.extract(PublishedVersion.make({
327
+ name: asset.name,
328
+ version: options.tag,
329
+ tarball: asset.url
330
+ })).pipe(Effect.mapError((cause) => new BundleFetchError({
331
+ source: "github",
332
+ reason: cause.reason === "notFound" ? "assetNotFound" : "download",
333
+ ref,
334
+ cause
335
+ }))));
336
+ if (Option.isNone(bundleRoot)) return yield* Effect.fail(new BundleFetchError({
337
+ source: "github",
338
+ reason: "notABundle",
339
+ ref,
340
+ detail: "the release asset contains no *.api.json model at its root, in package/, or in any top-level directory"
341
+ }));
342
+ return yield* persistAndRead("github", ref, key, bundleRoot.value, cacheDir);
343
+ }).pipe(Effect.scoped);
344
+ });
345
+ }
346
+
347
+ //#endregion
348
+ export { BundleFetchError, fetchGitHubReleaseBundle, fetchNpmBundle };
package/BundleHash.js ADDED
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ //#region src/BundleHash.ts
4
+ /**
5
+ * Serialize a JSON-shaped value canonically: object keys sorted recursively,
6
+ * `undefined`-valued keys dropped, arrays in declared order, no whitespace.
7
+ *
8
+ * @remarks
9
+ * The normalization half of the change-detection discipline: two values that
10
+ * differ only in key order or optional-key presence-as-`undefined` serialize
11
+ * identically, so their hashes match. Non-JSON leaves (functions, symbols)
12
+ * serialize as `null`, matching `JSON.stringify` semantics inside arrays.
13
+ *
14
+ * @public
15
+ */
16
+ function canonicalJson(value) {
17
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") return JSON.stringify(value);
18
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item === void 0 ? null : item)).join(",")}]`;
19
+ if (typeof value === "object") {
20
+ const record = value;
21
+ return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
22
+ }
23
+ return "null";
24
+ }
25
+ /**
26
+ * Normalize text for hashing: CRLF/CR line endings become LF and trailing
27
+ * whitespace at the end of the content is trimmed.
28
+ *
29
+ * @remarks
30
+ * The same file checked out with different line-ending settings must hash
31
+ * identically — coarse layer-hash comparison otherwise reports every file
32
+ * changed on the first cross-platform build.
33
+ *
34
+ * @public
35
+ */
36
+ function normalizeText(text) {
37
+ return text.replace(/\r\n?/g, "\n").trimEnd();
38
+ }
39
+ /**
40
+ * The lowercase hex SHA-256 of a string, UTF-8 encoded.
41
+ *
42
+ * @public
43
+ */
44
+ function sha256Hex(text) {
45
+ return createHash("sha256").update(text, "utf8").digest("hex");
46
+ }
47
+ /**
48
+ * Hash text content: {@link normalizeText} then {@link sha256Hex}.
49
+ *
50
+ * @public
51
+ */
52
+ function hashText(text) {
53
+ return sha256Hex(normalizeText(text));
54
+ }
55
+ /**
56
+ * Hash a JSON-shaped value: {@link canonicalJson} then {@link sha256Hex}.
57
+ *
58
+ * @public
59
+ */
60
+ function hashJsonValue(value) {
61
+ return sha256Hex(canonicalJson(value));
62
+ }
63
+ /**
64
+ * Hash one bundle layer file's raw text — the COARSE half of change
65
+ * detection (all layer hashes match → skip resolution entirely).
66
+ *
67
+ * @remarks
68
+ * Total: text that parses as JSON hashes canonically (key order and
69
+ * formatting churn do not read as change); text that does not parse falls
70
+ * back to {@link hashText}, so a broken file still gets a stable hash
71
+ * rather than an error — hashing is bookkeeping, not validation.
72
+ *
73
+ * @public
74
+ */
75
+ function hashLayerText(text) {
76
+ try {
77
+ return hashJsonValue(JSON.parse(text));
78
+ } catch {
79
+ return hashText(text);
80
+ }
81
+ }
82
+ /**
83
+ * Fingerprint every present field of a {@link ResolvedBundle} — the FINE
84
+ * half of change detection, hashing each field's `{ value, source }` pair.
85
+ *
86
+ * @remarks
87
+ * The source participates deliberately: an override flip (a field moving
88
+ * from `inferred` to an authored tier without changing value) is a
89
+ * semantically meaningful change and must read as one. Absent fields carry
90
+ * no fingerprint — their appearance or disappearance is itself the diff.
91
+ * The consuming store maps each key to its invalidation scope (version →
92
+ * version-embedding surfaces, tsconfig → all code blocks, …).
93
+ *
94
+ * @public
95
+ */
96
+ function fingerprintResolvedBundle(resolved) {
97
+ const fingerprints = {};
98
+ const fields = { ...resolved };
99
+ for (const key of Object.keys(fields).sort()) {
100
+ const field = fields[key];
101
+ if (field !== void 0) fingerprints[key] = hashJsonValue(field);
102
+ }
103
+ return fingerprints;
104
+ }
105
+
106
+ //#endregion
107
+ export { canonicalJson, fingerprintResolvedBundle, hashJsonValue, hashLayerText, hashText, normalizeText, sha256Hex };
@@ -0,0 +1,183 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ //#region src/BundleManifest.ts
4
+ /**
5
+ * The registry protocol families this reader knows how to do more than link
6
+ * to. `"npm"` means an npm-compatible registry — install commands and tarball
7
+ * fetching work against any instance of it — and `"jsr"` the jsr protocol.
8
+ *
9
+ * @remarks
10
+ * The manifest's `type` field is deliberately NOT constrained to these
11
+ * values: unknown future types must degrade to link-only rendering, not
12
+ * reject the manifest. Use {@link isKnownRegistryType} to branch.
13
+ *
14
+ * @public
15
+ */
16
+ const KNOWN_REGISTRY_TYPES = ["npm", "jsr"];
17
+ /**
18
+ * Whether a registry `type` value is a protocol family this reader
19
+ * recognizes. `false` means the registry entry should degrade to link-only
20
+ * rendering — it is never a validation failure.
21
+ *
22
+ * @public
23
+ */
24
+ function isKnownRegistryType(type) {
25
+ return KNOWN_REGISTRY_TYPES.includes(type);
26
+ }
27
+ /**
28
+ * One registry the documented package is published to.
29
+ *
30
+ * @remarks
31
+ * `type` is the PROTOCOL FAMILY (`"npm"` covers every npm-compatible
32
+ * registry), `name` the human instance label, `url` the package's page on
33
+ * that instance. Unknown `type` values decode successfully and degrade to
34
+ * link-only rendering (see {@link isKnownRegistryType}).
35
+ *
36
+ * @public
37
+ */
38
+ const RegistryRef = Schema.Struct({
39
+ /** The protocol family, e.g. `"npm"` or `"jsr"`. Unknown values are accepted. */
40
+ type: Schema.String,
41
+ /** The human instance label, e.g. `"npm"` or `"Savvy Web Registry"`. */
42
+ name: Schema.String,
43
+ /** The package's URL on that registry instance. */
44
+ url: Schema.String
45
+ });
46
+ /**
47
+ * One Open Graph image declared by the manifest.
48
+ *
49
+ * @remarks
50
+ * Exactly ONE of `path` (a bundle-relative asset the consuming platform
51
+ * publishes and resolves to a URL) or `url` (an absolute external URL used
52
+ * verbatim) must be present — the schema enforces the XOR. `type` is a MIME
53
+ * type, inferred from the file extension by the resolver when omitted; `alt`
54
+ * has a documented inference chain (tagline → description →
55
+ * `"<name> API documentation"`).
56
+ *
57
+ * @public
58
+ */
59
+ const OpenGraphImage = Schema.Struct({
60
+ /** Bundle-relative asset path. Mutually exclusive with `url`. */
61
+ path: Schema.optionalKey(Schema.String),
62
+ /** Absolute external URL, used verbatim. Mutually exclusive with `path`. */
63
+ url: Schema.optionalKey(Schema.String),
64
+ /** MIME type; inferred from the extension when omitted. */
65
+ type: Schema.optionalKey(Schema.String),
66
+ /** Pixel width; 1200×630 (1.91:1) is the cross-platform safe default. */
67
+ width: Schema.optionalKey(Schema.Int),
68
+ /** Pixel height. */
69
+ height: Schema.optionalKey(Schema.Int),
70
+ /** Alt text; inferred (tagline → description → fallback) when omitted. */
71
+ alt: Schema.optionalKey(Schema.String)
72
+ }).check(Schema.makeFilter((image) => image.path === void 0 !== (image.url === void 0) ? void 0 : "exactly one of \"path\" or \"url\" must be present", { title: "openGraph image source" }));
73
+ /**
74
+ * The manifest's Open Graph block: the asset-ish pieces only — most OG tags
75
+ * are page-level and derive at render time in the consuming platform.
76
+ *
77
+ * @remarks
78
+ * Multiple images follow OG array semantics: the first declared wins, extras
79
+ * are alternates (e.g. a portrait 1000×1500 variant).
80
+ *
81
+ * @public
82
+ */
83
+ const OpenGraphConfig = Schema.Struct({
84
+ /** Declared images, first-wins per OG array semantics. */
85
+ images: Schema.optionalKey(Schema.Array(OpenGraphImage)),
86
+ /** Embed accent color (e.g. Discord), a CSS color string. */
87
+ themeColor: Schema.optionalKey(Schema.String)
88
+ });
89
+ /**
90
+ * A pointer to the bundle's SBOM, computed by the bundler at publish and
91
+ * served as a downloadable static asset.
92
+ *
93
+ * @public
94
+ */
95
+ const SbomRef = Schema.Struct({
96
+ /** Bundle-relative path to the SBOM file. */
97
+ path: Schema.String,
98
+ /** SBOM format label, e.g. `"spdx-json"`. Unknown values are accepted. */
99
+ format: Schema.optionalKey(Schema.String)
100
+ });
101
+ /**
102
+ * The inherited project tier, flattened into the emitted manifest by the
103
+ * bundler (a fetched bundle has no parent directory to walk). Kept nested —
104
+ * structurally distinguishable from the leaf fields — because provenance is
105
+ * load-bearing for override detection.
106
+ *
107
+ * @public
108
+ */
109
+ const ProjectIdentity = Schema.Struct({
110
+ /** The project display name, e.g. `"Effected"` over leaf `@effected/store`. */
111
+ name: Schema.optionalKey(Schema.String),
112
+ /** The project tagline. */
113
+ tagline: Schema.optionalKey(Schema.String)
114
+ });
115
+ /**
116
+ * The versioned `tsdoctor.json` sidecar manifest — bundle layer 3.
117
+ *
118
+ * @remarks
119
+ * `spec` is the only required field; every other field enriches. Unknown
120
+ * top-level fields are ignored on decode (additive fields are minor spec
121
+ * revisions) and unknown enum-ish values (registry `type`, sbom `format`)
122
+ * degrade gracefully instead of rejecting — an old reader must be able to
123
+ * consume a new bundle.
124
+ *
125
+ * @public
126
+ */
127
+ const BundleManifest = Schema.Struct({
128
+ /** The integer spec version. This reader understands spec 1. */
129
+ spec: Schema.Literal(1),
130
+ /** Human display name (the npm name is dry; this one is SEO-friendly). */
131
+ name: Schema.optionalKey(Schema.String),
132
+ /** Short tagline. */
133
+ tagline: Schema.optionalKey(Schema.String),
134
+ /** Long description; overrides the package.json description when present. */
135
+ description: Schema.optionalKey(Schema.String),
136
+ /** The inherited project tier, flattened in at emit time. */
137
+ project: Schema.optionalKey(ProjectIdentity),
138
+ /** Open Graph assets. */
139
+ openGraph: Schema.optionalKey(OpenGraphConfig),
140
+ /** SBOM pointer. */
141
+ sbom: Schema.optionalKey(SbomRef),
142
+ /** Registries the package is published to. */
143
+ registries: Schema.optionalKey(Schema.Array(RegistryRef))
144
+ });
145
+ /**
146
+ * Raised when a present `tsdoctor.json` cannot be parsed or does not satisfy
147
+ * the {@link (BundleManifest:variable)} schema.
148
+ *
149
+ * @remarks
150
+ * Absence of the manifest is NEVER this error — layers enrich, never gate,
151
+ * so a missing sidecar is the normal case and reads as `Option.none()`.
152
+ *
153
+ * @public
154
+ */
155
+ var BundleManifestError = class extends Schema.TaggedError()("BundleManifestError", {
156
+ /** The manifest file path, when the failure is tied to a file on disk. */
157
+ path: Schema.optionalKey(Schema.String),
158
+ /** The underlying failure (JSON syntax or schema decode), preserved structurally. */
159
+ cause: Schema.Defect()
160
+ }) {
161
+ get message() {
162
+ return `Invalid tsdoctor.json manifest${this.path !== void 0 ? ` at ${this.path}` : ""}`;
163
+ }
164
+ };
165
+ /**
166
+ * Decode an unknown value into a {@link (BundleManifest:type)}.
167
+ *
168
+ * @remarks
169
+ * The typed boundary for manifest input that has already been parsed from
170
+ * JSON (plugin options, fetched payloads). File-based reading lives in
171
+ * `readBundle`, which routes through this after parsing.
172
+ *
173
+ * @public
174
+ */
175
+ function decodeBundleManifest(input, path) {
176
+ return Schema.decodeUnknownEffect(BundleManifest)(input).pipe(Effect.mapError((cause) => new BundleManifestError({
177
+ ...path !== void 0 ? { path } : {},
178
+ cause
179
+ })));
180
+ }
181
+
182
+ //#endregion
183
+ export { BundleManifest, BundleManifestError, KNOWN_REGISTRY_TYPES, OpenGraphConfig, OpenGraphImage, ProjectIdentity, RegistryRef, SbomRef, decodeBundleManifest, isKnownRegistryType };