dsh-plugin-update-checker 0.0.0-stage → 0.1.5

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/lib/client.js ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * dsh-plugin-update-checker — Browser half.
3
+ *
4
+ * Registers a "Check updates" tab into the Built-in plugins settings section
5
+ * (`settings.plugins.tab`), sitting beside the read-only inventory. The tab
6
+ * shows the running DSH runtime version, a prominent "Check updates" button,
7
+ * and — after the check — one row per installed plugin bundle reporting both:
8
+ *
9
+ * - compatibility with the current DSH runtime (peer-dependency verdict), and
10
+ * - update availability (published registry version vs the installed one).
11
+ *
12
+ * It calls only the host route this package ships (`/api/plugin-update-checker/
13
+ * scan`); it renders no local transport. A failed per-bundle lookup is shown as
14
+ * a row-level warning rather than blanking the report.
15
+ *
16
+ * Bundle id MUST equal package.json `name` (the dsh-client-modules Loader row
17
+ * resolver matches the registered module id to the package name).
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: "dsh-plugin-update-checker",
21
+ factory: (require) => {
22
+ var module = { exports: {} };
23
+ var exports = module.exports;
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
25
+ let react = require("react");
26
+ let react_jsx_runtime = require("react/jsx-runtime");
27
+
28
+ //#region dictionaries
29
+ const NS = "pluginUpdateChecker";
30
+ const zh = {
31
+ "tab": "检查更新",
32
+ "title": "插件更新与兼容性检查",
33
+ "intro": "一键检查所有已安装插件的最新版本,以及它们与当前 DSH 运行时的兼容性。",
34
+ "dshVersion": "当前 DSH 版本",
35
+ "check": "检查更新",
36
+ "checking": "检查中…",
37
+ "lastChecked": "上次检查",
38
+ "never": "尚未检查",
39
+ "error": "检查失败",
40
+ "retry": "重试",
41
+ "noPlugins": "没有检测到任何已安装的插件包。",
42
+ "col.plugin": "插件",
43
+ "col.installed": "已安装版本",
44
+ "col.latest": "最新版本",
45
+ "col.compat": "兼容性",
46
+ "col.update": "更新",
47
+ "compat.compatible": "兼容",
48
+ "compat.incompatible": "不兼容",
49
+ "compat.unknown": "未知",
50
+ "update.upToDate": "已是最新",
51
+ "update.updateAvailable": "有更新",
52
+ "update.unknown": "未知",
53
+ "update.unsupported": "不受支持",
54
+ "update.partOfRuntime": "运行时的一部分",
55
+ "update.error": "无法检查",
56
+ "compat.peers": "不兼容的 peer 依赖",
57
+ "exempted": "已豁免",
58
+ "updateBtn": "更新",
59
+ "updating": "更新中…",
60
+ "updated": "已更新",
61
+ "updateFailed": "更新失败",
62
+ "linked": "源码链接(请从源码更新)",
63
+ "linkedHint": "该插件以源码软链方式安装,无法一键更新;请到源码目录拉取最新代码后重启 DSH。",
64
+ "sourceLink": "前往源码仓库",
65
+ "runtimeName": "DSH 运行时",
66
+ "runtimeUpdateAvailable": "有可用更新(通过 DSH 自带更新器升级)",
67
+ "runtimeUpdateHint": "DSH 运行时由应用自带的更新器管理,无法在此一键更新。请在菜单中检查更新,或重新启动应用以应用更新。",
68
+ "sourcePath": "源码路径",
69
+ "restartRequired": "已更新,需重启 DSH 生效",
70
+ "manualRestart": "请手动重启:先停止 3080 端口上的 DSH 进程,再运行 `dsh restart` 或重新启动 DSH(也可在终端执行 `dsh stop && dsh restart`)。重启后更新才会生效。",
71
+ "summary": "共 {total} 个插件:{incompatible} 个不兼容,{available} 个有可用更新。"
72
+ };
73
+ const en = {
74
+ "tab": "Check updates",
75
+ "title": "Plugin update & compatibility check",
76
+ "intro": "Check every installed plugin for the latest published version and its compatibility with the running DSH runtime in one click.",
77
+ "dshVersion": "Running DSH version",
78
+ "check": "Check updates",
79
+ "checking": "Checking…",
80
+ "lastChecked": "Last checked",
81
+ "never": "Not checked yet",
82
+ "error": "Check failed",
83
+ "retry": "Retry",
84
+ "noPlugins": "No installed plugin bundles detected.",
85
+ "col.plugin": "Plugin",
86
+ "col.installed": "Installed",
87
+ "col.latest": "Latest",
88
+ "col.compat": "Compatibility",
89
+ "col.update": "Update",
90
+ "compat.compatible": "Compatible",
91
+ "compat.incompatible": "Incompatible",
92
+ "compat.unknown": "Unknown",
93
+ "update.upToDate": "Up to date",
94
+ "update.updateAvailable": "Update available",
95
+ "update.unknown": "Unknown",
96
+ "update.unsupported": "Unsupported",
97
+ "update.partOfRuntime": "Part of the runtime",
98
+ "update.error": "Cannot check",
99
+ "compat.peers": "Incompatible peer dependencies",
100
+ "exempted": "Exempted",
101
+ "updateBtn": "Update",
102
+ "updating": "Updating…",
103
+ "updated": "Updated",
104
+ "updateFailed": "Update failed",
105
+ "linked": "Source-linked (update from source)",
106
+ "linkedHint": "This plugin is installed as a source symlink and cannot be updated in one click. Pull the latest from its source repo, then restart DSH.",
107
+ "sourceLink": "Open source repository",
108
+ "runtimeName": "DSH runtime",
109
+ "runtimeUpdateAvailable": "Update available (upgrade via the app's own updater)",
110
+ "runtimeUpdateHint": "The DSH runtime is managed by the app's built-in updater and cannot be updated here. Check for updates from the menu, or restart the app to apply an update.",
111
+ "sourcePath": "Source path",
112
+ "restartRequired": "Updated — restart DSH to apply",
113
+ "manualRestart": "Restart DSH manually: stop the DSH process on port 3080, then run `dsh restart` (or `dsh stop && dsh restart`) from a terminal. The update takes effect only after the restart.",
114
+ "summary": "{total} plugins: {incompatible} incompatible, {available} with an update available."
115
+ };
116
+ //#endregion
117
+
118
+ const COLORS = {
119
+ primary: "var(--dsw-alias-state-business-primary)",
120
+ error: "var(--dsw-alias-state-error-primary)",
121
+ warning: "var(--dsw-alias-state-warning-primary, #c9851f)",
122
+ ok: "var(--dsw-alias-state-success-primary, #1f9d55)",
123
+ label: "var(--dsw-alias-label-primary)",
124
+ tertiary: "var(--dsw-alias-label-tertiary)",
125
+ border: "var(--dsw-alias-border-l4)",
126
+ bg: "var(--dsw-alias-bg-layer-1)",
127
+ // Referenced by the source-repository link. It used to be missing, so
128
+ // the literal fallback fired on every render and the link ignored the
129
+ // theme; declare the token so the link follows the palette.
130
+ link: "var(--dsw-alias-label-primary-bluish, #3b82f6)"
131
+ };
132
+
133
+ function badge(text, color) {
134
+ return react_jsx_runtime.jsx("span", {
135
+ style: {
136
+ display: "inline-flex",
137
+ alignItems: "center",
138
+ height: 18,
139
+ padding: "0 7px",
140
+ fontSize: 10,
141
+ fontWeight: 600,
142
+ lineHeight: 1,
143
+ borderRadius: "var(--dsw-radius-sm)",
144
+ color: color,
145
+ background: "color-mix(in srgb, " + color + " 14%, transparent)",
146
+ whiteSpace: "nowrap"
147
+ },
148
+ children: text
149
+ });
150
+ }
151
+
152
+ function compatBadge(compat, t) {
153
+ if (compat.status === "incompatible") {
154
+ return badge(compat.exempted ? t("compat.incompatible") + " · " + t("exempted") : t("compat.incompatible"), COLORS.error);
155
+ }
156
+ if (compat.status === "unknown") return badge(t("compat.unknown"), COLORS.tertiary);
157
+ return badge(t("compat.compatible"), COLORS.ok);
158
+ }
159
+
160
+ function updateBadge(update, t) {
161
+ switch (update.status) {
162
+ case "update-available":
163
+ return badge(t("update.updateAvailable"), COLORS.warning);
164
+ case "up-to-date":
165
+ return badge(t("update.upToDate"), COLORS.ok);
166
+ case "part-of-runtime":
167
+ return badge(t("update.partOfRuntime"), COLORS.tertiary);
168
+ case "unsupported":
169
+ return badge(t("update.unsupported"), COLORS.tertiary);
170
+ case "unknown":
171
+ default:
172
+ return update.error ? badge(t("update.error"), COLORS.tertiary) : badge(t("update.unknown"), COLORS.tertiary);
173
+ }
174
+ }
175
+
176
+ function UpdateCheckerTab(props) {
177
+ const { t, scan } = props;
178
+ const [loading, setLoading] = react.useState(false);
179
+ const [error, setError] = react.useState(null);
180
+ const [result, setResult] = react.useState(null);
181
+ // Per-plugin update state keyed by bundle name.
182
+ const [updating, setUpdating] = react.useState({});
183
+ const [outcome, setOutcome] = react.useState({});
184
+
185
+ const run = react.useCallback(async () => {
186
+ setLoading(true);
187
+ setError(null);
188
+ try {
189
+ // Use the `scan` handed in by the tab registration instead of a
190
+ // second fetch here: one transport, one place to change it.
191
+ const data = await scan();
192
+ if (!data || data.ok !== true) throw new Error(data?.error || "scan failed");
193
+ setResult(data);
194
+ setOutcome({});
195
+ } catch (err) {
196
+ setError(String(err?.message ?? err));
197
+ } finally {
198
+ setLoading(false);
199
+ }
200
+ }, [scan]);
201
+
202
+ const doUpdate = react.useCallback(async (name, latest, registry) => {
203
+ // The runtime ('dsh-runtime') is not installed via pnpm and must not
204
+ // be updated with installBundle; its upgrade is delivered by the app's
205
+ // own updater. The compute below already hides the button for any
206
+ // non-"update-available" row, but guard anyway so a future change
207
+ // cannot route a runtime upgrade into installBundle.
208
+ if (name === "dsh-runtime") return;
209
+ setUpdating((s) => ({ ...s, [name]: true }));
210
+ setOutcome((s) => { const n = { ...s }; delete n[name]; return n; });
211
+ try {
212
+ const pm = props.remote?.pluginManager;
213
+ if (!pm?.installBundle) throw new Error("plugin manager service unavailable in this client");
214
+ // installBundle defaults to registry.npmjs.org, which is often
215
+ // unreachable here; probe the reachable registry first (mirrors
216
+ // the GUI's own fast-registry selection) and pass it explicitly.
217
+ // fastest() answers a Remote envelope { ok, value } — unwrap it;
218
+ // passing the envelope itself fails wire validation (registry
219
+ // must be null | string).
220
+ let reg = typeof registry === "string" && registry !== "" ? registry : "";
221
+ if (!reg) {
222
+ try {
223
+ const probe = await props.remote?.pluginRegistryProbe?.fastest();
224
+ if (probe?.ok && typeof probe.value === "string" && probe.value !== "") reg = probe.value;
225
+ } catch { /* keep empty */ }
226
+ }
227
+ // Pin to the exact latest version. A bare `name` spec leaves a
228
+ // profile that already pins the exact version (e.g. "0.6.5")
229
+ // unchanged, so installBundle reports a false "restart-required"
230
+ // without actually upgrading — leaving the old version after
231
+ // restart. `name@latest` forces pnpm to swap the version, exactly
232
+ // like the GUI's "Add plugin" dialog (which passes the resolved
233
+ // latest version as the spec).
234
+ const spec = typeof latest === "string" && latest !== "" ? `${name}@${latest}` : name;
235
+ const result = await pm.installBundle(spec, {
236
+ enabled: true,
237
+ requestId: `update-checker:${name}:${Date.now()}`,
238
+ ...(reg ? { registry: reg } : {}),
239
+ });
240
+ if (!result?.ok) throw new Error(result?.error?.message || result?.error?.code || "install failed");
241
+ const value = result.value ?? {};
242
+ const application = value.application ?? "applied";
243
+ // `changed` is set by the host when the profile was actually
244
+ // modified (pnpm really swapped the version). installBundle may
245
+ // still report ok without changing anything (e.g. a bare spec
246
+ // against an already-pinned version), so surface that.
247
+ const changed = value.changed === true;
248
+ if (!changed) throw new Error("registry returned no newer version; profile unchanged");
249
+ setOutcome((s) => ({ ...s, [name]: { ok: true, restartRequired: application === "restart-required", application } }));
250
+ // When the change is live immediately, re-scan so the row flips
251
+ // to "up to date" instead of still showing "update available".
252
+ if (application !== "restart-required") { try { await run(); } catch { /* ignore */ } }
253
+ } catch (err) {
254
+ setOutcome((s) => ({ ...s, [name]: { ok: false, error: String(err?.message ?? err) } }));
255
+ } finally {
256
+ setUpdating((s) => ({ ...s, [name]: false }));
257
+ }
258
+ }, [run]);
259
+
260
+ const plugins = result?.plugins ?? [];
261
+ const incompatible = plugins.filter((p) => p.compatibility?.status === "incompatible").length;
262
+ const available = plugins.filter((p) => p.update?.status === "update-available").length;
263
+
264
+ return react_jsx_runtime.jsxs("div", {
265
+ style: { width: "100%", maxWidth: 760, display: "flex", flexDirection: "column", gap: 14, color: COLORS.label },
266
+ children: [
267
+ react_jsx_runtime.jsxs("div", {
268
+ style: { display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap" },
269
+ children: [
270
+ react_jsx_runtime.jsx("h3", { style: { margin: 0, fontSize: 13, fontWeight: 600 }, children: t("dshVersion") }),
271
+ react_jsx_runtime.jsx("code", { style: { fontSize: 13, color: COLORS.tertiary }, children: result?.dshVersion ?? (loading ? "…" : "—") })
272
+ ]
273
+ }),
274
+ react_jsx_runtime.jsxs("div", {
275
+ style: { display: "flex", alignItems: "center", gap: 12 },
276
+ children: [
277
+ react_jsx_runtime.jsx("button", {
278
+ type: "button",
279
+ disabled: loading,
280
+ onClick: () => run(),
281
+ style: {
282
+ appearance: "none",
283
+ cursor: loading ? "default" : "pointer",
284
+ border: "none",
285
+ borderRadius: "var(--dsw-radius-md)",
286
+ background: COLORS.primary,
287
+ color: "var(--dsw-alias-on-state-business, #fff)",
288
+ font: "inherit",
289
+ fontWeight: 600,
290
+ height: 36,
291
+ padding: "0 18px"
292
+ },
293
+ children: loading ? t("checking") : t("check")
294
+ }),
295
+ result ? react_jsx_runtime.jsx("span", { style: { fontSize: 13, color: COLORS.tertiary }, children: t("lastChecked") + ": " + new Date(result.checkedAt).toLocaleString() }) : null
296
+ ]
297
+ }),
298
+ error ? react_jsx_runtime.jsxs("div", {
299
+ style: { display: "flex", alignItems: "center", gap: 10, color: COLORS.error, fontSize: 13 },
300
+ children: [
301
+ react_jsx_runtime.jsx("span", { children: t("error") + ": " + error }),
302
+ react_jsx_runtime.jsx("button", {
303
+ type: "button",
304
+ onClick: () => run(),
305
+ style: { border: "0.5px solid " + COLORS.border, borderRadius: "var(--dsw-radius-sm)", background: "transparent", color: COLORS.label, font: "inherit", cursor: "pointer", padding: "4px 10px" },
306
+ children: t("retry")
307
+ })
308
+ ]
309
+ }) : null,
310
+ result && plugins.length > 0 ? react_jsx_runtime.jsx("p", {
311
+ style: { margin: 0, fontSize: 13, color: COLORS.tertiary },
312
+ children: t("summary", { total: plugins.length, incompatible, available })
313
+ }) : null,
314
+ !loading && result && plugins.length === 0 ? react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 13, color: COLORS.tertiary }, children: t("noPlugins") }) : null,
315
+ Object.values(outcome).some((o) => o?.restartRequired === true) ? react_jsx_runtime.jsxs("div", {
316
+ style: { display: "flex", flexDirection: "column", gap: 6, border: "0.5px solid " + COLORS.warning, borderRadius: "var(--dsw-radius-md)", background: "color-mix(in srgb, " + COLORS.warning + " 10%, transparent)", padding: "10px 12px", fontSize: 13, color: COLORS.warning },
317
+ children: [
318
+ react_jsx_runtime.jsx("span", { style: { fontWeight: 600 }, children: t("restartRequired") }),
319
+ react_jsx_runtime.jsx("span", { style: { color: COLORS.label, fontSize: 12 }, children: t("manualRestart") })
320
+ ]
321
+ }) : null,
322
+ result && plugins.length > 0 ? react_jsx_runtime.jsx("div", {
323
+ style: { display: "flex", flexDirection: "column", gap: 10 },
324
+ children: plugins.map((p) => react_jsx_runtime.jsxs("div", {
325
+ key: p.name,
326
+ style: { border: "0.5px solid " + COLORS.border, borderRadius: "var(--dsw-radius-md)", background: COLORS.bg, padding: "12px 14px", display: "flex", flexDirection: "column", gap: 8 },
327
+ children: [
328
+ react_jsx_runtime.jsxs("div", {
329
+ style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" },
330
+ children: [
331
+ react_jsx_runtime.jsx("span", { style: { fontWeight: 600, fontSize: 13 }, children: p.name === "dsh-runtime" ? t("runtimeName") : p.name }),
332
+ react_jsx_runtime.jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }, children: [compatBadge(p.compatibility, t), updateBadge(p.update, t), (() => {
333
+ if (p.linked) return badge(t("linked"), COLORS.tertiary);
334
+ // The runtime is updated via the app's own updater, never
335
+ // installBundle; show a "check the menu" badge, not a button.
336
+ if (p.name === "dsh-runtime" && p.update?.status === "update-available") {
337
+ return react_jsx_runtime.jsx("span", { style: { fontSize: 12, color: COLORS.warning }, children: t("runtimeUpdateAvailable") });
338
+ }
339
+ if (p.update?.status !== "update-available") return null;
340
+ const busy = updating[p.name] === true;
341
+ const o = outcome[p.name];
342
+ if (o?.ok) return badge(o.restartRequired ? t("restartRequired") : t("updated"), COLORS.ok);
343
+ return react_jsx_runtime.jsx("button", {
344
+ type: "button",
345
+ disabled: busy,
346
+ onClick: () => doUpdate(p.name, p.update?.latest, p.update?.registry ?? p.registry),
347
+ style: { appearance: "none", cursor: busy ? "default" : "pointer", border: "none", borderRadius: "var(--dsw-radius-sm)", background: COLORS.warning, color: "#fff", font: "inherit", fontWeight: 600, fontSize: 12, height: 24, padding: "0 12px" },
348
+ children: busy ? t("updating") : t("updateBtn")
349
+ });
350
+ })()] })
351
+ ]
352
+ }),
353
+ react_jsx_runtime.jsxs("div", {
354
+ style: { display: "flex", gap: 16, fontSize: 12, color: COLORS.tertiary, flexWrap: "wrap" },
355
+ children: [
356
+ react_jsx_runtime.jsxs("span", { children: [t("col.installed") + ": ", react_jsx_runtime.jsx("code", { children: p.installedVersion ?? "—" })] }),
357
+ react_jsx_runtime.jsxs("span", { children: [t("col.latest") + ": ", react_jsx_runtime.jsx("code", { children: p.linked ? t("linked") : (p.update?.latest ?? "—") })] })
358
+ ]
359
+ }),
360
+ p.compatibility?.status === "incompatible" && p.compatibility.peers ? react_jsx_runtime.jsxs("div", {
361
+ style: { fontSize: 12, color: COLORS.error },
362
+ children: [
363
+ react_jsx_runtime.jsx("span", { children: t("compat.peers") + ": " }),
364
+ react_jsx_runtime.jsxs("code", { children: Object.entries(p.compatibility.peers).map((e, _i) => react_jsx_runtime.jsx("div", { children: e[0] + " " + e[1] }, e[0])) })
365
+ ]
366
+ }) : null,
367
+ p.update?.status === "unknown" && p.update.error ? react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: COLORS.tertiary }, children: p.update.error }) : null,
368
+ p.update?.status === "unsupported" && p.update.error ? react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: COLORS.tertiary }, children: p.update.error }) : null,
369
+ p.linked ? react_jsx_runtime.jsxs("div", {
370
+ style: { display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: COLORS.tertiary },
371
+ children: [
372
+ react_jsx_runtime.jsx("span", { children: t("linkedHint") }),
373
+ react_jsx_runtime.jsx("span", { children: [t("sourcePath") + ": ", react_jsx_runtime.jsx("code", { children: p.source?.dir ?? "—" })] }),
374
+ p.source?.link ? react_jsx_runtime.jsx("a", {
375
+ href: p.source.link,
376
+ target: "_blank",
377
+ rel: "noreferrer",
378
+ style: { color: COLORS.link },
379
+ children: t("sourceLink")
380
+ }) : null
381
+ ]
382
+ }) : null,
383
+ outcome[p.name]?.ok === false ? react_jsx_runtime.jsxs("div", { style: { fontSize: 12, color: COLORS.error }, children: [react_jsx_runtime.jsx("span", { children: t("updateFailed") + ": " }), react_jsx_runtime.jsx("span", { children: outcome[p.name].error || "unknown error" })] }) : null,
384
+ p.name === "dsh-runtime" && p.update?.status === "update-available" ? react_jsx_runtime.jsx("div", {
385
+ style: { fontSize: 12, color: COLORS.warning },
386
+ children: t("runtimeUpdateHint")
387
+ }) : null
388
+ ]
389
+ }, p.name))
390
+ }) : null
391
+ ]
392
+ });
393
+ }
394
+
395
+ //#region plugin body
396
+ /** Services required by the Settings registration + the update action. */
397
+ const inject = [
398
+ "slots",
399
+ "locale",
400
+ "modules",
401
+ "remote",
402
+ "remote.pluginManager",
403
+ "remote.pluginRegistryProbe"
404
+ ];
405
+
406
+ /** Register the check-updates tab into the Built-in plugins settings section. */
407
+ function apply(ctx) {
408
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-update-checker: dictionaries");
409
+ const t = ctx.locale.bind(NS);
410
+ const scan = async () => {
411
+ const res = await fetch("/api/plugin-update-checker/scan", { method: "POST" });
412
+ return res.json();
413
+ };
414
+ ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
415
+ name: "settings.plugins.tab",
416
+ id: "update-checker",
417
+ order: 20,
418
+ label: () => t("tab"),
419
+ locale: NS,
420
+ inject: () => ({ t, scan, remote: ctx.remote })
421
+ }, UpdateCheckerTab), "plugin-update-checker: tab");
422
+ }
423
+ //#endregion
424
+
425
+ exports.apply = apply;
426
+ exports.inject = inject;
427
+ return module.exports;
428
+ }
429
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * dsh-plugin-update-checker — host/dsh.js
3
+ *
4
+ * 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
5
+ * require, isRuntimePackage, getDshRuntimeVersion, evaluatePluginCompatibility, readProfileVersionExemptions
6
+ */
7
+ import { readFileSync, existsSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { createRequire } from 'node:module'
11
+ import { default as semver } from 'semver'
12
+ import { activeProfileDir } from './profile.js'
13
+
14
+ const require = createRequire(import.meta.url)
15
+
16
+ /**
17
+ * Whether a bundle name belongs to the DSH *runtime* itself (as opposed to a
18
+ * user-installed plugin).
19
+ *
20
+ * Every @deepseek-ai/dsh* package is part of the runtime the launcher boots
21
+ * (dsh-base, dsh-web-app, dsh-app-boot, ...). The launcher symlinks them (it
22
+ * does not copy them) into the shared profiles tree from the runtime install,
23
+ * so the bundle scanner must NOT treat them as user source-links or as
24
+ * independently updatable plugins. The runtime carries a single version, which
25
+ * is checked as one unit (see checkDshRuntime), not bundle by bundle.
26
+ */
27
+ export function isRuntimePackage(name) {
28
+ return name === '@deepseek-ai/dsh' || (typeof name === 'string' && name.startsWith('@deepseek-ai/dsh-'))
29
+ }
30
+
31
+ /**
32
+ * Running DSH runtime version.
33
+ *
34
+ * The launcher ships "@deepseek-ai/dsh-app-boot" whose own "version" equals the
35
+ * running runtime. We read that JSON (never execute the module, because a
36
+ * link-installed plugin cannot resolve "@deepseek-ai/*" from its own tree).
37
+ *
38
+ * Resolution strategy, most to least reliable:
39
+ * 1. require.resolve("@deepseek-ai/dsh-app-boot/package.json") - works when the
40
+ * plugin is hoisted into the profile tree (the normal link case).
41
+ * 2. Walk UP from this file's real path's directory ancestors for
42
+ * node_modules/@deepseek-ai/dsh-app-boot/package.json. Note we must convert
43
+ * import.meta.url (a file:// URL) with fileURLToPath first, otherwise the
44
+ * ancestor walk starts from a "file://..." string and never matches.
45
+ * 3. Walk UP from the active profile's node_modules/@deepseek-ai/dsh-app-boot/
46
+ * package.json. The profile's shared tree is a DSH-maintained symlink that
47
+ * always points at the current runtime, so this is a reliable fallback for
48
+ * link:-installed plugins whose real path lives outside the profile tree.
49
+ * 4. DSH_VERSION env (some launchers set it).
50
+ */
51
+ export function getDshRuntimeVersion() {
52
+ try {
53
+ return JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh-app-boot/package.json'), 'utf8')).version
54
+ } catch { /* fall through */ }
55
+
56
+ const anchors = [
57
+ dirname(fileURLToPath(import.meta.url)),
58
+ activeProfileDir(),
59
+ ]
60
+ for (const start of anchors) {
61
+ let dir = start
62
+ for (;;) {
63
+ const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'package.json')
64
+ if (existsSync(candidate)) {
65
+ try { return JSON.parse(readFileSync(candidate, 'utf8')).version } catch { /* keep walking */ }
66
+ }
67
+ const parent = dirname(dir)
68
+ if (parent === dir) break
69
+ dir = parent
70
+ }
71
+ }
72
+ return process.env.DSH_VERSION || '0.0.0'
73
+ }
74
+
75
+ /**
76
+ * Reimplementation of @deepseek-ai/dsh-app-boot's evaluatePluginCompatibility
77
+ * (read-only, no shared-state side effects). Returns undefined when compatible,
78
+ * otherwise the { name, version, runtimeVersion, peers, exempted } shape the
79
+ * browser half renders.
80
+ */
81
+ export function evaluatePluginCompatibility(manifest, exemptions, runtimeVersion) {
82
+ if (!manifest) return undefined
83
+
84
+ // engines.dsh decides ALONE: when it is present the launcher ignores
85
+ // peerDependencies entirely (dsh-app-boot's unmetRequirements returns the
86
+ // engines verdict and never reaches the peer loop). Mirroring only the peer
87
+ // branch made this checker disagree with the launcher for any plugin that
88
+ // moved its requirement onto engines.dsh - it reported "compatible" for a
89
+ // bundle the launcher would skip.
90
+ const engines = manifest.engines
91
+ if (engines !== null && typeof engines === 'object'
92
+ && Object.prototype.hasOwnProperty.call(engines, 'dsh')) {
93
+ const range = engines.dsh
94
+ if (typeof range !== 'string') throw new Error('engines.dsh must be a string')
95
+ const met = range.trim() !== ''
96
+ && semver.satisfies(runtimeVersion, range, { includePrerelease: true })
97
+ if (met) return undefined
98
+ return {
99
+ name: manifest.name,
100
+ version: manifest.version,
101
+ runtimeVersion,
102
+ peers: { 'engines.dsh': range },
103
+ exempted: false,
104
+ }
105
+ }
106
+
107
+ if (!Object.prototype.hasOwnProperty.call(manifest, 'peerDependencies')) return undefined
108
+ const dependencies = manifest.peerDependencies
109
+ const peers = {}
110
+ for (const [name, range] of Object.entries(dependencies)) {
111
+ if (typeof range !== 'string') throw new Error(`peerDependencies[${JSON.stringify(name)}] must be a string`)
112
+ if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
113
+ const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? runtimeVersion : range
114
+ if (requirement.trim() === '' || !semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
115
+ peers[name] = range
116
+ }
117
+ }
118
+ if (Object.keys(peers).length === 0) return undefined
119
+ const name = manifest.name
120
+ const version = manifest.version
121
+ const key = `${name}@${version}`
122
+ return {
123
+ name,
124
+ version,
125
+ runtimeVersion,
126
+ peers,
127
+ exempted: (Object.prototype.hasOwnProperty.call(exemptions, key) ? exemptions[key] : undefined)?.includes(runtimeVersion) === true,
128
+ }
129
+ }
130
+
131
+ /** Read the profile's version-exemption map from compatibility.json. */
132
+ export function readProfileVersionExemptions(profileDir) {
133
+ try {
134
+ const raw = JSON.parse(readFileSync(join(profileDir, 'compatibility.json'), 'utf8'))
135
+ return raw?.exemptions ?? {}
136
+ } catch {
137
+ return {}
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The profile directory the launcher passed on the command line, when we can
143
+ * recognize it.
144
+ *
145
+ * The Electron desktop host does NOT export DSH_PROFILE into the host process
146
+ * (only DSH_CLIENT_VERSION), so the old `process.env.DSH_PROFILE || 'web'`
147
+ * fell back to 'web' and the scan silently reported the *web* profile's
148
+ * plugins while the app was actually running *desktop*. The launcher does pass
149
+ * the profile as an absolute path in argv (e.g.
150
+ * DeepSeek Harness --expose-internals <host.js> <app.asar/dsh>
151
+ * /Users/me/.dsh/profiles/desktop <runtime> ...),
152
+ * so argv is the authoritative source: it is the same value the launcher used
153
+ * to boot the runtime, not a guess.
154
+ *
155
+ * We only accept a candidate that (a) is absolute, (b) is not a flag, and
156
+ * (c) sits directly under a `profiles` directory — so runtime, app.asar and
157
+ * host.js paths never match. Returns undefined when nothing matches.
158
+ */