@tryaura/aura-cli 0.4.0 → 0.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 +1 -0
- package/dist/bin/aura.js +3 -3
- package/dist/index.d.ts +27 -1
- package/dist/index.js +2 -2
- package/dist/{run.boundary-fZhCH0bP.js → run.boundary-DSNajl5n.js} +1817 -103
- package/package.json +5 -4
|
@@ -2,17 +2,17 @@ import { A as displayPath, C as isRecord$4, D as SHARED_INSTRUCTIONS_TEMPLATE, E
|
|
|
2
2
|
import { COMMAND_NOT_FOUND_EXIT_CODE, DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_HTTP_TIMEOUT_MS, MAX_EXEC_OUTPUT_CHARACTERS, MAX_EXEC_TIMEOUT_MS, MAX_HTTP_RESPONSE_BYTES, MAX_HTTP_TIMEOUT_MS, McpWriteError, NOT_EXECUTABLE_EXIT_CODE, OUTPUT_LIMIT_EXIT_CODE, SHARED_INSTRUCTIONS_TEMPLATE_TOKEN, TIMEOUT_EXIT_CODE, defineOwnProperty, detectExecutable, hasMcpRedaction, jsonPropertyPath, mcpEnvironmentVariableNames, mcpServerNameProblem, normalizeMcpServerDefinition, parseMcpServerDefinition, parseMcpServerManifest, parseMcpServerManifestValue, parseSkillFrontmatter, parseSkillReferences, resolveMcpSecretNameCollisions, resolveSkillDirectory, splitSourceLines } from "@tryaura/aura-sdk";
|
|
3
3
|
import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
4
4
|
import { Buffer as Buffer$1, isUtf8 } from "node:buffer";
|
|
5
|
-
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { createHash, createPublicKey, randomUUID, timingSafeEqual, verify } from "node:crypto";
|
|
6
6
|
import { constants, homedir } from "node:os";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { constants as constants$1 } from "node:fs";
|
|
9
|
-
import { access, chmod, link, lstat, mkdir, open, opendir, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { constants as constants$1, createReadStream } from "node:fs";
|
|
9
|
+
import { access, chmod, copyFile, link, lstat, mkdir, open, opendir, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises";
|
|
10
10
|
import { applyPatch, createTwoFilesPatch, structuredPatch } from "diff";
|
|
11
|
-
import { coerce, parse, satisfies, valid, validRange } from "semver";
|
|
11
|
+
import { coerce, gt, parse, satisfies, valid, validRange } from "semver";
|
|
12
12
|
import { isDeepStrictEqual } from "node:util";
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
14
|
import { EnvHttpProxyAgent, fetch as fetch$1 } from "undici";
|
|
15
|
-
import { gunzipSync } from "node:zlib";
|
|
15
|
+
import { createGunzip, gunzipSync } from "node:zlib";
|
|
16
16
|
import process$1 from "node:process";
|
|
17
17
|
import { Builtins, Cli, Command, Option } from "clipanion/lib/advanced/index.js";
|
|
18
18
|
import { HelpCommand } from "clipanion/lib/advanced/HelpCommand.js";
|
|
@@ -1474,13 +1474,13 @@ async function applyPreparedOperation(prepared, plan, registerUndo, filesystem =
|
|
|
1474
1474
|
}
|
|
1475
1475
|
//#endregion
|
|
1476
1476
|
//#region ../core/src/fix-plan/journal-lock-record.ts
|
|
1477
|
-
const LOCK_STALE_MS = 3e5;
|
|
1477
|
+
const LOCK_STALE_MS$1 = 3e5;
|
|
1478
1478
|
const UNREADABLE_LOCK_GRACE_MS = 5e3;
|
|
1479
1479
|
async function inspectLock(path, now) {
|
|
1480
1480
|
try {
|
|
1481
1481
|
const owner = parseOwner(await readFile(path, "utf8"));
|
|
1482
1482
|
if (owner === void 0) return await unreadableLockIsStale(path, now) ? { kind: "stale" } : { kind: "active" };
|
|
1483
|
-
if (now().getTime() - owner.acquiredAtMs <= LOCK_STALE_MS || processIsAlive(owner.pid)) return {
|
|
1483
|
+
if (now().getTime() - owner.acquiredAtMs <= LOCK_STALE_MS$1 || processIsAlive(owner.pid)) return {
|
|
1484
1484
|
kind: "active",
|
|
1485
1485
|
owner
|
|
1486
1486
|
};
|
|
@@ -5786,7 +5786,7 @@ const MAX_CAUSE_DEPTH = 8;
|
|
|
5786
5786
|
const MAX_DETAIL_CHARACTERS = 300;
|
|
5787
5787
|
const MAX_REDIRECTS = 3;
|
|
5788
5788
|
const URL_PROBE_TIMEOUT_MS = 3e3;
|
|
5789
|
-
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
5789
|
+
const REDIRECT_STATUSES$1 = /* @__PURE__ */ new Set([
|
|
5790
5790
|
301,
|
|
5791
5791
|
302,
|
|
5792
5792
|
303,
|
|
@@ -5833,13 +5833,13 @@ async function probeMcpUrl(url, request) {
|
|
|
5833
5833
|
}
|
|
5834
5834
|
async function runProbe(url, signal, request) {
|
|
5835
5835
|
const redirects = { count: 0 };
|
|
5836
|
-
const head = await follow(url, "HEAD", signal, redirects, request);
|
|
5836
|
+
const head = await follow$1(url, "HEAD", signal, redirects, request);
|
|
5837
5837
|
if (head.kind !== "response") return outcomeProblem(head);
|
|
5838
5838
|
if (head.response.status !== 405) return responseProblem(head.response.status);
|
|
5839
|
-
const get = await follow(head.url, "GET", signal, redirects, request);
|
|
5839
|
+
const get = await follow$1(head.url, "GET", signal, redirects, request);
|
|
5840
5840
|
return get.kind === "response" ? responseProblem(get.response.status) : outcomeProblem(get);
|
|
5841
5841
|
}
|
|
5842
|
-
async function follow(initialUrl, method, signal, redirects, request) {
|
|
5842
|
+
async function follow$1(initialUrl, method, signal, redirects, request) {
|
|
5843
5843
|
let url = initialUrl;
|
|
5844
5844
|
while (true) {
|
|
5845
5845
|
const response = await request({
|
|
@@ -5847,7 +5847,7 @@ async function follow(initialUrl, method, signal, redirects, request) {
|
|
|
5847
5847
|
signal,
|
|
5848
5848
|
url
|
|
5849
5849
|
});
|
|
5850
|
-
if (!REDIRECT_STATUSES.has(response.status)) return {
|
|
5850
|
+
if (!REDIRECT_STATUSES$1.has(response.status)) return {
|
|
5851
5851
|
kind: "response",
|
|
5852
5852
|
response,
|
|
5853
5853
|
url
|
|
@@ -6708,6 +6708,72 @@ function environmentValue(variables, name) {
|
|
|
6708
6708
|
return variables[name] ?? variables[name.toUpperCase()];
|
|
6709
6709
|
}
|
|
6710
6710
|
//#endregion
|
|
6711
|
+
//#region ../core/src/workspace/disk-cache.ts
|
|
6712
|
+
/**
|
|
6713
|
+
* Shared primitives for the on-disk caches below `~/agents/.cache`.
|
|
6714
|
+
*
|
|
6715
|
+
* A cache here is an optimization and nothing more: every reader treats a malformed, oversized,
|
|
6716
|
+
* or unreadable entry as a miss, and a failed write leaves the world exactly as it was. Entries
|
|
6717
|
+
* are private to the user (`0700` directories, `0600` files) and land atomically via a temporary
|
|
6718
|
+
* file and rename, so a concurrent run never observes half an entry.
|
|
6719
|
+
*/
|
|
6720
|
+
/**
|
|
6721
|
+
* The caches' own reader, built once rather than per lookup.
|
|
6722
|
+
*
|
|
6723
|
+
* Deliberately not a caller-injected reader: entries are written here with `node:fs` and are
|
|
6724
|
+
* machine state rather than workspace content, so reading them through a substituted reader would
|
|
6725
|
+
* let a run see a cache it cannot write and write one it cannot see.
|
|
6726
|
+
*/
|
|
6727
|
+
const CACHE_READER = createFileReader();
|
|
6728
|
+
/**
|
|
6729
|
+
* Resolves one entry's location below `~/agents/.cache/<namespace>/<sha256(key)>`.
|
|
6730
|
+
*
|
|
6731
|
+
* Takes the home directory rather than a whole {@link Environment} so a caller that runs before
|
|
6732
|
+
* one exists — the startup updater, which decides whether to check for a release at all — reaches
|
|
6733
|
+
* the same directory layout as every other cache instead of inventing a second one.
|
|
6734
|
+
*/
|
|
6735
|
+
function cacheLocation(environment, namespace, key) {
|
|
6736
|
+
const directory = join(environment.homeDir, "agents", ".cache", namespace);
|
|
6737
|
+
const hash = createHash("sha256").update(key, "utf8").digest("hex");
|
|
6738
|
+
return {
|
|
6739
|
+
directory,
|
|
6740
|
+
path: join(directory, hash)
|
|
6741
|
+
};
|
|
6742
|
+
}
|
|
6743
|
+
/** Reads one entry's JSON envelope, or `undefined` for any reason it cannot be used. */
|
|
6744
|
+
async function readCacheEnvelope(location, maxBytes) {
|
|
6745
|
+
try {
|
|
6746
|
+
const contents = await CACHE_READER.read(location.path, { maxBytes });
|
|
6747
|
+
if (!contents.exists || contents.problem !== void 0 || contents.content === void 0) return;
|
|
6748
|
+
const value = JSON.parse(contents.content);
|
|
6749
|
+
return isPlainRecord$2(value) ? value : void 0;
|
|
6750
|
+
} catch {
|
|
6751
|
+
return;
|
|
6752
|
+
}
|
|
6753
|
+
}
|
|
6754
|
+
/** Stores one entry's envelope, treating any write failure as nothing having happened. */
|
|
6755
|
+
async function writeCacheEnvelope(location, envelope) {
|
|
6756
|
+
const temporary = join(location.directory, `.${randomUUID()}.tmp`);
|
|
6757
|
+
try {
|
|
6758
|
+
await mkdir(location.directory, {
|
|
6759
|
+
mode: 448,
|
|
6760
|
+
recursive: true
|
|
6761
|
+
});
|
|
6762
|
+
await writeFile(temporary, `${JSON.stringify(envelope)}\n`, {
|
|
6763
|
+
encoding: "utf8",
|
|
6764
|
+
mode: 384
|
|
6765
|
+
});
|
|
6766
|
+
await rename(temporary, location.path);
|
|
6767
|
+
} catch {
|
|
6768
|
+
try {
|
|
6769
|
+
await unlink(temporary);
|
|
6770
|
+
} catch {}
|
|
6771
|
+
}
|
|
6772
|
+
}
|
|
6773
|
+
function isPlainRecord$2(value) {
|
|
6774
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6775
|
+
}
|
|
6776
|
+
//#endregion
|
|
6711
6777
|
//#region ../core/src/skills/agenticskills-http.ts
|
|
6712
6778
|
/**
|
|
6713
6779
|
* Performs one bounded GET, retrying once on a transient failure unless the caller opts out.
|
|
@@ -6833,66 +6899,6 @@ function daysInMonth(year, month) {
|
|
|
6833
6899
|
return month >= 1 && month <= 12 ? 31 : void 0;
|
|
6834
6900
|
}
|
|
6835
6901
|
//#endregion
|
|
6836
|
-
//#region ../core/src/workspace/disk-cache.ts
|
|
6837
|
-
/**
|
|
6838
|
-
* Shared primitives for the on-disk caches below `~/agents/.cache`.
|
|
6839
|
-
*
|
|
6840
|
-
* A cache here is an optimization and nothing more: every reader treats a malformed, oversized,
|
|
6841
|
-
* or unreadable entry as a miss, and a failed write leaves the world exactly as it was. Entries
|
|
6842
|
-
* are private to the user (`0700` directories, `0600` files) and land atomically via a temporary
|
|
6843
|
-
* file and rename, so a concurrent run never observes half an entry.
|
|
6844
|
-
*/
|
|
6845
|
-
/**
|
|
6846
|
-
* The caches' own reader, built once rather than per lookup.
|
|
6847
|
-
*
|
|
6848
|
-
* Deliberately not a caller-injected reader: entries are written here with `node:fs` and are
|
|
6849
|
-
* machine state rather than workspace content, so reading them through a substituted reader would
|
|
6850
|
-
* let a run see a cache it cannot write and write one it cannot see.
|
|
6851
|
-
*/
|
|
6852
|
-
const CACHE_READER = createFileReader();
|
|
6853
|
-
/** Resolves one entry's location below `~/agents/.cache/<namespace>/<sha256(key)>`. */
|
|
6854
|
-
function cacheLocation(environment, namespace, key) {
|
|
6855
|
-
const directory = join(environment.homeDir, "agents", ".cache", namespace);
|
|
6856
|
-
const hash = createHash("sha256").update(key, "utf8").digest("hex");
|
|
6857
|
-
return {
|
|
6858
|
-
directory,
|
|
6859
|
-
path: join(directory, hash)
|
|
6860
|
-
};
|
|
6861
|
-
}
|
|
6862
|
-
/** Reads one entry's JSON envelope, or `undefined` for any reason it cannot be used. */
|
|
6863
|
-
async function readCacheEnvelope(location, maxBytes) {
|
|
6864
|
-
try {
|
|
6865
|
-
const contents = await CACHE_READER.read(location.path, { maxBytes });
|
|
6866
|
-
if (!contents.exists || contents.problem !== void 0 || contents.content === void 0) return;
|
|
6867
|
-
const value = JSON.parse(contents.content);
|
|
6868
|
-
return isPlainRecord$1(value) ? value : void 0;
|
|
6869
|
-
} catch {
|
|
6870
|
-
return;
|
|
6871
|
-
}
|
|
6872
|
-
}
|
|
6873
|
-
/** Stores one entry's envelope, treating any write failure as nothing having happened. */
|
|
6874
|
-
async function writeCacheEnvelope(location, envelope) {
|
|
6875
|
-
const temporary = join(location.directory, `.${randomUUID()}.tmp`);
|
|
6876
|
-
try {
|
|
6877
|
-
await mkdir(location.directory, {
|
|
6878
|
-
mode: 448,
|
|
6879
|
-
recursive: true
|
|
6880
|
-
});
|
|
6881
|
-
await writeFile(temporary, `${JSON.stringify(envelope)}\n`, {
|
|
6882
|
-
encoding: "utf8",
|
|
6883
|
-
mode: 384
|
|
6884
|
-
});
|
|
6885
|
-
await rename(temporary, location.path);
|
|
6886
|
-
} catch {
|
|
6887
|
-
try {
|
|
6888
|
-
await unlink(temporary);
|
|
6889
|
-
} catch {}
|
|
6890
|
-
}
|
|
6891
|
-
}
|
|
6892
|
-
function isPlainRecord$1(value) {
|
|
6893
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6894
|
-
}
|
|
6895
|
-
//#endregion
|
|
6896
6902
|
//#region ../core/src/skills/catalog-cache.ts
|
|
6897
6903
|
/** How long a cached catalog serves with no network request at all. */
|
|
6898
6904
|
const CATALOG_CACHE_FRESH_MS = 36e5;
|
|
@@ -6904,8 +6910,8 @@ const CATALOG_CACHE_FRESH_MS = 36e5;
|
|
|
6904
6910
|
*/
|
|
6905
6911
|
const CATALOG_CACHE_MAX_AGE_MS = 6048e5;
|
|
6906
6912
|
/** Doubled: the body is JSON stored inside JSON, so every quote costs one escape byte. */
|
|
6907
|
-
const MAX_CACHE_BYTES$
|
|
6908
|
-
const NAMESPACE$
|
|
6913
|
+
const MAX_CACHE_BYTES$2 = 8004096;
|
|
6914
|
+
const NAMESPACE$2 = "skill-catalogs";
|
|
6909
6915
|
/**
|
|
6910
6916
|
* Returns the cached document for one endpoint, or `undefined` for any reason it cannot be used.
|
|
6911
6917
|
*
|
|
@@ -6913,7 +6919,7 @@ const NAMESPACE$1 = "skill-catalogs";
|
|
|
6913
6919
|
* expired entry must never be the reason a run cannot list a catalog it could otherwise fetch.
|
|
6914
6920
|
*/
|
|
6915
6921
|
async function readCatalogCache(environment, endpoint) {
|
|
6916
|
-
const value = await readCacheEnvelope(cacheLocation(environment, NAMESPACE$
|
|
6922
|
+
const value = await readCacheEnvelope(cacheLocation(environment, NAMESPACE$2, endpoint), MAX_CACHE_BYTES$2);
|
|
6917
6923
|
if (value === void 0) return;
|
|
6918
6924
|
const body = value["body"];
|
|
6919
6925
|
const cachedAt = value["cachedAt"];
|
|
@@ -6929,7 +6935,7 @@ async function readCatalogCache(environment, endpoint) {
|
|
|
6929
6935
|
}
|
|
6930
6936
|
/** Stores one catalog body, treating any write failure as nothing having happened. */
|
|
6931
6937
|
async function writeCatalogCache(environment, endpoint, body, etag) {
|
|
6932
|
-
await writeCacheEnvelope(cacheLocation(environment, NAMESPACE$
|
|
6938
|
+
await writeCacheEnvelope(cacheLocation(environment, NAMESPACE$2, endpoint), {
|
|
6933
6939
|
body,
|
|
6934
6940
|
cachedAt: environment.now().getTime(),
|
|
6935
6941
|
endpoint,
|
|
@@ -8584,8 +8590,8 @@ function isJsonArray(value) {
|
|
|
8584
8590
|
//#endregion
|
|
8585
8591
|
//#region ../core/src/preset/cache.ts
|
|
8586
8592
|
const CACHE_TTL_MS = 864e5;
|
|
8587
|
-
const MAX_CACHE_BYTES = 260096;
|
|
8588
|
-
const NAMESPACE = "presets";
|
|
8593
|
+
const MAX_CACHE_BYTES$1 = 260096;
|
|
8594
|
+
const NAMESPACE$1 = "presets";
|
|
8589
8595
|
/**
|
|
8590
8596
|
* Returns the cached document for one reference, or `undefined` for any reason it cannot be used.
|
|
8591
8597
|
*
|
|
@@ -8595,7 +8601,7 @@ const NAMESPACE = "presets";
|
|
|
8595
8601
|
* so validating here would only duplicate that walk.
|
|
8596
8602
|
*/
|
|
8597
8603
|
async function readPresetCache(environment, reference) {
|
|
8598
|
-
const value = await readCacheEnvelope(cacheLocation(environment, NAMESPACE, reference), MAX_CACHE_BYTES);
|
|
8604
|
+
const value = await readCacheEnvelope(cacheLocation(environment, NAMESPACE$1, reference), MAX_CACHE_BYTES$1);
|
|
8599
8605
|
if (value === void 0) return;
|
|
8600
8606
|
const cachedAt = value["cachedAt"];
|
|
8601
8607
|
const preset = value["preset"];
|
|
@@ -8605,7 +8611,7 @@ async function readPresetCache(environment, reference) {
|
|
|
8605
8611
|
}
|
|
8606
8612
|
/** Stores one validated document, treating any write failure as nothing having happened. */
|
|
8607
8613
|
async function writePresetCache(environment, reference, preset) {
|
|
8608
|
-
await writeCacheEnvelope(cacheLocation(environment, NAMESPACE, reference), {
|
|
8614
|
+
await writeCacheEnvelope(cacheLocation(environment, NAMESPACE$1, reference), {
|
|
8609
8615
|
cachedAt: environment.now().getTime(),
|
|
8610
8616
|
preset,
|
|
8611
8617
|
reference
|
|
@@ -8671,7 +8677,7 @@ function extractPresetJson(compressed) {
|
|
|
8671
8677
|
const paths = /* @__PURE__ */ new Set();
|
|
8672
8678
|
while (offset + BLOCK <= archive.byteLength) {
|
|
8673
8679
|
const header = archive.subarray(offset, offset + BLOCK);
|
|
8674
|
-
if (isZeroBlock(header)) break;
|
|
8680
|
+
if (isZeroBlock$1(header)) break;
|
|
8675
8681
|
if (!validTarChecksum(header)) return invalid$1("npm preset tarball has an invalid header checksum.");
|
|
8676
8682
|
const name = tarName(header);
|
|
8677
8683
|
if (unsafeTarPath(name)) return invalid$1("npm preset tarball contains an unsafe entry path.");
|
|
@@ -8724,7 +8730,7 @@ function validTarChecksum(header) {
|
|
|
8724
8730
|
for (const [index, byte] of header.entries()) sum += index >= 148 && index < 156 ? 32 : byte;
|
|
8725
8731
|
return sum === Number.parseInt(raw, 8);
|
|
8726
8732
|
}
|
|
8727
|
-
function isZeroBlock(block) {
|
|
8733
|
+
function isZeroBlock$1(block) {
|
|
8728
8734
|
return block.every((byte) => byte === 0);
|
|
8729
8735
|
}
|
|
8730
8736
|
function invalid$1(problem) {
|
|
@@ -8817,8 +8823,8 @@ function npmDistribution(text) {
|
|
|
8817
8823
|
} catch {
|
|
8818
8824
|
return;
|
|
8819
8825
|
}
|
|
8820
|
-
const dist = isPlainRecord(value) ? value["dist"] : void 0;
|
|
8821
|
-
if (!isPlainRecord(dist)) return;
|
|
8826
|
+
const dist = isPlainRecord$1(value) ? value["dist"] : void 0;
|
|
8827
|
+
if (!isPlainRecord$1(dist)) return;
|
|
8822
8828
|
const tarball = registryTarball(dist["tarball"]);
|
|
8823
8829
|
if (tarball === void 0) return;
|
|
8824
8830
|
return {
|
|
@@ -8836,7 +8842,7 @@ function registryTarball(value) {
|
|
|
8836
8842
|
return;
|
|
8837
8843
|
}
|
|
8838
8844
|
}
|
|
8839
|
-
function isPlainRecord(value) {
|
|
8845
|
+
function isPlainRecord$1(value) {
|
|
8840
8846
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8841
8847
|
}
|
|
8842
8848
|
//#endregion
|
|
@@ -10530,7 +10536,7 @@ function positiveInteger(value) {
|
|
|
10530
10536
|
/** What the frame says while adapters probe the machine. Same sentence the setup wizard uses. */
|
|
10531
10537
|
const SCAN_PROMPT$1 = "Scanning this machine…";
|
|
10532
10538
|
/** What a run with nothing to animate, or nowhere to animate it, gets instead. */
|
|
10533
|
-
const IDLE = {
|
|
10539
|
+
const IDLE$1 = {
|
|
10534
10540
|
close: () => {},
|
|
10535
10541
|
report: () => {}
|
|
10536
10542
|
};
|
|
@@ -10547,7 +10553,7 @@ const IDLE = {
|
|
|
10547
10553
|
* existed. The frame is erased on close, so nothing it painted survives into the report.
|
|
10548
10554
|
*/
|
|
10549
10555
|
function startScanProgress(options) {
|
|
10550
|
-
if (options.rows.length === 0 || !isTerminal(options.stdout)) return IDLE;
|
|
10556
|
+
if (options.rows.length === 0 || !isTerminal(options.stdout)) return IDLE$1;
|
|
10551
10557
|
const style = createStyle(options.colorDepth);
|
|
10552
10558
|
const statuses = new Map(options.rows.map((row) => [row.id, "pending"]));
|
|
10553
10559
|
let painted = 0;
|
|
@@ -15731,7 +15737,7 @@ function compareEntries(left, right) {
|
|
|
15731
15737
|
if (leftRepo !== (right.repo === true)) return leftRepo ? -1 : 1;
|
|
15732
15738
|
const leftConfigured = left.existing !== void 0;
|
|
15733
15739
|
if (leftConfigured !== (right.existing !== void 0)) return leftConfigured ? -1 : 1;
|
|
15734
|
-
return entryName(left).localeCompare(entryName(right));
|
|
15740
|
+
return entryName$1(left).localeCompare(entryName$1(right));
|
|
15735
15741
|
}
|
|
15736
15742
|
/** The `repo/` namespace is reserved against plugins, so the prefix alone is provenance. */
|
|
15737
15743
|
function isRepoCatalogId(id) {
|
|
@@ -15739,9 +15745,9 @@ function isRepoCatalogId(id) {
|
|
|
15739
15745
|
}
|
|
15740
15746
|
/** User-facing name used by the picker and deterministic sorting. */
|
|
15741
15747
|
function mcpCatalogEntryName(entry) {
|
|
15742
|
-
return entryName(entry);
|
|
15748
|
+
return entryName$1(entry);
|
|
15743
15749
|
}
|
|
15744
|
-
function entryName(entry) {
|
|
15750
|
+
function entryName$1(entry) {
|
|
15745
15751
|
return entry.catalog?.manifest.name ?? entry.existing?.name ?? entry.key;
|
|
15746
15752
|
}
|
|
15747
15753
|
//#endregion
|
|
@@ -15790,14 +15796,14 @@ async function resolveSourceBatch(context, sourceId, ids) {
|
|
|
15790
15796
|
if (driver === void 0) {
|
|
15791
15797
|
if (directory === void 0) return;
|
|
15792
15798
|
const result = await resolveDirectorySkills(context.environment, directory, eligibleIds);
|
|
15793
|
-
record(context, sourceId, eligibleIds, result.skills, (id) => resolutionFailure(result.diagnostics, id));
|
|
15799
|
+
record$1(context, sourceId, eligibleIds, result.skills, (id) => resolutionFailure(result.diagnostics, id));
|
|
15794
15800
|
return;
|
|
15795
15801
|
}
|
|
15796
15802
|
const result = await resolveDriverSkills(context.environment, driver, eligibleIds);
|
|
15797
|
-
record(context, sourceId, eligibleIds, result.skills, (id) => result.problems.get(id) ?? "could not be fetched from its driver");
|
|
15803
|
+
record$1(context, sourceId, eligibleIds, result.skills, (id) => result.problems.get(id) ?? "could not be fetched from its driver");
|
|
15798
15804
|
}
|
|
15799
15805
|
/** Memoizes one batch's packs, then explains every requested id the batch did not return. */
|
|
15800
|
-
function record(context, sourceId, requestedIds, skills, failure) {
|
|
15806
|
+
function record$1(context, sourceId, requestedIds, skills, failure) {
|
|
15801
15807
|
for (const skill of skills) context.packs.set(skillIdentity(sourceId, skill.id), skill);
|
|
15802
15808
|
for (const id of requestedIds) {
|
|
15803
15809
|
const identity = skillIdentity(sourceId, id);
|
|
@@ -18625,14 +18631,1722 @@ var UndoCommand = class extends Command {
|
|
|
18625
18631
|
}
|
|
18626
18632
|
};
|
|
18627
18633
|
//#endregion
|
|
18628
|
-
//#region src/
|
|
18629
|
-
/**
|
|
18630
|
-
|
|
18631
|
-
|
|
18632
|
-
|
|
18633
|
-
|
|
18634
|
-
|
|
18635
|
-
|
|
18634
|
+
//#region src/update/limits.ts
|
|
18635
|
+
/**
|
|
18636
|
+
* Every bound the updater enforces, in one place.
|
|
18637
|
+
*
|
|
18638
|
+
* A limit that lives next to its single use gets relaxed by whoever is debugging that use. These
|
|
18639
|
+
* are the numbers a reviewer needs to see together to know an update cannot exhaust the disk, the
|
|
18640
|
+
* heap, the network, or the user's patience.
|
|
18641
|
+
*/
|
|
18642
|
+
/** Bytes accepted for one release archive, streamed and counted rather than buffered. */
|
|
18643
|
+
const MAX_ARCHIVE_BYTES = 268435456;
|
|
18644
|
+
/**
|
|
18645
|
+
* Bytes accepted from an archive's extracted entries, so a compression bomb cannot fill a disk.
|
|
18646
|
+
*
|
|
18647
|
+
* Larger than the archive bound because the archive is compressed, and small enough that the worst
|
|
18648
|
+
* case is a temporary file the transaction removes rather than a full disk.
|
|
18649
|
+
*/
|
|
18650
|
+
const MAX_EXTRACTED_BYTES = 536870912;
|
|
18651
|
+
/** Bytes accepted for one release-metadata document. */
|
|
18652
|
+
const MAX_METADATA_BYTES = 524288;
|
|
18653
|
+
/** Bytes accepted for one cached metadata entry. */
|
|
18654
|
+
const MAX_CACHE_BYTES = 65536;
|
|
18655
|
+
/**
|
|
18656
|
+
* How far ahead of now a signed manifest's `expiresAt` may sit.
|
|
18657
|
+
*
|
|
18658
|
+
* A signature proves who wrote a manifest, never that it is the newest one they wrote. Without a
|
|
18659
|
+
* bound, anything that can serve a stale-but-valid copy — a compromised edge, a caching proxy, a
|
|
18660
|
+
* mirror left behind — pins a fleet to a release forever, and the updater reports nothing because
|
|
18661
|
+
* "no newer release" is the quiet path. The window is what turns that freeze into an expiry.
|
|
18662
|
+
*
|
|
18663
|
+
* Capped rather than merely required, because a publisher who dates a manifest a decade out has
|
|
18664
|
+
* satisfied the field and rebuilt the same problem.
|
|
18665
|
+
*/
|
|
18666
|
+
const MAX_MANIFEST_FRESHNESS_MS = 2592e6;
|
|
18667
|
+
/**
|
|
18668
|
+
* Milliseconds the whole startup update may spend before the user's command starts.
|
|
18669
|
+
*
|
|
18670
|
+
* The one number that bounds the wait, because it is the only one the user experiences. Every step
|
|
18671
|
+
* below has its own ceiling, but a per-step bound is not an aggregate: metadata, a probe of the
|
|
18672
|
+
* installed binary, the transfer, and a probe of the staged one each finishing just inside their
|
|
18673
|
+
* own limit is a command that has not started yet. The download is given whatever is left of this,
|
|
18674
|
+
* so a slow transfer is abandoned rather than allowed to consume the sum of the other steps too.
|
|
18675
|
+
*/
|
|
18676
|
+
const STARTUP_UPDATE_BUDGET_MS = 24e4;
|
|
18677
|
+
/** Milliseconds one metadata request may take. */
|
|
18678
|
+
const METADATA_TIMEOUT_MS = 1e4;
|
|
18679
|
+
/** Milliseconds a version probe of an executable may take. */
|
|
18680
|
+
const VERSION_PROBE_TIMEOUT_MS = 3e4;
|
|
18681
|
+
/** How long a successful "already current" check stays fresh. */
|
|
18682
|
+
const CHECK_FRESH_MS = 864e5;
|
|
18683
|
+
/** How long a failed check waits before the next one, silently. */
|
|
18684
|
+
const CHECK_RETRY_MS = 36e5;
|
|
18685
|
+
/** First backoff step after an installation failure, doubled per attempt. */
|
|
18686
|
+
const INSTALL_BACKOFF_BASE_MS = 9e5;
|
|
18687
|
+
/** Ceiling the installation backoff doubles up to. */
|
|
18688
|
+
const INSTALL_BACKOFF_MAX_MS = 864e5;
|
|
18689
|
+
//#endregion
|
|
18690
|
+
//#region src/update/download.boundary.ts
|
|
18691
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
|
|
18692
|
+
301,
|
|
18693
|
+
302,
|
|
18694
|
+
303,
|
|
18695
|
+
307,
|
|
18696
|
+
308
|
|
18697
|
+
]);
|
|
18698
|
+
/** Progress reports emitted across one download, spread evenly over its declared length. */
|
|
18699
|
+
const PROGRESS_STEPS = 100;
|
|
18700
|
+
/**
|
|
18701
|
+
* Creates the archive downloader: a bounded, TLS-only stream straight to disk.
|
|
18702
|
+
*
|
|
18703
|
+
* This is the one place in the updater that reaches the network directly. `Environment.httpGet`
|
|
18704
|
+
* cannot do this job — it buffers into memory and refuses redirects outright — and a release
|
|
18705
|
+
* archive is both too large to hold in the heap and, for a private repository, served through a
|
|
18706
|
+
* redirect to a temporary signed URL. The rules `httpGet` enforces are reimplemented rather than
|
|
18707
|
+
* relaxed: HTTPS only, a hop limit, and `Authorization` stripped the moment the origin changes.
|
|
18708
|
+
*/
|
|
18709
|
+
function createUpdateDownload() {
|
|
18710
|
+
return async (request) => {
|
|
18711
|
+
try {
|
|
18712
|
+
return await stream(request);
|
|
18713
|
+
} catch {
|
|
18714
|
+
return {
|
|
18715
|
+
kind: "failure",
|
|
18716
|
+
reason: "network"
|
|
18717
|
+
};
|
|
18718
|
+
}
|
|
18719
|
+
};
|
|
18720
|
+
}
|
|
18721
|
+
async function stream(request) {
|
|
18722
|
+
const response = await follow(request, AbortSignal.timeout(request.timeoutMs));
|
|
18723
|
+
if (typeof response === "string") return {
|
|
18724
|
+
kind: "failure",
|
|
18725
|
+
reason: response
|
|
18726
|
+
};
|
|
18727
|
+
if (response.status !== 200 || response.body === null) return {
|
|
18728
|
+
kind: "failure",
|
|
18729
|
+
reason: "network"
|
|
18730
|
+
};
|
|
18731
|
+
const declared = response.headers.get("content-length");
|
|
18732
|
+
if (announcesReleaseLength(response) && declared !== String(request.expectedBytes)) return {
|
|
18733
|
+
kind: "failure",
|
|
18734
|
+
reason: "unexpected-length"
|
|
18735
|
+
};
|
|
18736
|
+
return await writeBody(response.body, request);
|
|
18737
|
+
}
|
|
18738
|
+
/**
|
|
18739
|
+
* Whether `content-length` describes the bytes this download will actually see.
|
|
18740
|
+
*
|
|
18741
|
+
* A proxy that applies its own `Content-Encoding` leaves the header describing the compressed form
|
|
18742
|
+
* while `fetch` hands over the decoded one, so comparing the two rejects a download that is
|
|
18743
|
+
* perfectly good. The count and the digest are both re-checked once the body has been read, so
|
|
18744
|
+
* skipping the header here costs nothing.
|
|
18745
|
+
*/
|
|
18746
|
+
function announcesReleaseLength(response) {
|
|
18747
|
+
const encoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
18748
|
+
return response.headers.get("content-length") !== null && (encoding === void 0 || encoding === "" || encoding === "identity");
|
|
18749
|
+
}
|
|
18750
|
+
/** Streams the body to a fresh file, counting and hashing as it goes. */
|
|
18751
|
+
async function writeBody(body, request) {
|
|
18752
|
+
const hash = createHash("sha256");
|
|
18753
|
+
let received = 0;
|
|
18754
|
+
const step = Math.ceil(request.expectedBytes / PROGRESS_STEPS);
|
|
18755
|
+
let reported = 0;
|
|
18756
|
+
const handle = await open(request.destinationPath, "wx", 384);
|
|
18757
|
+
try {
|
|
18758
|
+
for await (const chunk of body) {
|
|
18759
|
+
received += chunk.byteLength;
|
|
18760
|
+
if (received > request.expectedBytes) return {
|
|
18761
|
+
kind: "failure",
|
|
18762
|
+
reason: "too-large"
|
|
18763
|
+
};
|
|
18764
|
+
hash.update(chunk);
|
|
18765
|
+
await handle.write(chunk);
|
|
18766
|
+
if (received - reported >= step) {
|
|
18767
|
+
reported = received;
|
|
18768
|
+
request.onProgress?.(received, request.expectedBytes);
|
|
18769
|
+
}
|
|
18770
|
+
}
|
|
18771
|
+
if (received !== request.expectedBytes) return {
|
|
18772
|
+
kind: "failure",
|
|
18773
|
+
reason: "unexpected-length"
|
|
18774
|
+
};
|
|
18775
|
+
await handle.sync();
|
|
18776
|
+
return {
|
|
18777
|
+
kind: "downloaded",
|
|
18778
|
+
sha256: hash.digest("hex")
|
|
18779
|
+
};
|
|
18780
|
+
} finally {
|
|
18781
|
+
await handle.close();
|
|
18782
|
+
}
|
|
18783
|
+
}
|
|
18784
|
+
/**
|
|
18785
|
+
* Follows redirects by hand so each hop is vetted before it is taken.
|
|
18786
|
+
*
|
|
18787
|
+
* `Authorization` survives only a same-origin hop. GitHub answers an authenticated asset request
|
|
18788
|
+
* with a redirect to a signed storage URL that needs no credential of its own, so forwarding one
|
|
18789
|
+
* there would hand a repository token to a host the caller never named.
|
|
18790
|
+
*/
|
|
18791
|
+
async function follow(request, signal) {
|
|
18792
|
+
let url = vetHttpUrl(request.url);
|
|
18793
|
+
let headers = { ...request.headers };
|
|
18794
|
+
for (let hop = 0; hop <= 5; hop += 1) {
|
|
18795
|
+
if (!(url instanceof URL)) return url === "insecure-url" ? "insecure-url" : "network";
|
|
18796
|
+
const response = await fetch(url, {
|
|
18797
|
+
headers,
|
|
18798
|
+
method: "GET",
|
|
18799
|
+
redirect: "manual",
|
|
18800
|
+
signal
|
|
18801
|
+
});
|
|
18802
|
+
const location = response.headers.get("location");
|
|
18803
|
+
if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
|
|
18804
|
+
const origin = url.origin;
|
|
18805
|
+
url = vetHttpUrl(new URL(location, url).href);
|
|
18806
|
+
if (url instanceof URL && url.origin !== origin) headers = withoutAuthorization(headers);
|
|
18807
|
+
}
|
|
18808
|
+
return "network";
|
|
18809
|
+
}
|
|
18810
|
+
function withoutAuthorization(headers) {
|
|
18811
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "authorization"));
|
|
18812
|
+
}
|
|
18813
|
+
//#endregion
|
|
18814
|
+
//#region src/update/host.boundary.ts
|
|
18815
|
+
/** A version line and nothing else: one canonical-shaped version on one line. */
|
|
18816
|
+
const VERSION_LINE = /^[0-9][0-9A-Za-z.+-]*$/u;
|
|
18817
|
+
/**
|
|
18818
|
+
* The process seam the updater runs against.
|
|
18819
|
+
*
|
|
18820
|
+
* Named as a boundary because it is one: the process id, the ability to signal another process,
|
|
18821
|
+
* and forking a child are ambient state everywhere else in the CLI is forbidden from reading.
|
|
18822
|
+
* Everything below this file takes {@link UpdateHost} as a parameter, so the exception is one
|
|
18823
|
+
* module wide.
|
|
18824
|
+
*/
|
|
18825
|
+
const UPDATE_HOST = {
|
|
18826
|
+
download: createUpdateDownload(),
|
|
18827
|
+
isProcessAlive,
|
|
18828
|
+
pid: process$1.pid,
|
|
18829
|
+
probeVersion
|
|
18830
|
+
};
|
|
18831
|
+
/**
|
|
18832
|
+
* Whether a process id is still running, from the perspective of this user.
|
|
18833
|
+
*
|
|
18834
|
+
* Signal `0` performs the permission and existence checks without delivering anything. `EPERM`
|
|
18835
|
+
* means the process exists and belongs to someone else, which still counts as alive: the lock it
|
|
18836
|
+
* holds is not this run's to break.
|
|
18837
|
+
*/
|
|
18838
|
+
function isProcessAlive(pid) {
|
|
18839
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
18840
|
+
try {
|
|
18841
|
+
process$1.kill(pid, 0);
|
|
18842
|
+
return true;
|
|
18843
|
+
} catch (error) {
|
|
18844
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
18845
|
+
}
|
|
18846
|
+
}
|
|
18847
|
+
/**
|
|
18848
|
+
* Asks an executable what version it is.
|
|
18849
|
+
*
|
|
18850
|
+
* The child's environment is exactly what the caller passed, which is how a staged binary is
|
|
18851
|
+
* verified without giving it the chance to start an update of its own. Any outcome that is not one
|
|
18852
|
+
* well-formed version line reads as unknown, and an unknown version never satisfies the equality
|
|
18853
|
+
* the installer requires before it replaces anything.
|
|
18854
|
+
*/
|
|
18855
|
+
function probeVersion(executablePath, environmentVariables) {
|
|
18856
|
+
return new Promise((resolve) => {
|
|
18857
|
+
execFile(executablePath, ["--version"], {
|
|
18858
|
+
encoding: "utf8",
|
|
18859
|
+
env: { ...environmentVariables },
|
|
18860
|
+
timeout: VERSION_PROBE_TIMEOUT_MS
|
|
18861
|
+
}, (error, stdout) => {
|
|
18862
|
+
const version = stdout.trim();
|
|
18863
|
+
resolve(error === null && VERSION_LINE.test(version) ? version : void 0);
|
|
18864
|
+
});
|
|
18865
|
+
});
|
|
18866
|
+
}
|
|
18867
|
+
//#endregion
|
|
18868
|
+
//#region src/update/narrow.ts
|
|
18869
|
+
/**
|
|
18870
|
+
* Narrowing helpers for provider responses.
|
|
18871
|
+
*
|
|
18872
|
+
* Every release document is unknown input from a server, so nothing reads a field without proving
|
|
18873
|
+
* its shape first. These return `undefined` rather than throwing: a provider turns absence into a
|
|
18874
|
+
* refusal, and a refusal is never fatal to the command the user actually asked for.
|
|
18875
|
+
*/
|
|
18876
|
+
/** 64 lowercase hexadecimal characters, the only digest form the updater accepts. */
|
|
18877
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
18878
|
+
const GITHUB_DIGEST_PREFIX = "sha256:";
|
|
18879
|
+
function asRecord(value) {
|
|
18880
|
+
return isPlainRecord(value) ? value : void 0;
|
|
18881
|
+
}
|
|
18882
|
+
function asArray(value) {
|
|
18883
|
+
return Array.isArray(value) ? value : void 0;
|
|
18884
|
+
}
|
|
18885
|
+
/** A non-empty string, since an empty name or URL is never a value the updater can act on. */
|
|
18886
|
+
function asText(value) {
|
|
18887
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
18888
|
+
}
|
|
18889
|
+
/** A boolean, and only a boolean: a missing `immutable` field must not read as `false`. */
|
|
18890
|
+
function asFlag(value) {
|
|
18891
|
+
return typeof value === "boolean" ? value : void 0;
|
|
18892
|
+
}
|
|
18893
|
+
/** A positive safe integer within `ceiling`, which is what a byte size has to be to be usable. */
|
|
18894
|
+
function asSize(value, ceiling) {
|
|
18895
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) return;
|
|
18896
|
+
return value <= ceiling ? value : void 0;
|
|
18897
|
+
}
|
|
18898
|
+
/** A bare lowercase SHA-256 digest. */
|
|
18899
|
+
function asDigest(value) {
|
|
18900
|
+
const text = asText(value);
|
|
18901
|
+
return text !== void 0 && SHA256_PATTERN.test(text) ? text : void 0;
|
|
18902
|
+
}
|
|
18903
|
+
/** GitHub's `sha256:<hex>` asset digest, reduced to the bare hexadecimal form. */
|
|
18904
|
+
function asGitHubDigest(value) {
|
|
18905
|
+
const text = asText(value);
|
|
18906
|
+
if (text === void 0 || !text.startsWith(GITHUB_DIGEST_PREFIX)) return;
|
|
18907
|
+
return asDigest(text.slice(7));
|
|
18908
|
+
}
|
|
18909
|
+
/** Parses a document, treating any malformed body as an absent one. */
|
|
18910
|
+
function parseJson(text) {
|
|
18911
|
+
try {
|
|
18912
|
+
return JSON.parse(text);
|
|
18913
|
+
} catch {
|
|
18914
|
+
return;
|
|
18915
|
+
}
|
|
18916
|
+
}
|
|
18917
|
+
function isPlainRecord(value) {
|
|
18918
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18919
|
+
}
|
|
18920
|
+
//#endregion
|
|
18921
|
+
//#region src/update/cache.ts
|
|
18922
|
+
/**
|
|
18923
|
+
* Where update metadata lives, beside every other cache Aura keeps.
|
|
18924
|
+
*
|
|
18925
|
+
* The entry is machine state, not workspace content: `0700` directories, `0600` files, and
|
|
18926
|
+
* temporary-file renames, all inherited from the shared cache primitives.
|
|
18927
|
+
*/
|
|
18928
|
+
const NAMESPACE = "distribution-updates";
|
|
18929
|
+
const OUTCOMES = {
|
|
18930
|
+
"check-failed": "check-failed",
|
|
18931
|
+
current: "current",
|
|
18932
|
+
"install-failed": "install-failed"
|
|
18933
|
+
};
|
|
18934
|
+
/**
|
|
18935
|
+
* Reads the entry for one source, or `undefined` for every reason it cannot be used.
|
|
18936
|
+
*
|
|
18937
|
+
* Corruption, truncation, and a timestamp from the future are all misses rather than errors. A
|
|
18938
|
+
* changed source identity resolves to a different hashed path. A cache is only an optimization.
|
|
18939
|
+
*/
|
|
18940
|
+
async function readUpdateCache(homeDir, identity, now) {
|
|
18941
|
+
const value = await readCacheEnvelope(cacheLocation({ homeDir }, NAMESPACE, identity), MAX_CACHE_BYTES);
|
|
18942
|
+
if (value === void 0) return;
|
|
18943
|
+
const checkedAt = value["checkedAt"];
|
|
18944
|
+
const outcome = value["outcome"];
|
|
18945
|
+
if (typeof checkedAt !== "number" || checkedAt > now || typeof outcome !== "string") return;
|
|
18946
|
+
return narrowEntry(value, checkedAt, outcome);
|
|
18947
|
+
}
|
|
18948
|
+
/** Stores one entry, treating any write failure as nothing having happened. */
|
|
18949
|
+
async function writeUpdateCache(homeDir, identity, entry) {
|
|
18950
|
+
await writeCacheEnvelope(cacheLocation({ homeDir }, NAMESPACE, identity), { ...entry });
|
|
18951
|
+
}
|
|
18952
|
+
/**
|
|
18953
|
+
* Whether this run should ask the source for release metadata.
|
|
18954
|
+
*
|
|
18955
|
+
* The cadence is what keeps startup cheap and quiet: one successful check a day, one silent retry
|
|
18956
|
+
* an hour after a failure, and an exponential backoff per candidate version after an installation
|
|
18957
|
+
* that did not complete — so a machine that cannot write to its own install directory asks once,
|
|
18958
|
+
* then twice a day, rather than on every command.
|
|
18959
|
+
*/
|
|
18960
|
+
function shouldCheck(entry, now) {
|
|
18961
|
+
if (entry === void 0) return true;
|
|
18962
|
+
if (entry.outcome === "current") return now - entry.checkedAt >= CHECK_FRESH_MS;
|
|
18963
|
+
if (entry.outcome === "check-failed") return now - entry.checkedAt >= CHECK_RETRY_MS;
|
|
18964
|
+
return now >= nextInstallAttempt(entry);
|
|
18965
|
+
}
|
|
18966
|
+
/**
|
|
18967
|
+
* Attempts already spent on one candidate version.
|
|
18968
|
+
*
|
|
18969
|
+
* A different version starts at zero: backoff exists to stop retrying a release that will not
|
|
18970
|
+
* install, not to punish the next one for it.
|
|
18971
|
+
*/
|
|
18972
|
+
function attemptsFor(entry, version) {
|
|
18973
|
+
return entry?.failedVersion === version ? entry.failedAttempts ?? 0 : 0;
|
|
18974
|
+
}
|
|
18975
|
+
function nextInstallAttempt(entry) {
|
|
18976
|
+
const attempts = entry.failedAttempts ?? 0;
|
|
18977
|
+
if (attempts === 0) return entry.checkedAt;
|
|
18978
|
+
const delay = Math.min(INSTALL_BACKOFF_BASE_MS * 2 ** (attempts - 1), INSTALL_BACKOFF_MAX_MS);
|
|
18979
|
+
return entry.checkedAt + delay;
|
|
18980
|
+
}
|
|
18981
|
+
function narrowEntry(value, checkedAt, rawOutcome) {
|
|
18982
|
+
const outcome = OUTCOMES[rawOutcome];
|
|
18983
|
+
const failedVersion = asText(value["failedVersion"]);
|
|
18984
|
+
if (outcome === void 0 || outcome === "install-failed" && failedVersion === void 0) return;
|
|
18985
|
+
return {
|
|
18986
|
+
checkedAt,
|
|
18987
|
+
...narrowEtag(value),
|
|
18988
|
+
...narrowFailure(value),
|
|
18989
|
+
outcome
|
|
18990
|
+
};
|
|
18991
|
+
}
|
|
18992
|
+
function narrowEtag(value) {
|
|
18993
|
+
const etag = asText(value["etag"]);
|
|
18994
|
+
return etag === void 0 ? {} : { etag };
|
|
18995
|
+
}
|
|
18996
|
+
function narrowFailure(value) {
|
|
18997
|
+
const failedAttempts = asSize(value["failedAttempts"], Number.MAX_SAFE_INTEGER);
|
|
18998
|
+
const failedVersion = asText(value["failedVersion"]);
|
|
18999
|
+
return {
|
|
19000
|
+
...failedAttempts === void 0 ? {} : { failedAttempts },
|
|
19001
|
+
...failedVersion === void 0 ? {} : { failedVersion }
|
|
19002
|
+
};
|
|
19003
|
+
}
|
|
19004
|
+
//#endregion
|
|
19005
|
+
//#region src/update/diagnostics.ts
|
|
19006
|
+
/**
|
|
19007
|
+
* The updater's two diagnostic surfaces: a trace for whoever wired the distribution up, and a
|
|
19008
|
+
* progress line for whoever is waiting on the download.
|
|
19009
|
+
*
|
|
19010
|
+
* Neither belongs to the message contract in `docs/cli-ux.md`. The trace stays off unless a
|
|
19011
|
+
* variable asks for it, and the progress line is painted only on a terminal that can erase it
|
|
19012
|
+
* again — so redirected output, piped output, and `--json` stay byte-identical either way.
|
|
19013
|
+
*/
|
|
19014
|
+
/** Suffix on a distribution's disable variable, which is what names its debug variable. */
|
|
19015
|
+
const DEBUG_SUFFIX = "_DEBUG";
|
|
19016
|
+
const ENABLED_VALUES = /* @__PURE__ */ new Set([
|
|
19017
|
+
"1",
|
|
19018
|
+
"on",
|
|
19019
|
+
"true",
|
|
19020
|
+
"yes"
|
|
19021
|
+
]);
|
|
19022
|
+
/** What the progress frame says while the archive streams. */
|
|
19023
|
+
const DOWNLOAD_PROMPT = "Downloading…";
|
|
19024
|
+
/** The distribution-specific variable that disables startup updates. */
|
|
19025
|
+
function updateEnvironmentVariable(command) {
|
|
19026
|
+
return `${command.replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase()}_UPDATE`;
|
|
19027
|
+
}
|
|
19028
|
+
/**
|
|
19029
|
+
* The debug writer for one distribution.
|
|
19030
|
+
*
|
|
19031
|
+
* Derived from the command name rather than configured: `acme-dev` gets `ACME_DEV_UPDATE` and its
|
|
19032
|
+
* trace gets `ACME_DEV_UPDATE_DEBUG`, leaving neither as a field a distribution can mistype.
|
|
19033
|
+
*
|
|
19034
|
+
* It exists because every refusal in this subsystem is deliberately silent. That is right for the
|
|
19035
|
+
* user, whose command is the thing they asked about — and useless for the author of a distribution
|
|
19036
|
+
* whose updates are simply not happening, with nine gates and no way to tell which one fired.
|
|
19037
|
+
*/
|
|
19038
|
+
function createUpdateDebug(disableEnvironmentVariable, environmentVariables, stderr) {
|
|
19039
|
+
const value = environmentVariables[`${disableEnvironmentVariable}${DEBUG_SUFFIX}`];
|
|
19040
|
+
if (value === void 0 || !ENABLED_VALUES.has(value.trim().toLowerCase())) return () => void 0;
|
|
19041
|
+
return (message) => {
|
|
19042
|
+
stderr.write(`update: ${message}\n`);
|
|
19043
|
+
};
|
|
19044
|
+
}
|
|
19045
|
+
/** What a run with nowhere to paint gets instead. */
|
|
19046
|
+
const IDLE = {
|
|
19047
|
+
close: () => {},
|
|
19048
|
+
report: void 0
|
|
19049
|
+
};
|
|
19050
|
+
/**
|
|
19051
|
+
* Paints how much of the release archive has arrived.
|
|
19052
|
+
*
|
|
19053
|
+
* The download is the one part of an update the user waits on, and the archive is tens of
|
|
19054
|
+
* megabytes: on a thin connection a single unchanging line is indistinguishable from a hung
|
|
19055
|
+
* command. Erased on close, so nothing it painted survives into the outcome line.
|
|
19056
|
+
*/
|
|
19057
|
+
function startUpdateProgress(stderr) {
|
|
19058
|
+
if (!isTerminal(stderr)) return IDLE;
|
|
19059
|
+
let painted = 0;
|
|
19060
|
+
let closed = false;
|
|
19061
|
+
return {
|
|
19062
|
+
close: () => {
|
|
19063
|
+
if (closed) return;
|
|
19064
|
+
closed = true;
|
|
19065
|
+
stderr.write(eraseFrame(painted));
|
|
19066
|
+
painted = 0;
|
|
19067
|
+
},
|
|
19068
|
+
report: (received, total) => {
|
|
19069
|
+
if (closed) return;
|
|
19070
|
+
const frame = ` ${DOWNLOAD_PROMPT} ${String(percentage(received, total))}%\n`;
|
|
19071
|
+
stderr.write(`${eraseFrame(painted)}${frame}`);
|
|
19072
|
+
painted = countFrameRows(frame, terminalDimension(stderr, "columns") ?? 80);
|
|
19073
|
+
}
|
|
19074
|
+
};
|
|
19075
|
+
}
|
|
19076
|
+
/** Whole percent, never reaching 100 before the transfer has actually finished. */
|
|
19077
|
+
function percentage(received, total) {
|
|
19078
|
+
if (total <= 0) return 99;
|
|
19079
|
+
return Math.min(99, Math.floor(received / total * 100));
|
|
19080
|
+
}
|
|
19081
|
+
//#endregion
|
|
19082
|
+
//#region src/update/target.ts
|
|
19083
|
+
/**
|
|
19084
|
+
* The version a source build reports.
|
|
19085
|
+
*
|
|
19086
|
+
* Only release CI stamps a real version into the distribution manifest, so a developer running a
|
|
19087
|
+
* checkout is structurally indistinguishable from the newest release. Refusing this value keeps a
|
|
19088
|
+
* source build from replacing itself with a published binary.
|
|
19089
|
+
*/
|
|
19090
|
+
const UNSTAMPED_VERSION = "0.0.0";
|
|
19091
|
+
const TARGETS = {
|
|
19092
|
+
"darwin-arm64": "darwin-arm64",
|
|
19093
|
+
"darwin-x64": "darwin-x64",
|
|
19094
|
+
"linux-arm64": "linux-arm64",
|
|
19095
|
+
"linux-x64": "linux-x64"
|
|
19096
|
+
};
|
|
19097
|
+
/** The release target for one installation, or `undefined` when no release names this machine. */
|
|
19098
|
+
function releaseTarget(current) {
|
|
19099
|
+
return TARGETS[`${current.platform}-${current.arch}`];
|
|
19100
|
+
}
|
|
19101
|
+
/**
|
|
19102
|
+
* Whether a version is canonical semver a release can be selected by.
|
|
19103
|
+
*
|
|
19104
|
+
* Canonical rather than merely parseable: `1.4` and `v1.4.0` both describe a release, but only one
|
|
19105
|
+
* spelling can be compared against a tag, a probed `--version`, and a cached candidate and agree
|
|
19106
|
+
* with itself every time.
|
|
19107
|
+
*/
|
|
19108
|
+
function isInstallableVersion(value) {
|
|
19109
|
+
return valid(value) === value && value !== UNSTAMPED_VERSION;
|
|
19110
|
+
}
|
|
19111
|
+
/** Whether `candidate` is a strictly newer release than `current`. Prereleases order as semver. */
|
|
19112
|
+
function isNewerVersion(candidate, current) {
|
|
19113
|
+
return valid(candidate) === candidate && valid(current) === current && gt(candidate, current);
|
|
19114
|
+
}
|
|
19115
|
+
//#endregion
|
|
19116
|
+
//#region src/update/eligibility.ts
|
|
19117
|
+
/** Values that turn startup updates off, matching how `AURA_TELEMETRY=off` reads today. */
|
|
19118
|
+
const DISABLED_VALUES = /* @__PURE__ */ new Set([
|
|
19119
|
+
"0",
|
|
19120
|
+
"false",
|
|
19121
|
+
"no",
|
|
19122
|
+
"off"
|
|
19123
|
+
]);
|
|
19124
|
+
/**
|
|
19125
|
+
* Whether this run may install over its own executable.
|
|
19126
|
+
*
|
|
19127
|
+
* Every clause is a refusal, and the whole gate resolves before a single byte is requested: a run
|
|
19128
|
+
* that is not eligible makes no network request and touches no file. The interactive and `CI`
|
|
19129
|
+
* clauses are what keep a pipeline pinned to the binary it selected — a script that pinned
|
|
19130
|
+
* `v0.5.0` must still be running `0.5.0` an hour later.
|
|
19131
|
+
*/
|
|
19132
|
+
async function eligibleInstallation(request) {
|
|
19133
|
+
const { current, version } = request;
|
|
19134
|
+
if (version === void 0 || !isInstallableVersion(version)) return {
|
|
19135
|
+
kind: "refused",
|
|
19136
|
+
reason: "unstamped-version"
|
|
19137
|
+
};
|
|
19138
|
+
const target = releaseTarget(current);
|
|
19139
|
+
if (target === void 0) return {
|
|
19140
|
+
kind: "refused",
|
|
19141
|
+
reason: "unsupported-target"
|
|
19142
|
+
};
|
|
19143
|
+
const blocked = blockedBy(request);
|
|
19144
|
+
if (blocked !== void 0) return {
|
|
19145
|
+
kind: "refused",
|
|
19146
|
+
reason: blocked
|
|
19147
|
+
};
|
|
19148
|
+
if (!await isReplaceableFile(current.execPath)) return {
|
|
19149
|
+
kind: "refused",
|
|
19150
|
+
reason: "not-a-regular-file"
|
|
19151
|
+
};
|
|
19152
|
+
return {
|
|
19153
|
+
installation: {
|
|
19154
|
+
executablePath: current.execPath,
|
|
19155
|
+
target,
|
|
19156
|
+
version
|
|
19157
|
+
},
|
|
19158
|
+
kind: "eligible"
|
|
19159
|
+
};
|
|
19160
|
+
}
|
|
19161
|
+
/** The environment and terminal half of the gate, separated so its clauses stay readable. */
|
|
19162
|
+
function blockedBy(request) {
|
|
19163
|
+
if (isInformationalRun(request.argv)) return "informational-run";
|
|
19164
|
+
if (turnedOff(request)) return "disabled";
|
|
19165
|
+
if (inContinuousIntegration(request)) return "continuous-integration";
|
|
19166
|
+
return ownsTerminal(request) ? void 0 : "not-a-terminal";
|
|
19167
|
+
}
|
|
19168
|
+
/** Help and version output must remain immediate, including the argument-free root help screen. */
|
|
19169
|
+
function isInformationalRun(argv) {
|
|
19170
|
+
return argv.length === 0 || argv.some((value) => value === "--help" || value === "-h" || value === "--version");
|
|
19171
|
+
}
|
|
19172
|
+
function turnedOff(request) {
|
|
19173
|
+
const value = request.environmentVariables[request.disableEnvironmentVariable];
|
|
19174
|
+
return value !== void 0 && DISABLED_VALUES.has(value.trim().toLowerCase());
|
|
19175
|
+
}
|
|
19176
|
+
/**
|
|
19177
|
+
* Whether this looks like an automated runner.
|
|
19178
|
+
*
|
|
19179
|
+
* Any `CI` value counts. Providers disagree on whether it is `true`, `1`, or their own name, and an
|
|
19180
|
+
* update nobody asked for is the wrong way to discover which one this runner uses.
|
|
19181
|
+
*/
|
|
19182
|
+
function inContinuousIntegration(request) {
|
|
19183
|
+
const value = request.environmentVariables["CI"];
|
|
19184
|
+
return value !== void 0 && value !== "" && value !== "0";
|
|
19185
|
+
}
|
|
19186
|
+
/** All three streams, so neither a redirected report nor a piped-in script gets an update. */
|
|
19187
|
+
function ownsTerminal(request) {
|
|
19188
|
+
return isTerminal(request.stdin) && isTerminal(request.stdout) && isTerminal(request.stderr);
|
|
19189
|
+
}
|
|
19190
|
+
/**
|
|
19191
|
+
* Whether the path is a regular file the installer can rename over.
|
|
19192
|
+
*
|
|
19193
|
+
* `lstat` rather than `stat`: a symlink means some other installation — a package manager's shim,
|
|
19194
|
+
* a version manager's current-release pointer — owns this name, and replacing the link would
|
|
19195
|
+
* either detach it from its manager or write through it into a directory Aura does not own.
|
|
19196
|
+
*/
|
|
19197
|
+
async function isReplaceableFile(path) {
|
|
19198
|
+
try {
|
|
19199
|
+
return (await lstat(path)).isFile();
|
|
19200
|
+
} catch {
|
|
19201
|
+
return false;
|
|
19202
|
+
}
|
|
19203
|
+
}
|
|
19204
|
+
//#endregion
|
|
19205
|
+
//#region src/update/lock.ts
|
|
19206
|
+
/**
|
|
19207
|
+
* Takes the per-executable update lock, or gives up.
|
|
19208
|
+
*
|
|
19209
|
+
* Exclusive creation is the whole mechanism: two updaters racing on the same executable both call
|
|
19210
|
+
* `open(…, "wx")` and exactly one succeeds. Losing is not an error — the other process is already
|
|
19211
|
+
* installing the same release, and the command the user asked for runs either way.
|
|
19212
|
+
*/
|
|
19213
|
+
async function acquireUpdateLock(request) {
|
|
19214
|
+
const attempt = await claim(request);
|
|
19215
|
+
if (attempt.kind !== "held") return attempt;
|
|
19216
|
+
return await reclaimStale(request) ? claim(request) : attempt;
|
|
19217
|
+
}
|
|
19218
|
+
async function claim(request) {
|
|
19219
|
+
const token = randomUUID();
|
|
19220
|
+
try {
|
|
19221
|
+
const handle = await open(request.lockPath, "wx", 384);
|
|
19222
|
+
try {
|
|
19223
|
+
await handle.write(JSON.stringify({
|
|
19224
|
+
pid: request.host.pid,
|
|
19225
|
+
startedAt: request.now,
|
|
19226
|
+
token
|
|
19227
|
+
}));
|
|
19228
|
+
} finally {
|
|
19229
|
+
await handle.close();
|
|
19230
|
+
}
|
|
19231
|
+
return {
|
|
19232
|
+
kind: "acquired",
|
|
19233
|
+
lock: { release: () => removeOwned(request.lockPath, token) }
|
|
19234
|
+
};
|
|
19235
|
+
} catch (error) {
|
|
19236
|
+
return { kind: error instanceof Error && "code" in error && error.code === "EEXIST" ? "held" : "unavailable" };
|
|
19237
|
+
}
|
|
19238
|
+
}
|
|
19239
|
+
/**
|
|
19240
|
+
* Removes a lock whose owner is gone.
|
|
19241
|
+
*
|
|
19242
|
+
* Both conditions are required. Age alone would break a slow but healthy download on a thin
|
|
19243
|
+
* connection; a missing process alone would race a holder that has not written its record yet.
|
|
19244
|
+
* An unreadable or malformed lock counts as expired once it is old enough — nothing that can be
|
|
19245
|
+
* asked about it will ever answer.
|
|
19246
|
+
*/
|
|
19247
|
+
async function reclaimStale(request) {
|
|
19248
|
+
const contents = await read(request.lockPath);
|
|
19249
|
+
const record = asRecord(parseJson(contents));
|
|
19250
|
+
const startedAt = asSize(record?.["startedAt"], Number.MAX_SAFE_INTEGER) ?? await modifiedAt(request.lockPath);
|
|
19251
|
+
if (startedAt === void 0 || request.now - startedAt < 6e5) return false;
|
|
19252
|
+
const pid = asSize(record?.["pid"], Number.MAX_SAFE_INTEGER);
|
|
19253
|
+
if (pid !== void 0 && request.host.isProcessAlive(pid)) return false;
|
|
19254
|
+
return await removeUnchanged(request.lockPath, contents);
|
|
19255
|
+
}
|
|
19256
|
+
/**
|
|
19257
|
+
* When the lock file was last written, used when its contents cannot say.
|
|
19258
|
+
*
|
|
19259
|
+
* An updater killed between creating the lock and writing its record leaves a file that names no
|
|
19260
|
+
* process. Without this fallback that file would block every future update on the machine.
|
|
19261
|
+
*/
|
|
19262
|
+
async function modifiedAt(path) {
|
|
19263
|
+
try {
|
|
19264
|
+
return (await stat(path)).mtimeMs;
|
|
19265
|
+
} catch {
|
|
19266
|
+
return;
|
|
19267
|
+
}
|
|
19268
|
+
}
|
|
19269
|
+
async function read(path) {
|
|
19270
|
+
try {
|
|
19271
|
+
return await readFile(path, "utf8");
|
|
19272
|
+
} catch {
|
|
19273
|
+
return "";
|
|
19274
|
+
}
|
|
19275
|
+
}
|
|
19276
|
+
async function remove(path) {
|
|
19277
|
+
try {
|
|
19278
|
+
await unlink(path);
|
|
19279
|
+
} catch {}
|
|
19280
|
+
}
|
|
19281
|
+
/** Removes a stale lock only while it is still the exact record that was inspected. */
|
|
19282
|
+
async function removeUnchanged(path, expected) {
|
|
19283
|
+
const reclaimPath = `${path}.reclaim-${createHash("sha256").update(expected, "utf8").digest("hex").slice(0, 16)}`;
|
|
19284
|
+
try {
|
|
19285
|
+
await link(path, reclaimPath);
|
|
19286
|
+
} catch {
|
|
19287
|
+
return false;
|
|
19288
|
+
}
|
|
19289
|
+
try {
|
|
19290
|
+
if (await read(reclaimPath) !== expected || await read(path) !== expected) return false;
|
|
19291
|
+
await unlink(path);
|
|
19292
|
+
return true;
|
|
19293
|
+
} catch {
|
|
19294
|
+
return false;
|
|
19295
|
+
} finally {
|
|
19296
|
+
await remove(reclaimPath);
|
|
19297
|
+
}
|
|
19298
|
+
}
|
|
19299
|
+
/** A holder must never unlink a successor that reused the same path. */
|
|
19300
|
+
async function removeOwned(path, token) {
|
|
19301
|
+
if (asRecord(parseJson(await read(path)))?.["token"] === token) await remove(path);
|
|
19302
|
+
}
|
|
19303
|
+
//#endregion
|
|
19304
|
+
//#region src/update/tar-header.ts
|
|
19305
|
+
const NAME = {
|
|
19306
|
+
length: 100,
|
|
19307
|
+
offset: 0
|
|
19308
|
+
};
|
|
19309
|
+
const SIZE = {
|
|
19310
|
+
length: 12,
|
|
19311
|
+
offset: 124
|
|
19312
|
+
};
|
|
19313
|
+
const CHECKSUM = {
|
|
19314
|
+
length: 8,
|
|
19315
|
+
offset: 148
|
|
19316
|
+
};
|
|
19317
|
+
const TYPEFLAG_OFFSET = 156;
|
|
19318
|
+
const PREFIX = {
|
|
19319
|
+
length: 155,
|
|
19320
|
+
offset: 345
|
|
19321
|
+
};
|
|
19322
|
+
/** Type flags for a plain file. `0` is ustar; NUL is the historic spelling of the same thing. */
|
|
19323
|
+
const REGULAR_TYPES = /* @__PURE__ */ new Set(["0", "\0"]);
|
|
19324
|
+
/** Whether a block is all zeroes, which is how a tar announces its own end. */
|
|
19325
|
+
function isZeroBlock(block) {
|
|
19326
|
+
return block.every((byte) => byte === 0);
|
|
19327
|
+
}
|
|
19328
|
+
/**
|
|
19329
|
+
* One header, or `undefined` for any block this extractor refuses to act on.
|
|
19330
|
+
*
|
|
19331
|
+
* Refusal is deliberately undifferentiated: the caller aborts the whole archive either way, and a
|
|
19332
|
+
* reason string would only invite someone to relax a specific case later.
|
|
19333
|
+
*/
|
|
19334
|
+
function parseTarHeader(block) {
|
|
19335
|
+
if (!hasValidChecksum(block)) return;
|
|
19336
|
+
if (!REGULAR_TYPES.has(String.fromCharCode(block[TYPEFLAG_OFFSET] ?? 0))) return;
|
|
19337
|
+
const size = parseOctal(block, SIZE);
|
|
19338
|
+
const name = entryName(block);
|
|
19339
|
+
return size === void 0 || name === void 0 ? void 0 : {
|
|
19340
|
+
name,
|
|
19341
|
+
size
|
|
19342
|
+
};
|
|
19343
|
+
}
|
|
19344
|
+
/**
|
|
19345
|
+
* The entry path, refused unless it is a plain relative name.
|
|
19346
|
+
*
|
|
19347
|
+
* Absolute paths, parent traversal, and Windows separators are all ways to make an extraction land
|
|
19348
|
+
* somewhere the caller did not choose, and none of them appear in an archive this project builds.
|
|
19349
|
+
*/
|
|
19350
|
+
function entryName(block) {
|
|
19351
|
+
const prefix = readString(block, PREFIX);
|
|
19352
|
+
const base = readString(block, NAME);
|
|
19353
|
+
if (base === "") return;
|
|
19354
|
+
const joined = prefix === "" ? base : `${prefix}/${base}`;
|
|
19355
|
+
const normalized = joined.startsWith("./") ? joined.slice(2) : joined;
|
|
19356
|
+
const segments = normalized.split("/");
|
|
19357
|
+
if (normalized.startsWith("/") || normalized.includes("\\")) return;
|
|
19358
|
+
return segments.some((segment) => segment === ".." || segment === "") ? void 0 : normalized;
|
|
19359
|
+
}
|
|
19360
|
+
/**
|
|
19361
|
+
* The header checksum, which every tar writer sets and every reader is expected to verify.
|
|
19362
|
+
*
|
|
19363
|
+
* Checked here because it is the cheapest way to notice that the stream has drifted out of block
|
|
19364
|
+
* alignment — a state in which arbitrary payload bytes would otherwise be read as a header.
|
|
19365
|
+
*/
|
|
19366
|
+
function hasValidChecksum(block) {
|
|
19367
|
+
const declared = parseOctal(block, CHECKSUM);
|
|
19368
|
+
if (declared === void 0) return false;
|
|
19369
|
+
let sum = 0;
|
|
19370
|
+
for (const [index, byte] of block.entries()) {
|
|
19371
|
+
const inChecksum = index >= CHECKSUM.offset && index < CHECKSUM.offset + CHECKSUM.length;
|
|
19372
|
+
sum += inChecksum ? 32 : byte;
|
|
19373
|
+
}
|
|
19374
|
+
return sum === declared;
|
|
19375
|
+
}
|
|
19376
|
+
/**
|
|
19377
|
+
* A NUL- or space-terminated octal field.
|
|
19378
|
+
*
|
|
19379
|
+
* Base-256 encoded fields — the GNU extension for sizes beyond 8 GiB — are refused rather than
|
|
19380
|
+
* decoded: nothing this extracts is that large, and the size field is what bounds the write.
|
|
19381
|
+
*/
|
|
19382
|
+
function parseOctal(block, field) {
|
|
19383
|
+
if ((block.subarray(field.offset, field.offset + field.length)[0] ?? 0) & 128) return;
|
|
19384
|
+
const text = readString(block, field).trim();
|
|
19385
|
+
if (text === "") return 0;
|
|
19386
|
+
if (!/^[0-7]+$/u.test(text)) return;
|
|
19387
|
+
const value = Number.parseInt(text, 8);
|
|
19388
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
19389
|
+
}
|
|
19390
|
+
function readString(block, field) {
|
|
19391
|
+
const bytes = block.subarray(field.offset, field.offset + field.length);
|
|
19392
|
+
const end = bytes.indexOf(0);
|
|
19393
|
+
return new TextDecoder().decode(end === -1 ? bytes : bytes.subarray(0, end)).replace(/\0+$/u, "");
|
|
19394
|
+
}
|
|
19395
|
+
//#endregion
|
|
19396
|
+
//#region src/update/archive.ts
|
|
19397
|
+
/**
|
|
19398
|
+
* Extracts the expected files from a gzip tar, and refuses everything else.
|
|
19399
|
+
*
|
|
19400
|
+
* Allow-listed by name rather than filtered after the fact: the extractor never creates a path the
|
|
19401
|
+
* caller did not name, so no header field — however hostile — can decide where a byte lands.
|
|
19402
|
+
*/
|
|
19403
|
+
async function extractArchive(request) {
|
|
19404
|
+
const extractor = new TarExtractor(request);
|
|
19405
|
+
const source = createReadStream(request.archivePath);
|
|
19406
|
+
try {
|
|
19407
|
+
for await (const chunk of source.pipe(createGunzip())) {
|
|
19408
|
+
if (!(chunk instanceof Uint8Array)) return "unreadable-archive";
|
|
19409
|
+
extractor.push(chunk);
|
|
19410
|
+
await extractor.drain();
|
|
19411
|
+
if (extractor.failure !== void 0) return extractor.failure;
|
|
19412
|
+
if (extractor.done) break;
|
|
19413
|
+
}
|
|
19414
|
+
} catch {
|
|
19415
|
+
return "unreadable-archive";
|
|
19416
|
+
} finally {
|
|
19417
|
+
source.destroy();
|
|
19418
|
+
await extractor.close();
|
|
19419
|
+
}
|
|
19420
|
+
return extractor.failure ?? (extractor.extracted(request.requiredEntry) ? void 0 : "missing-executable");
|
|
19421
|
+
}
|
|
19422
|
+
/** A tar reader that consumes a byte stream one 512-byte block at a time. */
|
|
19423
|
+
var TarExtractor = class {
|
|
19424
|
+
failure;
|
|
19425
|
+
#request;
|
|
19426
|
+
#seen = /* @__PURE__ */ new Set();
|
|
19427
|
+
#chunks = [];
|
|
19428
|
+
#queued = 0;
|
|
19429
|
+
#state = "header";
|
|
19430
|
+
#remaining = 0;
|
|
19431
|
+
#padding = 0;
|
|
19432
|
+
#pending;
|
|
19433
|
+
#sink;
|
|
19434
|
+
#written = 0;
|
|
19435
|
+
#zeroBlocks = 0;
|
|
19436
|
+
#done = false;
|
|
19437
|
+
constructor(request) {
|
|
19438
|
+
this.#request = request;
|
|
19439
|
+
}
|
|
19440
|
+
push(chunk) {
|
|
19441
|
+
this.#chunks.push(chunk);
|
|
19442
|
+
this.#queued += chunk.byteLength;
|
|
19443
|
+
}
|
|
19444
|
+
extracted(name) {
|
|
19445
|
+
return this.#seen.has(name);
|
|
19446
|
+
}
|
|
19447
|
+
get done() {
|
|
19448
|
+
return this.#done;
|
|
19449
|
+
}
|
|
19450
|
+
/** Consumes as much of the queue as the current state allows, then waits for more bytes. */
|
|
19451
|
+
async drain() {
|
|
19452
|
+
while (this.failure === void 0 && !this.#done) if (!(this.#state === "header" ? await this.#readHeader() : this.#state === "body" ? await this.#readBody() : await this.#endEntry())) return;
|
|
19453
|
+
}
|
|
19454
|
+
async close() {
|
|
19455
|
+
const sink = this.#sink;
|
|
19456
|
+
this.#sink = void 0;
|
|
19457
|
+
this.#pending = void 0;
|
|
19458
|
+
this.#chunks = [];
|
|
19459
|
+
this.#queued = 0;
|
|
19460
|
+
try {
|
|
19461
|
+
await sink?.close();
|
|
19462
|
+
} catch {}
|
|
19463
|
+
}
|
|
19464
|
+
async #readHeader() {
|
|
19465
|
+
const block = this.#take(512);
|
|
19466
|
+
if (block === void 0) return false;
|
|
19467
|
+
if (isZeroBlock(block)) {
|
|
19468
|
+
this.#zeroBlocks += 1;
|
|
19469
|
+
this.#done = this.#zeroBlocks === 2;
|
|
19470
|
+
return true;
|
|
19471
|
+
}
|
|
19472
|
+
this.#zeroBlocks = 0;
|
|
19473
|
+
const entry = parseTarHeader(block);
|
|
19474
|
+
if (entry === void 0) {
|
|
19475
|
+
this.failure = "unexpected-entry";
|
|
19476
|
+
return false;
|
|
19477
|
+
}
|
|
19478
|
+
return await this.#openEntry(entry.name, entry.size);
|
|
19479
|
+
}
|
|
19480
|
+
async #openEntry(name, size) {
|
|
19481
|
+
const destination = this.#request.entries[name];
|
|
19482
|
+
if (destination === void 0 || this.#seen.has(name)) {
|
|
19483
|
+
this.failure = "unexpected-entry";
|
|
19484
|
+
return false;
|
|
19485
|
+
}
|
|
19486
|
+
this.#written += size;
|
|
19487
|
+
if (this.#written > this.#request.maxBytes) {
|
|
19488
|
+
this.failure = "too-large";
|
|
19489
|
+
return false;
|
|
19490
|
+
}
|
|
19491
|
+
this.#pending = name;
|
|
19492
|
+
this.#sink = await open(destination, "wx", 384);
|
|
19493
|
+
this.#remaining = size;
|
|
19494
|
+
this.#padding = (512 - size % 512) % 512;
|
|
19495
|
+
this.#state = size > 0 ? "body" : "padding";
|
|
19496
|
+
return true;
|
|
19497
|
+
}
|
|
19498
|
+
/**
|
|
19499
|
+
* Writes body bytes straight out of the queue.
|
|
19500
|
+
*
|
|
19501
|
+
* Deliberately not routed through {@link TarExtractor.#take}: gathering a contiguous buffer first
|
|
19502
|
+
* would copy the whole executable an extra time on its way to disk, and nothing here needs the
|
|
19503
|
+
* bytes in hand. Only headers and padding, which are one block at a time, do.
|
|
19504
|
+
*/
|
|
19505
|
+
async #readBody() {
|
|
19506
|
+
const count = Math.min(this.#remaining, this.#queued);
|
|
19507
|
+
if (count === 0) return false;
|
|
19508
|
+
let written = 0;
|
|
19509
|
+
while (written < count) {
|
|
19510
|
+
const slice = this.#shift(count - written);
|
|
19511
|
+
if (slice === void 0) return false;
|
|
19512
|
+
written += slice.byteLength;
|
|
19513
|
+
await this.#sink?.write(slice);
|
|
19514
|
+
}
|
|
19515
|
+
this.#remaining -= count;
|
|
19516
|
+
if (this.#remaining === 0) this.#state = "padding";
|
|
19517
|
+
return true;
|
|
19518
|
+
}
|
|
19519
|
+
async #endEntry() {
|
|
19520
|
+
if (this.#padding > 0) {
|
|
19521
|
+
if (this.#take(this.#padding) === void 0) return false;
|
|
19522
|
+
this.#padding = 0;
|
|
19523
|
+
}
|
|
19524
|
+
const sink = this.#sink;
|
|
19525
|
+
this.#sink = void 0;
|
|
19526
|
+
await sink?.sync();
|
|
19527
|
+
await sink?.close();
|
|
19528
|
+
if (this.#pending !== void 0) {
|
|
19529
|
+
this.#seen.add(this.#pending);
|
|
19530
|
+
this.#pending = void 0;
|
|
19531
|
+
}
|
|
19532
|
+
this.#state = "header";
|
|
19533
|
+
return true;
|
|
19534
|
+
}
|
|
19535
|
+
/** Up to `limit` bytes off the front of the queue, without copying them. */
|
|
19536
|
+
#shift(limit) {
|
|
19537
|
+
const chunk = this.#chunks[0];
|
|
19538
|
+
if (chunk === void 0) return;
|
|
19539
|
+
if (chunk.byteLength <= limit) {
|
|
19540
|
+
this.#chunks.shift();
|
|
19541
|
+
this.#queued -= chunk.byteLength;
|
|
19542
|
+
return chunk;
|
|
19543
|
+
}
|
|
19544
|
+
this.#chunks[0] = chunk.subarray(limit);
|
|
19545
|
+
this.#queued -= limit;
|
|
19546
|
+
return chunk.subarray(0, limit);
|
|
19547
|
+
}
|
|
19548
|
+
/** Exactly `count` bytes off the front of the queue, or `undefined` until they have arrived. */
|
|
19549
|
+
#take(count) {
|
|
19550
|
+
if (this.#queued < count) return;
|
|
19551
|
+
const out = new Uint8Array(count);
|
|
19552
|
+
let offset = 0;
|
|
19553
|
+
while (offset < count) {
|
|
19554
|
+
const chunk = this.#chunks[0];
|
|
19555
|
+
if (chunk === void 0) return;
|
|
19556
|
+
const needed = count - offset;
|
|
19557
|
+
if (chunk.byteLength <= needed) {
|
|
19558
|
+
out.set(chunk, offset);
|
|
19559
|
+
offset += chunk.byteLength;
|
|
19560
|
+
this.#chunks.shift();
|
|
19561
|
+
} else {
|
|
19562
|
+
out.set(chunk.subarray(0, needed), offset);
|
|
19563
|
+
offset += needed;
|
|
19564
|
+
this.#chunks[0] = chunk.subarray(needed);
|
|
19565
|
+
}
|
|
19566
|
+
}
|
|
19567
|
+
this.#queued -= count;
|
|
19568
|
+
return out;
|
|
19569
|
+
}
|
|
19570
|
+
};
|
|
19571
|
+
//#endregion
|
|
19572
|
+
//#region src/update/stage.ts
|
|
19573
|
+
/**
|
|
19574
|
+
* Turns a validated candidate into a verified executable sitting next to the installed one.
|
|
19575
|
+
*
|
|
19576
|
+
* Nothing here touches the installed binary. Every failure leaves temporary files for the caller
|
|
19577
|
+
* to remove and the running installation exactly as it was — which is the property that makes an
|
|
19578
|
+
* update safe to attempt on every startup.
|
|
19579
|
+
*/
|
|
19580
|
+
async function stageExecutable(request) {
|
|
19581
|
+
const download = await request.host.download({
|
|
19582
|
+
destinationPath: request.archivePath,
|
|
19583
|
+
expectedBytes: request.candidate.size,
|
|
19584
|
+
headers: request.downloadHeaders,
|
|
19585
|
+
...request.onProgress === void 0 ? {} : { onProgress: request.onProgress },
|
|
19586
|
+
timeoutMs: request.downloadTimeoutMs,
|
|
19587
|
+
url: request.candidate.downloadUrl
|
|
19588
|
+
});
|
|
19589
|
+
if (download.kind !== "downloaded") return `download-${download.reason}`;
|
|
19590
|
+
if (download.sha256 !== request.candidate.sha256) return "digest";
|
|
19591
|
+
const archive = await extract(request);
|
|
19592
|
+
if (archive !== void 0) return archive;
|
|
19593
|
+
return await verifyStaged(request);
|
|
19594
|
+
}
|
|
19595
|
+
function extract(request) {
|
|
19596
|
+
return extractArchive({
|
|
19597
|
+
archivePath: request.archivePath,
|
|
19598
|
+
entries: {
|
|
19599
|
+
LICENSE: request.licensePath,
|
|
19600
|
+
[request.entryName]: request.stagedPath
|
|
19601
|
+
},
|
|
19602
|
+
maxBytes: MAX_EXTRACTED_BYTES,
|
|
19603
|
+
requiredEntry: request.entryName
|
|
19604
|
+
});
|
|
19605
|
+
}
|
|
19606
|
+
/**
|
|
19607
|
+
* Proves the staged file is an executable that agrees about which version it is.
|
|
19608
|
+
*
|
|
19609
|
+
* This is the last gate before anything irreversible. A digest proves the bytes match a published
|
|
19610
|
+
* release; running the program proves the release is the one the metadata named, that it starts on
|
|
19611
|
+
* this machine at all, and that its architecture is the one this process is running.
|
|
19612
|
+
*/
|
|
19613
|
+
async function verifyStaged(request) {
|
|
19614
|
+
try {
|
|
19615
|
+
await chmod(request.stagedPath, request.executableMode);
|
|
19616
|
+
if (!(await lstat(request.stagedPath)).isFile()) return "staged-version";
|
|
19617
|
+
} catch {
|
|
19618
|
+
return "staged-version";
|
|
19619
|
+
}
|
|
19620
|
+
return await request.host.probeVersion(request.stagedPath, request.probeEnvironment) === request.candidate.version ? void 0 : "staged-version";
|
|
19621
|
+
}
|
|
19622
|
+
//#endregion
|
|
19623
|
+
//#region src/update/install.ts
|
|
19624
|
+
/**
|
|
19625
|
+
* Replaces the running distribution's executable with a newer release, or changes nothing.
|
|
19626
|
+
*
|
|
19627
|
+
* The transaction is ordered so the installed executable is only ever touched by two renames, both
|
|
19628
|
+
* within one directory: a recovery copy moves into place, then the verified staged file moves over
|
|
19629
|
+
* the original. A crash at any earlier point leaves temporary files and nothing else; a crash
|
|
19630
|
+
* between the two renames leaves a working binary and a copy of it.
|
|
19631
|
+
*/
|
|
19632
|
+
async function installUpdate(request) {
|
|
19633
|
+
const directory = dirname(request.executablePath);
|
|
19634
|
+
const attempt = await acquireUpdateLock({
|
|
19635
|
+
host: request.host,
|
|
19636
|
+
lockPath: `${request.executablePath}.update-lock`,
|
|
19637
|
+
now: request.now
|
|
19638
|
+
});
|
|
19639
|
+
if (attempt.kind !== "acquired") return attempt.kind === "held" ? { kind: "skipped" } : {
|
|
19640
|
+
kind: "failed",
|
|
19641
|
+
reason: "lock-unavailable"
|
|
19642
|
+
};
|
|
19643
|
+
try {
|
|
19644
|
+
return await transact(request, directory);
|
|
19645
|
+
} catch {
|
|
19646
|
+
return {
|
|
19647
|
+
kind: "failed",
|
|
19648
|
+
reason: "replace-failed"
|
|
19649
|
+
};
|
|
19650
|
+
} finally {
|
|
19651
|
+
await attempt.lock.release();
|
|
19652
|
+
}
|
|
19653
|
+
}
|
|
19654
|
+
async function transact(request, directory) {
|
|
19655
|
+
const installed = await request.host.probeVersion(request.executablePath, request.probeEnvironment);
|
|
19656
|
+
if (installed !== void 0 && !isNewerVersion(request.candidate.version, installed)) return { kind: "skipped" };
|
|
19657
|
+
const executableMode = await regularFileMode(request.executablePath);
|
|
19658
|
+
if (executableMode === void 0) return {
|
|
19659
|
+
kind: "failed",
|
|
19660
|
+
reason: "not-a-regular-file"
|
|
19661
|
+
};
|
|
19662
|
+
const scratch = join(directory, `.${request.command}-${randomUUID()}`);
|
|
19663
|
+
const paths = {
|
|
19664
|
+
archive: `${scratch}.tar.gz`,
|
|
19665
|
+
license: `${scratch}.LICENSE`,
|
|
19666
|
+
previous: `${scratch}.previous`,
|
|
19667
|
+
staged: `${scratch}.staged`
|
|
19668
|
+
};
|
|
19669
|
+
try {
|
|
19670
|
+
const failure = await stageExecutable({
|
|
19671
|
+
archivePath: paths.archive,
|
|
19672
|
+
candidate: request.candidate,
|
|
19673
|
+
downloadHeaders: request.downloadHeaders,
|
|
19674
|
+
downloadTimeoutMs: request.downloadTimeoutMs,
|
|
19675
|
+
entryName: request.command,
|
|
19676
|
+
executableMode,
|
|
19677
|
+
host: request.host,
|
|
19678
|
+
licensePath: paths.license,
|
|
19679
|
+
...request.onProgress === void 0 ? {} : { onProgress: request.onProgress },
|
|
19680
|
+
probeEnvironment: request.probeEnvironment,
|
|
19681
|
+
stagedPath: paths.staged
|
|
19682
|
+
});
|
|
19683
|
+
if (failure !== void 0) return failure === "digest" ? { kind: "refused" } : {
|
|
19684
|
+
kind: "failed",
|
|
19685
|
+
reason: failure
|
|
19686
|
+
};
|
|
19687
|
+
await swap(request, directory, paths);
|
|
19688
|
+
return { kind: "installed" };
|
|
19689
|
+
} finally {
|
|
19690
|
+
await discard([
|
|
19691
|
+
paths.archive,
|
|
19692
|
+
paths.license,
|
|
19693
|
+
paths.previous,
|
|
19694
|
+
paths.staged
|
|
19695
|
+
]);
|
|
19696
|
+
}
|
|
19697
|
+
}
|
|
19698
|
+
/** The two renames, in the order that leaves something runnable at every point between them. */
|
|
19699
|
+
async function swap(request, directory, paths) {
|
|
19700
|
+
await retainPrevious(request, paths.previous);
|
|
19701
|
+
await rename(paths.staged, request.executablePath);
|
|
19702
|
+
await replaceLicense(directory, paths.license);
|
|
19703
|
+
await syncDirectory(directory);
|
|
19704
|
+
}
|
|
19705
|
+
/**
|
|
19706
|
+
* Keeps one recovery copy beside the installed binary.
|
|
19707
|
+
*
|
|
19708
|
+
* A hard link costs no space and no read of a hundred-megabyte file; a copy is the fallback for
|
|
19709
|
+
* filesystems that refuse links. The intermediate rename is what makes the copy appear whole: a
|
|
19710
|
+
* reader either sees the previous `.previous` or the new one, never a partially written file.
|
|
19711
|
+
*/
|
|
19712
|
+
async function retainPrevious(request, temporary) {
|
|
19713
|
+
try {
|
|
19714
|
+
await link(request.executablePath, temporary);
|
|
19715
|
+
} catch {
|
|
19716
|
+
await copyFile(request.executablePath, temporary);
|
|
19717
|
+
}
|
|
19718
|
+
await rename(temporary, `${request.executablePath}.previous`);
|
|
19719
|
+
}
|
|
19720
|
+
/**
|
|
19721
|
+
* Updates the license text only where the installation already keeps one.
|
|
19722
|
+
*
|
|
19723
|
+
* Replacing a file the original install wrote is maintenance; creating one it never wrote would
|
|
19724
|
+
* put an unexpected file into a directory Aura shares with whatever else lives on the user's path.
|
|
19725
|
+
*/
|
|
19726
|
+
async function replaceLicense(directory, staged) {
|
|
19727
|
+
const installed = join(directory, "LICENSE");
|
|
19728
|
+
const mode = await regularFileMode(installed);
|
|
19729
|
+
if (mode === void 0 || !await isRegularFile(staged)) return;
|
|
19730
|
+
await chmod(staged, mode);
|
|
19731
|
+
await rename(staged, installed);
|
|
19732
|
+
}
|
|
19733
|
+
/**
|
|
19734
|
+
* Flushes the directory entry itself where the platform supports it.
|
|
19735
|
+
*
|
|
19736
|
+
* Without this the renames can still be in the filesystem's own buffers after the process exits;
|
|
19737
|
+
* a power loss then leaves a directory that lists neither the old name nor the new one.
|
|
19738
|
+
*/
|
|
19739
|
+
async function syncDirectory(directory) {
|
|
19740
|
+
try {
|
|
19741
|
+
const handle = await open(directory, "r");
|
|
19742
|
+
try {
|
|
19743
|
+
await handle.sync();
|
|
19744
|
+
} finally {
|
|
19745
|
+
await handle.close();
|
|
19746
|
+
}
|
|
19747
|
+
} catch {}
|
|
19748
|
+
}
|
|
19749
|
+
async function isRegularFile(path) {
|
|
19750
|
+
return await regularFileMode(path) !== void 0;
|
|
19751
|
+
}
|
|
19752
|
+
async function regularFileMode(path) {
|
|
19753
|
+
try {
|
|
19754
|
+
const status = await lstat(path);
|
|
19755
|
+
return status.isFile() ? status.mode & 511 : void 0;
|
|
19756
|
+
} catch {
|
|
19757
|
+
return;
|
|
19758
|
+
}
|
|
19759
|
+
}
|
|
19760
|
+
async function discard(paths) {
|
|
19761
|
+
for (const path of paths) try {
|
|
19762
|
+
await rm(path, { force: true });
|
|
19763
|
+
} catch {}
|
|
19764
|
+
}
|
|
19765
|
+
//#endregion
|
|
19766
|
+
//#region src/update/metadata.ts
|
|
19767
|
+
/**
|
|
19768
|
+
* Fetches one release-metadata document under the shared caps.
|
|
19769
|
+
*
|
|
19770
|
+
* Both providers ask the same questions of a response — did it arrive, is it a 304, is it a 200 —
|
|
19771
|
+
* and both must answer a credential-bearing request without letting the reason travel with the
|
|
19772
|
+
* result. Keeping that in one place is what stops the two from drifting apart.
|
|
19773
|
+
*/
|
|
19774
|
+
async function fetchMetadata(query, options) {
|
|
19775
|
+
const response = await query.httpGet({
|
|
19776
|
+
headers: {
|
|
19777
|
+
accept: options.accept,
|
|
19778
|
+
"user-agent": query.userAgent,
|
|
19779
|
+
...options.headers,
|
|
19780
|
+
...query.etag === void 0 ? {} : { "if-none-match": query.etag }
|
|
19781
|
+
},
|
|
19782
|
+
maxResponseBytes: MAX_METADATA_BYTES,
|
|
19783
|
+
timeoutMs: METADATA_TIMEOUT_MS,
|
|
19784
|
+
url: options.url
|
|
19785
|
+
});
|
|
19786
|
+
if (response.kind !== "response") return { kind: "failure" };
|
|
19787
|
+
if (response.status === 304) return { kind: "unchanged" };
|
|
19788
|
+
if (response.status !== 200) return { kind: "failure" };
|
|
19789
|
+
return {
|
|
19790
|
+
body: response.body,
|
|
19791
|
+
...response.etag === void 0 ? {} : { etag: response.etag },
|
|
19792
|
+
kind: "body"
|
|
19793
|
+
};
|
|
19794
|
+
}
|
|
19795
|
+
/**
|
|
19796
|
+
* The `Authorization` header for a configured credential, or no header at all.
|
|
19797
|
+
*
|
|
19798
|
+
* Built at the call site and never stored: the value leaves scope with the request it authorizes.
|
|
19799
|
+
*/
|
|
19800
|
+
function bearer(token) {
|
|
19801
|
+
return token === void 0 ? {} : { authorization: `Bearer ${token}` };
|
|
19802
|
+
}
|
|
19803
|
+
/** Headers the archive download carries. Rebuilt per lookup, so nothing cached holds a secret. */
|
|
19804
|
+
function downloadHeaders(token) {
|
|
19805
|
+
return {
|
|
19806
|
+
accept: "application/octet-stream",
|
|
19807
|
+
...bearer(token)
|
|
19808
|
+
};
|
|
19809
|
+
}
|
|
19810
|
+
function compareRelease(version, current) {
|
|
19811
|
+
if (version === void 0 || !isInstallableVersion(version)) return { kind: "invalid" };
|
|
19812
|
+
return isNewerVersion(version, current) ? {
|
|
19813
|
+
kind: "newer",
|
|
19814
|
+
version
|
|
19815
|
+
} : { kind: "current" };
|
|
19816
|
+
}
|
|
19817
|
+
/** An entity tag as an optional field, so absence stays absent rather than `undefined`-valued. */
|
|
19818
|
+
function etagField(etag) {
|
|
19819
|
+
return etag === void 0 ? {} : { etag };
|
|
19820
|
+
}
|
|
19821
|
+
/** Reads a source's configured credential, when it names one. */
|
|
19822
|
+
function sourceToken(query, tokenEnvironmentVariable) {
|
|
19823
|
+
return tokenEnvironmentVariable === void 0 ? void 0 : query.readVariable(tokenEnvironmentVariable);
|
|
19824
|
+
}
|
|
19825
|
+
//#endregion
|
|
19826
|
+
//#region src/update/github-release.ts
|
|
19827
|
+
const PUBLIC_API_BASE_URL = "https://api.github.com";
|
|
19828
|
+
const PUBLIC_API_HOST = "api.github.com";
|
|
19829
|
+
const PUBLIC_WEB_ORIGIN = "https://github.com";
|
|
19830
|
+
/**
|
|
19831
|
+
* REST API version this provider is written against.
|
|
19832
|
+
*
|
|
19833
|
+
* Pinned rather than omitted: the response fields the trust decision rests on — `immutable` and
|
|
19834
|
+
* the per-asset `digest` — are the ones a future default version could reshape.
|
|
19835
|
+
*/
|
|
19836
|
+
const GITHUB_API_VERSION = "2026-03-10";
|
|
19837
|
+
/**
|
|
19838
|
+
* Resolves the latest release of one GitHub or GitHub Enterprise Server repository.
|
|
19839
|
+
*
|
|
19840
|
+
* The trust boundary is the API's TLS connection plus immutable releases: one authenticated
|
|
19841
|
+
* document supplies the version, the asset, and the digest together, so there is no second
|
|
19842
|
+
* endpoint, moving URL, or long-lived signing key to defend.
|
|
19843
|
+
*/
|
|
19844
|
+
async function resolveGitHubRelease(source, query) {
|
|
19845
|
+
const baseUrl = source.apiBaseUrl ?? PUBLIC_API_BASE_URL;
|
|
19846
|
+
if (parseUrl(baseUrl) === void 0) return {
|
|
19847
|
+
kind: "failure",
|
|
19848
|
+
reason: "invalid-release"
|
|
19849
|
+
};
|
|
19850
|
+
const token = sourceToken(query, source.tokenEnvironmentVariable);
|
|
19851
|
+
const response = await fetchMetadata(query, {
|
|
19852
|
+
accept: "application/vnd.github+json",
|
|
19853
|
+
headers: {
|
|
19854
|
+
...bearer(token),
|
|
19855
|
+
"x-github-api-version": GITHUB_API_VERSION
|
|
19856
|
+
},
|
|
19857
|
+
url: `${trimSlash(baseUrl)}/repos/${source.owner}/${source.repository}/releases/latest`
|
|
19858
|
+
});
|
|
19859
|
+
if (response.kind !== "body") return response.kind === "unchanged" ? {
|
|
19860
|
+
etag: query.etag,
|
|
19861
|
+
kind: "current"
|
|
19862
|
+
} : {
|
|
19863
|
+
kind: "failure",
|
|
19864
|
+
reason: "network"
|
|
19865
|
+
};
|
|
19866
|
+
return narrowRelease(source, query, parseJson(response.body), response.etag, token);
|
|
19867
|
+
}
|
|
19868
|
+
/** Turns the response body into a candidate, refusing every release that is not exactly usable. */
|
|
19869
|
+
function narrowRelease(source, query, document, etag, token) {
|
|
19870
|
+
const release = asRecord(document);
|
|
19871
|
+
if (release === void 0 || !isPublished(release)) return {
|
|
19872
|
+
kind: "failure",
|
|
19873
|
+
reason: "invalid-release"
|
|
19874
|
+
};
|
|
19875
|
+
if (asFlag(release["immutable"]) !== true) return {
|
|
19876
|
+
kind: "failure",
|
|
19877
|
+
reason: "untrusted-release"
|
|
19878
|
+
};
|
|
19879
|
+
const tag = asText(release["tag_name"]) ?? "";
|
|
19880
|
+
const verdict = compareRelease(releaseVersion(tag), query.version);
|
|
19881
|
+
if (verdict.kind !== "newer") return verdict.kind === "current" ? {
|
|
19882
|
+
kind: "current",
|
|
19883
|
+
...etagField(etag)
|
|
19884
|
+
} : {
|
|
19885
|
+
kind: "failure",
|
|
19886
|
+
reason: "invalid-release"
|
|
19887
|
+
};
|
|
19888
|
+
const asset = selectAsset(source, release["assets"], assetName(query), tag);
|
|
19889
|
+
return asset === void 0 ? {
|
|
19890
|
+
kind: "failure",
|
|
19891
|
+
reason: "invalid-release"
|
|
19892
|
+
} : candidate$1(asset, verdict.version, etag, token);
|
|
19893
|
+
}
|
|
19894
|
+
/**
|
|
19895
|
+
* Whether the release is a published, final one.
|
|
19896
|
+
*
|
|
19897
|
+
* Absent reads as unusable rather than as `false`: a server that stopped reporting draft state must
|
|
19898
|
+
* not have every draft silently promoted to installable.
|
|
19899
|
+
*/
|
|
19900
|
+
function isPublished(release) {
|
|
19901
|
+
return asFlag(release["draft"]) === false && asFlag(release["prerelease"]) === false;
|
|
19902
|
+
}
|
|
19903
|
+
/** The version a `v`-prefixed tag names, or `undefined` for any other tag shape. */
|
|
19904
|
+
function releaseVersion(tag) {
|
|
19905
|
+
return tag.startsWith("v") ? tag.slice(1) : void 0;
|
|
19906
|
+
}
|
|
19907
|
+
/** The archive name a release must publish for this target, and the only one accepted. */
|
|
19908
|
+
function assetName(query) {
|
|
19909
|
+
return `${query.command}-${query.target}.tar.gz`;
|
|
19910
|
+
}
|
|
19911
|
+
/**
|
|
19912
|
+
* The candidate, downloading through the API when a token is configured.
|
|
19913
|
+
*
|
|
19914
|
+
* A private repository serves assets only from the API URL with an octet-stream `Accept`, which
|
|
19915
|
+
* answers with the bytes or with a redirect to a temporary signed URL. Public releases keep the
|
|
19916
|
+
* plain browser URL so an unauthenticated download stays unauthenticated.
|
|
19917
|
+
*/
|
|
19918
|
+
function candidate$1(asset, version, etag, token) {
|
|
19919
|
+
return {
|
|
19920
|
+
candidate: {
|
|
19921
|
+
downloadUrl: token === void 0 ? asset.browserUrl : asset.apiUrl,
|
|
19922
|
+
sha256: asset.sha256,
|
|
19923
|
+
size: asset.size,
|
|
19924
|
+
version
|
|
19925
|
+
},
|
|
19926
|
+
downloadHeaders: downloadHeaders(token),
|
|
19927
|
+
kind: "candidate",
|
|
19928
|
+
...etagField(etag)
|
|
19929
|
+
};
|
|
19930
|
+
}
|
|
19931
|
+
/** Selects exactly one target asset and narrows every field the download depends on. */
|
|
19932
|
+
function selectAsset(source, assets, expectedName, tag) {
|
|
19933
|
+
const entries = asArray(assets);
|
|
19934
|
+
if (entries === void 0) return;
|
|
19935
|
+
const matches = entries.map((entry) => asRecord(entry)).filter((entry) => entry !== void 0 && asText(entry["name"]) === expectedName);
|
|
19936
|
+
if (matches.length !== 1) return;
|
|
19937
|
+
return narrowAsset(source, matches[0], expectedName, tag);
|
|
19938
|
+
}
|
|
19939
|
+
function narrowAsset(source, asset, expectedName, tag) {
|
|
19940
|
+
if (asset === void 0) return;
|
|
19941
|
+
const size = asSize(asset["size"], MAX_ARCHIVE_BYTES);
|
|
19942
|
+
const sha256 = asGitHubDigest(asset["digest"]);
|
|
19943
|
+
const browserUrl = asText(asset["browser_download_url"]);
|
|
19944
|
+
const apiUrl = asText(asset["url"]);
|
|
19945
|
+
if (size === void 0 || sha256 === void 0 || browserUrl === void 0) return;
|
|
19946
|
+
if (apiUrl === void 0 || !isExpectedApiUrl(source, apiUrl)) return;
|
|
19947
|
+
if (browserUrl !== expectedBrowserUrl(source, tag, expectedName)) return;
|
|
19948
|
+
return {
|
|
19949
|
+
apiUrl,
|
|
19950
|
+
browserUrl,
|
|
19951
|
+
sha256,
|
|
19952
|
+
size
|
|
19953
|
+
};
|
|
19954
|
+
}
|
|
19955
|
+
function expectedBrowserUrl(source, tag, name) {
|
|
19956
|
+
return `${webOrigin(source)}/${source.owner}/${source.repository}/releases/download/${tag}/${name}`;
|
|
19957
|
+
}
|
|
19958
|
+
function isExpectedApiUrl(source, apiUrl) {
|
|
19959
|
+
const prefix = `${trimSlash(source.apiBaseUrl ?? PUBLIC_API_BASE_URL)}/repos/${source.owner}/${source.repository}/releases/assets/`;
|
|
19960
|
+
return apiUrl.startsWith(prefix) && /^[0-9]+$/u.test(apiUrl.slice(prefix.length));
|
|
19961
|
+
}
|
|
19962
|
+
function webOrigin(source) {
|
|
19963
|
+
const url = parseUrl(source.apiBaseUrl ?? PUBLIC_API_BASE_URL);
|
|
19964
|
+
if (url === void 0) return PUBLIC_WEB_ORIGIN;
|
|
19965
|
+
return url.hostname === PUBLIC_API_HOST ? PUBLIC_WEB_ORIGIN : url.origin;
|
|
19966
|
+
}
|
|
19967
|
+
function parseUrl(raw) {
|
|
19968
|
+
try {
|
|
19969
|
+
const url = new URL(raw);
|
|
19970
|
+
return url.username === "" && url.password === "" ? url : void 0;
|
|
19971
|
+
} catch {
|
|
19972
|
+
return;
|
|
19973
|
+
}
|
|
19974
|
+
}
|
|
19975
|
+
function trimSlash(value) {
|
|
19976
|
+
return value.replace(/\/+$/u, "");
|
|
19977
|
+
}
|
|
19978
|
+
//#endregion
|
|
19979
|
+
//#region src/update/signed-manifest.ts
|
|
19980
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
19981
|
+
const ED25519_KEY_BYTES = 32;
|
|
19982
|
+
const ED25519_SIGNATURE_BYTES = 64;
|
|
19983
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/u;
|
|
19984
|
+
/**
|
|
19985
|
+
* Resolves a release from one signed HTTPS manifest.
|
|
19986
|
+
*
|
|
19987
|
+
* For deployments a GitHub-shaped provider cannot serve honestly — an internal artifact service, or
|
|
19988
|
+
* a GitHub Enterprise Server without immutable releases and per-asset digests. The trust boundary
|
|
19989
|
+
* moves from the transport to the signature, so the manifest URL itself may be a stable "latest"
|
|
19990
|
+
* reference while every asset URL inside it stays pinned to the version it names.
|
|
19991
|
+
*/
|
|
19992
|
+
async function resolveSignedManifest(source, query) {
|
|
19993
|
+
const token = sourceToken(query, source.tokenEnvironmentVariable);
|
|
19994
|
+
const response = await fetchMetadata(query, {
|
|
19995
|
+
accept: "application/json",
|
|
19996
|
+
headers: bearer(token),
|
|
19997
|
+
url: source.manifestUrl
|
|
19998
|
+
});
|
|
19999
|
+
if (response.kind !== "body") return response.kind === "unchanged" ? {
|
|
20000
|
+
etag: query.etag,
|
|
20001
|
+
kind: "current"
|
|
20002
|
+
} : {
|
|
20003
|
+
kind: "failure",
|
|
20004
|
+
reason: "network"
|
|
20005
|
+
};
|
|
20006
|
+
return narrowManifest(source, query, response.body, response.etag, token);
|
|
20007
|
+
}
|
|
20008
|
+
function narrowManifest(source, query, body, etag, token) {
|
|
20009
|
+
const payload = verifyEnvelope(parseJson(body), source.trustedPublicKeys);
|
|
20010
|
+
if (payload === void 0) return {
|
|
20011
|
+
kind: "failure",
|
|
20012
|
+
reason: "untrusted-release"
|
|
20013
|
+
};
|
|
20014
|
+
const document = asRecord(parseJson(payload));
|
|
20015
|
+
if (!isFresh(document?.["expiresAt"], query.now)) return {
|
|
20016
|
+
kind: "failure",
|
|
20017
|
+
reason: "stale-manifest"
|
|
20018
|
+
};
|
|
20019
|
+
const verdict = compareRelease(asText(document?.["version"]), query.version);
|
|
20020
|
+
if (verdict.kind !== "newer") return verdict.kind === "current" ? {
|
|
20021
|
+
kind: "current",
|
|
20022
|
+
...etagField(etag)
|
|
20023
|
+
} : {
|
|
20024
|
+
kind: "failure",
|
|
20025
|
+
reason: "invalid-release"
|
|
20026
|
+
};
|
|
20027
|
+
return candidate(source, query, document?.["assets"], verdict.version, etag, token);
|
|
20028
|
+
}
|
|
20029
|
+
/** The resolved release, with the credential attached only where it is the caller's to send. */
|
|
20030
|
+
function candidate(source, query, assets, version, etag, token) {
|
|
20031
|
+
const archive = manifestAsset(assets, query.target, version);
|
|
20032
|
+
if (archive === void 0) return {
|
|
20033
|
+
kind: "failure",
|
|
20034
|
+
reason: "invalid-release"
|
|
20035
|
+
};
|
|
20036
|
+
return {
|
|
20037
|
+
candidate: {
|
|
20038
|
+
...archive,
|
|
20039
|
+
version
|
|
20040
|
+
},
|
|
20041
|
+
downloadHeaders: downloadHeaders(sameOrigin(source.manifestUrl, archive.downloadUrl) ? token : void 0),
|
|
20042
|
+
kind: "candidate",
|
|
20043
|
+
...etagField(etag)
|
|
20044
|
+
};
|
|
20045
|
+
}
|
|
20046
|
+
/**
|
|
20047
|
+
* Whether a signed manifest is still within the window it signed for.
|
|
20048
|
+
*
|
|
20049
|
+
* Required, not optional: a publisher who omits the field would otherwise get a document that is
|
|
20050
|
+
* valid forever, which is exactly the one an attacker wants to keep replaying. Epoch milliseconds
|
|
20051
|
+
* rather than a formatted timestamp, so reading it needs no clock, locale, or calendar.
|
|
20052
|
+
*/
|
|
20053
|
+
function isFresh(expiresAt, now) {
|
|
20054
|
+
const expiry = asSize(expiresAt, Number.MAX_SAFE_INTEGER);
|
|
20055
|
+
if (expiry === void 0 || expiry <= now) return false;
|
|
20056
|
+
return expiry - now <= MAX_MANIFEST_FRESHNESS_MS;
|
|
20057
|
+
}
|
|
20058
|
+
function sameOrigin(left, right) {
|
|
20059
|
+
try {
|
|
20060
|
+
return new URL(left).origin === new URL(right).origin;
|
|
20061
|
+
} catch {
|
|
20062
|
+
return false;
|
|
20063
|
+
}
|
|
20064
|
+
}
|
|
20065
|
+
/** Returns the payload only when one trusted key verifies its exact bytes. */
|
|
20066
|
+
function verifyEnvelope(envelope, trustedPublicKeys) {
|
|
20067
|
+
const document = asRecord(envelope);
|
|
20068
|
+
if (document === void 0 || document["schemaVersion"] !== 1) return;
|
|
20069
|
+
const payload = decodeBase64Url(asText(document["payload"]));
|
|
20070
|
+
const signature = decodeBase64Url(asText(document["signature"]));
|
|
20071
|
+
if (payload === void 0 || signature?.byteLength !== ED25519_SIGNATURE_BYTES) return;
|
|
20072
|
+
return trustedPublicKeys.map((key) => publicKey(key)).some((key) => key !== void 0 && verifySignature(key, payload, signature)) ? payload.toString("utf8") : void 0;
|
|
20073
|
+
}
|
|
20074
|
+
function manifestAsset(assets, target, version) {
|
|
20075
|
+
const entry = asRecord(asRecord(assets)?.[target]);
|
|
20076
|
+
const downloadUrl = asText(entry?.["downloadUrl"]);
|
|
20077
|
+
const sha256 = asDigest(entry?.["sha256"]);
|
|
20078
|
+
const size = asSize(entry?.["size"], MAX_ARCHIVE_BYTES);
|
|
20079
|
+
if (downloadUrl === void 0 || sha256 === void 0 || size === void 0) return;
|
|
20080
|
+
return isPinnedUrl(downloadUrl, version) ? {
|
|
20081
|
+
downloadUrl,
|
|
20082
|
+
sha256,
|
|
20083
|
+
size
|
|
20084
|
+
} : void 0;
|
|
20085
|
+
}
|
|
20086
|
+
function isPinnedUrl(raw, version) {
|
|
20087
|
+
try {
|
|
20088
|
+
const url = new URL(raw);
|
|
20089
|
+
return isAllowedHttpUrl(url) && `${url.pathname}${url.search}`.includes(version);
|
|
20090
|
+
} catch {
|
|
20091
|
+
return false;
|
|
20092
|
+
}
|
|
20093
|
+
}
|
|
20094
|
+
function verifySignature(key, payload, signature) {
|
|
20095
|
+
try {
|
|
20096
|
+
return verify(null, payload, key, signature);
|
|
20097
|
+
} catch {
|
|
20098
|
+
return false;
|
|
20099
|
+
}
|
|
20100
|
+
}
|
|
20101
|
+
function publicKey(encoded) {
|
|
20102
|
+
const raw = Buffer.from(encoded, "base64");
|
|
20103
|
+
if (raw.byteLength !== ED25519_KEY_BYTES) return;
|
|
20104
|
+
try {
|
|
20105
|
+
return createPublicKey({
|
|
20106
|
+
format: "der",
|
|
20107
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
20108
|
+
type: "spki"
|
|
20109
|
+
});
|
|
20110
|
+
} catch {
|
|
20111
|
+
return;
|
|
20112
|
+
}
|
|
20113
|
+
}
|
|
20114
|
+
function decodeBase64Url(value) {
|
|
20115
|
+
return value === void 0 || !BASE64URL_PATTERN.test(value) ? void 0 : Buffer.from(value, "base64url");
|
|
20116
|
+
}
|
|
20117
|
+
//#endregion
|
|
20118
|
+
//#region src/update/provider.ts
|
|
20119
|
+
/** Turns one distribution-specific source into a validated candidate. */
|
|
20120
|
+
function resolveUpdateSource(source, query) {
|
|
20121
|
+
return source.kind === "github-release" ? resolveGitHubRelease(source, query) : resolveSignedManifest(source, query);
|
|
20122
|
+
}
|
|
20123
|
+
/**
|
|
20124
|
+
* The cache key one source's metadata is stored under.
|
|
20125
|
+
*
|
|
20126
|
+
* Includes every build-time field that changes lookup or trust and never includes a credential
|
|
20127
|
+
* value. The complete manifest URL and trusted keys ensure either change selects a new cache entry.
|
|
20128
|
+
*/
|
|
20129
|
+
function sourceIdentity(source, command) {
|
|
20130
|
+
if (source.kind === "github-release") return JSON.stringify([
|
|
20131
|
+
"github-release",
|
|
20132
|
+
source.apiBaseUrl ?? "https://api.github.com",
|
|
20133
|
+
source.owner,
|
|
20134
|
+
source.repository,
|
|
20135
|
+
source.tokenEnvironmentVariable ?? "",
|
|
20136
|
+
command
|
|
20137
|
+
]);
|
|
20138
|
+
return JSON.stringify([
|
|
20139
|
+
"signed-manifest",
|
|
20140
|
+
source.manifestUrl,
|
|
20141
|
+
source.tokenEnvironmentVariable ?? "",
|
|
20142
|
+
source.trustedPublicKeys,
|
|
20143
|
+
command
|
|
20144
|
+
]);
|
|
20145
|
+
}
|
|
20146
|
+
//#endregion
|
|
20147
|
+
//#region src/update/run.ts
|
|
20148
|
+
/**
|
|
20149
|
+
* Installs a newer release before the requested command runs, or does nothing at all.
|
|
20150
|
+
*
|
|
20151
|
+
* Nothing here can change what the command does or what it exits with. Every failure path is
|
|
20152
|
+
* swallowed: the user asked Aura to check a repository, and an update that could not happen is not
|
|
20153
|
+
* a reason to refuse. The successfully updated executable is used by the next invocation — this
|
|
20154
|
+
* process keeps running the image it started with.
|
|
20155
|
+
*
|
|
20156
|
+
* Swallowed for the user, not for the developer: each refusal names itself through {@link debug},
|
|
20157
|
+
* which writes only when this distribution's debug variable asks it to.
|
|
20158
|
+
*/
|
|
20159
|
+
async function runStartupUpdate(request) {
|
|
20160
|
+
const disableEnvironmentVariable = updateEnvironmentVariable(request.branding.command);
|
|
20161
|
+
const debug = createUpdateDebug(disableEnvironmentVariable, request.environmentVariables, request.stderr);
|
|
20162
|
+
try {
|
|
20163
|
+
const verdict = await eligibleInstallation({
|
|
20164
|
+
...request,
|
|
20165
|
+
disableEnvironmentVariable,
|
|
20166
|
+
version: request.branding.version
|
|
20167
|
+
});
|
|
20168
|
+
if (verdict.kind !== "eligible") {
|
|
20169
|
+
debug(`skipped: ${verdict.reason}`);
|
|
20170
|
+
return;
|
|
20171
|
+
}
|
|
20172
|
+
await check(request, verdict.installation, debug);
|
|
20173
|
+
} catch {
|
|
20174
|
+
debug("skipped: unexpected-error");
|
|
20175
|
+
}
|
|
20176
|
+
}
|
|
20177
|
+
async function check(request, eligible, debug) {
|
|
20178
|
+
const now = request.now().getTime();
|
|
20179
|
+
const identity = sourceIdentity(request.updates, request.branding.command);
|
|
20180
|
+
const entry = await readUpdateCache(request.homeDir, identity, now);
|
|
20181
|
+
if (!shouldCheck(entry, now)) {
|
|
20182
|
+
debug(`skipped: cached-${entry?.outcome ?? "check"}`);
|
|
20183
|
+
return;
|
|
20184
|
+
}
|
|
20185
|
+
const resolution = await resolveUpdateSource(request.updates, {
|
|
20186
|
+
command: request.branding.command,
|
|
20187
|
+
...entry?.outcome === "current" && entry.etag !== void 0 ? { etag: entry.etag } : {},
|
|
20188
|
+
httpGet: request.httpGet,
|
|
20189
|
+
now,
|
|
20190
|
+
readVariable: (name) => readVariable(request.environmentVariables, name),
|
|
20191
|
+
target: eligible.target,
|
|
20192
|
+
userAgent: `${request.branding.command}/${eligible.version}`,
|
|
20193
|
+
version: eligible.version
|
|
20194
|
+
});
|
|
20195
|
+
debug(resolution.kind === "failure" ? `resolved: ${resolution.reason}` : `resolved: ${resolution.kind}`);
|
|
20196
|
+
await apply(request, eligible, {
|
|
20197
|
+
deadline: now + STARTUP_UPDATE_BUDGET_MS,
|
|
20198
|
+
debug,
|
|
20199
|
+
entry,
|
|
20200
|
+
identity,
|
|
20201
|
+
now,
|
|
20202
|
+
resolution
|
|
20203
|
+
});
|
|
20204
|
+
}
|
|
20205
|
+
async function apply(request, eligible, context) {
|
|
20206
|
+
const { entry, identity, now, resolution } = context;
|
|
20207
|
+
if (resolution.kind === "failure") {
|
|
20208
|
+
await writeUpdateCache(request.homeDir, identity, {
|
|
20209
|
+
checkedAt: now,
|
|
20210
|
+
outcome: "check-failed"
|
|
20211
|
+
});
|
|
20212
|
+
return;
|
|
20213
|
+
}
|
|
20214
|
+
if (resolution.kind !== "candidate") {
|
|
20215
|
+
const etag = resolution.kind === "current" ? resolution.etag ?? entry?.etag : entry?.etag;
|
|
20216
|
+
await writeUpdateCache(request.homeDir, identity, {
|
|
20217
|
+
checkedAt: now,
|
|
20218
|
+
...etag === void 0 ? {} : { etag },
|
|
20219
|
+
outcome: "current"
|
|
20220
|
+
});
|
|
20221
|
+
return;
|
|
20222
|
+
}
|
|
20223
|
+
const outcome = await install(request, eligible, resolution, context);
|
|
20224
|
+
context.debug(`installed: ${trace(outcome)}`);
|
|
20225
|
+
report(request, eligible, resolution.candidate.version, outcome);
|
|
20226
|
+
await record(request, identity, now, resolution, outcome, attemptsFor(entry, resolution.candidate.version));
|
|
20227
|
+
}
|
|
20228
|
+
/** The transaction, with the progress frame painted around it and always taken back down. */
|
|
20229
|
+
async function install(request, eligible, resolution, context) {
|
|
20230
|
+
const { candidate } = resolution;
|
|
20231
|
+
request.stderr.write(updatingLine(request.branding, eligible.version, candidate.version));
|
|
20232
|
+
const progress = startUpdateProgress(request.stderr);
|
|
20233
|
+
try {
|
|
20234
|
+
return await installUpdate({
|
|
20235
|
+
candidate,
|
|
20236
|
+
command: request.branding.command,
|
|
20237
|
+
downloadHeaders: resolution.downloadHeaders,
|
|
20238
|
+
downloadTimeoutMs: Math.max(0, context.deadline - request.now().getTime()),
|
|
20239
|
+
executablePath: eligible.executablePath,
|
|
20240
|
+
host: request.host,
|
|
20241
|
+
now: context.now,
|
|
20242
|
+
...progress.report === void 0 ? {} : { onProgress: progress.report },
|
|
20243
|
+
probeEnvironment: probeEnvironment(request)
|
|
20244
|
+
});
|
|
20245
|
+
} finally {
|
|
20246
|
+
progress.close();
|
|
20247
|
+
}
|
|
20248
|
+
}
|
|
20249
|
+
/** The debug trace for one outcome, which is the only place a failure names its reason. */
|
|
20250
|
+
function trace(outcome) {
|
|
20251
|
+
return outcome.kind === "failed" ? `failed: ${outcome.reason}` : outcome.kind;
|
|
20252
|
+
}
|
|
20253
|
+
/** One line, or none: the outcome table's message column. */
|
|
20254
|
+
function report(request, eligible, version, outcome) {
|
|
20255
|
+
if (outcome.kind === "installed") {
|
|
20256
|
+
request.stderr.write(updatedLine(request.branding, version));
|
|
20257
|
+
return;
|
|
20258
|
+
}
|
|
20259
|
+
if (outcome.kind === "refused") {
|
|
20260
|
+
request.stderr.write(digestRefusedLine(request.branding, version));
|
|
20261
|
+
return;
|
|
20262
|
+
}
|
|
20263
|
+
if (outcome.kind === "failed") {
|
|
20264
|
+
const manual = request.updates.manualUpdateUrl ?? request.branding.docsUrl;
|
|
20265
|
+
request.stderr.write(installFailedLine(request.branding, version, manual));
|
|
20266
|
+
}
|
|
20267
|
+
}
|
|
20268
|
+
/** Stores what happened, so a release that will not install is not retried on every command. */
|
|
20269
|
+
function record(request, identity, now, resolution, outcome, attempts) {
|
|
20270
|
+
if (outcome.kind === "installed" || outcome.kind === "skipped") return writeUpdateCache(request.homeDir, identity, {
|
|
20271
|
+
checkedAt: now,
|
|
20272
|
+
outcome: "current"
|
|
20273
|
+
});
|
|
20274
|
+
return writeUpdateCache(request.homeDir, identity, {
|
|
20275
|
+
checkedAt: now,
|
|
20276
|
+
...resolution.etag === void 0 ? {} : { etag: resolution.etag },
|
|
20277
|
+
failedAttempts: attempts + 1,
|
|
20278
|
+
failedVersion: resolution.candidate.version,
|
|
20279
|
+
outcome: "install-failed"
|
|
20280
|
+
});
|
|
20281
|
+
}
|
|
20282
|
+
/**
|
|
20283
|
+
* The environment a version probe runs in.
|
|
20284
|
+
*
|
|
20285
|
+
* Deliberately tiny, and deliberately carrying this distribution's own disable variable: the
|
|
20286
|
+
* installer verifies a staged binary by running it, and a child that started an update of its own
|
|
20287
|
+
* would recurse into the directory its parent is mid-transaction on.
|
|
20288
|
+
*/
|
|
20289
|
+
function probeEnvironment(request) {
|
|
20290
|
+
const home = request.environmentVariables["HOME"];
|
|
20291
|
+
const path = request.environmentVariables["PATH"];
|
|
20292
|
+
return {
|
|
20293
|
+
...home === void 0 ? {} : { HOME: home },
|
|
20294
|
+
...path === void 0 ? {} : { PATH: path },
|
|
20295
|
+
NO_COLOR: "1",
|
|
20296
|
+
[updateEnvironmentVariable(request.branding.command)]: "off"
|
|
20297
|
+
};
|
|
20298
|
+
}
|
|
20299
|
+
/** Reads one variable at the moment of use, treating an empty value as unset. */
|
|
20300
|
+
function readVariable(environmentVariables, name) {
|
|
20301
|
+
const value = environmentVariables[name];
|
|
20302
|
+
return value === void 0 || value === "" ? void 0 : value;
|
|
20303
|
+
}
|
|
20304
|
+
function updatingLine(branding, from, to) {
|
|
20305
|
+
return `Updating ${branding.displayName} ${from} -> ${to}...\n`;
|
|
20306
|
+
}
|
|
20307
|
+
function updatedLine(branding, to) {
|
|
20308
|
+
return `Updated ${branding.displayName} to ${to}. The new version will be used on your next run.\n`;
|
|
20309
|
+
}
|
|
20310
|
+
function installFailedLine(branding, version, manualUpdateUrl) {
|
|
20311
|
+
const suffix = manualUpdateUrl === void 0 ? "" : ` Update manually: ${manualUpdateUrl}`;
|
|
20312
|
+
return `${branding.displayName} could not install the ${version} update.${suffix}\n`;
|
|
20313
|
+
}
|
|
20314
|
+
function digestRefusedLine(branding, version) {
|
|
20315
|
+
return `${branding.displayName} refused the ${version} update: the download did not match the release's published SHA-256 digest. Nothing was installed.\n`;
|
|
20316
|
+
}
|
|
20317
|
+
//#endregion
|
|
20318
|
+
//#region src/run.boundary.ts
|
|
20319
|
+
/** Runs one build-time-composed Aura distribution. */
|
|
20320
|
+
function runCli(distro, runtime) {
|
|
20321
|
+
return run(distro, runtime);
|
|
20322
|
+
}
|
|
20323
|
+
/** Runs a compiled standalone distribution that explicitly owns its executable. */
|
|
20324
|
+
function runStandaloneCli(distro, updates, current, runtime) {
|
|
20325
|
+
return run(distro, runtime, {
|
|
20326
|
+
current,
|
|
20327
|
+
updates
|
|
20328
|
+
});
|
|
20329
|
+
}
|
|
20330
|
+
async function run(distro, runtime, startupUpdate) {
|
|
20331
|
+
const resolved = resolveRuntime(runtime, distro.branding);
|
|
20332
|
+
const telemetry = createTelemetryRecorder({
|
|
20333
|
+
distroVersion: distro.branding.version,
|
|
20334
|
+
now: resolved.now,
|
|
20335
|
+
sink: telemetryEnabled(resolved.environmentVariables) ? distro.telemetry : void 0
|
|
20336
|
+
});
|
|
20337
|
+
if (startupUpdate !== void 0) await runStartupUpdate({
|
|
20338
|
+
argv: resolved.argv,
|
|
20339
|
+
branding: distro.branding,
|
|
20340
|
+
current: startupUpdate.current,
|
|
20341
|
+
environmentVariables: resolved.environmentVariables,
|
|
20342
|
+
homeDir: resolved.homeDir,
|
|
20343
|
+
host: UPDATE_HOST,
|
|
20344
|
+
httpGet: resolved.httpGet ?? createHttpGet(),
|
|
20345
|
+
now: resolved.now,
|
|
20346
|
+
stderr: resolved.stderr,
|
|
20347
|
+
stdin: resolved.stdin,
|
|
20348
|
+
stdout: resolved.stdout,
|
|
20349
|
+
updates: startupUpdate.updates
|
|
18636
20350
|
});
|
|
18637
20351
|
const verdict = await runResolved(distro, resolved, telemetry);
|
|
18638
20352
|
await telemetry.flush();
|
|
@@ -18760,4 +20474,4 @@ function applyExitCode(exitCode, runtime) {
|
|
|
18760
20474
|
runtime.setExitCode(exitCode);
|
|
18761
20475
|
}
|
|
18762
20476
|
//#endregion
|
|
18763
|
-
export { vetHttpUrl as i,
|
|
20477
|
+
export { vetHttpUrl as a, failureReason as i, runStandaloneCli as n, clampHttpTimeout as r, runCli as t };
|