@wjm200509/dsh-plugin-version-watch 0.0.0-stage → 0.1.3
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 +48 -3
- package/icon.svg +6 -0
- package/lib/client.js +613 -0
- package/lib/index.js +657 -0
- package/lib/lockfile.js +100 -0
- package/lib/spec.js +175 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -4
- package/patch.yml +6 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-version-watch — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Two authenticated Fetch routes serve the Settings page:
|
|
5
|
+
*
|
|
6
|
+
* GET /api/version-watch/status — installed profile bundles with their
|
|
7
|
+
* version, source, and update check (Git commit or registry version).
|
|
8
|
+
* POST /api/version-watch/update — update one plugin in place: a Git
|
|
9
|
+
* source is removed and reinstalled at its recorded spec (so pnpm
|
|
10
|
+
* resolves the newest commit), a registry source uses the plugin
|
|
11
|
+
* manager's own update path.
|
|
12
|
+
*
|
|
13
|
+
* The plugin manager owns every mutation, so HMR applies the result to the
|
|
14
|
+
* running process: a successful update is live and needs no restart. A Git
|
|
15
|
+
* reinstallation that fails leaves the plugin uninstalled; the response says
|
|
16
|
+
* so and carries the redacted spec.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { commitFromResolution, parseImporterDependencies } from "./lockfile.js";
|
|
22
|
+
import { githubRawManifestUrl, parseDependencySpec, redactSource } from "./spec.js";
|
|
23
|
+
|
|
24
|
+
/** Authenticated Host route answering the Settings page's reads. */
|
|
25
|
+
const STATUS_ROUTE = "/api/version-watch/status";
|
|
26
|
+
/** Authenticated Host route performing one update. */
|
|
27
|
+
const UPDATE_ROUTE = "/api/version-watch/update";
|
|
28
|
+
/** Bound on one `git ls-remote` run. */
|
|
29
|
+
const GIT_TIMEOUT_MS = 25_000;
|
|
30
|
+
/** Bound on one remote `package.json` read. */
|
|
31
|
+
const MANIFEST_TIMEOUT_MS = 15_000;
|
|
32
|
+
/** How long a successful check is reused before the remote is asked again. */
|
|
33
|
+
const CHECK_TTL_MS = 5 * 60 * 1000;
|
|
34
|
+
/** Console/diagnostic text kept short; the failure stays recognizable. */
|
|
35
|
+
const MAX_DETAIL = 400;
|
|
36
|
+
/** Parallel remote checks while listing plugins. */
|
|
37
|
+
const CHECK_CONCURRENCY = 4;
|
|
38
|
+
|
|
39
|
+
/** Successful checks, keyed by package name. */
|
|
40
|
+
const checkCache = new Map();
|
|
41
|
+
/** One update at a time: a second removal/installation must not overlap. */
|
|
42
|
+
let updateRunning = false;
|
|
43
|
+
|
|
44
|
+
/** Cordis plugin name. */
|
|
45
|
+
export const name = "version-watch";
|
|
46
|
+
|
|
47
|
+
/** Services this plugin cannot work without. */
|
|
48
|
+
export const inject = ["connection", "pluginManager"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* JSON response for one route call.
|
|
52
|
+
* @param {number} status - HTTP status.
|
|
53
|
+
* @param {unknown} body - JSON-serializable body.
|
|
54
|
+
* @returns {Response}
|
|
55
|
+
*/
|
|
56
|
+
function reply(status, body) {
|
|
57
|
+
return new Response(JSON.stringify(body), {
|
|
58
|
+
status,
|
|
59
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keep failure text bounded.
|
|
65
|
+
* @param {string} text - diagnostic.
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function truncate(text) {
|
|
69
|
+
const value = String(text ?? "");
|
|
70
|
+
return value.length > MAX_DETAIL ? `${value.slice(0, MAX_DETAIL)}…` : value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flatten a ChangeResult's error into one readable detail.
|
|
75
|
+
* @param {{ code?: string, diagnostic?: string } | undefined} error - manager error.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function errorDetail(error) {
|
|
79
|
+
return truncate([error?.code, error?.diagnostic].filter((part) => typeof part === "string" && part !== "").join(": "));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Flatten an installation result's diagnostics, keeping pnpm's own words.
|
|
84
|
+
* @param {unknown} result - ChangeResult.
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function installDetail(result) {
|
|
88
|
+
const candidate = result;
|
|
89
|
+
const parts = [
|
|
90
|
+
candidate?.error?.code,
|
|
91
|
+
candidate?.error?.diagnostic,
|
|
92
|
+
candidate?.packageResult?.kind,
|
|
93
|
+
candidate?.packageResult?.output,
|
|
94
|
+
].filter((part) => typeof part === "string" && part !== "");
|
|
95
|
+
return truncate(parts.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The current profile directory, when the Host exposes it.
|
|
100
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
101
|
+
* @returns {string | null}
|
|
102
|
+
*/
|
|
103
|
+
function profileDirOf(ctx) {
|
|
104
|
+
const profile = ctx.get("profileContext");
|
|
105
|
+
if (profile !== undefined && typeof profile.dir === "string" && profile.dir !== "") return profile.dir;
|
|
106
|
+
const fromEnv = process.env.DSH_PROFILE_DIR;
|
|
107
|
+
return typeof fromEnv === "string" && fromEnv !== "" ? fromEnv : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read a UTF-8 file, treating absence as no content.
|
|
112
|
+
* @param {string} path - absolute path.
|
|
113
|
+
* @returns {Promise<string | null>}
|
|
114
|
+
*/
|
|
115
|
+
async function readText(path) {
|
|
116
|
+
try {
|
|
117
|
+
return await readFile(path, "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the profile manifest, its lockfile importer versions, and its directory.
|
|
125
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
126
|
+
* @returns {Promise<{ dir: string | null, manifest: Record<string, any> | null, lock: Map<string, { specifier: string | null, version: string | null }> }>}
|
|
127
|
+
*/
|
|
128
|
+
async function profileState(ctx) {
|
|
129
|
+
const dir = profileDirOf(ctx);
|
|
130
|
+
let manifest = null;
|
|
131
|
+
let lock = new Map();
|
|
132
|
+
if (dir !== null) {
|
|
133
|
+
const manifestText = await readText(join(dir, "package.json"));
|
|
134
|
+
if (manifestText !== null) {
|
|
135
|
+
try {
|
|
136
|
+
manifest = JSON.parse(manifestText);
|
|
137
|
+
} catch {
|
|
138
|
+
manifest = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const lockText = await readText(join(dir, "pnpm-lock.yaml"));
|
|
142
|
+
if (lockText !== null) lock = parseImporterDependencies(lockText);
|
|
143
|
+
}
|
|
144
|
+
return { dir, manifest, lock };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run one `git ls-remote` without credentials helpers or terminal prompts.
|
|
149
|
+
* @param {string} repoUrl - repository URL.
|
|
150
|
+
* @param {string} ref - `HEAD`, a branch, a tag, or a commit.
|
|
151
|
+
* @returns {Promise<string>}
|
|
152
|
+
*/
|
|
153
|
+
function gitLsRemote(repoUrl, ref) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
execFile(
|
|
156
|
+
"git",
|
|
157
|
+
["-c", "credential.helper=", "ls-remote", "--quiet", repoUrl, ref],
|
|
158
|
+
{
|
|
159
|
+
timeout: GIT_TIMEOUT_MS,
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
162
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
163
|
+
},
|
|
164
|
+
(error, stdout) => (error === null ? resolve(stdout) : reject(error)),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the newest commit the repository's branch/tag/commit names.
|
|
171
|
+
* @param {{ repoUrl?: string | null, ref?: string | null, pinned?: string | null }} info - parsed spec.
|
|
172
|
+
* @returns {Promise<string>}
|
|
173
|
+
*/
|
|
174
|
+
async function latestCommit(info) {
|
|
175
|
+
if (typeof info.pinned === "string" && info.pinned !== "") return info.pinned;
|
|
176
|
+
if (typeof info.repoUrl !== "string" || info.repoUrl === "") throw new Error("repository URL could not be parsed");
|
|
177
|
+
const target = info.ref ?? "HEAD";
|
|
178
|
+
const stdout = await gitLsRemote(info.repoUrl, target);
|
|
179
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
180
|
+
if (lines.length === 0) throw new Error(`git ls-remote returned nothing for ${target}`);
|
|
181
|
+
const wanted = target === "HEAD" ? ["HEAD"] : [`refs/heads/${target}`, `refs/tags/${target}`];
|
|
182
|
+
const match = lines.find((line) => wanted.some((name) => line.endsWith(`\t${name}`)));
|
|
183
|
+
const sha = (match ?? lines[0]).split(/\s+/)[0];
|
|
184
|
+
if (!/^[0-9a-f]{40}/i.test(sha)) throw new Error("remote commit could not be parsed");
|
|
185
|
+
return sha.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read the version a package declares at one remote commit, on GitHub.
|
|
190
|
+
* @param {{ repoUrl?: string | null, subdir?: string | null }} info - parsed spec.
|
|
191
|
+
* @param {string} commit - remote commit.
|
|
192
|
+
* @returns {Promise<string | null>}
|
|
193
|
+
*/
|
|
194
|
+
async function remoteVersion(info, commit) {
|
|
195
|
+
const url = githubRawManifestUrl(info.repoUrl ?? null, commit, info.subdir ?? null);
|
|
196
|
+
if (url === null) return null;
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(url, {
|
|
199
|
+
signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS),
|
|
200
|
+
headers: { "user-agent": "dsh-plugin-version-watch" },
|
|
201
|
+
});
|
|
202
|
+
if (!response.ok) return null;
|
|
203
|
+
const text = await response.text();
|
|
204
|
+
if (text.length > 512 * 1024) return null;
|
|
205
|
+
const manifest = JSON.parse(text);
|
|
206
|
+
return typeof manifest.version === "string" ? manifest.version : null;
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve one plugin's update check.
|
|
214
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
215
|
+
* @param {{ name: string, info: ReturnType<typeof parseDependencySpec>, installedCommit: string | null, bundle: any }} plugin - facts.
|
|
216
|
+
* @returns {Promise<{ status: string, installedCommit: string | null, remoteCommit: string | null, remoteVersion: string | null, latestVersion: string | null, reason: string | null }>}
|
|
217
|
+
*/
|
|
218
|
+
async function resolveCheck(ctx, { name, info, installedCommit, bundle }) {
|
|
219
|
+
if (info.kind === "git") {
|
|
220
|
+
if (info.semver !== null) {
|
|
221
|
+
return { status: "unsupported", installedCommit, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: "semver-range" };
|
|
222
|
+
}
|
|
223
|
+
let remoteCommit;
|
|
224
|
+
try {
|
|
225
|
+
remoteCommit = await latestCommit(info);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return { status: "failed", installedCommit, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: truncate(String(error?.message ?? error)) };
|
|
228
|
+
}
|
|
229
|
+
const version = await remoteVersion(info, remoteCommit);
|
|
230
|
+
if (installedCommit === null) {
|
|
231
|
+
return { status: "unknown", installedCommit: null, remoteCommit, remoteVersion: version, latestVersion: null, reason: "no-installed-commit" };
|
|
232
|
+
}
|
|
233
|
+
if (remoteCommit === installedCommit) {
|
|
234
|
+
return { status: "current", installedCommit, remoteCommit, remoteVersion: version, latestVersion: null, reason: null };
|
|
235
|
+
}
|
|
236
|
+
return { status: "available", installedCommit, remoteCommit, remoteVersion: version, latestVersion: version, reason: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (info.kind === "registry") {
|
|
240
|
+
if (bundle !== undefined && bundle.updatable !== true) {
|
|
241
|
+
return { status: "unsupported", installedCommit: null, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: "not-updatable" };
|
|
242
|
+
}
|
|
243
|
+
let answer;
|
|
244
|
+
try {
|
|
245
|
+
answer = await ctx.pluginManager.listVersions(name);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { status: "failed", installedCommit: null, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: truncate(String(error?.message ?? error)) };
|
|
248
|
+
}
|
|
249
|
+
if (answer?.status !== "listed") {
|
|
250
|
+
return { status: "failed", installedCommit: null, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: truncate(answer?.reason ?? answer?.problem ?? "") };
|
|
251
|
+
}
|
|
252
|
+
const installable = answer.versions.find(
|
|
253
|
+
(version) => version.bundle === true && version.prerelease !== true && version.deprecated === undefined && version.incompatible === undefined,
|
|
254
|
+
);
|
|
255
|
+
return {
|
|
256
|
+
status: installable === undefined ? "current" : "available",
|
|
257
|
+
installedCommit: null,
|
|
258
|
+
remoteCommit: null,
|
|
259
|
+
remoteVersion: null,
|
|
260
|
+
latestVersion: installable?.version ?? null,
|
|
261
|
+
reason: null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { status: "unsupported", installedCommit: null, remoteCommit: null, remoteVersion: null, latestVersion: null, reason: info.kind };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read one installed package's manifest from the profile's `node_modules`.
|
|
270
|
+
* Used as a fallback while the plugin manager is recomposing, so a plugin
|
|
271
|
+
* does not vanish from the page just because its bundle metadata is briefly
|
|
272
|
+
* unavailable.
|
|
273
|
+
* @param {string | null} dir - profile directory.
|
|
274
|
+
* @param {string} name - package name.
|
|
275
|
+
* @returns {Promise<Record<string, any> | null>}
|
|
276
|
+
*/
|
|
277
|
+
async function readInstalledManifest(dir, name) {
|
|
278
|
+
if (dir === null) return null;
|
|
279
|
+
const text = await readText(join(dir, "node_modules", name, "package.json"));
|
|
280
|
+
if (text === null) return null;
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(text);
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the response entry for one recorded dependency.
|
|
290
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
291
|
+
* @param {{ name: string, spec: string, bundle: any, lock: Map<string, any>, force: boolean, dir: string | null }} input - facts.
|
|
292
|
+
* @returns {Promise<object | null>}
|
|
293
|
+
*/
|
|
294
|
+
async function checkPlugin(ctx, { name, spec, bundle, lock, force, dir }) {
|
|
295
|
+
const installed = await readInstalledManifest(dir, name);
|
|
296
|
+
if (bundle === undefined && installed?.dsh?.bundle === undefined) return null;
|
|
297
|
+
if (bundle !== undefined && bundle.availability === "installation") return null;
|
|
298
|
+
const info = parseDependencySpec(spec);
|
|
299
|
+
const installedCommit = commitFromResolution(lock.get(name)?.version);
|
|
300
|
+
const cached = checkCache.get(name);
|
|
301
|
+
let check;
|
|
302
|
+
if (!force && cached !== undefined && cached.installedCommit === installedCommit && Date.now() - cached.at < CHECK_TTL_MS) {
|
|
303
|
+
check = cached.check;
|
|
304
|
+
} else {
|
|
305
|
+
check = await resolveCheck(ctx, { name, info, installedCommit, bundle });
|
|
306
|
+
checkCache.set(name, { at: Date.now(), installedCommit, check });
|
|
307
|
+
}
|
|
308
|
+
// A bundle the manager cannot read right now (for example between a removal
|
|
309
|
+
// and its reinstallation) keeps its row, but offers no update action until
|
|
310
|
+
// the next read sees it again.
|
|
311
|
+
if ((bundle === undefined || bundle.availability !== "profile") && check.status !== "unsupported") {
|
|
312
|
+
check = { ...check, status: "unknown", reason: "unavailable" };
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
name,
|
|
316
|
+
title: bundle?.meta?.title ?? null,
|
|
317
|
+
description: bundle?.meta?.description ?? null,
|
|
318
|
+
version: bundle?.version ?? installed?.version ?? null,
|
|
319
|
+
enabled: typeof bundle?.enabled === "boolean" ? bundle.enabled : null,
|
|
320
|
+
official: bundle?.official === true,
|
|
321
|
+
source: redactSource(bundle?.source ?? spec),
|
|
322
|
+
kind: info.kind,
|
|
323
|
+
check,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run one worker per item with a bounded number of concurrent workers.
|
|
329
|
+
* @template T, R
|
|
330
|
+
* @param {readonly T[]} items - work items.
|
|
331
|
+
* @param {number} limit - concurrency bound.
|
|
332
|
+
* @param {(item: T) => Promise<R>} worker - per-item work.
|
|
333
|
+
* @returns {Promise<R[]>}
|
|
334
|
+
*/
|
|
335
|
+
async function mapLimit(items, limit, worker) {
|
|
336
|
+
const results = new Array(items.length);
|
|
337
|
+
let next = 0;
|
|
338
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
339
|
+
for (;;) {
|
|
340
|
+
const index = next;
|
|
341
|
+
next += 1;
|
|
342
|
+
if (index >= items.length) return;
|
|
343
|
+
results[index] = await worker(items[index]);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
await Promise.all(runners);
|
|
347
|
+
return results;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Serve the installed-plugin list and its update checks.
|
|
352
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
353
|
+
* @param {boolean} force - bypass the short check cache.
|
|
354
|
+
* @returns {Promise<object>}
|
|
355
|
+
*/
|
|
356
|
+
async function statusPayload(ctx, force) {
|
|
357
|
+
const state = await profileState(ctx);
|
|
358
|
+
const bundles = (await ctx.pluginManager.listBundles().catch(() => [])) ?? [];
|
|
359
|
+
const byName = new Map(bundles.map((bundle) => [bundle.name, bundle]));
|
|
360
|
+
const specs = new Map();
|
|
361
|
+
if (state.manifest?.dependencies !== null && typeof state.manifest?.dependencies === "object") {
|
|
362
|
+
for (const [name, spec] of Object.entries(state.manifest.dependencies)) {
|
|
363
|
+
if (typeof spec === "string") specs.set(name, spec);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
for (const bundle of bundles) {
|
|
367
|
+
if (bundle.installed === true && bundle.availability === "profile" && typeof bundle.source === "string") specs.set(bundle.name, bundle.source);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const names = [...specs.keys()];
|
|
371
|
+
const entries = await mapLimit(names, CHECK_CONCURRENCY, (name) =>
|
|
372
|
+
checkPlugin(ctx, { name, spec: specs.get(name), bundle: byName.get(name), lock: state.lock, force, dir: state.dir }),
|
|
373
|
+
);
|
|
374
|
+
return {
|
|
375
|
+
checkedAt: Date.now(),
|
|
376
|
+
hmr: ctx.get("hmr") !== undefined,
|
|
377
|
+
profileKnown: state.dir !== null,
|
|
378
|
+
plugins: entries.filter((entry) => entry !== null),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Update one plugin, mutating the profile through the plugin manager.
|
|
384
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
385
|
+
* @param {string} name - installed package name.
|
|
386
|
+
* @returns {Promise<Response>}
|
|
387
|
+
*/
|
|
388
|
+
async function updatePayload(ctx, name) {
|
|
389
|
+
const state = await profileState(ctx);
|
|
390
|
+
const spec = state.manifest?.dependencies?.[name];
|
|
391
|
+
if (typeof spec !== "string") {
|
|
392
|
+
return reply(404, { ok: false, status: "failed", error: { code: "no-dependency", detail: name } });
|
|
393
|
+
}
|
|
394
|
+
const bundles = (await ctx.pluginManager.listBundles().catch(() => [])) ?? [];
|
|
395
|
+
const bundle = bundles.find((candidate) => candidate.name === name && candidate.availability === "profile");
|
|
396
|
+
if (bundle === undefined) {
|
|
397
|
+
return reply(404, { ok: false, status: "failed", error: { code: "no-bundle", detail: name } });
|
|
398
|
+
}
|
|
399
|
+
const info = parseDependencySpec(spec);
|
|
400
|
+
if (info.kind === "git") return updateGitPlugin(ctx, { name, spec, info, bundle });
|
|
401
|
+
if (info.kind === "registry") return updateRegistryPlugin(ctx, { name, bundle });
|
|
402
|
+
return reply(422, {
|
|
403
|
+
ok: false,
|
|
404
|
+
status: "unsupported",
|
|
405
|
+
kind: info.kind,
|
|
406
|
+
source: redactSource(spec),
|
|
407
|
+
error: { code: "unsupported-source", detail: info.kind },
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Install one spec after its previous version was removed.
|
|
413
|
+
*
|
|
414
|
+
* A failure that happened before the package was installed — the manager's
|
|
415
|
+
* GitHub reachability check or a pnpm network error — is retried, because the
|
|
416
|
+
* plugin is already uninstalled and a transient network problem should not
|
|
417
|
+
* leave it that way.
|
|
418
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
419
|
+
* @param {string} spec - recorded install spec.
|
|
420
|
+
* @param {{ enabled: boolean }} options - install options.
|
|
421
|
+
* @returns {Promise<{ result?: any, thrown?: unknown, attempts: number }>}
|
|
422
|
+
*/
|
|
423
|
+
async function installWithRetry(ctx, spec, options) {
|
|
424
|
+
const maxAttempts = 3;
|
|
425
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
result = await ctx.pluginManager.installBundle(spec, options);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
return { thrown: error, attempts: attempt };
|
|
431
|
+
}
|
|
432
|
+
const transient = result?.application === "failed"
|
|
433
|
+
&& (result.failedAt === "spec-host" || result.packageResult?.kind === "timeout" || result.packageResult?.kind === "network");
|
|
434
|
+
if (!transient || attempt === maxAttempts) return { result, attempts: attempt };
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 1500 * attempt));
|
|
436
|
+
}
|
|
437
|
+
return { result: undefined, attempts: maxAttempts };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Remove and reinstall a Git-sourced plugin, resolving the newest commit.
|
|
442
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
443
|
+
* @param {{ name: string, spec: string, info: ReturnType<typeof parseDependencySpec>, bundle: any }} input - facts.
|
|
444
|
+
* @returns {Promise<Response>}
|
|
445
|
+
*/
|
|
446
|
+
async function updateGitPlugin(ctx, { name, spec, info, bundle }) {
|
|
447
|
+
if (info.semver !== null) {
|
|
448
|
+
return reply(422, { ok: false, status: "unsupported", kind: "git", source: redactSource(spec), error: { code: "semver-range", detail: info.semver } });
|
|
449
|
+
}
|
|
450
|
+
let remoteCommit;
|
|
451
|
+
try {
|
|
452
|
+
remoteCommit = await latestCommit(info);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
return reply(502, {
|
|
455
|
+
ok: false,
|
|
456
|
+
status: "failed",
|
|
457
|
+
kind: "git",
|
|
458
|
+
step: "check",
|
|
459
|
+
source: redactSource(spec),
|
|
460
|
+
error: { code: "remote-unreachable", detail: truncate(String(error?.message ?? error)) },
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const before = await profileState(ctx);
|
|
464
|
+
const installedCommit = commitFromResolution(before.lock.get(name)?.version);
|
|
465
|
+
if (installedCommit !== null && installedCommit === remoteCommit) {
|
|
466
|
+
checkCache.delete(name);
|
|
467
|
+
return reply(200, {
|
|
468
|
+
ok: true,
|
|
469
|
+
status: "current",
|
|
470
|
+
kind: "git",
|
|
471
|
+
from: { version: bundle.version ?? null, commit: installedCommit },
|
|
472
|
+
to: { version: await remoteVersion(info, remoteCommit), commit: remoteCommit },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let removal;
|
|
477
|
+
try {
|
|
478
|
+
removal = await ctx.pluginManager.removeBundle(name);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
return reply(502, {
|
|
481
|
+
ok: false,
|
|
482
|
+
status: "failed",
|
|
483
|
+
kind: "git",
|
|
484
|
+
step: "remove",
|
|
485
|
+
removed: false,
|
|
486
|
+
source: redactSource(spec),
|
|
487
|
+
error: { code: "removal-failed", detail: truncate(String(error?.message ?? error)) },
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (removal === null || removal === undefined || removal.application !== "applied" || removal.error !== undefined) {
|
|
491
|
+
return reply(502, {
|
|
492
|
+
ok: false,
|
|
493
|
+
status: "failed",
|
|
494
|
+
kind: "git",
|
|
495
|
+
step: "remove",
|
|
496
|
+
removed: false,
|
|
497
|
+
source: redactSource(spec),
|
|
498
|
+
error: { code: "removal-failed", detail: errorDetail(removal?.error) },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
checkCache.delete(name);
|
|
503
|
+
const installation = await installWithRetry(ctx, spec, { enabled: bundle.enabled === true });
|
|
504
|
+
if (installation.thrown !== undefined) {
|
|
505
|
+
return reply(502, {
|
|
506
|
+
ok: false,
|
|
507
|
+
status: "failed",
|
|
508
|
+
kind: "git",
|
|
509
|
+
step: "install",
|
|
510
|
+
removed: true,
|
|
511
|
+
source: redactSource(spec),
|
|
512
|
+
to: { version: null, commit: remoteCommit },
|
|
513
|
+
error: { code: "install-failed", detail: truncate(String(installation.thrown?.message ?? installation.thrown)) },
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const installedResult = installation.result;
|
|
517
|
+
const fresh = await profileState(ctx);
|
|
518
|
+
const freshBundles = (await ctx.pluginManager.listBundles().catch(() => [])) ?? [];
|
|
519
|
+
const freshBundle = freshBundles.find((candidate) => candidate.name === name);
|
|
520
|
+
const to = { version: freshBundle?.version ?? null, commit: commitFromResolution(fresh.lock.get(name)?.version) ?? remoteCommit };
|
|
521
|
+
const application = installedResult?.application;
|
|
522
|
+
if (installedResult === undefined || application === "failed" || application === "cancelled") {
|
|
523
|
+
return reply(502, {
|
|
524
|
+
ok: false,
|
|
525
|
+
status: "failed",
|
|
526
|
+
kind: "git",
|
|
527
|
+
step: "install",
|
|
528
|
+
removed: true,
|
|
529
|
+
source: redactSource(spec),
|
|
530
|
+
to,
|
|
531
|
+
application,
|
|
532
|
+
attempts: installation.attempts,
|
|
533
|
+
error: { code: "install-failed", detail: installDetail(installedResult) },
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return reply(200, {
|
|
537
|
+
ok: true,
|
|
538
|
+
status: application === "restart-required" ? "restart-required" : "updated",
|
|
539
|
+
kind: "git",
|
|
540
|
+
from: { version: bundle.version ?? null, commit: installedCommit },
|
|
541
|
+
to,
|
|
542
|
+
application,
|
|
543
|
+
attempts: installation.attempts,
|
|
544
|
+
restartReason: installedResult?.restartReason ?? null,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Update a registry-installed plugin through the plugin manager's own path.
|
|
550
|
+
* @param {import("cordis").Context} ctx - Host context.
|
|
551
|
+
* @param {{ name: string, bundle: any }} input - facts.
|
|
552
|
+
* @returns {Promise<Response>}
|
|
553
|
+
*/
|
|
554
|
+
async function updateRegistryPlugin(ctx, { name, bundle }) {
|
|
555
|
+
let answer;
|
|
556
|
+
try {
|
|
557
|
+
answer = await ctx.pluginManager.listVersions(name);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
return reply(502, { ok: false, status: "failed", kind: "registry", error: { code: "version-check-failed", detail: truncate(String(error?.message ?? error)) } });
|
|
560
|
+
}
|
|
561
|
+
if (answer?.status !== "listed") {
|
|
562
|
+
return reply(502, {
|
|
563
|
+
ok: false,
|
|
564
|
+
status: "failed",
|
|
565
|
+
kind: "registry",
|
|
566
|
+
error: { code: "version-check-failed", detail: truncate(answer?.reason ?? answer?.problem ?? "") },
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const target = answer.versions.find(
|
|
570
|
+
(version) => version.bundle === true && version.prerelease !== true && version.deprecated === undefined && version.incompatible === undefined,
|
|
571
|
+
);
|
|
572
|
+
if (target === undefined) {
|
|
573
|
+
return reply(200, { ok: true, status: "current", kind: "registry", from: { version: bundle.version ?? null, commit: null }, to: { version: null, commit: null } });
|
|
574
|
+
}
|
|
575
|
+
let result;
|
|
576
|
+
try {
|
|
577
|
+
result = await ctx.pluginManager.updateBundle(name, target.version, { requestId: `version-watch-${Date.now().toString(36)}` });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
return reply(502, { ok: false, status: "failed", kind: "registry", error: { code: "update-failed", detail: truncate(String(error?.message ?? error)) } });
|
|
580
|
+
}
|
|
581
|
+
const application = result?.result?.application;
|
|
582
|
+
if (application === "failed" || application === "cancelled") {
|
|
583
|
+
return reply(502, {
|
|
584
|
+
ok: false,
|
|
585
|
+
status: "failed",
|
|
586
|
+
kind: "registry",
|
|
587
|
+
step: result?.step ?? "install",
|
|
588
|
+
to: { version: target.version, commit: null },
|
|
589
|
+
application,
|
|
590
|
+
error: { code: "install-failed", detail: installDetail(result?.result) },
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return reply(200, {
|
|
594
|
+
ok: true,
|
|
595
|
+
status: application === "restart-required" ? "restart-required" : "updated",
|
|
596
|
+
kind: "registry",
|
|
597
|
+
from: { version: bundle.version ?? null, commit: null },
|
|
598
|
+
to: { version: target.version, commit: null },
|
|
599
|
+
application,
|
|
600
|
+
restartReason: result?.result?.restartReason ?? null,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Register the two routes while the services they need exist.
|
|
606
|
+
* @param {import("cordis").Context} ctx - Host plugin context.
|
|
607
|
+
*/
|
|
608
|
+
export function apply(ctx) { ctx.inject(["connection", "pluginManager"], (scope) => {
|
|
609
|
+
scope.effect(
|
|
610
|
+
() =>
|
|
611
|
+
scope.connection.fetch.register({
|
|
612
|
+
path: STATUS_ROUTE,
|
|
613
|
+
methods: ["GET"],
|
|
614
|
+
requestBody: "buffered",
|
|
615
|
+
fetch: async (request) => {
|
|
616
|
+
const force = new URL(request.url).searchParams.get("refresh") === "1";
|
|
617
|
+
try {
|
|
618
|
+
return reply(200, await statusPayload(scope, force));
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return reply(500, { plugins: [], error: { code: "status-failed", detail: truncate(String(error?.message ?? error)) } });
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
"version-watch: status route",
|
|
625
|
+
);
|
|
626
|
+
scope.effect(
|
|
627
|
+
() =>
|
|
628
|
+
scope.connection.fetch.register({
|
|
629
|
+
path: UPDATE_ROUTE,
|
|
630
|
+
methods: ["POST"],
|
|
631
|
+
requestBody: "buffered",
|
|
632
|
+
fetch: async (request) => {
|
|
633
|
+
if (updateRunning) {
|
|
634
|
+
return reply(409, { ok: false, status: "failed", error: { code: "busy", detail: "another update is running" } });
|
|
635
|
+
}
|
|
636
|
+
const body = await request.json().catch(() => null);
|
|
637
|
+
const target = body?.name;
|
|
638
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
639
|
+
return reply(400, { ok: false, status: "failed", error: { code: "invalid-name", detail: "" } });
|
|
640
|
+
}
|
|
641
|
+
updateRunning = true;
|
|
642
|
+
try {
|
|
643
|
+
return await updatePayload(scope, target.trim());
|
|
644
|
+
} catch (error) {
|
|
645
|
+
return reply(500, { ok: false, status: "failed", error: { code: "update-failed", detail: truncate(String(error?.message ?? error)) } });
|
|
646
|
+
} finally {
|
|
647
|
+
updateRunning = false;
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
}),
|
|
651
|
+
"version-watch: update route",
|
|
652
|
+
);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Internals exercised by the host tests; not part of the plugin contract. */
|
|
657
|
+
export const __test = { latestCommit, resolveCheck, statusPayload, updatePayload, updateGitPlugin };
|