runtime-resolver 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # runtime-resolver
2
+
3
+ [![npm version](https://img.shields.io/npm/v/runtime-resolver)](https://www.npmjs.com/package/runtime-resolver)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Resolve semver-compatible versions of Node.js, Bun, and Deno runtimes. Fetches available versions from GitHub with automatic offline fallback via a build-time cache.
7
+
8
+ ## Features
9
+
10
+ - Resolve matching versions for Node.js, Bun, and Deno with a single call
11
+ - Filter Node.js results by release phase (current, active-lts, maintenance-lts)
12
+ - Control version granularity with increment levels (latest, minor, patch) for all runtimes
13
+ - Resolve a single version from a semver range with `resolveVersion`
14
+ - Control data freshness with `freshness` option (`"auto"`, `"api"`, or `"cache"`)
15
+ - Track data provenance with the `source` field (`"api"` or `"cache"`)
16
+ - Input validation with typed `InvalidInputError` for all resolvers
17
+ - Offline fallback using bundled version data when GitHub is unreachable
18
+ - CLI with structured JSON output for CI/CD pipelines
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install runtime-resolver
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```typescript
29
+ import { resolveNode, resolveBun, resolveDeno } from "runtime-resolver";
30
+
31
+ const node = await resolveNode({ semverRange: ">=20" });
32
+ console.log(node.latest); // e.g. "22.14.0"
33
+ console.log(node.source); // "api" or "cache"
34
+ console.log(node.default); // latest LTS version
35
+
36
+ const bun = await resolveBun({ semverRange: ">=1.1", increments: "minor" });
37
+ const deno = await resolveDeno({ semverRange: ">=2", increments: "minor" });
38
+ ```
39
+
40
+ Set a `GITHUB_TOKEN` or `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable
41
+ for authenticated requests. Without one, the resolver falls back to cached data.
42
+
43
+ ### CLI
44
+
45
+ ```bash
46
+ npx runtime-resolver --node ">=22" --bun "^1" --deno ">=2" --pretty
47
+ ```
48
+
49
+ Output is structured JSON with a `$schema` reference for editor auto-complete. Print the full schema with `--schema`:
50
+
51
+ ```bash
52
+ npx runtime-resolver --schema
53
+ ```
54
+
55
+ #### Useful `jq` recipes
56
+
57
+ ```bash
58
+ # Get the latest resolved Node.js version
59
+ npx runtime-resolver --node ">=22" | jq -r '.results.node.latest'
60
+
61
+ # List all matching versions as plain lines
62
+ npx runtime-resolver --deno ">=2" | jq -r '.results.deno.versions[]'
63
+
64
+ # Exit non-zero when any runtime fails to resolve
65
+ npx runtime-resolver --node ">=22" --bun "^1" | jq -e '.ok'
66
+
67
+ # Extract only the LTS version for Node.js
68
+ npx runtime-resolver --node ">=20" --node-phases active-lts | jq -r '.results.node.lts'
69
+
70
+ # Build a comma-separated matrix for GitHub Actions
71
+ npx runtime-resolver --node ">=20" --increments minor \
72
+ | jq -r '[.results.node.versions[]] | join(",")'
73
+ ```
74
+
75
+ ### Effect API
76
+
77
+ Power users can compose resolvers with custom layers and typed error handling instead of the Promise wrappers.
78
+
79
+ ```typescript
80
+ import { Effect, Layer } from "effect";
81
+ import {
82
+ NodeResolver,
83
+ NodeResolverLive,
84
+ GitHubClientLive,
85
+ GitHubTokenAuth,
86
+ VersionCacheLive,
87
+ } from "runtime-resolver";
88
+
89
+ const program = Effect.gen(function* () {
90
+ const resolver = yield* NodeResolver;
91
+ return yield* resolver.resolve({
92
+ semverRange: ">=22",
93
+ phases: ["active-lts"],
94
+ });
95
+ });
96
+
97
+ const layer = NodeResolverLive.pipe(
98
+ Layer.provide(Layer.merge(
99
+ GitHubClientLive.pipe(Layer.provide(GitHubTokenAuth)),
100
+ VersionCacheLive,
101
+ )),
102
+ );
103
+
104
+ Effect.runPromise(program.pipe(Effect.provide(layer)));
105
+ ```
106
+
107
+ All services (`NodeResolver`, `BunResolver`, `DenoResolver`, `GitHubClient`,
108
+ `VersionCache`), live layers, auth layers (`GitHubTokenAuth`, `GitHubAppAuth`,
109
+ `GitHubAutoAuth`), and typed errors (`AuthenticationError`, `NetworkError`,
110
+ `RateLimitError`, `VersionNotFoundError`, etc.) are exported from the package
111
+ root.
112
+
113
+ ## Documentation
114
+
115
+ For configuration, API reference, and advanced usage, see [docs](./docs/).
116
+
117
+ ## License
118
+
119
+ [MIT](./LICENSE)
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Options } from "@effect/cli";
3
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
+ import { readFileSync } from "node:fs";
5
+ import { BunResolver, Console, NodeResolverLive, Layer, GitHubAppAuth, GitHubAutoAuth, BunResolverLive, DenoResolverLive, GitHubClientLive, Cause, Effect, GitHubTokenAuthFromToken, VersionCacheLive, Option, NodeResolver, DenoResolver } from "../969.js";
6
+ const GitHubLayer = GitHubClientLive.pipe(Layer.provide(GitHubAutoAuth));
7
+ const SharedLayer = Layer.merge(GitHubLayer, VersionCacheLive);
8
+ const NodeLayer = NodeResolverLive.pipe(Layer.provide(SharedLayer));
9
+ const BunLayer = BunResolverLive.pipe(Layer.provide(SharedLayer));
10
+ const DenoLayer = DenoResolverLive.pipe(Layer.provide(SharedLayer));
11
+ const SCHEMA_URL = "https://raw.githubusercontent.com/spencerbeggs/runtime-resolver/main/runtime-resolver.schema.json";
12
+ const nodeOption = Options.text("node").pipe(Options.optional);
13
+ const bunOption = Options.text("bun").pipe(Options.optional);
14
+ const denoOption = Options.text("deno").pipe(Options.optional);
15
+ const nodePhasesOption = Options.text("node-phases").pipe(Options.optional);
16
+ const incrementsOption = Options.text("increments").pipe(Options.optional);
17
+ const nodeDefaultOption = Options.text("node-default").pipe(Options.optional);
18
+ const bunDefaultOption = Options.text("bun-default").pipe(Options.optional);
19
+ const denoDefaultOption = Options.text("deno-default").pipe(Options.optional);
20
+ const freshnessOption = Options.text("freshness").pipe(Options.optional);
21
+ const nodeDateOption = Options.text("node-date").pipe(Options.optional);
22
+ const prettyOption = Options.boolean("pretty").pipe(Options.withDefault(false));
23
+ const schemaOption = Options.boolean("schema").pipe(Options.withDefault(false));
24
+ const tokenOption = Options.text("token").pipe(Options.optional);
25
+ const appIdOption = Options.text("app-id").pipe(Options.optional);
26
+ const appPrivateKeyOption = Options.text("app-private-key").pipe(Options.optional);
27
+ const appInstallationIdOption = Options.text("app-installation-id").pipe(Options.optional);
28
+ const VALID_PHASES = [
29
+ "current",
30
+ "active-lts",
31
+ "maintenance-lts",
32
+ "end-of-life"
33
+ ];
34
+ const VALID_INCREMENTS = [
35
+ "latest",
36
+ "minor",
37
+ "patch"
38
+ ];
39
+ const VALID_FRESHNESS = [
40
+ "auto",
41
+ "api",
42
+ "cache"
43
+ ];
44
+ const validatePhases = (raw)=>{
45
+ const phases = raw.split(",").map((s)=>s.trim());
46
+ for (const p of phases)if (!VALID_PHASES.includes(p)) throw new Error(`Invalid phase: "${p}". Valid values: ${VALID_PHASES.join(", ")}`);
47
+ return phases;
48
+ };
49
+ const validateIncrements = (raw)=>{
50
+ if (!VALID_INCREMENTS.includes(raw)) throw new Error(`Invalid increments value: "${raw}". Valid values: ${VALID_INCREMENTS.join(", ")}`);
51
+ return raw;
52
+ };
53
+ const validateFreshness = (raw)=>{
54
+ if (!VALID_FRESHNESS.includes(raw)) throw new Error(`Invalid freshness value: "${raw}". Valid values: ${VALID_FRESHNESS.join(", ")}`);
55
+ return raw;
56
+ };
57
+ const serializeError = (error)=>{
58
+ if (error && "object" == typeof error && "_tag" in error && "message" in error) {
59
+ const { _tag, message, ...rest } = error;
60
+ return {
61
+ _tag,
62
+ message,
63
+ ...rest
64
+ };
65
+ }
66
+ return {
67
+ _tag: "UnknownError",
68
+ message: error instanceof Error ? error.message : String(error)
69
+ };
70
+ };
71
+ const toSuccess = (name, result)=>[
72
+ name,
73
+ {
74
+ ok: true,
75
+ source: result.source,
76
+ versions: [
77
+ ...result.versions
78
+ ],
79
+ latest: result.latest,
80
+ ...void 0 !== result.lts ? {
81
+ lts: result.lts
82
+ } : {},
83
+ ...void 0 !== result.default ? {
84
+ default: result.default
85
+ } : {}
86
+ }
87
+ ];
88
+ const toError = (name, error)=>[
89
+ name,
90
+ {
91
+ ok: false,
92
+ error: serializeError(error)
93
+ }
94
+ ];
95
+ const resolveNode = (semverRange, phases, increments, defaultVersion, date, freshness, authOverride)=>Effect.gen(function*() {
96
+ const resolver = yield* NodeResolver;
97
+ const result = yield* resolver.resolve({
98
+ semverRange,
99
+ ...phases ? {
100
+ phases
101
+ } : {},
102
+ ...increments ? {
103
+ increments
104
+ } : {},
105
+ ...defaultVersion ? {
106
+ defaultVersion
107
+ } : {},
108
+ ...date ? {
109
+ date
110
+ } : {},
111
+ ...freshness ? {
112
+ freshness
113
+ } : {}
114
+ });
115
+ return toSuccess("node", result);
116
+ }).pipe(Effect.provide(authOverride ? NodeResolverLive.pipe(Layer.provide(Layer.merge(GitHubClientLive.pipe(Layer.provide(authOverride)), VersionCacheLive))) : NodeLayer), Effect.catchAll((error)=>Effect.succeed(toError("node", error))));
117
+ const resolveBun = (semverRange, increments, defaultVersion, freshness, authOverride)=>Effect.gen(function*() {
118
+ const resolver = yield* BunResolver;
119
+ const result = yield* resolver.resolve({
120
+ semverRange,
121
+ ...increments ? {
122
+ increments
123
+ } : {},
124
+ ...defaultVersion ? {
125
+ defaultVersion
126
+ } : {},
127
+ ...freshness ? {
128
+ freshness
129
+ } : {}
130
+ });
131
+ return toSuccess("bun", result);
132
+ }).pipe(Effect.provide(authOverride ? BunResolverLive.pipe(Layer.provide(Layer.merge(GitHubClientLive.pipe(Layer.provide(authOverride)), VersionCacheLive))) : BunLayer), Effect.catchAll((error)=>Effect.succeed(toError("bun", error))));
133
+ const resolveDeno = (semverRange, increments, defaultVersion, freshness, authOverride)=>Effect.gen(function*() {
134
+ const resolver = yield* DenoResolver;
135
+ const result = yield* resolver.resolve({
136
+ semverRange,
137
+ ...increments ? {
138
+ increments
139
+ } : {},
140
+ ...defaultVersion ? {
141
+ defaultVersion
142
+ } : {},
143
+ ...freshness ? {
144
+ freshness
145
+ } : {}
146
+ });
147
+ return toSuccess("deno", result);
148
+ }).pipe(Effect.provide(authOverride ? DenoResolverLive.pipe(Layer.provide(Layer.merge(GitHubClientLive.pipe(Layer.provide(authOverride)), VersionCacheLive))) : DenoLayer), Effect.catchAll((error)=>Effect.succeed(toError("deno", error))));
149
+ const formatResponse = (ok, results, pretty)=>{
150
+ const response = {
151
+ $schema: SCHEMA_URL,
152
+ ok,
153
+ results
154
+ };
155
+ return JSON.stringify(response, null, pretty ? 2 : void 0);
156
+ };
157
+ const resolveHandler = (args)=>Effect.gen(function*() {
158
+ if (args.schema) {
159
+ const resolveFlags = [
160
+ args.node,
161
+ args.bun,
162
+ args.deno,
163
+ args.nodePhases,
164
+ args.increments,
165
+ args.freshness,
166
+ args.nodeDefault,
167
+ args.bunDefault,
168
+ args.denoDefault,
169
+ args.nodeDate,
170
+ args.token,
171
+ args.appId,
172
+ args.appPrivateKey,
173
+ args.appInstallationId
174
+ ];
175
+ const hasResolveFlags = resolveFlags.some((f)=>Option.isSome(f));
176
+ if (hasResolveFlags) return void (yield* Console.error("Error: --schema cannot be combined with resolve flags (--node, --bun, --deno, etc.)"));
177
+ const { cliJsonSchema } = yield* Effect.promise(()=>import("../235.js").then((mod)=>({
178
+ cliJsonSchema: mod.cliJsonSchema
179
+ })));
180
+ yield* Console.log(JSON.stringify(cliJsonSchema, null, 2));
181
+ return;
182
+ }
183
+ const hasToken = Option.isSome(args.token);
184
+ const hasAppId = Option.isSome(args.appId);
185
+ const hasAppPrivateKey = Option.isSome(args.appPrivateKey);
186
+ const hasAppInstallationId = Option.isSome(args.appInstallationId);
187
+ if (hasToken && (hasAppId || hasAppPrivateKey)) return void (yield* Console.error("Error: --token and --app-id/--app-private-key are mutually exclusive"));
188
+ if (hasAppId !== hasAppPrivateKey) return void (yield* Console.error("Error: --app-id and --app-private-key must both be provided"));
189
+ if (hasAppInstallationId && !hasAppId) return void (yield* Console.error("Error: --app-installation-id requires --app-id and --app-private-key"));
190
+ let resolvedPrivateKey;
191
+ if (hasAppPrivateKey) {
192
+ const keyValue = args.appPrivateKey.value;
193
+ if (keyValue.startsWith("@")) {
194
+ const filePath = keyValue.slice(1);
195
+ try {
196
+ resolvedPrivateKey = readFileSync(filePath, "utf-8");
197
+ } catch {
198
+ yield* Console.error(`Error: Cannot read private key file: ${filePath}`);
199
+ return;
200
+ }
201
+ } else resolvedPrivateKey = keyValue;
202
+ }
203
+ let authLayerOverride;
204
+ if (hasToken) authLayerOverride = GitHubTokenAuthFromToken(args.token.value);
205
+ else if (hasAppId && resolvedPrivateKey) authLayerOverride = GitHubAppAuth({
206
+ appId: args.appId.value,
207
+ privateKey: resolvedPrivateKey,
208
+ ...hasAppInstallationId ? {
209
+ installationId: Number(args.appInstallationId.value)
210
+ } : {}
211
+ });
212
+ const hasNode = Option.isSome(args.node);
213
+ const hasBun = Option.isSome(args.bun);
214
+ const hasDeno = Option.isSome(args.deno);
215
+ if (!hasNode && !hasBun && !hasDeno) return void (yield* Console.error("No runtime specified. Use --node, --bun, or --deno to resolve versions.\nRun with --help for usage information."));
216
+ let validatedPhases;
217
+ if (Option.isSome(args.nodePhases)) try {
218
+ validatedPhases = validatePhases(args.nodePhases.value);
219
+ } catch (e) {
220
+ yield* Console.error(e.message);
221
+ return;
222
+ }
223
+ let validatedIncrements;
224
+ if (Option.isSome(args.increments)) try {
225
+ validatedIncrements = validateIncrements(args.increments.value);
226
+ } catch (e) {
227
+ yield* Console.error(e.message);
228
+ return;
229
+ }
230
+ let validatedFreshness;
231
+ if (Option.isSome(args.freshness)) try {
232
+ validatedFreshness = validateFreshness(args.freshness.value);
233
+ } catch (e) {
234
+ yield* Console.error(e.message);
235
+ return;
236
+ }
237
+ let nodeDate;
238
+ if (Option.isSome(args.nodeDate)) {
239
+ nodeDate = new Date(args.nodeDate.value);
240
+ if (Number.isNaN(nodeDate.getTime())) return void (yield* Console.error(`Invalid date: "${args.nodeDate.value}". Use ISO 8601 format (e.g. 2024-01-15).`));
241
+ }
242
+ const tasks = [];
243
+ if (hasNode) {
244
+ const nodeDefaultVersion = Option.isSome(args.nodeDefault) ? args.nodeDefault.value : void 0;
245
+ tasks.push(resolveNode(args.node.value, validatedPhases, validatedIncrements, nodeDefaultVersion, nodeDate, validatedFreshness, authLayerOverride));
246
+ }
247
+ if (hasBun) {
248
+ const bunDefaultVersion = Option.isSome(args.bunDefault) ? args.bunDefault.value : void 0;
249
+ tasks.push(resolveBun(args.bun.value, validatedIncrements, bunDefaultVersion, validatedFreshness, authLayerOverride));
250
+ }
251
+ if (hasDeno) {
252
+ const denoDefaultVersion = Option.isSome(args.denoDefault) ? args.denoDefault.value : void 0;
253
+ tasks.push(resolveDeno(args.deno.value, validatedIncrements, denoDefaultVersion, validatedFreshness, authLayerOverride));
254
+ }
255
+ const entries = yield* Effect.all(tasks, {
256
+ concurrency: "unbounded"
257
+ });
258
+ const results = Object.fromEntries(entries);
259
+ const hasError = Object.values(results).some((r)=>!r.ok);
260
+ yield* Console.log(formatResponse(!hasError, results, args.pretty));
261
+ });
262
+ const rootCommand = Command.make("runtime-resolver", {
263
+ node: nodeOption,
264
+ bun: bunOption,
265
+ deno: denoOption,
266
+ nodePhases: nodePhasesOption,
267
+ increments: incrementsOption,
268
+ freshness: freshnessOption,
269
+ nodeDefault: nodeDefaultOption,
270
+ bunDefault: bunDefaultOption,
271
+ denoDefault: denoDefaultOption,
272
+ nodeDate: nodeDateOption,
273
+ pretty: prettyOption,
274
+ schema: schemaOption,
275
+ token: tokenOption,
276
+ appId: appIdOption,
277
+ appPrivateKey: appPrivateKeyOption,
278
+ appInstallationId: appInstallationIdOption
279
+ }, resolveHandler);
280
+ const cli = Command.run(rootCommand, {
281
+ name: "runtime-resolver",
282
+ version: "0.1.0"
283
+ });
284
+ const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Effect.catchAllCause((cause)=>{
285
+ const defects = Cause.defects(cause);
286
+ if (defects.length > 0) return Console.error(Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
287
+ return Effect.failCause(cause);
288
+ }));
289
+ NodeRuntime.runMain(main);