dsh-multi-provider-usage 0.0.0-stage → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +427 -2
- package/README.zh.md +377 -0
- package/cordis.patch.yml +15 -0
- package/docs/widget.png +0 -0
- package/lib/auto-update.js +570 -0
- package/lib/client.js +2226 -0
- package/lib/index.js +1290 -0
- package/lib/opencode-console.js +556 -0
- package/lib/opencode-session.js +139 -0
- package/lib/providers.js +919 -0
- package/lib/util.js +27 -0
- package/package.json +64 -4
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* dsh-multi-provider-usage — self-update.
|
|
4
|
+
*
|
|
5
|
+
* Two install shapes, two strategies:
|
|
6
|
+
*
|
|
7
|
+
* - `checkout` (`link:` / `file:` directory): fast-forward the git checkout,
|
|
8
|
+
* but only when it is on `main`, clean, and can fast-forward `origin/main`.
|
|
9
|
+
* - `package` (npm / market / tarball install): download the GitHub release
|
|
10
|
+
* tarball and replace the packaged files in place. Every file is written
|
|
11
|
+
* to a temp file inside the package directory first and then renamed over
|
|
12
|
+
* the target, which keeps pnpm's hardlinked store untouched.
|
|
13
|
+
*
|
|
14
|
+
* Neither strategy restarts the Host: the running module graph is stale once
|
|
15
|
+
* the files land, so the UI reports `restartRequired`. The updater never
|
|
16
|
+
* touches a symlinked checkout and never runs when the install is a `link:`
|
|
17
|
+
* that has local changes.
|
|
18
|
+
*
|
|
19
|
+
* All external effects are injectable (`fetchImpl`, `execImpl`, `readVersion`)
|
|
20
|
+
* so the tests can exercise the decision and file work offline.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { gunzipSync } from "node:zlib";
|
|
28
|
+
|
|
29
|
+
export const AUTO_UPDATE_REPO = "wjw1-Evan/dsh-provider-usage";
|
|
30
|
+
export const AUTO_UPDATE_RELEASE_URL = `https://api.github.com/repos/${AUTO_UPDATE_REPO}/releases/latest`;
|
|
31
|
+
/** Delay before the first background check, so activation is never blocked. */
|
|
32
|
+
const FIRST_CHECK_DELAY_MS = 15_000;
|
|
33
|
+
/** Hourly tick; the configured check interval gates actual network calls. */
|
|
34
|
+
const TICK_MS = 3600_000;
|
|
35
|
+
const DEFAULT_CHECK_HOURS = 6;
|
|
36
|
+
/** Upper bound on a release tarball, to keep a bad download out of memory. */
|
|
37
|
+
const MAX_TARBALL_BYTES = 25 * 1024 * 1024;
|
|
38
|
+
/** Files a release tarball may replace inside an installed package. */
|
|
39
|
+
const PACKAGED_PREFIXES = ["lib/", "docs/"];
|
|
40
|
+
const PACKAGED_FILES = ["package.json", "cordis.patch.yml"];
|
|
41
|
+
|
|
42
|
+
const execFileAsync = promisify(execFile);
|
|
43
|
+
|
|
44
|
+
/** Parse `v1.2.3` / `1.2.3` into numeric parts; returns null when unusable. */
|
|
45
|
+
export function parseVersion(value) {
|
|
46
|
+
const text = String(value ?? "").trim().replace(/^v/i, "").split("-")[0];
|
|
47
|
+
const parts = text.split(".").map((part) => Number(part));
|
|
48
|
+
if (parts.length === 0 || parts.some((part) => !Number.isSafeInteger(part) || part < 0)) return null;
|
|
49
|
+
while (parts.length < 3) parts.push(0);
|
|
50
|
+
return parts.slice(0, 3);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `latest` is a strictly higher semver than `current`. */
|
|
54
|
+
export function isNewerVersion(latest, current) {
|
|
55
|
+
const a = parseVersion(latest);
|
|
56
|
+
const b = parseVersion(current);
|
|
57
|
+
if (a === null || b === null) return false;
|
|
58
|
+
for (let index = 0; index < 3; index += 1) {
|
|
59
|
+
if (a[index] !== b[index]) return a[index] > b[index];
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the profile dependency spec, or null when it cannot be read. */
|
|
65
|
+
function profileSpec(profileDir, packageName) {
|
|
66
|
+
if (typeof profileDir !== "string" || profileDir.length === 0) return null;
|
|
67
|
+
try {
|
|
68
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
|
|
69
|
+
const spec = manifest?.dependencies?.[packageName];
|
|
70
|
+
return typeof spec === "string" ? spec : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Expand `link:`/`file:` specs relative to the profile directory. */
|
|
77
|
+
function specPath(profileDir, spec) {
|
|
78
|
+
const prefix = spec.startsWith("link:") ? "link:" : spec.startsWith("file:") ? "file:" : null;
|
|
79
|
+
if (prefix === null) return null;
|
|
80
|
+
const raw = spec.slice(prefix.length);
|
|
81
|
+
if (raw.startsWith("/")) return raw;
|
|
82
|
+
return join(profileDir, raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify the running installation.
|
|
87
|
+
* `checkout` means a local linked directory we may fast-forward; `package`
|
|
88
|
+
* means an installed copy we update by replacing its packaged files.
|
|
89
|
+
* @returns {{ kind: "checkout" | "package", checkout: string | null }}
|
|
90
|
+
*/
|
|
91
|
+
export function detectInstall({ packageDir, profileDir, packageName }) {
|
|
92
|
+
const spec = profileSpec(profileDir, packageName);
|
|
93
|
+
if (typeof spec === "string" && spec.startsWith("link:")) {
|
|
94
|
+
const checkout = specPath(profileDir, spec);
|
|
95
|
+
if (checkout !== null) return { kind: "checkout", checkout };
|
|
96
|
+
}
|
|
97
|
+
if (typeof spec === "string" && spec.startsWith("file:")) {
|
|
98
|
+
// A file: directory can be a checkout, but a file: tarball cannot. Only
|
|
99
|
+
// treat it as a checkout when the path looks like a directory.
|
|
100
|
+
const path = specPath(profileDir, spec);
|
|
101
|
+
if (path !== null && !path.endsWith(".tgz") && !path.endsWith(".tar.gz")) {
|
|
102
|
+
return { kind: "checkout", checkout: path };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { kind: "package", checkout: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Format one exec failure without leaking huge output. */
|
|
109
|
+
function failure(result) {
|
|
110
|
+
const stderr = String(result?.stderr ?? "").trim();
|
|
111
|
+
return stderr.length > 0 ? stderr.slice(0, 240) : `git exited ${String(result?.code ?? "nonzero")}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fast-forward a clean `main` checkout to `origin/main`.
|
|
116
|
+
*
|
|
117
|
+
* @param {{
|
|
118
|
+
* checkout: string,
|
|
119
|
+
* execImpl?: (file: string, args: string[], options: object) => Promise<{ stdout?: string, stderr?: string }>,
|
|
120
|
+
* readVersion?: (checkout: string) => string,
|
|
121
|
+
* }} input
|
|
122
|
+
* @returns {Promise<{ updated: boolean, reason: string | null, version?: string }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function applyCheckoutUpdate(input) {
|
|
125
|
+
const { checkout, execImpl = execFileAsync, readVersion = readPackageVersion } = input;
|
|
126
|
+
const run = async (args) => {
|
|
127
|
+
try {
|
|
128
|
+
const result = await execImpl("git", ["-C", checkout, ...args], { windowsHide: true });
|
|
129
|
+
return { code: 0, stdout: String(result?.stdout ?? ""), stderr: String(result?.stderr ?? "") };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
code: typeof error?.code === "number" ? error.code : 1,
|
|
133
|
+
stdout: String(error?.stdout ?? ""),
|
|
134
|
+
stderr: String(error?.stderr ?? error?.message ?? error)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
140
|
+
if (branch.code !== 0) return { updated: false, reason: `not a git checkout: ${failure(branch)}` };
|
|
141
|
+
if (branch.stdout.trim() !== "main") return { updated: false, reason: `checkout is on "${branch.stdout.trim() || "unknown"}", not main` };
|
|
142
|
+
|
|
143
|
+
const status = await run(["status", "--porcelain"]);
|
|
144
|
+
if (status.code !== 0) return { updated: false, reason: `cannot read checkout status: ${failure(status)}` };
|
|
145
|
+
if (status.stdout.trim() !== "") return { updated: false, reason: "checkout has local changes" };
|
|
146
|
+
|
|
147
|
+
const fetched = await run(["fetch", "--quiet", "origin", "main"]);
|
|
148
|
+
if (fetched.code !== 0) return { updated: false, reason: `git fetch failed: ${failure(fetched)}` };
|
|
149
|
+
|
|
150
|
+
const ancestor = await run(["merge-base", "--is-ancestor", "HEAD", "origin/main"]);
|
|
151
|
+
if (ancestor.code !== 0) return { updated: false, reason: "checkout has diverged from origin/main" };
|
|
152
|
+
|
|
153
|
+
const merged = await run(["merge", "--ff-only", "origin/main"]);
|
|
154
|
+
if (merged.code !== 0) return { updated: false, reason: `git merge failed: ${failure(merged)}` };
|
|
155
|
+
|
|
156
|
+
const version = readVersion(checkout);
|
|
157
|
+
return { updated: true, reason: null, version };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Read the version from a checkout's package.json. */
|
|
161
|
+
function readPackageVersion(checkout) {
|
|
162
|
+
try {
|
|
163
|
+
const pkg = JSON.parse(readFileSync(join(checkout, "package.json"), "utf8"));
|
|
164
|
+
return typeof pkg?.version === "string" ? pkg.version : "";
|
|
165
|
+
} catch {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── release tarball handling ────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** Read a NUL-padded tar header field as UTF-8. */
|
|
173
|
+
function headerText(header, start, length) {
|
|
174
|
+
const slice = header.subarray(start, start + length);
|
|
175
|
+
const end = slice.indexOf(0);
|
|
176
|
+
return slice.subarray(0, end === -1 ? slice.length : end).toString("utf8");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Decode a pax extended header body into a record map. */
|
|
180
|
+
function parsePaxRecords(body) {
|
|
181
|
+
const records = {};
|
|
182
|
+
let offset = 0;
|
|
183
|
+
while (offset < body.length) {
|
|
184
|
+
const space = body.indexOf(0x20, offset);
|
|
185
|
+
if (space === -1) break;
|
|
186
|
+
const length = Number(body.subarray(offset, space).toString("utf8"));
|
|
187
|
+
if (!Number.isSafeInteger(length) || length <= 0) break;
|
|
188
|
+
const record = body.subarray(space + 1, offset + length - 1).toString("utf8");
|
|
189
|
+
const equals = record.indexOf("=");
|
|
190
|
+
if (equals > 0) records[record.slice(0, equals)] = record.slice(equals + 1);
|
|
191
|
+
offset += length;
|
|
192
|
+
}
|
|
193
|
+
return records;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Normalize a tar path; returns null for absolute or parent-escaping paths. */
|
|
197
|
+
function normalizeTarPath(path) {
|
|
198
|
+
const parts = String(path).replace(/\\/g, "/").split("/");
|
|
199
|
+
const safe = [];
|
|
200
|
+
for (const part of parts) {
|
|
201
|
+
if (part === "" || part === ".") continue;
|
|
202
|
+
if (part === "..") return null;
|
|
203
|
+
safe.push(part);
|
|
204
|
+
}
|
|
205
|
+
return safe.length === 0 ? null : safe.join("/");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse an uncompressed tar buffer into a `path → contents` map.
|
|
210
|
+
* Only regular files are returned; directories and links are skipped.
|
|
211
|
+
*
|
|
212
|
+
* @param {Buffer} buffer
|
|
213
|
+
* @returns {Map<string, Buffer>}
|
|
214
|
+
*/
|
|
215
|
+
export function parseTarball(buffer) {
|
|
216
|
+
/** @type {Map<string, Buffer>} */
|
|
217
|
+
const entries = new Map();
|
|
218
|
+
let offset = 0;
|
|
219
|
+
/** @type {string | null} */
|
|
220
|
+
let pendingPath = null;
|
|
221
|
+
while (offset + 512 <= buffer.length) {
|
|
222
|
+
const header = buffer.subarray(offset, offset + 512);
|
|
223
|
+
let blank = true;
|
|
224
|
+
for (const byte of header) {
|
|
225
|
+
if (byte !== 0) {
|
|
226
|
+
blank = false;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (blank) break;
|
|
231
|
+
const rawName = headerText(header, 0, 100);
|
|
232
|
+
const prefix = headerText(header, 345, 155);
|
|
233
|
+
const size = Number.parseInt(headerText(header, 124, 12).trim() || "0", 8);
|
|
234
|
+
const flag = header[156];
|
|
235
|
+
const name = prefix.length > 0 ? `${prefix}/${rawName}` : rawName;
|
|
236
|
+
const bodyStart = offset + 512;
|
|
237
|
+
const bodyEnd = bodyStart + (Number.isFinite(size) && size > 0 ? size : 0);
|
|
238
|
+
const body = buffer.subarray(bodyStart, bodyEnd);
|
|
239
|
+
offset = bodyStart + Math.ceil((Number.isFinite(size) && size > 0 ? size : 0) / 512) * 512;
|
|
240
|
+
const type = flag === 0 ? "0" : String.fromCharCode(flag);
|
|
241
|
+
if (type === "x") {
|
|
242
|
+
pendingPath = parsePaxRecords(body).path ?? null;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (type === "L") {
|
|
246
|
+
// GNU long-name entry: the body is the path for the next header.
|
|
247
|
+
pendingPath = body.toString("utf8").replace(/\0+$/, "") || null;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (type === "g") continue;
|
|
251
|
+
if (type !== "0") continue;
|
|
252
|
+
const path = normalizeTarPath(pendingPath ?? name);
|
|
253
|
+
pendingPath = null;
|
|
254
|
+
if (path !== null) entries.set(path, Buffer.from(body));
|
|
255
|
+
}
|
|
256
|
+
return entries;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** True when a relative path belongs to the published package surface. */
|
|
260
|
+
function isPackagedFile(relative) {
|
|
261
|
+
if (PACKAGED_FILES.includes(relative)) return true;
|
|
262
|
+
return PACKAGED_PREFIXES.some((prefix) => relative.startsWith(prefix));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Replace an installed package's files with the release tarball contents.
|
|
267
|
+
*
|
|
268
|
+
* The package directory must be a real directory (a `link:` checkout is a
|
|
269
|
+
* symlink and is refused). Files are staged next to the targets so the final
|
|
270
|
+
* `rename` is atomic and never edits a pnpm store hardlink in place.
|
|
271
|
+
*
|
|
272
|
+
* @param {{
|
|
273
|
+
* packageDir: string,
|
|
274
|
+
* tarballUrl: string,
|
|
275
|
+
* fetchImpl?: typeof fetch,
|
|
276
|
+
* packageName?: string | null,
|
|
277
|
+
* expectVersion?: string | null,
|
|
278
|
+
* maxBytes?: number,
|
|
279
|
+
* logger?: { info?: Function, warn?: Function },
|
|
280
|
+
* }} options
|
|
281
|
+
* @returns {Promise<{ updated: true, version: string, files: number }>}
|
|
282
|
+
*/
|
|
283
|
+
export async function applyPackageUpdate(options) {
|
|
284
|
+
const {
|
|
285
|
+
packageDir,
|
|
286
|
+
tarballUrl,
|
|
287
|
+
fetchImpl = fetch,
|
|
288
|
+
packageName = null,
|
|
289
|
+
expectVersion = null,
|
|
290
|
+
maxBytes = MAX_TARBALL_BYTES,
|
|
291
|
+
logger = {}
|
|
292
|
+
} = options;
|
|
293
|
+
if (typeof packageDir !== "string" || packageDir.length === 0) throw new Error("no package directory to update");
|
|
294
|
+
if (typeof tarballUrl !== "string" || tarballUrl.length === 0) throw new Error("release has no tarball URL");
|
|
295
|
+
|
|
296
|
+
const stat = lstatSync(packageDir);
|
|
297
|
+
if (stat.isSymbolicLink()) throw new Error(`${packageDir} is a symlink; refusing to replace a linked checkout`);
|
|
298
|
+
|
|
299
|
+
const res = await fetchImpl(tarballUrl, {
|
|
300
|
+
headers: { Accept: "application/octet-stream", "User-Agent": "dsh-multi-provider-usage" },
|
|
301
|
+
signal: AbortSignal.timeout(30_000)
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) throw new Error(`release tarball download failed with HTTP ${res.status}`);
|
|
304
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
305
|
+
if (body.length === 0) throw new Error("release tarball is empty");
|
|
306
|
+
if (body.length > maxBytes) throw new Error(`release tarball is larger than ${maxBytes} bytes`);
|
|
307
|
+
|
|
308
|
+
const entries = parseTarball(gunzipSync(body));
|
|
309
|
+
if (entries.size === 0) throw new Error("release tarball has no files");
|
|
310
|
+
const roots = new Set([...entries.keys()].map((path) => path.split("/")[0]));
|
|
311
|
+
if (roots.size !== 1) throw new Error("release tarball does not have exactly one root directory");
|
|
312
|
+
const root = [...roots][0];
|
|
313
|
+
|
|
314
|
+
const manifestBytes = entries.get(`${root}/package.json`);
|
|
315
|
+
if (manifestBytes === undefined) throw new Error("release tarball has no package.json");
|
|
316
|
+
/** @type {{ name?: string, version?: string }} */
|
|
317
|
+
let manifest;
|
|
318
|
+
try {
|
|
319
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
320
|
+
} catch {
|
|
321
|
+
throw new Error("release package.json is not valid JSON");
|
|
322
|
+
}
|
|
323
|
+
if (typeof packageName === "string" && packageName.length > 0 && manifest?.name !== packageName) {
|
|
324
|
+
throw new Error(`release is package "${String(manifest?.name)}", expected "${packageName}"`);
|
|
325
|
+
}
|
|
326
|
+
if (parseVersion(manifest?.version) === null) throw new Error("release package.json has no usable version");
|
|
327
|
+
if (typeof expectVersion === "string" && expectVersion.length > 0 && String(parseVersion(manifest.version)) !== String(parseVersion(expectVersion))) {
|
|
328
|
+
throw new Error(`release tarball is v${manifest.version}, expected v${expectVersion}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @type {Map<string, Buffer>} */
|
|
332
|
+
const files = new Map();
|
|
333
|
+
for (const [path, contents] of entries) {
|
|
334
|
+
if (!path.startsWith(`${root}/`)) continue;
|
|
335
|
+
const relative = path.slice(root.length + 1);
|
|
336
|
+
if (relative.length === 0 || relative.includes("../")) continue;
|
|
337
|
+
if (!isPackagedFile(relative)) continue;
|
|
338
|
+
files.set(relative, contents);
|
|
339
|
+
}
|
|
340
|
+
if (!files.has("lib/index.js") || !files.has("lib/client.js")) {
|
|
341
|
+
throw new Error("release tarball is missing lib/index.js or lib/client.js");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Stage inside packageDir: a rename across filesystems would fail, and the
|
|
345
|
+
// temp names are easy to clean up if the download turns out to be bad.
|
|
346
|
+
const staging = join(packageDir, `.dsh-update-${process.pid}-${Date.now()}`);
|
|
347
|
+
/** @type {{ temp: string, target: string }[]} */
|
|
348
|
+
const staged = [];
|
|
349
|
+
try {
|
|
350
|
+
for (const [relative, contents] of files) {
|
|
351
|
+
const temp = join(staging, relative);
|
|
352
|
+
mkdirSync(dirname(temp), { recursive: true });
|
|
353
|
+
writeFileSync(temp, contents, { mode: 0o644 });
|
|
354
|
+
staged.push({ temp, target: join(packageDir, relative) });
|
|
355
|
+
}
|
|
356
|
+
for (const { temp, target } of staged) {
|
|
357
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
358
|
+
renameSync(temp, target);
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
rmSync(staging, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
logger.info?.(`dsh-multi-provider-usage: replaced ${files.size} files in ${packageDir} with v${manifest.version}`);
|
|
364
|
+
return { updated: true, version: String(manifest.version), files: files.size };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Fetch the latest GitHub release tag, page URL and tarball URL. */
|
|
368
|
+
async function fetchLatestRelease(fetchImpl) {
|
|
369
|
+
const res = await fetchImpl(AUTO_UPDATE_RELEASE_URL, {
|
|
370
|
+
headers: {
|
|
371
|
+
Accept: "application/vnd.github+json",
|
|
372
|
+
"User-Agent": "dsh-multi-provider-usage"
|
|
373
|
+
},
|
|
374
|
+
signal: AbortSignal.timeout(10_000)
|
|
375
|
+
});
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
const body = await res.text().catch(() => "");
|
|
378
|
+
throw new Error(`GitHub release check ${res.status}: ${body.slice(0, 200)}`);
|
|
379
|
+
}
|
|
380
|
+
const release = await res.json();
|
|
381
|
+
const tag = typeof release?.tag_name === "string" ? release.tag_name.replace(/^v/i, "") : "";
|
|
382
|
+
if (parseVersion(tag) === null) throw new Error("GitHub release has no usable tag");
|
|
383
|
+
return {
|
|
384
|
+
version: tag,
|
|
385
|
+
url: typeof release?.html_url === "string" ? release.html_url : null,
|
|
386
|
+
publishedAt: typeof release?.published_at === "string" ? release.published_at : null,
|
|
387
|
+
tarballUrl: typeof release?.tarball_url === "string" && release.tarball_url.length > 0
|
|
388
|
+
? release.tarball_url
|
|
389
|
+
: `https://github.com/${AUTO_UPDATE_REPO}/archive/refs/tags/v${tag}.tar.gz`
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Start the background checker.
|
|
395
|
+
*
|
|
396
|
+
* @param {{
|
|
397
|
+
* packageDir: string,
|
|
398
|
+
* profileDir: string,
|
|
399
|
+
* packageName: string,
|
|
400
|
+
* currentVersion: string,
|
|
401
|
+
* getConfig: () => { autoUpdate?: boolean, autoUpdateCheckHours?: number },
|
|
402
|
+
* fetchImpl?: typeof fetch,
|
|
403
|
+
* execImpl?: Function,
|
|
404
|
+
* now?: () => number,
|
|
405
|
+
* logger?: { info?: Function, warn?: Function },
|
|
406
|
+
* timers?: { setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function },
|
|
407
|
+
* detect?: typeof detectInstall,
|
|
408
|
+
* }} options
|
|
409
|
+
*/
|
|
410
|
+
export function startAutoUpdate(options) {
|
|
411
|
+
const {
|
|
412
|
+
packageDir,
|
|
413
|
+
profileDir,
|
|
414
|
+
packageName,
|
|
415
|
+
currentVersion,
|
|
416
|
+
getConfig,
|
|
417
|
+
fetchImpl = fetch,
|
|
418
|
+
execImpl = execFileAsync,
|
|
419
|
+
now = Date.now,
|
|
420
|
+
logger = {},
|
|
421
|
+
timers = { setTimeout, clearTimeout, setInterval, clearInterval },
|
|
422
|
+
detect = detectInstall
|
|
423
|
+
} = options;
|
|
424
|
+
const state = {
|
|
425
|
+
status: "idle",
|
|
426
|
+
currentVersion,
|
|
427
|
+
latestVersion: null,
|
|
428
|
+
checkedAt: null,
|
|
429
|
+
updatedAt: null,
|
|
430
|
+
installKind: null,
|
|
431
|
+
version: null,
|
|
432
|
+
restartRequired: false,
|
|
433
|
+
message: null,
|
|
434
|
+
releaseUrl: null
|
|
435
|
+
};
|
|
436
|
+
let disposed = false;
|
|
437
|
+
let firstTimer = null;
|
|
438
|
+
let tickTimer = null;
|
|
439
|
+
let inFlight = null;
|
|
440
|
+
let lastCheckAt = 0;
|
|
441
|
+
|
|
442
|
+
const patch = (next) => {
|
|
443
|
+
Object.assign(state, next);
|
|
444
|
+
return state;
|
|
445
|
+
};
|
|
446
|
+
const configuredHours = () => {
|
|
447
|
+
const value = Number(getConfig()?.autoUpdateCheckHours);
|
|
448
|
+
return Number.isFinite(value) && value >= 1 && value <= 168 ? value : DEFAULT_CHECK_HOURS;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const checkNow = async (force = false) => {
|
|
452
|
+
if (disposed) return { ...state };
|
|
453
|
+
if (inFlight !== null) return inFlight;
|
|
454
|
+
const config = getConfig() ?? {};
|
|
455
|
+
if (config.autoUpdate === false) {
|
|
456
|
+
return patch({ status: "disabled", message: "automatic updates are disabled" });
|
|
457
|
+
}
|
|
458
|
+
if (currentVersion === "0.0.0" || parseVersion(currentVersion) === null) {
|
|
459
|
+
return patch({ status: "skipped", message: "development version" });
|
|
460
|
+
}
|
|
461
|
+
if (!force && now() - lastCheckAt < configuredHours() * 3600_000) return { ...state };
|
|
462
|
+
lastCheckAt = now();
|
|
463
|
+
patch({ status: "checking", message: null });
|
|
464
|
+
inFlight = (async () => {
|
|
465
|
+
try {
|
|
466
|
+
const release = await fetchLatestRelease(fetchImpl);
|
|
467
|
+
const install = detect({ packageDir, profileDir, packageName });
|
|
468
|
+
patch({
|
|
469
|
+
latestVersion: release.version,
|
|
470
|
+
checkedAt: now(),
|
|
471
|
+
installKind: install.kind,
|
|
472
|
+
releaseUrl: release.url
|
|
473
|
+
});
|
|
474
|
+
// `currentVersion` never changes in a running host, so a release
|
|
475
|
+
// this session already applied would be re-downloaded every cycle
|
|
476
|
+
// until restart. Compare against what this session applied.
|
|
477
|
+
const appliedVersion = typeof state.version === "string" && parseVersion(state.version) !== null
|
|
478
|
+
? state.version
|
|
479
|
+
: currentVersion;
|
|
480
|
+
if (!isNewerVersion(release.version, appliedVersion)) {
|
|
481
|
+
if (appliedVersion !== currentVersion) {
|
|
482
|
+
// Already self-updated to this release; restore the earlier
|
|
483
|
+
// "restart required" notice instead of re-applying.
|
|
484
|
+
return patch({ status: "updated" });
|
|
485
|
+
}
|
|
486
|
+
return patch({ status: "up-to-date", message: null, restartRequired: false });
|
|
487
|
+
}
|
|
488
|
+
if (install.kind === "checkout" && typeof install.checkout === "string") {
|
|
489
|
+
const result = await applyCheckoutUpdate({
|
|
490
|
+
checkout: install.checkout,
|
|
491
|
+
execImpl,
|
|
492
|
+
readVersion: readPackageVersion
|
|
493
|
+
});
|
|
494
|
+
if (!result.updated) {
|
|
495
|
+
return patch({ status: "skipped", message: result.reason, restartRequired: false });
|
|
496
|
+
}
|
|
497
|
+
if (!isNewerVersion(result.version, currentVersion)) {
|
|
498
|
+
return patch({
|
|
499
|
+
status: "skipped",
|
|
500
|
+
message: `checkout is at ${result.version || "an unknown version"}; release is ${release.version}`,
|
|
501
|
+
restartRequired: false
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
logger.info?.(`dsh-multi-provider-usage: auto-updated checkout to ${result.version}; restart DSH to apply`);
|
|
505
|
+
return patch({
|
|
506
|
+
status: "updated",
|
|
507
|
+
version: result.version,
|
|
508
|
+
updatedAt: now(),
|
|
509
|
+
restartRequired: true,
|
|
510
|
+
message: "checkout updated; restart DSH to apply"
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// Installed package (npm / market / tarball): replace the packaged
|
|
514
|
+
// files in place. The running Host keeps the old module graph until
|
|
515
|
+
// it is restarted, which the UI reports.
|
|
516
|
+
const result = await applyPackageUpdate({
|
|
517
|
+
packageDir,
|
|
518
|
+
tarballUrl: release.tarballUrl,
|
|
519
|
+
fetchImpl,
|
|
520
|
+
packageName,
|
|
521
|
+
expectVersion: release.version,
|
|
522
|
+
logger
|
|
523
|
+
});
|
|
524
|
+
logger.info?.(`dsh-multi-provider-usage: self-updated package to ${result.version}; restart DSH to apply`);
|
|
525
|
+
return patch({
|
|
526
|
+
status: "updated",
|
|
527
|
+
version: result.version,
|
|
528
|
+
updatedAt: now(),
|
|
529
|
+
restartRequired: true,
|
|
530
|
+
message: "package files replaced; restart DSH to apply"
|
|
531
|
+
});
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
534
|
+
logger.warn?.(`dsh-multi-provider-usage: auto-update failed: ${message}`);
|
|
535
|
+
return patch({ status: "available", message, restartRequired: false });
|
|
536
|
+
} finally {
|
|
537
|
+
inFlight = null;
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
return inFlight;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const start = () => {
|
|
544
|
+
// Keep the schedule even while disabled: enabling the setting later must
|
|
545
|
+
// start checking without a host restart.
|
|
546
|
+
if (getConfig()?.autoUpdate === false) patch({ status: "disabled", message: "automatic updates are disabled" });
|
|
547
|
+
firstTimer = timers.setTimeout(() => {
|
|
548
|
+
void checkNow(false).catch(() => {});
|
|
549
|
+
}, FIRST_CHECK_DELAY_MS);
|
|
550
|
+
firstTimer?.unref?.();
|
|
551
|
+
tickTimer = timers.setInterval(() => {
|
|
552
|
+
void checkNow(false).catch(() => {});
|
|
553
|
+
}, TICK_MS);
|
|
554
|
+
tickTimer?.unref?.();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
start();
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
getState: () => ({ ...state }),
|
|
561
|
+
checkNow,
|
|
562
|
+
dispose() {
|
|
563
|
+
disposed = true;
|
|
564
|
+
if (firstTimer !== null) timers.clearTimeout(firstTimer);
|
|
565
|
+
if (tickTimer !== null) timers.clearInterval(tickTimer);
|
|
566
|
+
firstTimer = null;
|
|
567
|
+
tickTimer = null;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|