@theappagency/valinor 9.2.0 → 9.5.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 CHANGED
@@ -18,7 +18,14 @@ The launcher is **trivial by design** and contains no Valinor functionality itse
18
18
  version runs **fully offline** with no network I/O;
19
19
  4. executes the cached CLI, passing through all arguments and the exit code.
20
20
 
21
- No npm registry auth, no `.npmrc`, no tokens, no secrets — `npx` it anywhere, including CI.
21
+ No npm registry auth, no `.npmrc`, no tokens, no secrets — `npx` it anywhere, including CI. It runs on
22
+ Node 22 or later, so a GitHub runner's default Node works without a `setup-node` step.
23
+
24
+ A download that fails for a transient reason (a dropped connection, a stalled transfer, a timeout, or an
25
+ HTTP 408, 425, 429 or 5xx) is retried with jittered backoff: 3 attempts per file within a 2-minute budget by default. A 404
26
+ and a checksum mismatch are never retried. When `review-trigger-run` still cannot be fetched after the
27
+ retries, the launcher prints a warning and exits 0: the verb posts nothing when it cannot run, and the next
28
+ wake-up or the hourly sweep evaluates the PR again. Every other command exits 1.
22
29
  **The launcher sends no telemetry.** Its only network activity is fetching the artifact above.
23
30
 
24
31
  ## Environment variables
@@ -28,7 +35,10 @@ No npm registry auth, no `.npmrc`, no tokens, no secrets — `npx` it anywhere,
28
35
  | `VALINOR_VERSION` | Run a specific version or dist-tag (e.g. `3.1.0`, `latest`), overriding the launcher's own pinned version. |
29
36
  | `VALINOR_DOWNLOADS_URL` | Override the downloads endpoint base URL (staging/fixtures). |
30
37
  | `VALINOR_CACHE_DIR` | Override the cache location (default `~/.valinor/cache`). |
31
- | `VALINOR_FETCH_TIMEOUT_MS` | Per-request fetch timeout in milliseconds (default `30000`) — a silent/blackholed endpoint aborts here rather than hanging. |
38
+ | `VALINOR_FETCH_TIMEOUT_MS` | Per-attempt timeout in milliseconds, covering the response body as well as the headers (default `30000`) — a silent/blackholed endpoint or a stalled transfer aborts here rather than hanging. |
39
+ | `VALINOR_FETCH_ATTEMPTS` | Attempts per file for a transient failure: a whole number, at least `1` (default `3`). |
40
+ | `VALINOR_FETCH_BACKOFF_MS` | Retry backoff base in milliseconds (default `1000`); attempt *n* waits 50–100% of base × 2^(n−1). |
41
+ | `VALINOR_FETCH_TOTAL_TIMEOUT_MS` | The whole download's budget in milliseconds, across every attempt (default `120000`). |
32
42
  | `VALINOR_LAUNCHER_DEBUG` | Print full error causes (default output is the actionable message only). |
33
43
 
34
44
  ## Licensing
package/bin/valinor.js CHANGED
@@ -1,12 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // The launcher bin: delegate to lib/launcher.js and render its loud, actionable errors without a
3
3
  // stack trace (the cause chain is preserved on the error for VALINOR_LAUNCHER_DEBUG=1 diagnosis).
4
- import { run } from "../lib/launcher.js";
4
+ import { run, softFailWarning } from "../lib/launcher.js";
5
5
 
6
+ const args = process.argv.slice(2);
6
7
  try {
7
- process.exitCode = await run(process.argv.slice(2));
8
+ process.exitCode = await run(args);
8
9
  } catch (e) {
9
- console.error(`valinor launcher: ${e instanceof Error ? e.message : String(e)}`);
10
+ const message = e instanceof Error ? e.message : String(e);
11
+ const warning = softFailWarning(e, args);
12
+ if (warning) {
13
+ // Exit 0 for a verb whose not-running IS its fail-closed outcome (see SOFT_FAIL_VERBS); the
14
+ // annotation keeps the infrastructure error visible on the run without a red check on the PR.
15
+ console.error(`valinor launcher: warning — ${warning}\n${message}`);
16
+ if (process.env.GITHUB_ACTIONS === "true") console.log(`::warning title=Valinor bundle download failed::${warning}`);
17
+ process.exitCode = 0;
18
+ } else {
19
+ console.error(`valinor launcher: ${message}`);
20
+ if (process.env.GITHUB_ACTIONS === "true" && e?.transient === true) {
21
+ console.log("::error title=Valinor bundle download failed::The Valinor downloads endpoint did not answer after retries. This is an infrastructure error; re-run the job.");
22
+ }
23
+ process.exitCode = 1;
24
+ }
10
25
  if (process.env.VALINOR_LAUNCHER_DEBUG) console.error(e);
11
- process.exitCode = 1;
12
26
  }
package/lib/launcher.js CHANGED
@@ -16,6 +16,7 @@ import { execFileSync, spawnSync } from "node:child_process";
16
16
  import { createHash, randomBytes } from "node:crypto";
17
17
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
+ import { setTimeout as sleep } from "node:timers/promises";
19
20
  import { join } from "node:path";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
@@ -126,43 +127,124 @@ export function parseSha256Sums(text, filename) {
126
127
  }
127
128
 
128
129
  /**
129
- * The per-request timeout (ms): a firewalled/blackholed endpoint that ACCEPTS a connection but never
130
- * replies must fail LOUDLY in seconds, not hang forever. Reads the supplied env (NOT a hard-coded
130
+ * The per-ATTEMPT timeout (ms): a firewalled/blackholed endpoint that ACCEPTS a connection but never
131
+ * replies must fail LOUDLY in seconds, not hang forever. The signal covers the response BODY as well as
132
+ * the headers, so a transfer that stalls mid-body aborts here too. Reads the supplied env (NOT a hard-coded
131
133
  * `process.env` — `run()` threads its own env through so a caller/test override actually engages).
132
134
  * Default 30s; tests dial it down to prove the abort fires.
133
135
  */
134
136
  export function fetchTimeoutMs(env = process.env) {
135
- const parsed = Number(env.VALINOR_FETCH_TIMEOUT_MS);
136
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
137
+ return positiveNumber(env.VALINOR_FETCH_TIMEOUT_MS, 30_000);
138
+ }
139
+
140
+ function positiveNumber(raw, fallback) {
141
+ const parsed = Number(raw);
142
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
143
+ }
144
+
145
+ /** A whole number of at least one, else the default: a fractional count must never floor to zero attempts. */
146
+ function positiveInteger(raw, fallback) {
147
+ const parsed = Number(raw);
148
+ return Number.isInteger(parsed) && parsed >= 1 ? parsed : fallback;
137
149
  }
138
150
 
139
151
  /**
140
- * GET a URL, throwing a loud error naming the URL on any non-200, TIMEOUT, or network failure.
141
- * `timeoutMs` is REQUIRED (resolved once by the caller from its env) — passing it explicitly is what
142
- * fixes the bug where `fetchTimeoutMs()` read the ambient `process.env` and a `run(args, env)`
143
- * override was silently dropped. A timed-out abort surfaces as `TimeoutError`, named in the message.
152
+ * How the launcher retries a download. A stalled or dropped transfer is TRANSIENT: on 2026-09-25 a
153
+ * handful of CI runs across two consumer repos saw the tarball's body stall for the whole 30s while the
154
+ * endpoint served the same bytes in under half a second to every other run. One fresh attempt clears
155
+ * that, so a transient failure is retried with jittered exponential backoff inside a bounded total
156
+ * budget. Only transient failures retry: a 404 (a retracted or missing artifact), a malformed manifest,
157
+ * and a SHA-256 mismatch stay terminal on the first sight — retrying them would only delay a loud refusal.
158
+ *
159
+ * VALINOR_FETCH_ATTEMPTS attempts per file, default 3
160
+ * VALINOR_FETCH_BACKOFF_MS backoff base, default 1000 (attempt n waits 50–100% of base × 2^(n-1))
161
+ * VALINOR_FETCH_TOTAL_TIMEOUT_MS the whole download's budget, default 120000 — a later attempt gets
162
+ * only what is left, so a run never waits longer than this
144
163
  */
145
- async function fetchOrThrow(url, fetchImpl, timeoutMs) {
146
- let res;
147
- try {
148
- res = await (fetchImpl ?? fetch)(url, { redirect: "follow", signal: AbortSignal.timeout(timeoutMs) });
149
- } catch (e) {
150
- // AbortSignal.timeout rejects with a DOMException named "TimeoutError" — surface that distinctly
151
- // so the failure reads as "timed out after Nms", not an opaque cause code.
152
- const cause =
153
- e?.name === "TimeoutError"
154
- ? `timed out after ${timeoutMs}ms`
155
- : (e?.cause?.code ?? e?.code ?? (e instanceof Error ? e.message : String(e)));
156
- throw new Error(`could not reach ${url} (${cause})`, { cause: e });
164
+ export function fetchPolicy(env = process.env) {
165
+ return {
166
+ timeoutMs: fetchTimeoutMs(env),
167
+ attempts: positiveInteger(env.VALINOR_FETCH_ATTEMPTS, 3),
168
+ backoffMs: positiveNumber(env.VALINOR_FETCH_BACKOFF_MS, 1_000),
169
+ totalTimeoutMs: positiveNumber(env.VALINOR_FETCH_TOTAL_TIMEOUT_MS, 120_000),
170
+ };
171
+ }
172
+
173
+ /** HTTP statuses worth another attempt: the server said "not now", never "not here". */
174
+ function isTransientStatus(status) {
175
+ return status === 408 || status === 425 || status === 429 || status >= 500;
176
+ }
177
+
178
+ /** An error carrying whether another attempt could succeed. `transient` rides onto the error `run()`
179
+ * throws, so the bin can tell an outage (retry later) from a refusal (fix something). */
180
+ function fetchError(message, transient, cause) {
181
+ const e = new Error(message, cause === undefined ? undefined : { cause });
182
+ e.transient = transient;
183
+ return e;
184
+ }
185
+
186
+ /** The reason a request failed, in words: a timeout names its duration, anything else its cause code. */
187
+ function describeFailure(e, timeoutMs, phase) {
188
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") return `timed out after ${timeoutMs}ms ${phase}`;
189
+ return String(e?.cause?.code ?? e?.code ?? (e instanceof Error ? e.message : e));
190
+ }
191
+
192
+ /**
193
+ * GET a URL and READ ITS BODY with `read(res)` under ONE abort signal per attempt, retrying transient
194
+ * failures. Reading the body inside the attempt is the fix for the 2026-09-25 failures: the body used to
195
+ * be read after this function returned, so a mid-body stall escaped as a bare "The operation was aborted
196
+ * due to timeout" with no URL, no retry, and nothing to act on. Throws a loud error naming the URL, the
197
+ * attempt count and the last cause. `cancel` (optional) stops the loop — the in-flight request and any
198
+ * backoff wait — once a sibling download has already failed terminally, so the process never idles out
199
+ * the rest of the budget on a run that must fail anyway.
200
+ */
201
+ async function fetchBody(url, read, fetchImpl, policy, cancel) {
202
+ const deadline = Date.now() + policy.totalTimeoutMs;
203
+ let last;
204
+ let made = 0;
205
+ for (let attempt = 1; attempt <= policy.attempts; attempt++) {
206
+ const remaining = deadline - Date.now();
207
+ if (remaining <= 0) break;
208
+ cancel?.throwIfAborted();
209
+ made = attempt;
210
+ const timeoutMs = Math.min(policy.timeoutMs, remaining);
211
+ const timeout = AbortSignal.timeout(timeoutMs);
212
+ const signal = cancel ? AbortSignal.any([timeout, cancel]) : timeout;
213
+ let res;
214
+ try {
215
+ res = await (fetchImpl ?? fetch)(url, { redirect: "follow", signal });
216
+ } catch (e) {
217
+ last = fetchError(`could not reach ${url} (${describeFailure(e, timeoutMs, "waiting for a response")})`, true, e);
218
+ }
219
+ if (res && !res.ok) {
220
+ last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
221
+ await res.body?.cancel().catch(() => {});
222
+ } else if (res) {
223
+ try {
224
+ return await read(res);
225
+ } catch (e) {
226
+ // A body that PARSES wrong (a malformed manifest) is a refusal, not an outage: never retried.
227
+ last = fetchError(`could not download ${url} (${describeFailure(e, timeoutMs, "reading the response body")})`, !(e instanceof SyntaxError), e);
228
+ }
229
+ }
230
+ cancel?.throwIfAborted();
231
+ if (!last.transient) throw last;
232
+ if (attempt < policy.attempts) {
233
+ const wait = policy.backoffMs * 2 ** (attempt - 1) * (0.5 + Math.random() * 0.5);
234
+ if (Date.now() + wait >= deadline) break;
235
+ await sleep(wait, undefined, { signal: cancel });
236
+ }
157
237
  }
158
- if (!res.ok) throw new Error(`${url} returned HTTP ${res.status}`);
159
- return res;
238
+ throw fetchError(
239
+ `${last?.message ?? `could not reach ${url}`} — gave up after ${made} attempt(s) within the ${policy.totalTimeoutMs}ms budget`,
240
+ true,
241
+ last,
242
+ );
160
243
  }
161
244
 
162
- /** Fetch + validate the endpoint manifest. `timeoutMs` is threaded from the caller's env. */
163
- export async function fetchManifest(base, fetchImpl, timeoutMs = fetchTimeoutMs()) {
164
- const res = await fetchOrThrow(`${base}/manifest.json`, fetchImpl, timeoutMs);
165
- return res.json();
245
+ /** Fetch + validate the endpoint manifest, retrying transient failures. `policy` is threaded from the caller's env. */
246
+ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy()) {
247
+ return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy);
166
248
  }
167
249
 
168
250
  /**
@@ -173,17 +255,23 @@ export async function fetchManifest(base, fetchImpl, timeoutMs = fetchTimeoutMs(
173
255
  * a temp dir and atomically renamed into place, so a crashed install can never half-populate a
174
256
  * version dir that a later run would trust.
175
257
  */
176
- export async function ensureVersion(base, version, root, fetchImpl, timeoutMs = fetchTimeoutMs()) {
258
+ export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy()) {
177
259
  const versionDir = join(root, version);
178
260
  if (existsSync(join(versionDir, ".complete"))) return versionDir; // cache hit — zero network.
179
261
 
180
262
  const tarballUrl = `${base}/v/${version}/valinor.tgz`;
181
- const [tarballRes, sumsRes] = await Promise.all([
182
- fetchOrThrow(tarballUrl, fetchImpl, timeoutMs),
183
- fetchOrThrow(`${base}/v/${version}/SHA256SUMS`, fetchImpl, timeoutMs),
263
+ // Either file failing for good fails the install, so it cancels the other's retry loop at once.
264
+ const siblings = new AbortController();
265
+ const settle = (p) =>
266
+ p.catch((e) => {
267
+ siblings.abort(e);
268
+ throw e;
269
+ });
270
+ const [tarball, sums] = await Promise.all([
271
+ settle(fetchBody(tarballUrl, async (res) => Buffer.from(await res.arrayBuffer()), fetchImpl, policy, siblings.signal)),
272
+ settle(fetchBody(`${base}/v/${version}/SHA256SUMS`, (res) => res.text(), fetchImpl, policy, siblings.signal)),
184
273
  ]);
185
- const tarball = Buffer.from(await tarballRes.arrayBuffer());
186
- const expected = parseSha256Sums(await sumsRes.text(), "valinor.tgz");
274
+ const expected = parseSha256Sums(sums, "valinor.tgz");
187
275
  const actual = createHash("sha256").update(tarball).digest("hex");
188
276
  if (actual !== expected) {
189
277
  throw new Error(
@@ -243,9 +331,9 @@ export async function run(args, env = process.env) {
243
331
  const base = downloadsUrl(env);
244
332
  const root = cacheRoot(env);
245
333
  const requested = requestedVersion(env);
246
- // Resolve the timeout ONCE from THIS call's env (the bug fix — `fetchOrThrow` used to read the
247
- // ambient process.env, so a `run(args, env)` override never engaged) and thread it into every fetch.
248
- const timeoutMs = fetchTimeoutMs(env);
334
+ // Resolve the fetch policy ONCE from THIS call's env (an earlier bug read the ambient process.env, so a
335
+ // `run(args, env)` override never engaged) and thread it into every fetch.
336
+ const policy = fetchPolicy(env);
249
337
 
250
338
  let version;
251
339
  if (SEMVER_RE.test(requested)) {
@@ -253,24 +341,53 @@ export async function run(args, env = process.env) {
253
341
  } else {
254
342
  let manifest;
255
343
  try {
256
- manifest = await fetchManifest(base, undefined, timeoutMs);
344
+ manifest = await fetchManifest(base, undefined, policy);
257
345
  } catch (e) {
258
- throw new Error(`${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`, { cause: e });
346
+ throw withHint(e, `${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`);
259
347
  }
260
348
  version = resolveVersion(manifest, requested);
261
349
  }
262
350
 
263
351
  let versionDir;
264
352
  try {
265
- versionDir = await ensureVersion(base, version, root, undefined, timeoutMs);
353
+ versionDir = await ensureVersion(base, version, root, undefined, policy);
266
354
  } catch (e) {
267
355
  const msg = e instanceof Error ? e.message : String(e);
268
356
  // Integrity failures are terminal as-is; reachability failures get the offline hint.
269
- throw new Error(msg.includes("SHA-256 MISMATCH") ? msg : `${msg}\n${offlineHint(base, root)}`, { cause: e });
357
+ throw withHint(e, msg.includes("SHA-256 MISMATCH") ? msg : `${msg}\n${offlineHint(base, root)}`);
270
358
  }
271
359
  return spawnCli(cachedBinPath(versionDir), args);
272
360
  }
273
361
 
362
+ /** Re-wrap an error with the actionable message, keeping whether it was transient. */
363
+ function withHint(e, message) {
364
+ const wrapped = new Error(message, { cause: e });
365
+ wrapped.transient = e?.transient === true;
366
+ return wrapped;
367
+ }
368
+
369
+ /**
370
+ * The verbs whose NOT RUNNING is already their fail-closed outcome. `review-trigger-run` posts a review
371
+ * trigger only on a fully green head and exits 0 on every error of its own ("failing closed (no
372
+ * trigger)"), because the next wake or the hourly sweep re-evaluates the same PR. A launcher that could
373
+ * not fetch the bundle has produced exactly that outcome — nothing posted — so failing the job would add
374
+ * nothing but a red check on the PR head that no later wake clears (a later run attaches its check to a
375
+ * different commit). Only a TRANSIENT failure qualifies: a missing artifact or a SHA-256 mismatch still
376
+ * exits 1, loudly. A gate verb is never listed here — a gate that did not run must never read as passed.
377
+ */
378
+ export const SOFT_FAIL_VERBS = new Set(["review-trigger-run"]);
379
+
380
+ /** The warning to print instead of failing, when `error` is a transient fetch failure for a
381
+ * {@link SOFT_FAIL_VERBS} verb; `null` when the launcher must fail as usual. */
382
+ export function softFailWarning(error, args) {
383
+ if (error?.transient !== true || !SOFT_FAIL_VERBS.has(args[0])) return null;
384
+ return (
385
+ `could not download the Valinor bundle, so \`${args[0]}\` did not run and posted nothing (fail-closed). ` +
386
+ "This is an infrastructure error, not a check result: the next wake-up or the hourly sweep re-evaluates, " +
387
+ "and re-running this job retries now."
388
+ );
389
+ }
390
+
274
391
  /** The actionable degradation message: name the endpoint + the offline cache state. */
275
392
  function offlineHint(base, root) {
276
393
  const cached = cachedVersions(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theappagency/valinor",
3
- "version": "9.2.0",
3
+ "version": "9.5.0",
4
4
  "description": "Public launcher for the Valinor CLI — fetches, SHA-256-verifies, caches, and runs the proprietary Valinor bundle from downloads.valinorci.com. Zero setup, zero secrets, zero npm auth.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,7 @@
16
16
  "lib"
17
17
  ],
18
18
  "engines": {
19
- "node": ">=24"
19
+ "node": ">=22"
20
20
  },
21
21
  "license": "MIT",
22
22
  "publishConfig": {