runtime-resolver 0.3.12 → 0.3.14
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/bin/runtime-resolver.d.ts +1 -0
- package/bin/runtime-resolver.js +29 -261
- package/cli/commands/resolve.js +239 -0
- package/cli/schemas/json-schema.js +12 -0
- package/cli/schemas/response.js +86 -0
- package/data/bun-defaults.js +850 -0
- package/data/deno-defaults.js +1526 -0
- package/data/node-defaults.js +4400 -0
- package/errors/AuthenticationError.js +35 -0
- package/errors/CacheError.js +32 -0
- package/errors/FreshnessError.js +38 -0
- package/errors/InvalidInputError.js +33 -0
- package/errors/NetworkError.js +34 -0
- package/errors/ParseError.js +33 -0
- package/errors/RateLimitError.js +39 -0
- package/errors/VersionNotFoundError.js +37 -0
- package/index.d.ts +2577 -2608
- package/index.js +158 -132
- package/layers/AutoBunCacheLive.js +47 -0
- package/layers/AutoDenoCacheLive.js +47 -0
- package/layers/AutoNodeCacheLive.js +58 -0
- package/layers/BunReleaseCacheLive.js +25 -0
- package/layers/BunResolverLive.js +85 -0
- package/layers/BunVersionFetcherLive.js +55 -0
- package/layers/DenoReleaseCacheLive.js +25 -0
- package/layers/DenoResolverLive.js +84 -0
- package/layers/DenoVersionFetcherLive.js +51 -0
- package/layers/FreshBunCacheLive.js +60 -0
- package/layers/FreshDenoCacheLive.js +60 -0
- package/layers/FreshNodeCacheLive.js +55 -0
- package/layers/GitHubAppAuth.js +58 -0
- package/layers/GitHubAutoAuth.js +70 -0
- package/layers/GitHubClientLive.js +130 -0
- package/layers/GitHubTokenAuth.js +50 -0
- package/layers/NodeReleaseCacheLive.js +77 -0
- package/layers/NodeResolverLive.js +107 -0
- package/layers/NodeScheduleFetcherLive.js +37 -0
- package/layers/NodeVersionFetcherLive.js +55 -0
- package/layers/OfflineBunCacheLive.js +40 -0
- package/layers/OfflineDenoCacheLive.js +40 -0
- package/layers/OfflineNodeCacheLive.js +39 -0
- package/layers/RuntimeCacheLive.js +78 -0
- package/lib/retry.js +27 -0
- package/lib/semver.js +21 -0
- package/package.json +57 -68
- package/schemas/bun-release.js +61 -0
- package/schemas/deno-release.js +61 -0
- package/schemas/github.js +74 -0
- package/schemas/node-release.js +130 -0
- package/schemas/node-schedule.js +110 -0
- package/schemas/node.js +68 -0
- package/services/BunReleaseCache.js +33 -0
- package/services/BunResolver.js +36 -0
- package/services/BunVersionFetcher.js +41 -0
- package/services/DenoReleaseCache.js +33 -0
- package/services/DenoResolver.js +36 -0
- package/services/DenoVersionFetcher.js +41 -0
- package/services/GitHubClient.js +40 -0
- package/services/NodeReleaseCache.js +41 -0
- package/services/NodeResolver.js +44 -0
- package/services/NodeScheduleFetcher.js +38 -0
- package/services/NodeVersionFetcher.js +39 -0
- package/services/OctokitInstance.js +44 -0
- package/tsdoc-metadata.json +11 -11
- package/612.js +0 -23
- package/711.js +0 -7554
- package/json-schema.js +0 -95
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DenoReleaseCache } from "../services/DenoReleaseCache.js";
|
|
2
|
+
import { VersionNotFoundError } from "../errors/VersionNotFoundError.js";
|
|
3
|
+
import { DenoResolver } from "../services/DenoResolver.js";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
5
|
+
import { SemVer } from "semver-effect";
|
|
6
|
+
|
|
7
|
+
//#region src/layers/DenoResolverLive.ts
|
|
8
|
+
/**
|
|
9
|
+
* Provides the {@link DenoResolver} service backed by a {@link DenoReleaseCache}.
|
|
10
|
+
*
|
|
11
|
+
* This layer composes with any of the three Deno cache strategy layers to form
|
|
12
|
+
* a complete resolver stack. It implements semver range filtering and increment
|
|
13
|
+
* grouping (latest per major, latest per minor, or all patch versions) using
|
|
14
|
+
* the releases already loaded into the cache.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { DenoResolverLive, AutoDenoCacheLive, DenoVersionFetcherLive, GitHubClientLive, GitHubAutoAuth } from "runtime-resolver";
|
|
19
|
+
* import { DenoResolver } from "runtime-resolver";
|
|
20
|
+
* import { Effect, Layer } from "effect";
|
|
21
|
+
*
|
|
22
|
+
* const GitHubLayer = GitHubClientLive.pipe(Layer.provide(GitHubAutoAuth));
|
|
23
|
+
* const CacheLayer = AutoDenoCacheLive.pipe(Layer.provide(DenoVersionFetcherLive.pipe(Layer.provide(GitHubLayer))));
|
|
24
|
+
* const ResolverLayer = DenoResolverLive.pipe(Layer.provide(CacheLayer));
|
|
25
|
+
*
|
|
26
|
+
* const program = Effect.gen(function* () {
|
|
27
|
+
* const resolver = yield* DenoResolver;
|
|
28
|
+
* const result = yield* resolver.resolve({ semverRange: "^2.0.0" });
|
|
29
|
+
* console.log(result.latest);
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* Effect.runPromise(program.pipe(Effect.provide(ResolverLayer)));
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @see {@link DenoResolver}
|
|
36
|
+
* @see {@link DenoReleaseCache}
|
|
37
|
+
* @see {@link AutoDenoCacheLive}
|
|
38
|
+
* @see {@link FreshDenoCacheLive}
|
|
39
|
+
* @see {@link OfflineDenoCacheLive}
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const DenoResolverLive = Layer.effect(DenoResolver, Effect.gen(function* () {
|
|
43
|
+
const cache = yield* DenoReleaseCache;
|
|
44
|
+
return { resolve: (options) => Effect.gen(function* () {
|
|
45
|
+
const semverRange = options?.semverRange ?? "*";
|
|
46
|
+
const increments = options?.increments ?? "latest";
|
|
47
|
+
const matching = yield* cache.filter(semverRange).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
48
|
+
let resultReleases;
|
|
49
|
+
if (increments === "latest") {
|
|
50
|
+
const groups = /* @__PURE__ */ new Map();
|
|
51
|
+
for (const r of matching) {
|
|
52
|
+
const existing = groups.get(r.version.major);
|
|
53
|
+
if (!existing || SemVer.gt(r.version, existing.version)) groups.set(r.version.major, r);
|
|
54
|
+
}
|
|
55
|
+
resultReleases = [...groups.values()];
|
|
56
|
+
} else if (increments === "minor") {
|
|
57
|
+
const groups = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const r of matching) {
|
|
59
|
+
const key = `${r.version.major}.${r.version.minor}`;
|
|
60
|
+
const existing = groups.get(key);
|
|
61
|
+
if (!existing || SemVer.gt(r.version, existing.version)) groups.set(key, r);
|
|
62
|
+
}
|
|
63
|
+
resultReleases = [...groups.values()];
|
|
64
|
+
} else resultReleases = [...matching];
|
|
65
|
+
if (resultReleases.length === 0) return yield* Effect.fail(new VersionNotFoundError({
|
|
66
|
+
runtime: "deno",
|
|
67
|
+
constraint: semverRange,
|
|
68
|
+
message: `No Deno versions found matching "${semverRange}"`
|
|
69
|
+
}));
|
|
70
|
+
const versions = SemVer.rsort(resultReleases.map((r) => r.version)).map((v) => v.toString());
|
|
71
|
+
const latest = versions[0];
|
|
72
|
+
let resolvedDefault;
|
|
73
|
+
if (options?.defaultVersion) resolvedDefault = yield* cache.resolve(options.defaultVersion).pipe(Effect.map((r) => r.version.toString()), Effect.catchAll(() => Effect.succeed(void 0)));
|
|
74
|
+
return {
|
|
75
|
+
source: "api",
|
|
76
|
+
versions,
|
|
77
|
+
latest,
|
|
78
|
+
...resolvedDefault ? { default: resolvedDefault } : {}
|
|
79
|
+
};
|
|
80
|
+
}) };
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
export { DenoResolverLive };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { DenoVersionFetcher } from "../services/DenoVersionFetcher.js";
|
|
2
|
+
import { retryOnRateLimit } from "../lib/retry.js";
|
|
3
|
+
import { tryParseSemVer } from "../lib/semver.js";
|
|
4
|
+
import { GitHubClient } from "../services/GitHubClient.js";
|
|
5
|
+
import { Effect, Layer, Option } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/layers/DenoVersionFetcherLive.ts
|
|
8
|
+
/**
|
|
9
|
+
* Provides the {@link DenoVersionFetcher} service using the live GitHub API.
|
|
10
|
+
*
|
|
11
|
+
* Fetches Deno releases from the `denoland/deno` GitHub repository using the
|
|
12
|
+
* provided {@link GitHubClient}. Draft releases and pre-releases are excluded.
|
|
13
|
+
* Tag names are normalized by stripping any leading `v` before parsing as
|
|
14
|
+
* semver.
|
|
15
|
+
*
|
|
16
|
+
* This layer is a required dependency of {@link AutoDenoCacheLive} and
|
|
17
|
+
* {@link FreshDenoCacheLive}. It is not needed by {@link OfflineDenoCacheLive}.
|
|
18
|
+
*
|
|
19
|
+
* @see {@link DenoVersionFetcher}
|
|
20
|
+
* @see {@link GitHubClient}
|
|
21
|
+
* @see {@link AutoDenoCacheLive}
|
|
22
|
+
* @see {@link FreshDenoCacheLive}
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
const DenoVersionFetcherLive = Layer.effect(DenoVersionFetcher, Effect.gen(function* () {
|
|
26
|
+
const client = yield* GitHubClient;
|
|
27
|
+
return { fetch: () => Effect.gen(function* () {
|
|
28
|
+
const releases = yield* retryOnRateLimit(client.listReleases("denoland", "deno", {
|
|
29
|
+
perPage: 100,
|
|
30
|
+
pages: 3
|
|
31
|
+
}));
|
|
32
|
+
const fallbackDate = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
33
|
+
const parsed = yield* Effect.forEach(releases.filter((r) => !r.draft && !r.prerelease), (release) => {
|
|
34
|
+
const stripped = release.tag_name.startsWith("v") ? release.tag_name.slice(1) : release.tag_name;
|
|
35
|
+
return tryParseSemVer(stripped).pipe(Effect.map((opt) => Option.isSome(opt) ? Option.some({
|
|
36
|
+
version: opt.value,
|
|
37
|
+
input: {
|
|
38
|
+
version: stripped,
|
|
39
|
+
date: release.published_at ?? fallbackDate
|
|
40
|
+
}
|
|
41
|
+
}) : Option.none()));
|
|
42
|
+
}, { concurrency: "unbounded" });
|
|
43
|
+
return {
|
|
44
|
+
versions: parsed.flatMap(Option.toArray).map(({ version }) => version),
|
|
45
|
+
inputs: parsed.flatMap(Option.toArray).map(({ input }) => input)
|
|
46
|
+
};
|
|
47
|
+
}) };
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { DenoVersionFetcherLive };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { BunRelease } from "../schemas/bun-release.js";
|
|
2
|
+
import { BunReleaseCache } from "../services/BunReleaseCache.js";
|
|
3
|
+
import { BunVersionFetcher } from "../services/BunVersionFetcher.js";
|
|
4
|
+
import { BunReleaseCacheLive } from "./BunReleaseCacheLive.js";
|
|
5
|
+
import { FreshnessError } from "../errors/FreshnessError.js";
|
|
6
|
+
import { Effect, Layer } from "effect";
|
|
7
|
+
import { SemVerParserLive, VersionCacheLive } from "semver-effect";
|
|
8
|
+
|
|
9
|
+
//#region src/layers/FreshBunCacheLive.ts
|
|
10
|
+
const freshSetup = Effect.gen(function* () {
|
|
11
|
+
const cache = yield* BunReleaseCache;
|
|
12
|
+
const { inputs } = yield* (yield* BunVersionFetcher).fetch().pipe(Effect.catchTags({
|
|
13
|
+
AuthenticationError: (err) => Effect.fail(new FreshnessError({
|
|
14
|
+
strategy: "api",
|
|
15
|
+
message: `Fresh data required but authentication failed: ${err.message}`
|
|
16
|
+
})),
|
|
17
|
+
NetworkError: (err) => Effect.fail(new FreshnessError({
|
|
18
|
+
strategy: "api",
|
|
19
|
+
message: `Fresh data required but network unavailable: ${err.message}`
|
|
20
|
+
})),
|
|
21
|
+
ParseError: (err) => Effect.fail(new FreshnessError({
|
|
22
|
+
strategy: "api",
|
|
23
|
+
message: `Fresh data required but parse failed: ${err.message}`
|
|
24
|
+
})),
|
|
25
|
+
RateLimitError: (err) => Effect.fail(new FreshnessError({
|
|
26
|
+
strategy: "api",
|
|
27
|
+
message: `Fresh data required but rate limit exceeded: ${err.message}`
|
|
28
|
+
}))
|
|
29
|
+
}));
|
|
30
|
+
const releases = yield* Effect.all(inputs.map((input) => BunRelease.fromInput(input)), { concurrency: "unbounded" }).pipe(Effect.orElseSucceed(() => []));
|
|
31
|
+
yield* cache.load(releases);
|
|
32
|
+
});
|
|
33
|
+
const SemVerLayer = VersionCacheLive.pipe(Layer.provide(SemVerParserLive));
|
|
34
|
+
const BaseCacheLayer = BunReleaseCacheLive.pipe(Layer.provide(SemVerLayer));
|
|
35
|
+
/**
|
|
36
|
+
* Provides the {@link BunReleaseCache} service using the "Fresh" cache strategy.
|
|
37
|
+
*
|
|
38
|
+
* Requires live release data from the GitHub API via {@link BunVersionFetcher}.
|
|
39
|
+
* If the fetch fails for any reason — authentication errors, network errors,
|
|
40
|
+
* parse errors, or rate-limit errors — the layer fails with a `FreshnessError`
|
|
41
|
+
* rather than falling back to stale data.
|
|
42
|
+
*
|
|
43
|
+
* Use this strategy in contexts where working with outdated version data would
|
|
44
|
+
* be incorrect or misleading, such as CI pipelines or tooling that must reflect
|
|
45
|
+
* the true current state of Bun releases.
|
|
46
|
+
*
|
|
47
|
+
* The three Bun cache strategy layers are:
|
|
48
|
+
* - {@link AutoBunCacheLive} — tries API first, falls back to bundled defaults on any error
|
|
49
|
+
* - `FreshBunCacheLive` — requires live API data, fails with `FreshnessError` if unavailable
|
|
50
|
+
* - {@link OfflineBunCacheLive} — uses only bundled defaults, no network I/O
|
|
51
|
+
*
|
|
52
|
+
* @see {@link BunReleaseCache}
|
|
53
|
+
* @see {@link AutoBunCacheLive}
|
|
54
|
+
* @see {@link OfflineBunCacheLive}
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
const FreshBunCacheLive = BaseCacheLayer.pipe(Layer.tap((ctx) => freshSetup.pipe(Effect.provide(ctx))));
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { FreshBunCacheLive };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DenoRelease } from "../schemas/deno-release.js";
|
|
2
|
+
import { DenoReleaseCache } from "../services/DenoReleaseCache.js";
|
|
3
|
+
import { DenoVersionFetcher } from "../services/DenoVersionFetcher.js";
|
|
4
|
+
import { DenoReleaseCacheLive } from "./DenoReleaseCacheLive.js";
|
|
5
|
+
import { FreshnessError } from "../errors/FreshnessError.js";
|
|
6
|
+
import { Effect, Layer } from "effect";
|
|
7
|
+
import { SemVerParserLive, VersionCacheLive } from "semver-effect";
|
|
8
|
+
|
|
9
|
+
//#region src/layers/FreshDenoCacheLive.ts
|
|
10
|
+
const freshSetup = Effect.gen(function* () {
|
|
11
|
+
const cache = yield* DenoReleaseCache;
|
|
12
|
+
const { inputs } = yield* (yield* DenoVersionFetcher).fetch().pipe(Effect.catchTags({
|
|
13
|
+
AuthenticationError: (err) => Effect.fail(new FreshnessError({
|
|
14
|
+
strategy: "api",
|
|
15
|
+
message: `Fresh data required but authentication failed: ${err.message}`
|
|
16
|
+
})),
|
|
17
|
+
NetworkError: (err) => Effect.fail(new FreshnessError({
|
|
18
|
+
strategy: "api",
|
|
19
|
+
message: `Fresh data required but network unavailable: ${err.message}`
|
|
20
|
+
})),
|
|
21
|
+
ParseError: (err) => Effect.fail(new FreshnessError({
|
|
22
|
+
strategy: "api",
|
|
23
|
+
message: `Fresh data required but parse failed: ${err.message}`
|
|
24
|
+
})),
|
|
25
|
+
RateLimitError: (err) => Effect.fail(new FreshnessError({
|
|
26
|
+
strategy: "api",
|
|
27
|
+
message: `Fresh data required but rate limit exceeded: ${err.message}`
|
|
28
|
+
}))
|
|
29
|
+
}));
|
|
30
|
+
const releases = yield* Effect.all(inputs.map((input) => DenoRelease.fromInput(input)), { concurrency: "unbounded" }).pipe(Effect.orElseSucceed(() => []));
|
|
31
|
+
yield* cache.load(releases);
|
|
32
|
+
});
|
|
33
|
+
const SemVerLayer = VersionCacheLive.pipe(Layer.provide(SemVerParserLive));
|
|
34
|
+
const BaseCacheLayer = DenoReleaseCacheLive.pipe(Layer.provide(SemVerLayer));
|
|
35
|
+
/**
|
|
36
|
+
* Provides the {@link DenoReleaseCache} service using the "Fresh" cache strategy.
|
|
37
|
+
*
|
|
38
|
+
* Requires live release data from the GitHub API via {@link DenoVersionFetcher}.
|
|
39
|
+
* If the fetch fails for any reason — authentication errors, network errors,
|
|
40
|
+
* parse errors, or rate-limit errors — the layer fails with a `FreshnessError`
|
|
41
|
+
* rather than falling back to stale data.
|
|
42
|
+
*
|
|
43
|
+
* Use this strategy in contexts where working with outdated version data would
|
|
44
|
+
* be incorrect or misleading, such as CI pipelines or tooling that must reflect
|
|
45
|
+
* the true current state of Deno releases.
|
|
46
|
+
*
|
|
47
|
+
* The three Deno cache strategy layers are:
|
|
48
|
+
* - {@link AutoDenoCacheLive} — tries API first, falls back to bundled defaults on any error
|
|
49
|
+
* - `FreshDenoCacheLive` — requires live API data, fails with `FreshnessError` if unavailable
|
|
50
|
+
* - {@link OfflineDenoCacheLive} — uses only bundled defaults, no network I/O
|
|
51
|
+
*
|
|
52
|
+
* @see {@link DenoReleaseCache}
|
|
53
|
+
* @see {@link AutoDenoCacheLive}
|
|
54
|
+
* @see {@link OfflineDenoCacheLive}
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
const FreshDenoCacheLive = BaseCacheLayer.pipe(Layer.tap((ctx) => freshSetup.pipe(Effect.provide(ctx))));
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { FreshDenoCacheLive };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NodeReleaseCache } from "../services/NodeReleaseCache.js";
|
|
2
|
+
import { NodeScheduleFetcher } from "../services/NodeScheduleFetcher.js";
|
|
3
|
+
import { NodeVersionFetcher } from "../services/NodeVersionFetcher.js";
|
|
4
|
+
import { NodeReleaseCacheLive } from "./NodeReleaseCacheLive.js";
|
|
5
|
+
import { FreshnessError } from "../errors/FreshnessError.js";
|
|
6
|
+
import { Effect, Layer } from "effect";
|
|
7
|
+
import { SemVerParserLive, VersionCacheLive } from "semver-effect";
|
|
8
|
+
|
|
9
|
+
//#region src/layers/FreshNodeCacheLive.ts
|
|
10
|
+
const freshSetup = Effect.gen(function* () {
|
|
11
|
+
const cache = yield* NodeReleaseCache;
|
|
12
|
+
const versionFetcher = yield* NodeVersionFetcher;
|
|
13
|
+
const scheduleFetcher = yield* NodeScheduleFetcher;
|
|
14
|
+
const [fetchResult, scheduleData] = yield* Effect.all([versionFetcher.fetch(), scheduleFetcher.fetch()]).pipe(Effect.catchTags({
|
|
15
|
+
NetworkError: (err) => Effect.fail(new FreshnessError({
|
|
16
|
+
strategy: "api",
|
|
17
|
+
message: `Fresh data required but network unavailable: ${err.message}`
|
|
18
|
+
})),
|
|
19
|
+
ParseError: (err) => Effect.fail(new FreshnessError({
|
|
20
|
+
strategy: "api",
|
|
21
|
+
message: `Fresh data required but response could not be parsed: ${err.message}`
|
|
22
|
+
}))
|
|
23
|
+
}));
|
|
24
|
+
yield* cache.updateSchedule(scheduleData);
|
|
25
|
+
yield* cache.loadFromInputs(fetchResult.inputs);
|
|
26
|
+
});
|
|
27
|
+
const SemVerLayer = VersionCacheLive.pipe(Layer.provide(SemVerParserLive));
|
|
28
|
+
const BaseCacheLayer = NodeReleaseCacheLive.pipe(Layer.provide(SemVerLayer));
|
|
29
|
+
/**
|
|
30
|
+
* Provides the {@link NodeReleaseCache} service using the "Fresh" cache strategy.
|
|
31
|
+
*
|
|
32
|
+
* Requires live release data from `nodejs.org/dist/index.json` via
|
|
33
|
+
* {@link NodeVersionFetcher} and the release schedule from the `nodejs/Release`
|
|
34
|
+
* repository via {@link NodeScheduleFetcher}. If either fetch fails — due to
|
|
35
|
+
* network errors or parse errors — the layer fails with a `FreshnessError`
|
|
36
|
+
* rather than falling back to stale data.
|
|
37
|
+
*
|
|
38
|
+
* Use this strategy in contexts where working with outdated version data would
|
|
39
|
+
* be incorrect or misleading, such as CI pipelines or tooling that must reflect
|
|
40
|
+
* the true current state of Node.js releases and LTS schedule.
|
|
41
|
+
*
|
|
42
|
+
* The three Node cache strategy layers are:
|
|
43
|
+
* - {@link AutoNodeCacheLive} — tries API first, falls back to bundled defaults on any error
|
|
44
|
+
* - `FreshNodeCacheLive` — requires live API data, fails with `FreshnessError` if unavailable
|
|
45
|
+
* - {@link OfflineNodeCacheLive} — uses only bundled defaults, no network I/O
|
|
46
|
+
*
|
|
47
|
+
* @see {@link NodeReleaseCache}
|
|
48
|
+
* @see {@link AutoNodeCacheLive}
|
|
49
|
+
* @see {@link OfflineNodeCacheLive}
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
const FreshNodeCacheLive = BaseCacheLayer.pipe(Layer.tap((ctx) => freshSetup.pipe(Effect.provide(ctx))));
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
export { FreshNodeCacheLive };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { OctokitInstance } from "../services/OctokitInstance.js";
|
|
2
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { Octokit } from "octokit";
|
|
5
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
6
|
+
|
|
7
|
+
//#region src/layers/GitHubAppAuth.ts
|
|
8
|
+
const resolveInstallationId = async (auth, installationId) => {
|
|
9
|
+
if (installationId) return installationId;
|
|
10
|
+
const { token: jwt } = await auth({ type: "app" });
|
|
11
|
+
const response = await fetch("https://api.github.com/app/installations?per_page=100", { headers: {
|
|
12
|
+
Authorization: `Bearer ${jwt}`,
|
|
13
|
+
Accept: "application/vnd.github+json"
|
|
14
|
+
} });
|
|
15
|
+
if (!response.ok) throw new Error(`Failed to list installations: ${response.status}`);
|
|
16
|
+
const installations = await response.json();
|
|
17
|
+
if (installations.length === 0) throw new Error("No installations found for this GitHub App");
|
|
18
|
+
return installations[0].id;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Provides an {@link OctokitInstance} authenticated as a GitHub App installation.
|
|
22
|
+
*
|
|
23
|
+
* Accepts a {@link GitHubAppAuthConfig} and uses `@octokit/auth-app` to obtain
|
|
24
|
+
* an installation access token. If `installationId` is omitted, the first
|
|
25
|
+
* installation returned by the GitHub API is used automatically (suitable for
|
|
26
|
+
* apps with a single installation). If authentication fails for any reason the
|
|
27
|
+
* layer fails with an `AuthenticationError`.
|
|
28
|
+
*
|
|
29
|
+
* Use this auth method when your integration runs as a GitHub App rather than
|
|
30
|
+
* as an individual user or a CI token.
|
|
31
|
+
*
|
|
32
|
+
* @param config - App credentials and optional installation ID.
|
|
33
|
+
*
|
|
34
|
+
* @see {@link GitHubAppAuthConfig}
|
|
35
|
+
* @see {@link OctokitInstance}
|
|
36
|
+
* @see {@link GitHubAutoAuth}
|
|
37
|
+
* @see {@link GitHubTokenAuth}
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
const GitHubAppAuth = (config) => Layer.effect(OctokitInstance, Effect.tryPromise({
|
|
41
|
+
try: async () => {
|
|
42
|
+
const auth = createAppAuth({
|
|
43
|
+
appId: config.appId,
|
|
44
|
+
privateKey: config.privateKey
|
|
45
|
+
});
|
|
46
|
+
return new Octokit({ auth: (await auth({
|
|
47
|
+
type: "installation",
|
|
48
|
+
installationId: await resolveInstallationId(auth, config.installationId)
|
|
49
|
+
})).token });
|
|
50
|
+
},
|
|
51
|
+
catch: (error) => new AuthenticationError({
|
|
52
|
+
method: "app",
|
|
53
|
+
message: error instanceof Error ? error.message : String(error)
|
|
54
|
+
})
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { GitHubAppAuth };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { OctokitInstance } from "../services/OctokitInstance.js";
|
|
2
|
+
import { GitHubAppAuth } from "./GitHubAppAuth.js";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { Octokit } from "octokit";
|
|
5
|
+
|
|
6
|
+
//#region src/layers/GitHubAutoAuth.ts
|
|
7
|
+
/**
|
|
8
|
+
* Provides an {@link OctokitInstance} by auto-detecting the available
|
|
9
|
+
* authentication credentials from environment variables at layer construction
|
|
10
|
+
* time.
|
|
11
|
+
*
|
|
12
|
+
* Credentials are evaluated in the following priority order:
|
|
13
|
+
* 1. **GitHub App** — when both `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`
|
|
14
|
+
* are set, authenticates as a GitHub App installation via {@link GitHubAppAuth}.
|
|
15
|
+
* `GITHUB_APP_INSTALLATION_ID` is optional; if absent the first installation
|
|
16
|
+
* is used automatically.
|
|
17
|
+
* 2. **Personal Access Token** — when `GITHUB_PERSONAL_ACCESS_TOKEN` is set,
|
|
18
|
+
* authenticates with that token.
|
|
19
|
+
* 3. **`GITHUB_TOKEN`** — when `GITHUB_TOKEN` is set (e.g., the default token
|
|
20
|
+
* injected in GitHub Actions), authenticates with that token.
|
|
21
|
+
* 4. **Unauthenticated** — falls back to an unauthenticated Octokit instance
|
|
22
|
+
* when no credentials are found (lower rate limits apply).
|
|
23
|
+
*
|
|
24
|
+
* If both App and token credentials are present, App auth takes precedence and
|
|
25
|
+
* a warning is logged. This layer fails with `AuthenticationError` only when
|
|
26
|
+
* App credentials are present but the App authentication itself fails.
|
|
27
|
+
*
|
|
28
|
+
* Use this layer as the default auth strategy in most applications. Switch to
|
|
29
|
+
* {@link GitHubTokenAuth} or {@link GitHubAppAuth} when you need explicit control
|
|
30
|
+
* over which credential source is used.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { GitHubClientLive, GitHubAutoAuth } from "runtime-resolver";
|
|
35
|
+
* import { Layer } from "effect";
|
|
36
|
+
*
|
|
37
|
+
* const GitHubLayer = GitHubClientLive.pipe(Layer.provide(GitHubAutoAuth));
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @see {@link OctokitInstance}
|
|
41
|
+
* @see {@link GitHubTokenAuth}
|
|
42
|
+
* @see {@link GitHubAppAuth}
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
const GitHubAutoAuth = Layer.effect(OctokitInstance, Effect.gen(function* () {
|
|
46
|
+
const appId = process.env.GITHUB_APP_ID;
|
|
47
|
+
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
|
|
48
|
+
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
|
|
49
|
+
const pat = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
50
|
+
const token = process.env.GITHUB_TOKEN;
|
|
51
|
+
const hasApp = appId && privateKey;
|
|
52
|
+
if (hasApp && (pat || token)) {
|
|
53
|
+
const tokenSource = pat ? "GITHUB_PERSONAL_ACCESS_TOKEN" : "GITHUB_TOKEN";
|
|
54
|
+
yield* Effect.logWarning(`Multiple GitHub credential sources found. Using GitHub App authentication (from GITHUB_APP_ID). Ignoring ${tokenSource}.`);
|
|
55
|
+
}
|
|
56
|
+
if (hasApp) {
|
|
57
|
+
const appLayer = GitHubAppAuth({
|
|
58
|
+
appId,
|
|
59
|
+
privateKey,
|
|
60
|
+
...installationId ? { installationId: Number(installationId) } : {}
|
|
61
|
+
});
|
|
62
|
+
return yield* Effect.provide(OctokitInstance, appLayer);
|
|
63
|
+
}
|
|
64
|
+
if (pat) return new Octokit({ auth: pat });
|
|
65
|
+
if (token) return new Octokit({ auth: token });
|
|
66
|
+
return new Octokit();
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { GitHubAutoAuth };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { GitHubClient } from "../services/GitHubClient.js";
|
|
2
|
+
import { OctokitInstance } from "../services/OctokitInstance.js";
|
|
3
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
4
|
+
import { NetworkError } from "../errors/NetworkError.js";
|
|
5
|
+
import { ParseError } from "../errors/ParseError.js";
|
|
6
|
+
import { RateLimitError } from "../errors/RateLimitError.js";
|
|
7
|
+
import { GitHubReleaseList, GitHubTagList } from "../schemas/github.js";
|
|
8
|
+
import { Effect, Layer, Schema } from "effect";
|
|
9
|
+
|
|
10
|
+
//#region src/layers/GitHubClientLive.ts
|
|
11
|
+
const mapOctokitError = (error, url) => {
|
|
12
|
+
const err = error;
|
|
13
|
+
if (err.status === 401) return new AuthenticationError({
|
|
14
|
+
method: "token",
|
|
15
|
+
message: `GitHub API authentication failed for ${url}`
|
|
16
|
+
});
|
|
17
|
+
if (err.status === 403 || err.status === 429) {
|
|
18
|
+
const headers = err.response?.headers ?? {};
|
|
19
|
+
const retryAfterStr = headers["retry-after"];
|
|
20
|
+
return new RateLimitError({
|
|
21
|
+
...retryAfterStr ? { retryAfter: Number.parseInt(retryAfterStr, 10) } : {},
|
|
22
|
+
limit: headers["x-ratelimit-limit"] ? Number.parseInt(headers["x-ratelimit-limit"], 10) : 0,
|
|
23
|
+
remaining: headers["x-ratelimit-remaining"] ? Number.parseInt(headers["x-ratelimit-remaining"], 10) : 0,
|
|
24
|
+
message: `GitHub API rate limit exceeded for ${url}`
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return new NetworkError({
|
|
28
|
+
url,
|
|
29
|
+
...err.status !== void 0 ? { status: err.status } : {},
|
|
30
|
+
message: error instanceof Error ? error.message : String(error)
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const decodeTagList = Schema.decodeUnknown(GitHubTagList);
|
|
34
|
+
const decodeReleaseList = Schema.decodeUnknown(GitHubReleaseList);
|
|
35
|
+
/**
|
|
36
|
+
* Provides the {@link GitHubClient} service backed by an {@link OctokitInstance}.
|
|
37
|
+
*
|
|
38
|
+
* This layer wraps an Octokit instance and exposes a typed, Effect-native API
|
|
39
|
+
* for listing GitHub releases, listing tags, and fetching arbitrary JSON from
|
|
40
|
+
* URLs. HTTP errors are mapped to typed domain errors (`NetworkError`,
|
|
41
|
+
* `RateLimitError`, `AuthenticationError`) so they can be handled precisely in
|
|
42
|
+
* calling code.
|
|
43
|
+
*
|
|
44
|
+
* Pair this layer with one of the auth layers to form a complete GitHub
|
|
45
|
+
* integration stack:
|
|
46
|
+
* - {@link GitHubAutoAuth} — auto-detects auth from environment variables
|
|
47
|
+
* - {@link GitHubTokenAuth} — authenticates using a personal access token
|
|
48
|
+
* - {@link GitHubAppAuth} — authenticates as a GitHub App installation
|
|
49
|
+
*
|
|
50
|
+
* @see {@link GitHubClient}
|
|
51
|
+
* @see {@link OctokitInstance}
|
|
52
|
+
* @see {@link GitHubAutoAuth}
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
55
|
+
const GitHubClientLive = Layer.effect(GitHubClient, Effect.gen(function* () {
|
|
56
|
+
const octokit = yield* OctokitInstance;
|
|
57
|
+
return {
|
|
58
|
+
listTags: (owner, repo, options) => Effect.gen(function* () {
|
|
59
|
+
const perPage = options?.perPage ?? 100;
|
|
60
|
+
const maxPages = options?.pages ?? Number.POSITIVE_INFINITY;
|
|
61
|
+
const allTags = [];
|
|
62
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
63
|
+
const response = yield* Effect.tryPromise({
|
|
64
|
+
try: () => octokit.rest.repos.listTags({
|
|
65
|
+
owner,
|
|
66
|
+
repo,
|
|
67
|
+
per_page: perPage,
|
|
68
|
+
page
|
|
69
|
+
}),
|
|
70
|
+
catch: (error) => mapOctokitError(error, `repos/${owner}/${repo}/tags`)
|
|
71
|
+
});
|
|
72
|
+
const tags = yield* decodeTagList(response.data).pipe(Effect.mapError((e) => new ParseError({
|
|
73
|
+
source: `repos/${owner}/${repo}/tags`,
|
|
74
|
+
message: `Schema validation failed: ${e.message}`
|
|
75
|
+
})));
|
|
76
|
+
allTags.push(...tags);
|
|
77
|
+
if (response.data.length < perPage) break;
|
|
78
|
+
}
|
|
79
|
+
return allTags;
|
|
80
|
+
}),
|
|
81
|
+
listReleases: (owner, repo, options) => Effect.gen(function* () {
|
|
82
|
+
const perPage = options?.perPage ?? 100;
|
|
83
|
+
const maxPages = options?.pages ?? Number.POSITIVE_INFINITY;
|
|
84
|
+
const allReleases = [];
|
|
85
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
86
|
+
const response = yield* Effect.tryPromise({
|
|
87
|
+
try: () => octokit.rest.repos.listReleases({
|
|
88
|
+
owner,
|
|
89
|
+
repo,
|
|
90
|
+
per_page: perPage,
|
|
91
|
+
page
|
|
92
|
+
}),
|
|
93
|
+
catch: (error) => mapOctokitError(error, `repos/${owner}/${repo}/releases`)
|
|
94
|
+
});
|
|
95
|
+
const releases = yield* decodeReleaseList(response.data).pipe(Effect.mapError((e) => new ParseError({
|
|
96
|
+
source: `repos/${owner}/${repo}/releases`,
|
|
97
|
+
message: `Schema validation failed: ${e.message}`
|
|
98
|
+
})));
|
|
99
|
+
allReleases.push(...releases);
|
|
100
|
+
if (response.data.length < perPage) break;
|
|
101
|
+
}
|
|
102
|
+
return allReleases;
|
|
103
|
+
}),
|
|
104
|
+
getJson: (url, schema) => Effect.gen(function* () {
|
|
105
|
+
const response = yield* Effect.tryPromise({
|
|
106
|
+
try: () => fetch(url),
|
|
107
|
+
catch: (error) => new NetworkError({
|
|
108
|
+
url,
|
|
109
|
+
message: error instanceof Error ? error.message : String(error)
|
|
110
|
+
})
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) yield* Effect.fail(new NetworkError({
|
|
113
|
+
url,
|
|
114
|
+
status: response.status,
|
|
115
|
+
message: `HTTP ${response.status}: ${response.statusText}`
|
|
116
|
+
}));
|
|
117
|
+
const json = yield* Effect.tryPromise({
|
|
118
|
+
try: () => response.json(),
|
|
119
|
+
catch: () => new ParseError({
|
|
120
|
+
source: url,
|
|
121
|
+
message: "Failed to parse JSON response"
|
|
122
|
+
})
|
|
123
|
+
});
|
|
124
|
+
return yield* schema.decode(json);
|
|
125
|
+
})
|
|
126
|
+
};
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
export { GitHubClientLive };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { OctokitInstance } from "../services/OctokitInstance.js";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { Octokit } from "octokit";
|
|
4
|
+
|
|
5
|
+
//#region src/layers/GitHubTokenAuth.ts
|
|
6
|
+
const resolveToken = Effect.sync(() => process.env.GITHUB_PERSONAL_ACCESS_TOKEN || process.env.GITHUB_TOKEN || "");
|
|
7
|
+
/**
|
|
8
|
+
* Provides an {@link OctokitInstance} authenticated with a GitHub personal
|
|
9
|
+
* access token read from the environment at layer construction time.
|
|
10
|
+
*
|
|
11
|
+
* The token is resolved by checking, in order:
|
|
12
|
+
* 1. `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable
|
|
13
|
+
* 2. `GITHUB_TOKEN` environment variable
|
|
14
|
+
*
|
|
15
|
+
* If neither variable is set the layer constructs an unauthenticated Octokit
|
|
16
|
+
* instance (lower rate limits apply). This layer never fails.
|
|
17
|
+
*
|
|
18
|
+
* Use {@link GitHubTokenAuthFromToken} when you have the token value available
|
|
19
|
+
* programmatically at construction time.
|
|
20
|
+
*
|
|
21
|
+
* @see {@link OctokitInstance}
|
|
22
|
+
* @see {@link GitHubTokenAuthFromToken}
|
|
23
|
+
* @see {@link GitHubAutoAuth}
|
|
24
|
+
* @see {@link GitHubAppAuth}
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
const GitHubTokenAuth = Layer.effect(OctokitInstance, Effect.gen(function* () {
|
|
28
|
+
const token = yield* resolveToken;
|
|
29
|
+
return new Octokit(token ? { auth: token } : {});
|
|
30
|
+
}));
|
|
31
|
+
/**
|
|
32
|
+
* Provides an {@link OctokitInstance} authenticated with the given personal
|
|
33
|
+
* access token string.
|
|
34
|
+
*
|
|
35
|
+
* Use this variant when the token is available as a value in your program
|
|
36
|
+
* rather than read from an environment variable. The resulting layer never
|
|
37
|
+
* fails.
|
|
38
|
+
*
|
|
39
|
+
* @param token - A GitHub personal access token string.
|
|
40
|
+
*
|
|
41
|
+
* @see {@link OctokitInstance}
|
|
42
|
+
* @see {@link GitHubTokenAuth}
|
|
43
|
+
* @see {@link GitHubAutoAuth}
|
|
44
|
+
* @see {@link GitHubAppAuth}
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
const GitHubTokenAuthFromToken = (token) => Layer.succeed(OctokitInstance, new Octokit({ auth: token }));
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { GitHubTokenAuth, GitHubTokenAuthFromToken };
|