@theappagency/valinor 10.18.0 → 10.20.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 +21 -2
- package/lib/launcher.js +199 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,11 +24,30 @@ GitHub runner's default Node works without a `setup-node` step.
|
|
|
24
24
|
**Authentication.** The downloads endpoint can require a credential. In GitHub Actions the launcher mints one
|
|
25
25
|
itself: give the job `permissions: id-token: write` and it requests a GitHub OIDC token for the audience
|
|
26
26
|
`https://downloads.theappagency.com`, so the repository stores no secret. Elsewhere, set
|
|
27
|
-
`VALINOR_DOWNLOADS_TOKEN`. With neither, the launcher sends no credential, which works
|
|
28
|
-
|
|
27
|
+
`VALINOR_DOWNLOADS_TOKEN`. With neither, the launcher sends no credential, which works only from GitHub
|
|
28
|
+
Actions and allow-listed networks. An HTTP 401 or 403 is never retried; the error names the fix. A
|
|
29
29
|
`pull_request` run from a fork cannot mint an OIDC token, so it needs `VALINOR_DOWNLOADS_TOKEN` once the
|
|
30
30
|
endpoint requires authentication.
|
|
31
31
|
|
|
32
|
+
**Getting a downloads token.** Ask whoever runs your Valinor downloads endpoint for a personal token: each
|
|
33
|
+
person gets their own, so one person's access can be withdrawn without touching anyone else's. Keep it in
|
|
34
|
+
your secret store and export it as `VALINOR_DOWNLOADS_TOKEN` in the shell that runs Valinor; never commit
|
|
35
|
+
it.
|
|
36
|
+
|
|
37
|
+
**Which 403 you got.** A 403 is named by who sent it. "The endpoint verified the GitHub OIDC token, but this
|
|
38
|
+
repository's owner is not authorized" comes from the endpoint itself, for a CI token from an organization it
|
|
39
|
+
does not serve. "HTTP 403 from Cloudflare's firewall in front of the downloads endpoint" means the request
|
|
40
|
+
never reached the endpoint: without a credential, set `VALINOR_DOWNLOADS_TOKEN`; with one, retry from
|
|
41
|
+
another network and report the Ray ID the message gives.
|
|
42
|
+
|
|
43
|
+
**A failed download is a hard stop.** Valinor does not run, and nothing runs in its place: there is no
|
|
44
|
+
switch, variable or flag that skips the download, and no other cached, local or `npx` copy stands in.
|
|
45
|
+
A cache hit runs offline only after the kept tarball re-hashes to the SHA-256 recorded when it was first
|
|
46
|
+
verified; a cache entry that fails that check is refused, and the error says to delete it and run again
|
|
47
|
+
with network access. Installs live under `verified/` in the cache. An entry an older launcher wrote (which
|
|
48
|
+
kept no tarball) is never run, moved or deleted: the version is downloaded and verified once into
|
|
49
|
+
`verified/`. Concurrent runs never replace an install another run is using.
|
|
50
|
+
|
|
32
51
|
A download that fails for a transient reason (a dropped connection, a stalled transfer, a timeout, or an
|
|
33
52
|
HTTP 408, 425, 429 or 5xx) is retried with jittered backoff: 3 attempts per file within a 2-minute budget by default. A 404
|
|
34
53
|
and a checksum mismatch are never retried. When `review-trigger-run` still cannot be fetched after the
|
package/lib/launcher.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// • One credential per run, only when the endpoint asks for one: a GitHub Actions OIDC token minted
|
|
11
11
|
// for the downloads audience, else VALINOR_DOWNLOADS_TOKEN, else none (see downloadsAuthorization).
|
|
12
12
|
// • Fail closed on integrity: a checksum mismatch DELETES the download and refuses to run.
|
|
13
|
-
// • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all
|
|
14
|
-
// only a dist-tag (`latest`) needs the manifest.
|
|
13
|
+
// • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all, once its
|
|
14
|
+
// kept tarball re-hashes to the digest recorded at install; only a dist-tag (`latest`) needs the manifest.
|
|
15
|
+
// • A failed download is a HARD STOP: nothing (no cached, local or npx copy) runs in the bundle's place.
|
|
15
16
|
// • Loud, actionable degradation: an unreachable endpoint names the URL it tried AND the local
|
|
16
17
|
// cache state (what versions could still run offline) — never a bare stack trace.
|
|
17
18
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -19,7 +20,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
19
20
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
22
|
-
import { join } from "node:path";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
23
24
|
import { fileURLToPath } from "node:url";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -82,11 +83,15 @@ export function requestedVersion(env = process.env) {
|
|
|
82
83
|
return own === "0.0.0" ? "latest" : own;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* The versions present in the local cache (complete installs that kept their verified tarball, so they can run
|
|
88
|
+
* offline), newest-ish last. Never throws. It only feeds diagnostics: each one is still re-verified before it runs.
|
|
89
|
+
*/
|
|
86
90
|
export function cachedVersions(root) {
|
|
87
91
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
const verified = join(root, VERIFIED_CACHE_DIR);
|
|
93
|
+
return readdirSync(verified)
|
|
94
|
+
.filter((name) => SEMVER_RE.test(name) && existsSync(join(verified, name, ".complete")) && existsSync(join(verified, name, "valinor.tgz")))
|
|
90
95
|
.sort();
|
|
91
96
|
} catch (e) {
|
|
92
97
|
// ENOENT only = "no cache dir yet" — a genuinely empty cache. Anything else (EACCES, EIO, …)
|
|
@@ -190,20 +195,95 @@ function onceAuthorization(resolveAuthorization) {
|
|
|
190
195
|
return () => (pending ??= resolveAuthorization());
|
|
191
196
|
}
|
|
192
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The first words of the downloads Worker's OWN 403 body (sites/downloads/src/worker.js `refuse`): the only 403 the
|
|
200
|
+
* endpoint itself sends, for a verified GitHub OIDC token whose repository owner is not on its allowlist.
|
|
201
|
+
*/
|
|
202
|
+
const OWNER_REFUSAL_PREFIX = "forbidden: the GitHub OIDC token is valid";
|
|
203
|
+
|
|
204
|
+
/** The doc that says how an operator gets a downloads token: this package's own README, which ships with it. */
|
|
205
|
+
const DOWNLOADS_TOKEN_DOC = "the \"Authentication\" section of the @theappagency/valinor README";
|
|
206
|
+
|
|
207
|
+
/** Said after every refusal: a failed download is a hard stop, and nothing else runs in Valinor's place. */
|
|
208
|
+
const HARD_STOP = "Valinor did not run, and nothing runs in its place: no cached, local or npx copy is used instead.";
|
|
209
|
+
|
|
210
|
+
/** The title Cloudflare's own block page carries, near the top of its HTML. */
|
|
211
|
+
const CLOUDFLARE_BLOCK_PAGE_RE = /<title>[^<]*\|\s*Cloudflare\s*<\/title>/i;
|
|
212
|
+
|
|
213
|
+
/** At most this much of a refusal body is read: enough for the Worker's one-line answer and a block page's title. */
|
|
214
|
+
export const REFUSAL_BODY_PREFIX_BYTES = 4096;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Who answered a 403, from its headers and at most {@link REFUSAL_BODY_PREFIX_BYTES} of its body. `owner` is the
|
|
218
|
+
* Worker's own plain-text OIDC-owner refusal. `edge` is Cloudflare's firewall in front of it, and needs a
|
|
219
|
+
* Cloudflare-specific signal: a `cf-mitigated` header, or a `cf-ray` header on Cloudflare's HTML block page. The request
|
|
220
|
+
* never reached the Worker. Anything else (`other`, an HTML page from a corporate proxy included) is a 403 from
|
|
221
|
+
* something in front of the endpoint that is not named.
|
|
222
|
+
*/
|
|
223
|
+
export function classify403(headers, body) {
|
|
224
|
+
const type = String(headers?.get?.("content-type") ?? "").toLowerCase();
|
|
225
|
+
const text = String(body ?? "");
|
|
226
|
+
if (type.startsWith("text/plain") && text.startsWith(OWNER_REFUSAL_PREFIX)) return "owner";
|
|
227
|
+
if (headers?.get?.("cf-mitigated")) return "edge";
|
|
228
|
+
if (headers?.get?.("cf-ray") && type.startsWith("text/html") && CLOUDFLARE_BLOCK_PAGE_RE.test(text)) return "edge";
|
|
229
|
+
return "other";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read at most `max` bytes of a response body as text, then cancel the rest, so a large refusal page is never held
|
|
234
|
+
* in memory. A body that cannot be read yields what was read so far.
|
|
235
|
+
*/
|
|
236
|
+
async function readBodyPrefix(res, max = REFUSAL_BODY_PREFIX_BYTES) {
|
|
237
|
+
const reader = res.body?.getReader?.();
|
|
238
|
+
if (!reader) return "";
|
|
239
|
+
const chunks = [];
|
|
240
|
+
let size = 0;
|
|
241
|
+
try {
|
|
242
|
+
while (size < max) {
|
|
243
|
+
const { done, value } = await reader.read();
|
|
244
|
+
if (done) break;
|
|
245
|
+
chunks.push(value);
|
|
246
|
+
size += value.byteLength;
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// keep what was read
|
|
250
|
+
} finally {
|
|
251
|
+
await reader.cancel().catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max));
|
|
254
|
+
}
|
|
255
|
+
|
|
193
256
|
/** The refusal for a 401/403 — an auth answer is never an outage, so it names the fix, not the network. */
|
|
194
|
-
function authRefusal(url, status, sentCredential) {
|
|
257
|
+
function authRefusal(url, status, sentCredential, headers, body) {
|
|
195
258
|
const fix =
|
|
196
259
|
"In GitHub Actions, add `permissions: id-token: write` to this job so the launcher can mint an OIDC token " +
|
|
197
260
|
"(`valinor refresh-workflows --apply` adds it to Valinor's own workflows); elsewhere, set VALINOR_DOWNLOADS_TOKEN.";
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
261
|
+
let message;
|
|
262
|
+
if (status === 403) {
|
|
263
|
+
const who = classify403(headers, body);
|
|
264
|
+
const ray = headers?.get?.("cf-ray");
|
|
265
|
+
const rayNote = ray ? ` (Cloudflare Ray ID ${ray})` : "";
|
|
266
|
+
if (who === "owner") {
|
|
267
|
+
message = `${url} returned HTTP 403: the endpoint verified the GitHub OIDC token, but this repository's owner is not authorized to download Valinor.`;
|
|
268
|
+
} else if (!sentCredential) {
|
|
269
|
+
const where = who === "edge" ? `Cloudflare's firewall in front of the downloads endpoint${rayNote}` : "something between this machine and the downloads endpoint (a proxy or firewall)";
|
|
270
|
+
message =
|
|
271
|
+
`${url} returned HTTP 403 from ${where}; the endpoint itself never answers 403 to a request without a credential. ` +
|
|
272
|
+
"This request carried none, and a download without a credential is admitted only from GitHub Actions and allow-listed networks. " +
|
|
273
|
+
`Set VALINOR_DOWNLOADS_TOKEN to your own downloads token (how to get one: ${DOWNLOADS_TOKEN_DOC}); ` +
|
|
274
|
+
"in GitHub Actions, add `permissions: id-token: write` to the job instead.";
|
|
275
|
+
} else {
|
|
276
|
+
const where = who === "edge" ? `Cloudflare's firewall in front of the downloads endpoint${rayNote}` : "something between this machine and the downloads endpoint (a proxy or firewall)";
|
|
277
|
+
message =
|
|
278
|
+
`${url} returned HTTP 403 from ${where}, not from the endpoint, although the request carried a credential. ` +
|
|
279
|
+
`The credential was never checked. Retry from another network, or report the refusal${ray ? ` with Ray ID ${ray}` : ""}.`;
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
message = sentCredential
|
|
283
|
+
? `${url} returned HTTP 401: the downloads endpoint refused the credential the launcher sent (expired or not valid for it). ${fix}`
|
|
284
|
+
: `${url} returned HTTP 401: the downloads endpoint requires authentication and this run sent no credential. ${fix}`;
|
|
285
|
+
}
|
|
286
|
+
const e = fetchError(`${message}\n${HARD_STOP}`, false);
|
|
207
287
|
e.auth = true;
|
|
208
288
|
return e;
|
|
209
289
|
}
|
|
@@ -304,7 +384,10 @@ async function fetchBody(url, read, fetchImpl, policy, cancel, authorization) {
|
|
|
304
384
|
last = fetchError(`could not reach ${url} (${describeFailure(e, timeoutMs, "waiting for a response")})`, true, e);
|
|
305
385
|
}
|
|
306
386
|
if (res && (res.status === 401 || res.status === 403)) {
|
|
307
|
-
|
|
387
|
+
// A 403's body says WHO refused (the Worker's one-line answer, or a block page's title), so a bounded prefix of
|
|
388
|
+
// it is read, unless a `cf-mitigated` header already says so; every other body is cancelled unread.
|
|
389
|
+
const body = res.status === 403 && !res.headers.get("cf-mitigated") ? await readBodyPrefix(res) : "";
|
|
390
|
+
last = authRefusal(url, res.status, credential !== null, res.headers, body);
|
|
308
391
|
await res.body?.cancel().catch(() => {});
|
|
309
392
|
} else if (res && !res.ok) {
|
|
310
393
|
last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
|
|
@@ -337,18 +420,96 @@ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy(), aut
|
|
|
337
420
|
return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy, undefined, authorization);
|
|
338
421
|
}
|
|
339
422
|
|
|
423
|
+
/** A cached version that failed its check: terminal, never retried, and never given the offline hint. */
|
|
424
|
+
function cacheRefusal(versionDir, why) {
|
|
425
|
+
const e = new Error(
|
|
426
|
+
`the cached Valinor at ${versionDir} failed verification: ${why}. Refusing to run it. ` +
|
|
427
|
+
`Delete ${versionDir} and run again with network access, so the launcher downloads and verifies it afresh.\n${HARD_STOP}`,
|
|
428
|
+
);
|
|
429
|
+
e.transient = false;
|
|
430
|
+
e.integrity = true;
|
|
431
|
+
return e;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Check a cached version before it runs. The cache keeps the VERIFIED tarball beside the extracted package, and
|
|
438
|
+
* `.complete` records the SHA-256 that SHA256SUMS gave for it at install. A hit re-hashes the kept tarball against
|
|
439
|
+
* that digest on every run, so a `.complete` marker on its own is never enough: a bare or malformed marker, or a
|
|
440
|
+
* tarball whose bytes are not the recorded ones (or a missing one), is refused. Returns { state: "absent" } (no
|
|
441
|
+
* marker: an ordinary cache miss) or { state: "verified" }, and throws {@link cacheRefusal} otherwise. This check
|
|
442
|
+
* cannot stop someone who writes a matching tarball AND digest into the cache, but anyone able to do that can run
|
|
443
|
+
* `node` on any file already; it closes the bare-marker shortcut.
|
|
444
|
+
*/
|
|
445
|
+
export function verifyCachedVersion(versionDir) {
|
|
446
|
+
const marker = join(versionDir, ".complete");
|
|
447
|
+
if (!existsSync(marker)) return { state: "absent" };
|
|
448
|
+
let recorded;
|
|
449
|
+
try {
|
|
450
|
+
recorded = readFileSync(marker, "utf8").trim();
|
|
451
|
+
} catch (e) {
|
|
452
|
+
throw cacheRefusal(versionDir, `its .complete marker could not be read (${e?.code ?? e})`);
|
|
453
|
+
}
|
|
454
|
+
if (!DIGEST_RE.test(recorded)) throw cacheRefusal(versionDir, "its .complete marker records no valid SHA-256 digest");
|
|
455
|
+
const kept = join(versionDir, "valinor.tgz");
|
|
456
|
+
if (!existsSync(kept)) throw cacheRefusal(versionDir, "it has no kept tarball to re-hash");
|
|
457
|
+
let actual;
|
|
458
|
+
try {
|
|
459
|
+
actual = createHash("sha256").update(readFileSync(kept)).digest("hex");
|
|
460
|
+
} catch (e) {
|
|
461
|
+
throw cacheRefusal(versionDir, `its kept tarball could not be read (${e?.code ?? e})`);
|
|
462
|
+
}
|
|
463
|
+
if (actual !== recorded) throw cacheRefusal(versionDir, `its kept tarball hashes to ${actual}, not the ${recorded} recorded when it was verified`);
|
|
464
|
+
return { state: "verified" };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Where this launcher keeps its installs: `<cache root>/verified/<version>/`, apart from the `<cache root>/<version>/`
|
|
469
|
+
* entries older launchers wrote (which kept no tarball, so cannot be re-verified). An older entry is never read, moved
|
|
470
|
+
* or deleted here — an older launcher may be running from it — so it is simply not a hit: the version is downloaded
|
|
471
|
+
* and verified once into this directory. Not a semver name, so an older launcher's cache listing never sees it.
|
|
472
|
+
*/
|
|
473
|
+
export const VERIFIED_CACHE_DIR = "verified";
|
|
474
|
+
|
|
475
|
+
/** The directory this launcher installs `version` into (see {@link VERIFIED_CACHE_DIR}). */
|
|
476
|
+
export function versionCacheDir(root, version) {
|
|
477
|
+
return join(root, VERIFIED_CACHE_DIR, version);
|
|
478
|
+
}
|
|
479
|
+
|
|
340
480
|
/**
|
|
341
481
|
* Ensure `version` is present + verified in the cache; returns the cache dir for that version.
|
|
342
|
-
* Cache HIT (a completed prior install
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
482
|
+
* Cache HIT (a completed prior install whose kept tarball still matches its recorded digest, see
|
|
483
|
+
* {@link verifyCachedVersion}) is served with NO network I/O. On a miss: download the tarball AND its
|
|
484
|
+
* SHA256SUMS, recompute the digest, and FAIL CLOSED on mismatch (the partial download is deleted; nothing
|
|
485
|
+
* executable is left behind). The verified tarball is kept and extracted into a fresh staging dir that is
|
|
486
|
+
* renamed into place. A rename never replaces an existing install, so a version dir another process is running
|
|
487
|
+
* from is never moved or deleted: a run that loses the race uses the winner's install once it verifies.
|
|
347
488
|
*/
|
|
348
489
|
export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy(), authorization) {
|
|
349
|
-
const versionDir =
|
|
350
|
-
if (
|
|
490
|
+
const versionDir = versionCacheDir(root, version);
|
|
491
|
+
if (verifyCachedVersion(versionDir).state === "verified") return versionDir; // cache hit — zero network.
|
|
492
|
+
const legacyDir = join(root, version);
|
|
493
|
+
if (!existsSync(join(legacyDir, ".complete"))) return installVersion(base, version, versionDir, fetchImpl, policy, authorization);
|
|
494
|
+
try {
|
|
495
|
+
return await installVersion(base, version, versionDir, fetchImpl, policy, authorization);
|
|
496
|
+
} catch (e) {
|
|
497
|
+
if (e?.auth === true || e?.integrity === true || String(e?.message).includes("SHA-256 MISMATCH")) throw e;
|
|
498
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
499
|
+
const wrapped = new Error(
|
|
500
|
+
`the cached Valinor at ${legacyDir} was written by an older launcher that did not keep the verified tarball, so it is never ` +
|
|
501
|
+
`run; this version is downloaded and verified once into ${versionDir}, and that download failed: ${msg}\n` +
|
|
502
|
+
`Run again with network access (outside GitHub Actions, with VALINOR_DOWNLOADS_TOKEN set).\n${HARD_STOP}`,
|
|
503
|
+
{ cause: e },
|
|
504
|
+
);
|
|
505
|
+
wrapped.transient = e?.transient === true;
|
|
506
|
+
wrapped.integrity = true;
|
|
507
|
+
throw wrapped;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
351
510
|
|
|
511
|
+
/** Download, verify and install `version` into `versionDir`. */
|
|
512
|
+
async function installVersion(base, version, versionDir, fetchImpl, policy, authorization) {
|
|
352
513
|
const tarballUrl = `${base}/v/${version}/valinor.tgz`;
|
|
353
514
|
// Either file failing for good fails the install, so it cancels the other's retry loop at once.
|
|
354
515
|
const siblings = new AbortController();
|
|
@@ -370,20 +531,28 @@ export async function ensureVersion(base, version, root, fetchImpl, policy = fet
|
|
|
370
531
|
);
|
|
371
532
|
}
|
|
372
533
|
|
|
373
|
-
const
|
|
374
|
-
mkdirSync(
|
|
534
|
+
const parent = dirname(versionDir);
|
|
535
|
+
mkdirSync(parent, { recursive: true });
|
|
536
|
+
const stagingDir = join(parent, `.tmp-${version}-${randomBytes(6).toString("hex")}`);
|
|
537
|
+
mkdirSync(stagingDir);
|
|
375
538
|
try {
|
|
376
539
|
const tgzPath = join(stagingDir, "valinor.tgz");
|
|
377
540
|
writeFileSync(tgzPath, tarball);
|
|
378
541
|
// `tar` ships with every supported platform (incl. Windows 10+'s bsdtar); using it (rather than
|
|
379
|
-
// npm) keeps the install registry-free and auth-free by construction.
|
|
542
|
+
// npm) keeps the install registry-free and auth-free by construction. The tarball stays: every later
|
|
543
|
+
// cache hit re-hashes it against the digest recorded below.
|
|
380
544
|
execFileSync("tar", ["-xzf", tgzPath, "-C", stagingDir]);
|
|
381
|
-
rmSync(tgzPath);
|
|
382
545
|
writeFileSync(join(stagingDir, ".complete"), `${expected}\n`);
|
|
383
546
|
try {
|
|
547
|
+
// A directory rename never replaces a non-empty directory, so an install another process is running from
|
|
548
|
+
// is never moved or deleted under it.
|
|
384
549
|
renameSync(stagingDir, versionDir);
|
|
385
550
|
} catch (e) {
|
|
386
|
-
|
|
551
|
+
// Lost a concurrent race: theirs is used only if it verifies like any other cache hit.
|
|
552
|
+
if (verifyCachedVersion(versionDir).state === "verified") {
|
|
553
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
554
|
+
return versionDir;
|
|
555
|
+
}
|
|
387
556
|
throw e;
|
|
388
557
|
}
|
|
389
558
|
} catch (e) {
|
|
@@ -446,7 +615,7 @@ export async function run(args, env = process.env) {
|
|
|
446
615
|
} catch (e) {
|
|
447
616
|
const msg = e instanceof Error ? e.message : String(e);
|
|
448
617
|
// Integrity and authentication failures are terminal as-is; reachability failures get the offline hint.
|
|
449
|
-
throw withHint(e, msg.includes("SHA-256 MISMATCH") || e?.auth === true ? msg : `${msg}\n${offlineHint(base, root)}`);
|
|
618
|
+
throw withHint(e, msg.includes("SHA-256 MISMATCH") || e?.auth === true || e?.integrity === true ? msg : `${msg}\n${offlineHint(base, root)}`);
|
|
450
619
|
}
|
|
451
620
|
return spawnCli(cachedBinPath(versionDir), args);
|
|
452
621
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theappagency/valinor",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.20.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",
|