@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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/CHANGELOG.md +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M update source — version check + verified download (GitHub Releases only).
|
|
3
|
+
*
|
|
4
|
+
* Design constraints (v0.2.3):
|
|
5
|
+
* - pure functions + injectable `fetch`, so every branch is testable offline
|
|
6
|
+
* - NOTHING throws at the caller: every entry point returns `{ok:false, error}`
|
|
7
|
+
* with the HTTP status and a machine-readable `code`
|
|
8
|
+
* - a hung socket must never wedge the scheduler: every request runs inside an
|
|
9
|
+
* AbortController race that settles even if `fetch` ignores the signal
|
|
10
|
+
* - the tarball is hashed WHILE it streams to a temp file, then renamed into
|
|
11
|
+
* place; a checksum failure removes the temp file and never touches `destPath`
|
|
12
|
+
* - a token may be supplied, and must never appear in a log line or an error
|
|
13
|
+
*
|
|
14
|
+
* ── Version comparison rules (the interesting one) ──────────────────────────
|
|
15
|
+
* SemVer 2.0.0 precedence, which means:
|
|
16
|
+
*
|
|
17
|
+
* 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2
|
|
18
|
+
* < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0
|
|
19
|
+
*
|
|
20
|
+
* Two consequences worth stating out loud:
|
|
21
|
+
*
|
|
22
|
+
* a) A pre-release sorts BELOW the same version's release ("1.0.0-rc.1 < 1.0.0").
|
|
23
|
+
* So a relay running 1.0.0-rc.1 sees 1.0.0 as an update, and one running
|
|
24
|
+
* 1.0.0 does NOT see 1.0.0-rc.1 as a downgrade-worthy update.
|
|
25
|
+
*
|
|
26
|
+
* b) A pre-release still sorts ABOVE any lower triple: 0.2.0-rc.1 > 0.1.2.
|
|
27
|
+
* That is correct SemVer and is what `isNewer` reports. In practice this does
|
|
28
|
+
* not offer pre-releases to stable users, because GitHub's
|
|
29
|
+
* `GET /releases/latest` endpoint already excludes drafts and pre-releases --
|
|
30
|
+
* it only ever returns a published, non-pre-release tag. The rule here is the
|
|
31
|
+
* library-level truth for callers that pass a tag in explicitly.
|
|
32
|
+
*
|
|
33
|
+
* Build metadata (`+sha.abc`) is ignored for precedence, per SemVer §10.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
37
|
+
import fsp from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_REPO = 'TwinsEarth/dsh-windows2macos';
|
|
41
|
+
export const DEFAULT_API_BASE = 'https://api.github.com';
|
|
42
|
+
/**
|
|
43
|
+
* Default per-request budget. Measured on this project's own CI machine: a COLD
|
|
44
|
+
* first connection to api.github.com took ~18s (DNS/TLS warm-up), while warm
|
|
45
|
+
* calls take ~0.1-1.5s. A 15s default would therefore report a spurious TIMEOUT
|
|
46
|
+
* on the very first scheduled check after a reboot -- and a missed update looks
|
|
47
|
+
* exactly like "you are up to date". 30s cannot wedge a 3x/day scheduler.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
50
|
+
export const DEFAULT_USER_AGENT = 'w2m-update-check';
|
|
51
|
+
export const DEFAULT_TARBALL_PATTERN = /\.(?:tgz|tar\.gz)$/i;
|
|
52
|
+
export const DEFAULT_SUMS_PATTERN = /^SHA256SUMS(?:\.txt)?$/i;
|
|
53
|
+
export const GITHUB_API_VERSION = '2022-11-28';
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* errors */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
/** Every failure path funnels through this so callers can switch on `code`. */
|
|
60
|
+
export class UpdateSourceError extends Error {
|
|
61
|
+
constructor(code, message, detail = {}) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'UpdateSourceError';
|
|
64
|
+
this.code = code;
|
|
65
|
+
Object.assign(this, detail);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Raised by `compareVersions` when an input cannot be parsed. */
|
|
70
|
+
export class VersionError extends Error {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'VersionError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fail = (code, message, detail = {}) => ({ ok: false, code, error: message, ...detail });
|
|
78
|
+
|
|
79
|
+
/** Failure with the HTTP status attached, as required by the contract. */
|
|
80
|
+
const httpFail = (code, message, status, detail = {}) => fail(code, message, { status, ...detail });
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------------ */
|
|
83
|
+
/* secret hygiene */
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove anything token-shaped from a string that is about to be logged or
|
|
88
|
+
* returned. Belt and braces: the exact token if we know it, `Bearer xxx`, and
|
|
89
|
+
* `?access_token=` style query parameters.
|
|
90
|
+
*/
|
|
91
|
+
export function redact(text, token = null) {
|
|
92
|
+
let out = String(text ?? '');
|
|
93
|
+
if (token) out = out.split(String(token)).join('[REDACTED]');
|
|
94
|
+
out = out.replace(/([?&](?:access_token|token|api_key|apikey|key)=)[^&\s"']+/gi, '$1[REDACTED]');
|
|
95
|
+
out = out.replace(/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, 'Bearer [REDACTED]');
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Turn any thrown value into the `{ok:false, ...}` contract shape. */
|
|
100
|
+
function toFailure(err, token, fallbackCode = 'NETWORK') {
|
|
101
|
+
const code = err instanceof UpdateSourceError ? err.code : fallbackCode;
|
|
102
|
+
const status = err instanceof UpdateSourceError ? err.status : undefined;
|
|
103
|
+
const cause = err?.cause?.message ? `: ${err.cause.message}` : '';
|
|
104
|
+
const message = redact(`${err?.message ?? String(err)}${cause}`, token);
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code,
|
|
108
|
+
error: message || 'update source failure',
|
|
109
|
+
...(status !== undefined ? { status } : {}),
|
|
110
|
+
...(err instanceof UpdateSourceError && err.rateLimited !== undefined
|
|
111
|
+
? { rateLimited: err.rateLimited, rateLimit: err.rateLimit } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
/* versions */
|
|
117
|
+
/* ------------------------------------------------------------------ */
|
|
118
|
+
|
|
119
|
+
const VERSION_RE = /^v?(\d{1,9})(?:\.(\d{1,9}))?(?:\.(\d{1,9}))?(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse a release tag. Accepts `v0.1.2`, `0.1.2`, `1.2`, `v2`, optional
|
|
123
|
+
* `-prerelease` and `+build`. Returns `null` for anything else — including
|
|
124
|
+
* four-component versions, which are not SemVer.
|
|
125
|
+
*
|
|
126
|
+
* @returns {{major:number,minor:number,patch:number,pre:string|null,build:string|null,raw:string}|null}
|
|
127
|
+
*/
|
|
128
|
+
export function parseVersion(tag) {
|
|
129
|
+
if (typeof tag !== 'string') return null;
|
|
130
|
+
const raw = tag.trim();
|
|
131
|
+
if (raw === '') return null;
|
|
132
|
+
const m = VERSION_RE.exec(raw);
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
return {
|
|
135
|
+
major: Number(m[1]),
|
|
136
|
+
minor: Number(m[2] ?? 0),
|
|
137
|
+
patch: Number(m[3] ?? 0),
|
|
138
|
+
pre: m[4] ?? null,
|
|
139
|
+
build: m[5] ?? null,
|
|
140
|
+
raw,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** SemVer §11 identifier precedence; `null` (a release) outranks any pre-release. */
|
|
145
|
+
function comparePrerelease(a, b) {
|
|
146
|
+
if (a === b) return 0;
|
|
147
|
+
if (a === null) return 1;
|
|
148
|
+
if (b === null) return -1;
|
|
149
|
+
const A = String(a).split('.');
|
|
150
|
+
const B = String(b).split('.');
|
|
151
|
+
for (let i = 0; i < Math.max(A.length, B.length); i += 1) {
|
|
152
|
+
if (i >= A.length) return -1; // shorter identifier list sorts lower
|
|
153
|
+
if (i >= B.length) return 1;
|
|
154
|
+
const x = A[i];
|
|
155
|
+
const y = B[i];
|
|
156
|
+
const xNum = /^\d+$/.test(x);
|
|
157
|
+
const yNum = /^\d+$/.test(y);
|
|
158
|
+
if (xNum && yNum) {
|
|
159
|
+
const d = Number(x) - Number(y);
|
|
160
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
161
|
+
} else if (xNum) {
|
|
162
|
+
return -1; // numeric identifiers sort below alphanumeric ones
|
|
163
|
+
} else if (yNum) {
|
|
164
|
+
return 1;
|
|
165
|
+
} else if (x !== y) {
|
|
166
|
+
return x < y ? -1 : 1; // ASCII lexical order
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Accept a tag string or an already-parsed version; everything else is null. */
|
|
173
|
+
function coerceVersion(value) {
|
|
174
|
+
if (typeof value === 'string') return parseVersion(value);
|
|
175
|
+
if (value !== null && typeof value === 'object'
|
|
176
|
+
&& Number.isInteger(value.major) && Number.isInteger(value.minor) && Number.isInteger(value.patch)) {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Compare two versions (strings or `parseVersion` results) -> -1 | 0 | 1.
|
|
184
|
+
*
|
|
185
|
+
* THROWS `VersionError` when either side is unparseable. That is deliberate:
|
|
186
|
+
* silently returning 0 for garbage is how "never downgrade" logic rots. Data
|
|
187
|
+
* from the network must go through `isNewer`, which never throws.
|
|
188
|
+
*/
|
|
189
|
+
export function compareVersions(a, b) {
|
|
190
|
+
const pa = coerceVersion(a);
|
|
191
|
+
const pb = coerceVersion(b);
|
|
192
|
+
if (!pa) throw new VersionError(`cannot parse version: ${JSON.stringify(a)}`);
|
|
193
|
+
if (!pb) throw new VersionError(`cannot parse version: ${JSON.stringify(b)}`);
|
|
194
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
195
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
196
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
197
|
+
return comparePrerelease(pa.pre ?? null, pb.pre ?? null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The update gate: true ONLY when `candidate` is strictly newer.
|
|
202
|
+
* Total function — equal, older and unparseable all return false, and it never
|
|
203
|
+
* throws, because it runs unattended on a schedule.
|
|
204
|
+
*/
|
|
205
|
+
export function isNewer(candidate, current) {
|
|
206
|
+
try {
|
|
207
|
+
return compareVersions(candidate, current) === 1;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
/* SHA256SUMS */
|
|
215
|
+
/* ------------------------------------------------------------------ */
|
|
216
|
+
|
|
217
|
+
// GNU coreutils format: "<64 hex><space><space><name>", optionally "*name" for
|
|
218
|
+
// binary mode, and some tools prefix "./".
|
|
219
|
+
const SUMS_LINE_RE = /^([0-9a-fA-F]{64})[ \t]+[*]?(.+?)[ \t]*$/;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse a `SHA256SUMS` file into `Map<normalized filename, lowercase hash>`.
|
|
223
|
+
* Blank lines and `#` comments are ignored. A structurally wrong line, or two
|
|
224
|
+
* entries for the same name with different hashes, is an explicit error.
|
|
225
|
+
*/
|
|
226
|
+
export function parseSha256Sums(text) {
|
|
227
|
+
if (typeof text !== 'string') {
|
|
228
|
+
throw new UpdateSourceError('BAD_SUMS', 'SHA256SUMS content must be a string');
|
|
229
|
+
}
|
|
230
|
+
const entries = new Map();
|
|
231
|
+
const lines = text.split(/\r?\n/);
|
|
232
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
233
|
+
const line = lines[i];
|
|
234
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
|
|
235
|
+
const m = SUMS_LINE_RE.exec(line);
|
|
236
|
+
if (!m) {
|
|
237
|
+
throw new UpdateSourceError(
|
|
238
|
+
'BAD_SUMS_FORMAT',
|
|
239
|
+
`SHA256SUMS line ${i + 1} is not "<64-hex> <filename>": ${JSON.stringify(line.slice(0, 160))}`,
|
|
240
|
+
{ line: i + 1 },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const name = m[2].trim().replace(/^\.\//, '');
|
|
244
|
+
if (name === '') {
|
|
245
|
+
throw new UpdateSourceError('BAD_SUMS_FORMAT', `SHA256SUMS line ${i + 1} has an empty filename`, { line: i + 1 });
|
|
246
|
+
}
|
|
247
|
+
const hash = m[1].toLowerCase();
|
|
248
|
+
const existing = entries.get(name);
|
|
249
|
+
if (existing !== undefined && existing !== hash) {
|
|
250
|
+
throw new UpdateSourceError(
|
|
251
|
+
'BAD_SUMS_CONFLICT',
|
|
252
|
+
`SHA256SUMS lists ${name} twice with different hashes`,
|
|
253
|
+
{ line: i + 1, filename: name },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (existing === undefined) entries.set(name, hash);
|
|
257
|
+
}
|
|
258
|
+
if (entries.size === 0) {
|
|
259
|
+
throw new UpdateSourceError('BAD_SUMS', 'SHA256SUMS contains no entries');
|
|
260
|
+
}
|
|
261
|
+
return entries;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Hash for `filename`, or `null` when the file is not listed. Exact name first, then basename. */
|
|
265
|
+
export function findChecksum(text, filename) {
|
|
266
|
+
const entries = parseSha256Sums(text);
|
|
267
|
+
const target = String(filename ?? '');
|
|
268
|
+
if (entries.has(target)) return entries.get(target);
|
|
269
|
+
const base = path.basename(target);
|
|
270
|
+
for (const [name, hash] of entries) {
|
|
271
|
+
if (path.basename(name) === base) return hash;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Like `findChecksum`, but an absent entry is an explicit error rather than `null`. */
|
|
277
|
+
export function requireChecksum(text, filename) {
|
|
278
|
+
const hash = findChecksum(text, filename);
|
|
279
|
+
if (hash === null) {
|
|
280
|
+
throw new UpdateSourceError('CHECKSUM_NOT_FOUND', `SHA256SUMS has no entry for ${filename}`, { filename });
|
|
281
|
+
}
|
|
282
|
+
return hash;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* ------------------------------------------------------------------ */
|
|
286
|
+
/* HTTP plumbing */
|
|
287
|
+
/* ------------------------------------------------------------------ */
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A request guard that ALWAYS settles.
|
|
291
|
+
*
|
|
292
|
+
* `controller.abort()` stops a well-behaved `fetch`, but the race against
|
|
293
|
+
* `promise` is what guarantees the scheduler is never wedged by an
|
|
294
|
+
* implementation that ignores the signal. The timer is intentionally NOT
|
|
295
|
+
* unref'd: an unref'd timer would let a bare CLI process exit silently while the
|
|
296
|
+
* request is still pending.
|
|
297
|
+
*/
|
|
298
|
+
function createTimeoutGuard(timeoutMs, externalSignal = null) {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
let timer = null;
|
|
301
|
+
let rejectGuard;
|
|
302
|
+
const promise = new Promise((_, reject) => { rejectGuard = reject; });
|
|
303
|
+
promise.catch(() => { /* the race may settle first; never unhandled */ });
|
|
304
|
+
|
|
305
|
+
const failGuard = (err) => {
|
|
306
|
+
try { controller.abort(err); } catch { /* ignore */ }
|
|
307
|
+
rejectGuard(err);
|
|
308
|
+
};
|
|
309
|
+
timer = setTimeout(
|
|
310
|
+
() => failGuard(new UpdateSourceError('TIMEOUT', `request timed out after ${timeoutMs}ms`)),
|
|
311
|
+
timeoutMs,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const onExternalAbort = () => failGuard(new UpdateSourceError('ABORTED', 'request aborted by the caller'));
|
|
315
|
+
if (externalSignal) {
|
|
316
|
+
if (externalSignal.aborted) onExternalAbort();
|
|
317
|
+
else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
signal: controller.signal,
|
|
322
|
+
promise,
|
|
323
|
+
get aborted() { return controller.signal.aborted; },
|
|
324
|
+
cleanup() {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
externalSignal?.removeEventListener?.('abort', onExternalAbort);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Run one request + body consumption under a single timeout budget, so a server
|
|
333
|
+
* that sends headers and then stalls mid-body is bounded too.
|
|
334
|
+
*/
|
|
335
|
+
async function withGuard({ timeoutMs, signal, fetchImpl, url, headers, consume }) {
|
|
336
|
+
const guard = createTimeoutGuard(timeoutMs, signal);
|
|
337
|
+
const run = (async () => {
|
|
338
|
+
const res = await fetchImpl(url, { method: 'GET', headers, signal: guard.signal, redirect: 'follow' });
|
|
339
|
+
return consume(res);
|
|
340
|
+
})();
|
|
341
|
+
run.catch(() => { /* raced out; swallow so it is never an unhandled rejection */ });
|
|
342
|
+
try {
|
|
343
|
+
return await Promise.race([run, guard.promise]);
|
|
344
|
+
} finally {
|
|
345
|
+
guard.cleanup();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function buildHeaders({ token = null, userAgent = DEFAULT_USER_AGENT, accept = 'application/vnd.github+json' } = {}) {
|
|
350
|
+
const headers = {
|
|
351
|
+
accept,
|
|
352
|
+
'user-agent': userAgent,
|
|
353
|
+
'x-github-api-version': GITHUB_API_VERSION,
|
|
354
|
+
};
|
|
355
|
+
// Optional, and never echoed anywhere.
|
|
356
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
357
|
+
return headers;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function headerGet(headers, name) {
|
|
361
|
+
if (!headers) return null;
|
|
362
|
+
if (typeof headers.get === 'function') return headers.get(name);
|
|
363
|
+
return headers[name] ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Recognise GitHub's rate limiting: 429 always, 403 with limit headers. */
|
|
367
|
+
function rateLimitInfo(status, headers) {
|
|
368
|
+
const detail = {};
|
|
369
|
+
const limit = headerGet(headers, 'x-ratelimit-limit');
|
|
370
|
+
const remaining = headerGet(headers, 'x-ratelimit-remaining');
|
|
371
|
+
const reset = headerGet(headers, 'x-ratelimit-reset');
|
|
372
|
+
const retryAfter = headerGet(headers, 'retry-after');
|
|
373
|
+
if (limit != null && limit !== '') detail.limit = Number(limit);
|
|
374
|
+
if (remaining != null && remaining !== '') detail.remaining = Number(remaining);
|
|
375
|
+
if (reset != null && /^\d+$/.test(String(reset))) detail.resetAt = new Date(Number(reset) * 1000).toISOString();
|
|
376
|
+
if (retryAfter != null && retryAfter !== '') {
|
|
377
|
+
const n = Number(retryAfter);
|
|
378
|
+
detail.retryAfterSeconds = Number.isFinite(n) ? n : String(retryAfter);
|
|
379
|
+
}
|
|
380
|
+
return { rateLimited: status === 403 || status === 429, detail };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function rateLimitMessage(status, detail) {
|
|
384
|
+
const bits = [];
|
|
385
|
+
if (detail.limit !== undefined) bits.push(`limit ${detail.limit}/hour`);
|
|
386
|
+
if (detail.remaining !== undefined) bits.push(`remaining ${detail.remaining}`);
|
|
387
|
+
if (detail.resetAt) bits.push(`resets at ${detail.resetAt}`);
|
|
388
|
+
if (detail.retryAfterSeconds !== undefined) bits.push(`retry after ${detail.retryAfterSeconds}s`);
|
|
389
|
+
let msg = `GitHub rate limit reached (HTTP ${status})${bits.length > 0 ? `: ${bits.join(', ')}` : ''}. `
|
|
390
|
+
+ 'Unauthenticated requests are limited to 60/hour per IP; pass a token to raise the limit.';
|
|
391
|
+
if (status === 403) msg += ' A 403 can also mean the token cannot see this repository.';
|
|
392
|
+
return msg;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function safeText(res) {
|
|
396
|
+
try {
|
|
397
|
+
if (typeof res?.text !== 'function') return '';
|
|
398
|
+
const text = await res.text();
|
|
399
|
+
return typeof text === 'string' ? text.slice(0, 400) : '';
|
|
400
|
+
} catch {
|
|
401
|
+
return '';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Shared HTTP-failure mapping for both the API call and the download. */
|
|
406
|
+
function describeHttpFailure({ label, url, status, statusText, headers, bodyText, token }) {
|
|
407
|
+
const info = rateLimitInfo(status, headers);
|
|
408
|
+
const suffix = statusText ? ` ${statusText}` : '';
|
|
409
|
+
if (info.rateLimited) {
|
|
410
|
+
return httpFail('RATE_LIMITED', redact(`${label} returned HTTP ${status}${suffix} for ${url}. ${rateLimitMessage(status, info.detail)}`, token),
|
|
411
|
+
status, { rateLimited: true, rateLimit: info.detail });
|
|
412
|
+
}
|
|
413
|
+
const body = bodyText ? ` Body: ${bodyText.trim().slice(0, 200)}` : '';
|
|
414
|
+
return httpFail('HTTP_ERROR', redact(`${label} returned HTTP ${status}${suffix} for ${url}.${body}`, token),
|
|
415
|
+
status, { rateLimited: false });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function toRegExp(pattern, fallback) {
|
|
419
|
+
if (pattern instanceof RegExp) return new RegExp(pattern.source, pattern.flags.replace(/g/g, ''));
|
|
420
|
+
if (typeof pattern === 'string' && pattern !== '') return new RegExp(pattern, 'i');
|
|
421
|
+
return fallback;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function assetView(asset) {
|
|
425
|
+
return {
|
|
426
|
+
name: String(asset.name),
|
|
427
|
+
url: typeof asset.browser_download_url === 'string' ? asset.browser_download_url : null,
|
|
428
|
+
size: Number.isFinite(asset.size) ? asset.size : null,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* ------------------------------------------------------------------ */
|
|
433
|
+
/* fetchLatestRelease */
|
|
434
|
+
/* ------------------------------------------------------------------ */
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* `GET {apiBase}/repos/{repo}/releases/latest`.
|
|
438
|
+
*
|
|
439
|
+
* GitHub's `latest` endpoint excludes drafts and pre-releases, so `tag` here is
|
|
440
|
+
* always a published release tag.
|
|
441
|
+
*
|
|
442
|
+
* @returns {Promise<{ok:true, tag:string, version:object|null, assets:{tarball:object,sums:object}, htmlUrl:string|null, publishedAt:string|null, prerelease:boolean, name:string|null, status:number}
|
|
443
|
+
* | {ok:false, code:string, error:string, status?:number, rateLimited?:boolean}>}
|
|
444
|
+
*/
|
|
445
|
+
export async function fetchLatestRelease({
|
|
446
|
+
repo = DEFAULT_REPO,
|
|
447
|
+
fetchImpl = globalThis.fetch,
|
|
448
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
449
|
+
token = null,
|
|
450
|
+
apiBase = DEFAULT_API_BASE,
|
|
451
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
452
|
+
tarballPattern = DEFAULT_TARBALL_PATTERN,
|
|
453
|
+
sumsPattern = DEFAULT_SUMS_PATTERN,
|
|
454
|
+
signal = null,
|
|
455
|
+
} = {}) {
|
|
456
|
+
try {
|
|
457
|
+
if (typeof fetchImpl !== 'function') {
|
|
458
|
+
throw new UpdateSourceError('NO_FETCH', 'no fetch implementation available (Node.js 18+ required)');
|
|
459
|
+
}
|
|
460
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
461
|
+
throw new UpdateSourceError('BAD_TIMEOUT', `timeoutMs must be a positive number, got ${JSON.stringify(timeoutMs)}`);
|
|
462
|
+
}
|
|
463
|
+
if (typeof repo !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
464
|
+
throw new UpdateSourceError('BAD_REPO', `repo must look like "owner/name", got ${JSON.stringify(repo)}`);
|
|
465
|
+
}
|
|
466
|
+
const base = String(apiBase).replace(/\/+$/, '');
|
|
467
|
+
const url = `${base}/repos/${repo}/releases/latest`;
|
|
468
|
+
const tarballRe = toRegExp(tarballPattern, DEFAULT_TARBALL_PATTERN);
|
|
469
|
+
const sumsRe = toRegExp(sumsPattern, DEFAULT_SUMS_PATTERN);
|
|
470
|
+
|
|
471
|
+
const result = await withGuard({
|
|
472
|
+
timeoutMs, signal, fetchImpl, url,
|
|
473
|
+
headers: buildHeaders({ token, userAgent }),
|
|
474
|
+
consume: async (res) => {
|
|
475
|
+
const status = Number(res.status);
|
|
476
|
+
const common = { status, ok: res.ok === true, statusText: res.statusText, headers: res.headers };
|
|
477
|
+
if (!common.ok) return { ...common, bodyText: await safeText(res) };
|
|
478
|
+
try {
|
|
479
|
+
return { ...common, json: await res.json() };
|
|
480
|
+
} catch (err) {
|
|
481
|
+
return { ...common, jsonError: err };
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
if (!result.ok) {
|
|
487
|
+
return describeHttpFailure({
|
|
488
|
+
label: 'GitHub API', url, status: result.status, statusText: result.statusText,
|
|
489
|
+
headers: result.headers, bodyText: result.bodyText ?? '', token,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (result.jsonError) {
|
|
493
|
+
return httpFail('BAD_JSON', redact(`GitHub API returned a non-JSON body (HTTP ${result.status}) for ${url}`, token), result.status);
|
|
494
|
+
}
|
|
495
|
+
const json = result.json;
|
|
496
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) {
|
|
497
|
+
return httpFail('BAD_JSON', `GitHub API returned an unexpected payload (HTTP ${result.status})`, result.status);
|
|
498
|
+
}
|
|
499
|
+
const tag = typeof json.tag_name === 'string' ? json.tag_name.trim() : '';
|
|
500
|
+
if (tag === '') {
|
|
501
|
+
return httpFail('NO_TAG', `release payload has no tag_name (HTTP ${result.status})`, result.status);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const assets = Array.isArray(json.assets) ? json.assets.filter((a) => a && typeof a.name === 'string') : [];
|
|
505
|
+
const names = assets.map((a) => a.name);
|
|
506
|
+
const tarballAsset = assets.find((a) => tarballRe.test(a.name));
|
|
507
|
+
if (!tarballAsset) {
|
|
508
|
+
return httpFail('ASSET_MISSING',
|
|
509
|
+
`no release asset matches ${tarballRe} for ${tag}; available: [${names.join(', ')}]`, result.status);
|
|
510
|
+
}
|
|
511
|
+
const sumsAsset = assets.find((a) => sumsRe.test(a.name));
|
|
512
|
+
if (!sumsAsset) {
|
|
513
|
+
return httpFail('ASSET_MISSING',
|
|
514
|
+
`no SHA256SUMS asset for ${tag}; cannot verify a download without it. available: [${names.join(', ')}]`, result.status);
|
|
515
|
+
}
|
|
516
|
+
const tarball = assetView(tarballAsset);
|
|
517
|
+
const sums = assetView(sumsAsset);
|
|
518
|
+
if (!tarball.url) return httpFail('ASSET_MISSING', `tarball asset ${tarball.name} has no download URL`, result.status);
|
|
519
|
+
if (!sums.url) return httpFail('ASSET_MISSING', `checksum asset ${sums.name} has no download URL`, result.status);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
ok: true,
|
|
523
|
+
status: result.status,
|
|
524
|
+
tag,
|
|
525
|
+
version: parseVersion(tag), // null when the tag is not SemVer -> isNewer() is false
|
|
526
|
+
assets: { tarball, sums },
|
|
527
|
+
htmlUrl: typeof json.html_url === 'string' ? json.html_url : null,
|
|
528
|
+
publishedAt: typeof json.published_at === 'string' ? json.published_at : null,
|
|
529
|
+
prerelease: json.prerelease === true,
|
|
530
|
+
name: typeof json.name === 'string' ? json.name : null,
|
|
531
|
+
};
|
|
532
|
+
} catch (err) {
|
|
533
|
+
return toFailure(err, token);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* ------------------------------------------------------------------ */
|
|
538
|
+
/* streaming download + verification */
|
|
539
|
+
/* ------------------------------------------------------------------ */
|
|
540
|
+
|
|
541
|
+
function toBuffer(value) {
|
|
542
|
+
if (Buffer.isBuffer(value)) return value;
|
|
543
|
+
if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
544
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
545
|
+
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
|
546
|
+
return Buffer.from(value);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Feed a response body to `onChunk` one chunk at a time.
|
|
551
|
+
* Supports both a web `ReadableStream` (what `fetch` returns) and a Node
|
|
552
|
+
* async-iterable, so the caller can inject either.
|
|
553
|
+
*/
|
|
554
|
+
async function consumeBody(body, onChunk) {
|
|
555
|
+
if (body === null || body === undefined) return 0;
|
|
556
|
+
if (typeof body.getReader === 'function') {
|
|
557
|
+
const reader = body.getReader();
|
|
558
|
+
let total = 0;
|
|
559
|
+
for (;;) {
|
|
560
|
+
const { done, value } = await reader.read();
|
|
561
|
+
if (done) break;
|
|
562
|
+
if (value === undefined || value === null) continue;
|
|
563
|
+
const buf = toBuffer(value);
|
|
564
|
+
total += buf.length;
|
|
565
|
+
await onChunk(buf);
|
|
566
|
+
}
|
|
567
|
+
return total;
|
|
568
|
+
}
|
|
569
|
+
if (typeof body[Symbol.asyncIterator] === 'function') {
|
|
570
|
+
let total = 0;
|
|
571
|
+
for await (const chunk of body) {
|
|
572
|
+
const buf = toBuffer(chunk);
|
|
573
|
+
total += buf.length;
|
|
574
|
+
await onChunk(buf);
|
|
575
|
+
}
|
|
576
|
+
return total;
|
|
577
|
+
}
|
|
578
|
+
throw new UpdateSourceError('BAD_BODY', 'response body is neither a web ReadableStream nor async-iterable');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Stream `url` to `destPath`, hashing as it goes.
|
|
583
|
+
*
|
|
584
|
+
* The bytes land in `<destPath>.part-<pid>-<rand>` in the SAME directory (so the
|
|
585
|
+
* final `rename` is atomic on one filesystem). Nothing is put in place until the
|
|
586
|
+
* sha256 matches; on any failure the temp file is removed and an existing
|
|
587
|
+
* `destPath` is left exactly as it was.
|
|
588
|
+
*
|
|
589
|
+
* @returns {Promise<{ok:true, bytes:number, sha256:string, path:string}
|
|
590
|
+
* | {ok:false, code:string, error:string, status?:number}>}
|
|
591
|
+
*/
|
|
592
|
+
export async function downloadVerified({
|
|
593
|
+
url,
|
|
594
|
+
sha256,
|
|
595
|
+
destPath,
|
|
596
|
+
fetchImpl = globalThis.fetch,
|
|
597
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
598
|
+
token = null,
|
|
599
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
600
|
+
expectedBytes = null,
|
|
601
|
+
signal = null,
|
|
602
|
+
} = {}) {
|
|
603
|
+
let tempPath = null;
|
|
604
|
+
try {
|
|
605
|
+
if (typeof fetchImpl !== 'function') {
|
|
606
|
+
throw new UpdateSourceError('NO_FETCH', 'no fetch implementation available (Node.js 18+ required)');
|
|
607
|
+
}
|
|
608
|
+
if (typeof url !== 'string' || url === '') {
|
|
609
|
+
throw new UpdateSourceError('BAD_URL', 'url is required');
|
|
610
|
+
}
|
|
611
|
+
if (typeof destPath !== 'string' || destPath === '') {
|
|
612
|
+
throw new UpdateSourceError('BAD_DEST', 'destPath is required');
|
|
613
|
+
}
|
|
614
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
615
|
+
throw new UpdateSourceError('BAD_TIMEOUT', `timeoutMs must be a positive number, got ${JSON.stringify(timeoutMs)}`);
|
|
616
|
+
}
|
|
617
|
+
const expected = typeof sha256 === 'string' ? sha256.trim().toLowerCase() : '';
|
|
618
|
+
if (expected === '') {
|
|
619
|
+
throw new UpdateSourceError('MISSING_CHECKSUM',
|
|
620
|
+
'refusing to download without an expected sha256: an unverified artifact must never be installed');
|
|
621
|
+
}
|
|
622
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
623
|
+
throw new UpdateSourceError('BAD_CHECKSUM', `expected sha256 must be 64 lowercase hex characters, got ${JSON.stringify(sha256)}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const target = path.resolve(destPath);
|
|
627
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
628
|
+
tempPath = `${target}.part-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
629
|
+
|
|
630
|
+
const hash = createHash('sha256');
|
|
631
|
+
const handle = await fsp.open(tempPath, 'wx', 0o600);
|
|
632
|
+
let bytes = 0;
|
|
633
|
+
let httpFailure = null;
|
|
634
|
+
try {
|
|
635
|
+
const result = await withGuard({
|
|
636
|
+
timeoutMs, signal, fetchImpl, url,
|
|
637
|
+
headers: buildHeaders({ token, userAgent, accept: 'application/octet-stream' }),
|
|
638
|
+
consume: async (res) => {
|
|
639
|
+
if (res.ok !== true) {
|
|
640
|
+
return { ok: false, status: Number(res.status), statusText: res.statusText, headers: res.headers, bodyText: await safeText(res) };
|
|
641
|
+
}
|
|
642
|
+
const written = await consumeBody(res.body, async (chunk) => {
|
|
643
|
+
hash.update(chunk); // hash while streaming: the file is never held in memory
|
|
644
|
+
await handle.write(chunk);
|
|
645
|
+
});
|
|
646
|
+
return { ok: true, status: Number(res.status), written };
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
if (!result.ok) {
|
|
651
|
+
httpFailure = describeHttpFailure({
|
|
652
|
+
label: 'download', url, status: result.status, statusText: result.statusText,
|
|
653
|
+
headers: result.headers, bodyText: result.bodyText ?? '', token,
|
|
654
|
+
});
|
|
655
|
+
} else {
|
|
656
|
+
bytes = result.written;
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
await handle.close().catch(() => {});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (httpFailure) return httpFailure;
|
|
663
|
+
|
|
664
|
+
const digest = hash.digest('hex');
|
|
665
|
+
if (digest !== expected) {
|
|
666
|
+
return fail('CHECKSUM_MISMATCH',
|
|
667
|
+
`sha256 mismatch for ${url}: expected ${expected}, got ${digest}`, { expected, actual: digest, bytes });
|
|
668
|
+
}
|
|
669
|
+
if (expectedBytes !== null && Number.isFinite(expectedBytes) && bytes !== expectedBytes) {
|
|
670
|
+
return fail('SIZE_MISMATCH',
|
|
671
|
+
`size mismatch for ${url}: expected ${expectedBytes} bytes, got ${bytes}`, { expected: expectedBytes, actual: bytes });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
await fsp.rename(tempPath, target);
|
|
675
|
+
tempPath = null; // renamed into place: the finally-cleanup must not remove it
|
|
676
|
+
return { ok: true, bytes, sha256: digest, path: target };
|
|
677
|
+
} catch (err) {
|
|
678
|
+
return toFailure(err, token);
|
|
679
|
+
} finally {
|
|
680
|
+
if (tempPath !== null) {
|
|
681
|
+
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export default { fetchLatestRelease, downloadVerified, parseVersion, compareVersions, isNewer, parseSha256Sums, findChecksum };
|