dshmarket 1.45.1 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +51 -33
- package/lib/discovery-compatibility.js +7 -1
- package/lib/install.js +1 -1
- package/lib/pnpm-compat.js +23 -0
- package/lib/profile.js +32 -3
- package/lib/routes.js +34 -9
- package/lib/sources.js +12 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/profile.d.ts +24 -0
- package/lib/types/sources.d.ts +10 -0
- package/lib/types/updates.d.ts +12 -4
- package/lib/updates.js +27 -4
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +41 -32
- package/src/client/SettingsCard.tsx +9 -3
- package/src/client/locales.ts +6 -0
- package/src/client/market-data.ts +11 -0
- package/src/discovery-compatibility.ts +7 -1
- package/src/install.ts +1 -1
- package/src/pnpm-compat.ts +24 -1
- package/src/profile.ts +32 -3
- package/src/routes.ts +35 -7
- package/src/sources.ts +13 -0
- package/src/updates.ts +35 -5
package/client/client.js
CHANGED
|
@@ -40,6 +40,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
40
40
|
setSelfUpdateReady: "有新版本",
|
|
41
41
|
setSelfUpdateHint: "更新会下载新版本,重启后生效。",
|
|
42
42
|
setSelfUpToDateHint: "",
|
|
43
|
+
setSelfHostManagedHint: "这份市场由桌面宿主安装,新版本请在桌面端更新。",
|
|
43
44
|
setSelfUpdate: "更新",
|
|
44
45
|
setSelfUpdatedHint: "已下载完成。重启 DeepSeek Harness 后新版本才会生效——前端页面会立即更新,服务端不会。",
|
|
45
46
|
setRegion: "下载区域",
|
|
@@ -154,6 +155,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
154
155
|
updateFail: "更新失败",
|
|
155
156
|
upToDate: "已是最新",
|
|
156
157
|
linkedDev: "本地开发",
|
|
158
|
+
hostUpdateReady: "有新版本 {0}",
|
|
159
|
+
hostUpdateHint: "这份由桌面宿主安装和更新,市场只提醒,不在这里更新",
|
|
157
160
|
notesLink: "更新内容",
|
|
158
161
|
notesRelease: "版本说明",
|
|
159
162
|
notesCommits: "提交记录",
|
|
@@ -570,6 +573,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
570
573
|
setSelfUpdateReady: "New version available:",
|
|
571
574
|
setSelfUpdateHint: "Updating downloads the new version; it takes effect after a restart.",
|
|
572
575
|
setSelfUpToDateHint: "",
|
|
576
|
+
setSelfHostManagedHint: "This copy was installed by the desktop host; update it from the desktop app.",
|
|
573
577
|
setSelfUpdate: "Update",
|
|
574
578
|
setSelfUpdatedHint: "Downloaded. Restart DeepSeek Harness for it to take effect — the frontend updates at once, the server does not.",
|
|
575
579
|
setRegion: "Download region",
|
|
@@ -684,6 +688,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
684
688
|
updateFail: "Update failed",
|
|
685
689
|
upToDate: "Up to date",
|
|
686
690
|
linkedDev: "local",
|
|
691
|
+
hostUpdateReady: "New version {0}",
|
|
692
|
+
hostUpdateHint: "Installed and updated by the desktop host; the market only reports it",
|
|
687
693
|
notesLink: "What changed",
|
|
688
694
|
notesRelease: "Release notes",
|
|
689
695
|
notesCommits: "Commits",
|
|
@@ -1268,6 +1274,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1268
1274
|
function installedForCatalog(installed, bundles) {
|
|
1269
1275
|
return Object.fromEntries([...bundles.map((name) => [name, "*"]), ...Object.entries(installed)]);
|
|
1270
1276
|
}
|
|
1277
|
+
/**
|
|
1278
|
+
* A `link:` the desktop host wrote for one of its generations (#497). The
|
|
1279
|
+
* test the server applies (`isGenerationLink` in sources.ts), repeated here
|
|
1280
|
+
* because the client bundle cannot import server modules.
|
|
1281
|
+
*/
|
|
1282
|
+
function isGenerationSpec(spec) {
|
|
1283
|
+
return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec);
|
|
1284
|
+
}
|
|
1271
1285
|
function groupSwitchState(members, disabled) {
|
|
1272
1286
|
const list = members ?? [];
|
|
1273
1287
|
if (list.length === 0) return "empty";
|
|
@@ -6440,6 +6454,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
6440
6454
|
const [visibleCatsOneRow, setVisibleCatsOneRow] = (0, react.useState)(null);
|
|
6441
6455
|
const catsWrapRef = (0, react.useRef)(null);
|
|
6442
6456
|
const [catsStuck, setCatsStuck] = (0, react.useState)(false);
|
|
6457
|
+
/** While the sticky header is pinned, expansion is this flag — not
|
|
6458
|
+
* `catsOpen`. Becoming stuck collapses on the SAME render (stuckExpanded
|
|
6459
|
+
* starts false) instead of a follow-up `useLayoutEffect` that flipped
|
|
6460
|
+
* `catsOpen` and forced a second commit; that delayed height change is
|
|
6461
|
+
* what lined up with the host Settings dialog hitching after tab 收放.
|
|
6462
|
+
* An explicit chevron click while stuck sets this true and keeps
|
|
6463
|
+
* `catsOpen` in sync so unstuck restores the user's choice. */
|
|
6464
|
+
const [stuckExpanded, setStuckExpanded] = (0, react.useState)(false);
|
|
6443
6465
|
const [catsSentinel, setCatsSentinel] = (0, react.useState)(null);
|
|
6444
6466
|
const refreshInstalled = (0, react.useCallback)((force) => {
|
|
6445
6467
|
fetch(api("/dsh-market/installed"), { cache: "no-store" }).then((res) => res.json()).then((body) => {
|
|
@@ -8496,7 +8518,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8496
8518
|
if (leftView && root !== null && wrap !== null) {
|
|
8497
8519
|
if (root.scrollHeight - root.clientHeight <= wrap.offsetHeight) return;
|
|
8498
8520
|
}
|
|
8499
|
-
setCatsStuck(leftView);
|
|
8521
|
+
setCatsStuck((prev) => prev === leftView ? prev : leftView);
|
|
8500
8522
|
}, {
|
|
8501
8523
|
root: bodyRef.current,
|
|
8502
8524
|
threshold: 0
|
|
@@ -8504,28 +8526,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8504
8526
|
observer.observe(catsSentinel);
|
|
8505
8527
|
return () => observer.disconnect();
|
|
8506
8528
|
}, [catsSentinel]);
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
* a display-only override. An earlier version faked this by computing a
|
|
8510
|
-
* separate "effectively open" value for rendering while leaving `catsOpen`
|
|
8511
|
-
* itself true; the chevron's own click handler only ever toggled the real
|
|
8512
|
-
* `catsOpen`, so while stuck it flipped a value the render path had
|
|
8513
|
-
* already stopped consulting — clicking "expand" did nothing visible
|
|
8514
|
-
* (reported: "吸顶滚动了之后,展开没反应了"). Driving the same state the
|
|
8515
|
-
* chevron drives means the chevron always works, stuck or not.
|
|
8516
|
-
*/
|
|
8517
|
-
const catsAutoCollapsedRef = (0, react.useRef)(false);
|
|
8518
|
-
(0, react.useLayoutEffect)(() => {
|
|
8519
|
-
if (catsStuck) {
|
|
8520
|
-
if (catsOpen) {
|
|
8521
|
-
setCatsOpen(false);
|
|
8522
|
-
catsAutoCollapsedRef.current = true;
|
|
8523
|
-
}
|
|
8524
|
-
} else if (catsAutoCollapsedRef.current) {
|
|
8525
|
-
setCatsOpen(true);
|
|
8526
|
-
catsAutoCollapsedRef.current = false;
|
|
8527
|
-
}
|
|
8529
|
+
(0, react.useEffect)(() => {
|
|
8530
|
+
if (!catsStuck) setStuckExpanded(false);
|
|
8528
8531
|
}, [catsStuck]);
|
|
8532
|
+
/** Expanded chips + chevron share one value. Stuck uses `stuckExpanded`
|
|
8533
|
+
* so pinning collapses without rewriting `catsOpen` in a layout effect
|
|
8534
|
+
* (see stuckExpanded state). Leaving stuck falls back to `catsOpen`,
|
|
8535
|
+
* which still holds the pre-pin / in-pin user choice. */
|
|
8536
|
+
const catsExpanded = catsStuck ? stuckExpanded : catsOpen;
|
|
8529
8537
|
/**
|
|
8530
8538
|
* A fresh install (hotUrls/hotNames) and a toggle/group action
|
|
8531
8539
|
* (refreshNames) both end in the same place — "reload the page" — and
|
|
@@ -8975,7 +8983,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8975
8983
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
8976
8984
|
className: Market_module_css_default.body,
|
|
8977
8985
|
ref: bodyRef,
|
|
8978
|
-
onScroll: (e) =>
|
|
8986
|
+
onScroll: (e) => {
|
|
8987
|
+
const show = e.currentTarget.scrollTop > 400;
|
|
8988
|
+
setShowTop((prev) => prev === show ? prev : show);
|
|
8989
|
+
},
|
|
8979
8990
|
children: tab === "backup" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
8980
8991
|
className: Market_module_css_default.backupGrid,
|
|
8981
8992
|
children: [
|
|
@@ -9257,8 +9268,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9257
9268
|
className: visibleCats === null ? `${Market_module_css_default.catsWrap} ${Market_module_css_default.catsCollapsed}` : Market_module_css_default.catsWrap,
|
|
9258
9269
|
children: (() => {
|
|
9259
9270
|
const budget = catsStuck ? visibleCatsOneRow : visibleCats;
|
|
9260
|
-
const ordered = orderedCategories(categories, cat,
|
|
9261
|
-
const shown =
|
|
9271
|
+
const ordered = orderedCategories(categories, cat, catsExpanded, budget);
|
|
9272
|
+
const shown = catsExpanded || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1));
|
|
9262
9273
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
9263
9274
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
9264
9275
|
"data-chip": "1",
|
|
@@ -9276,11 +9287,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9276
9287
|
variant: "ghost",
|
|
9277
9288
|
size: "sm",
|
|
9278
9289
|
className: Market_module_css_default.catsToggle,
|
|
9279
|
-
icon:
|
|
9280
|
-
"aria-label":
|
|
9290
|
+
icon: catsExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronUpOutline14, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 14 }),
|
|
9291
|
+
"aria-label": catsExpanded ? t("catsLess") : t("catsMore"),
|
|
9281
9292
|
onClick: () => {
|
|
9282
|
-
|
|
9283
|
-
|
|
9293
|
+
const next = !catsExpanded;
|
|
9294
|
+
if (catsStuck) setStuckExpanded(next);
|
|
9295
|
+
setCatsOpen(next);
|
|
9284
9296
|
}
|
|
9285
9297
|
})
|
|
9286
9298
|
] });
|
|
@@ -9828,7 +9840,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9828
9840
|
const missing = pendingBackup !== null && !installedFiles.includes(name);
|
|
9829
9841
|
const entry = data === null ? void 0 : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]);
|
|
9830
9842
|
const status = updates[name];
|
|
9831
|
-
const
|
|
9843
|
+
const generation = status?.kind === "generation" || isGenerationSpec(String(spec));
|
|
9844
|
+
const localDev = !generation && (/^(?:link|file):/i.test(String(spec)) || status?.kind === "linked");
|
|
9832
9845
|
const act = activations[name];
|
|
9833
9846
|
const meta = act !== void 0 ? activationMeta(act.state, t) : null;
|
|
9834
9847
|
const version = status && status.version ? "v" + status.version : "";
|
|
@@ -9949,7 +9962,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9949
9962
|
]
|
|
9950
9963
|
});
|
|
9951
9964
|
})(),
|
|
9952
|
-
status !== void 0 && status.updateAvailable && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
9965
|
+
status !== void 0 && (status.updateAvailable || generation && status.latest != null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
9953
9966
|
className: Market_module_css_default.noteRow,
|
|
9954
9967
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
9955
9968
|
type: "button",
|
|
@@ -10095,6 +10108,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
10095
10108
|
className: Market_module_css_default.warnBtn,
|
|
10096
10109
|
disabled: true,
|
|
10097
10110
|
children: t("updating")
|
|
10111
|
+
}) : status !== void 0 && generation && status.latest != null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
10112
|
+
className: Market_module_css_default.metaTag,
|
|
10113
|
+
title: t("hostUpdateHint"),
|
|
10114
|
+
children: t("hostUpdateReady").replace("{0}", status.latest)
|
|
10098
10115
|
}) : status && status.updateAvailable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
10099
10116
|
variant: "primary",
|
|
10100
10117
|
size: "sm",
|
|
@@ -10734,7 +10751,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
10734
10751
|
updateAvailable: own.updateAvailable === true,
|
|
10735
10752
|
latest: own.latest ?? null,
|
|
10736
10753
|
channelSwitch: own.channelSwitch ?? null,
|
|
10737
|
-
restoreRequired: own.restoreRequired === true
|
|
10754
|
+
restoreRequired: own.restoreRequired === true,
|
|
10755
|
+
hostManaged: own.kind === "generation"
|
|
10738
10756
|
};
|
|
10739
10757
|
}
|
|
10740
10758
|
/**
|
|
@@ -10960,7 +10978,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
10960
10978
|
}, [post, t]);
|
|
10961
10979
|
/** One label + hint block with an optional action, the host's row shape. */
|
|
10962
10980
|
const row = (label, hint, action) => (0, react.createElement)("div", { className: Market_module_css_default.setRow }, (0, react.createElement)("div", { className: Market_module_css_default.setLabelBox }, (0, react.createElement)("div", { className: Market_module_css_default.setLabel }, label), (0, react.createElement)("div", { className: Market_module_css_default.setHint }, hint)), action);
|
|
10963
|
-
const body = phase === "removed" ? row(t("setSelfRemoved"), t("setSelfRemovedHint"), null) : (0, react.createElement)(react.Fragment, null, status?.selfManaged === true ? row(update?.updateAvailable === true && update.latest !== null ? `${t("setSelfUpdateReady")} ${update.latest}` : update?.channelSwitch != null ? `${t("setChannelSwitch")} ${update.channelSwitch}` : t("setSelfUpToDate"), phase === "updated" ? t("setSelfUpdatedHint") : update?.channelSwitch != null ? t("setChannelSwitchHint") : update?.updateAvailable === true ? t("setSelfUpdateHint") : t("setSelfUpToDateHint"), phase === "updated" ? null : update?.updateAvailable === true ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
10981
|
+
const body = phase === "removed" ? row(t("setSelfRemoved"), t("setSelfRemovedHint"), null) : (0, react.createElement)(react.Fragment, null, status?.selfManaged === true ? row((update?.updateAvailable === true || update?.hostManaged === true) && update.latest !== null ? `${t("setSelfUpdateReady")} ${update.latest}` : update?.channelSwitch != null ? `${t("setChannelSwitch")} ${update.channelSwitch}` : t("setSelfUpToDate"), phase === "updated" ? t("setSelfUpdatedHint") : update?.channelSwitch != null ? t("setChannelSwitchHint") : update?.updateAvailable === true ? t("setSelfUpdateHint") : update?.hostManaged === true && update.latest !== null ? t("setSelfHostManagedHint") : t("setSelfUpToDateHint"), phase === "updated" ? null : update?.updateAvailable === true ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
10964
10982
|
variant: "primary",
|
|
10965
10983
|
size: "sm",
|
|
10966
10984
|
disabled: busy,
|
|
@@ -36,6 +36,12 @@ function record(value) {
|
|
|
36
36
|
export function manifestFacts(value) {
|
|
37
37
|
const manifest = record(value) ?? {};
|
|
38
38
|
const engines = record(manifest.engines);
|
|
39
|
+
// #577: the ecosystem declares the host requirement in BOTH shapes —
|
|
40
|
+
// top-level `engines.dsh` and `dsh.engines.dsh` under the manifest's own
|
|
41
|
+
// `dsh` field (the natural home, and what e.g. @linxin666/dsh-web-all
|
|
42
|
+
// publishes). Neither position is authoritative, so read both; when a
|
|
43
|
+
// manifest carries both, the top-level declaration wins.
|
|
44
|
+
const dshEngines = record(record(manifest.dsh)?.engines);
|
|
39
45
|
const peers = record(manifest.peerDependencies);
|
|
40
46
|
const peerDependencies = {};
|
|
41
47
|
for (const [name, raw] of Object.entries(peers ?? {})) {
|
|
@@ -47,7 +53,7 @@ export function manifestFacts(value) {
|
|
|
47
53
|
}
|
|
48
54
|
return {
|
|
49
55
|
version: range(manifest.version),
|
|
50
|
-
enginesDsh: range(engines?.dsh),
|
|
56
|
+
enginesDsh: range(engines?.dsh) ?? range(dshEngines?.dsh),
|
|
51
57
|
peerDependencies,
|
|
52
58
|
};
|
|
53
59
|
}
|
package/lib/install.js
CHANGED
|
@@ -97,7 +97,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
97
97
|
logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
|
|
98
98
|
result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
|
|
99
99
|
}
|
|
100
|
-
else if (failure?.code === 'fetch-404'
|
|
100
|
+
else if ((failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
|
|
101
101
|
&& isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
|
|
102
102
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
103
103
|
&& !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)) {
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -341,6 +341,29 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
341
341
|
message: `有一个依赖在 registry 上不存在${zh},pnpm 因此拒绝任何安装操作。它可能是之前失败操作残留在 profile package.json 里的幽灵依赖(可手动删除该行),也可能是需要登录的私有包 / a dependency cannot be resolved from the registry${en}; pnpm refuses every install while it is present. It may be a ghost entry left in the profile's package.json by an earlier failed operation (remove that line by hand), or a private package needing registry credentials`,
|
|
342
342
|
};
|
|
343
343
|
}
|
|
344
|
+
// #569: a host peer that only ever published pre-releases (e.g.
|
|
345
|
+
// `@deepseek-ai/dsh-tools` with nothing above `-rc.*`) resolves to NO
|
|
346
|
+
// stable version, and pnpm reports ERR_PNPM_NO_MATCHING_VERSION — the
|
|
347
|
+
// package exists, the requested range matches nothing. This is the same
|
|
348
|
+
// unpublished-host-peer shape #289 recovers from, just with a different
|
|
349
|
+
// error code, so the classifier names it separately and the #289 retry
|
|
350
|
+
// shares it via install.ts's gate. Real output (pnpm 12.4.1): see
|
|
351
|
+
// tests/pnpm-compat.spec.ts, which pins this wording.
|
|
352
|
+
if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
|
|
353
|
+
const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
|
|
354
|
+
?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/');
|
|
355
|
+
const zh = pkg === undefined ? '' : `(${pkg})`;
|
|
356
|
+
const en = pkg === undefined ? '' : ` (${pkg})`;
|
|
357
|
+
const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg);
|
|
358
|
+
return {
|
|
359
|
+
code: 'no-matching-version',
|
|
360
|
+
recoverable: false,
|
|
361
|
+
pkg,
|
|
362
|
+
message: hostPeer
|
|
363
|
+
? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
|
|
364
|
+
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
344
367
|
// #389 by @qq1054435284: on Windows, pnpm stages the new version in a
|
|
345
368
|
// sibling `<name>_tmp_<pid>_<n>` directory and renames it over the old one.
|
|
346
369
|
// Windows refuses that rename while any file underneath the target is open,
|
package/lib/profile.js
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSy
|
|
|
7
7
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
8
8
|
import { isDeepStrictEqual } from 'node:util';
|
|
9
9
|
import { resolveDshHome } from './home-paths.js';
|
|
10
|
-
import { githubRemoteIdentities, githubRepoIdentities } from './sources.js';
|
|
10
|
+
import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec } from './sources.js';
|
|
11
11
|
/**
|
|
12
12
|
* Whether a profile name follows DSH's own directory-name contract.
|
|
13
13
|
*
|
|
@@ -408,12 +408,41 @@ export function readInstalledRepoIdentities(profile, name, spec, explicitDir) {
|
|
|
408
408
|
* Discover declared repository identities and weaker local-origin hints. A
|
|
409
409
|
* package.json repository declaration is authoritative; Git origin is only a
|
|
410
410
|
* disambiguation hint because a checkout may legitimately point at a fork.
|
|
411
|
+
*
|
|
412
|
+
* Read for local AND registry specs, but never for a spec that already names
|
|
413
|
+
* its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
|
|
414
|
+
*
|
|
415
|
+
* The bug: two same-named catalog entries and an ordinary npm install. The
|
|
416
|
+
* manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
|
|
417
|
+
* — is the one fact that says WHICH of the two is installed, and it sits in
|
|
418
|
+
* the same package.json for an npm install as for a local one. This returned
|
|
419
|
+
* empty for anything not `link:`/`file:`, so the client fell back to name
|
|
420
|
+
* matching, found two candidates, and matched NEITHER: the Discover card kept
|
|
421
|
+
* offering Install on a plugin that was running.
|
|
422
|
+
*
|
|
423
|
+
* Why a `github:`/URL install must NOT be read the same way: its spec already
|
|
424
|
+
* states the source, and the manifest can disagree with it. A fork installed
|
|
425
|
+
* as `github:myfork/plugin` usually still declares the UPSTREAM repository,
|
|
426
|
+
* because almost nobody edits that field when forking. Adding it as an
|
|
427
|
+
* identity made the upstream's card read as installed — measured, and the
|
|
428
|
+
* same mistake as #485: a weaker signal allowed to outvote a definite one.
|
|
429
|
+
* The first version of this fix widened to every spec kind and had exactly
|
|
430
|
+
* that hole.
|
|
431
|
+
*
|
|
432
|
+
* What stays local-only for the same reason it always was: the git-origin
|
|
433
|
+
* hint (there is no checkout to read for a registry install) and the local
|
|
434
|
+
* source directory walk.
|
|
411
435
|
*/
|
|
412
436
|
export function readInstalledRepoEvidence(profile, name, spec, explicitDir) {
|
|
413
|
-
if (!PACKAGE_NAME_RE.test(name)
|
|
437
|
+
if (!PACKAGE_NAME_RE.test(name))
|
|
438
|
+
return { identities: [], hints: [] };
|
|
439
|
+
const local = /^(?:link|file):/i.test(spec);
|
|
440
|
+
// A spec that names its own source is the authority on it; see above.
|
|
441
|
+
if (!local && (isGitHostedSpec(spec) || /^https?:/i.test(spec.trim()))) {
|
|
414
442
|
return { identities: [], hints: [] };
|
|
443
|
+
}
|
|
415
444
|
const root = profileDir(profile, explicitDir);
|
|
416
|
-
const sourceDir = localSpecDirectory(root, spec);
|
|
445
|
+
const sourceDir = local ? localSpecDirectory(root, spec) : null;
|
|
417
446
|
const installedDir = installedPackageDirectory(root, name);
|
|
418
447
|
const manifestDir = installedDir ?? sourceDir;
|
|
419
448
|
const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir);
|
package/lib/routes.js
CHANGED
|
@@ -28,7 +28,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
|
|
|
28
28
|
import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
|
|
29
29
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
30
30
|
import { trialValidate } from './trial.js';
|
|
31
|
-
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
31
|
+
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
32
32
|
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
33
33
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
34
34
|
import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
|
|
@@ -1048,6 +1048,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1048
1048
|
return false;
|
|
1049
1049
|
}
|
|
1050
1050
|
};
|
|
1051
|
+
/**
|
|
1052
|
+
* The npm package an install with no registry spec of its own is compared
|
|
1053
|
+
* against: a `file:` package matched to the catalog (#429), or a
|
|
1054
|
+
* generation the desktop host linked in (#497). Null for everything else
|
|
1055
|
+
* — a developer's own `link:` checkout is never compared online.
|
|
1056
|
+
*/
|
|
1057
|
+
const onlineSourceOf = (plugins, name, spec) => {
|
|
1058
|
+
if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec))
|
|
1059
|
+
return null;
|
|
1060
|
+
const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
|
|
1061
|
+
const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints);
|
|
1062
|
+
const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
|
|
1063
|
+
return target !== null && NPM_NAME_RE.test(target) ? target : null;
|
|
1064
|
+
};
|
|
1051
1065
|
const disposers = [
|
|
1052
1066
|
host.webServer.register({
|
|
1053
1067
|
kind: 'exact',
|
|
@@ -1109,7 +1123,22 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1109
1123
|
const force = forceCheckFrom(request);
|
|
1110
1124
|
const channel = activeChannel();
|
|
1111
1125
|
const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
|
|
1112
|
-
|
|
1126
|
+
// The same source lookup the market page makes, so a generation
|
|
1127
|
+
// (#497) or a catalog-matched local package answers here with the
|
|
1128
|
+
// release it can be compared against rather than with nothing.
|
|
1129
|
+
const spec = readInstalled(config.profile, activeProfileDir)[name];
|
|
1130
|
+
const onlineSourceFor = new Map();
|
|
1131
|
+
if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
|
|
1132
|
+
try {
|
|
1133
|
+
const source = onlineSourceOf((await loadRegistry()).plugins, name, spec);
|
|
1134
|
+
if (source !== null)
|
|
1135
|
+
onlineSourceFor.set(name, source);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name];
|
|
1113
1142
|
if (update === undefined) {
|
|
1114
1143
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
|
|
1115
1144
|
return;
|
|
@@ -2373,13 +2402,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2373
2402
|
const migration = findGitToNpmMigration(registry.plugins, spec);
|
|
2374
2403
|
if (migration !== null)
|
|
2375
2404
|
sourceMigrationFor.set(name, migration);
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
|
|
2380
|
-
const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
|
|
2381
|
-
if (target !== null && NPM_NAME_RE.test(target))
|
|
2382
|
-
onlineSourceFor.set(name, target);
|
|
2405
|
+
const source = onlineSourceOf(registry.plugins, name, spec);
|
|
2406
|
+
if (source !== null)
|
|
2407
|
+
onlineSourceFor.set(name, source);
|
|
2383
2408
|
}
|
|
2384
2409
|
}
|
|
2385
2410
|
catch (error) {
|
package/lib/sources.js
CHANGED
|
@@ -492,6 +492,18 @@ export function gitUploadPackUrl(spec) {
|
|
|
492
492
|
const base = url.toString().replace(/\/+$/, '');
|
|
493
493
|
return `${base}/info/refs?service=git-upload-pack`;
|
|
494
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* A `link:` that points into a generation the desktop host materialised
|
|
497
|
+
* (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
|
|
498
|
+
* That is the host's production install, not a developer's checkout — it
|
|
499
|
+
* came from the registry and has releases to compare against. The host
|
|
500
|
+
* recognises its own installs by the `.generations/live/` segment, and no
|
|
501
|
+
* hand-written link ever lands under that directory, so the same test is
|
|
502
|
+
* enough here.
|
|
503
|
+
*/
|
|
504
|
+
export function isGenerationLink(spec) {
|
|
505
|
+
return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec);
|
|
506
|
+
}
|
|
495
507
|
export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.js';
|
|
496
508
|
/**
|
|
497
509
|
* pnpm add target for restoring a local checkout onto a catalog entry.
|
|
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
|
|
|
33
33
|
*/
|
|
34
34
|
export declare const HOST_NAMESPACE_RE: RegExp;
|
|
35
35
|
export interface PnpmFailure {
|
|
36
|
-
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency';
|
|
36
|
+
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency';
|
|
37
37
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
38
38
|
message: string;
|
|
39
39
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -121,6 +121,30 @@ export interface InstalledRepoEvidence {
|
|
|
121
121
|
* Discover declared repository identities and weaker local-origin hints. A
|
|
122
122
|
* package.json repository declaration is authoritative; Git origin is only a
|
|
123
123
|
* disambiguation hint because a checkout may legitimately point at a fork.
|
|
124
|
+
*
|
|
125
|
+
* Read for local AND registry specs, but never for a spec that already names
|
|
126
|
+
* its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
|
|
127
|
+
*
|
|
128
|
+
* The bug: two same-named catalog entries and an ordinary npm install. The
|
|
129
|
+
* manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
|
|
130
|
+
* — is the one fact that says WHICH of the two is installed, and it sits in
|
|
131
|
+
* the same package.json for an npm install as for a local one. This returned
|
|
132
|
+
* empty for anything not `link:`/`file:`, so the client fell back to name
|
|
133
|
+
* matching, found two candidates, and matched NEITHER: the Discover card kept
|
|
134
|
+
* offering Install on a plugin that was running.
|
|
135
|
+
*
|
|
136
|
+
* Why a `github:`/URL install must NOT be read the same way: its spec already
|
|
137
|
+
* states the source, and the manifest can disagree with it. A fork installed
|
|
138
|
+
* as `github:myfork/plugin` usually still declares the UPSTREAM repository,
|
|
139
|
+
* because almost nobody edits that field when forking. Adding it as an
|
|
140
|
+
* identity made the upstream's card read as installed — measured, and the
|
|
141
|
+
* same mistake as #485: a weaker signal allowed to outvote a definite one.
|
|
142
|
+
* The first version of this fix widened to every spec kind and had exactly
|
|
143
|
+
* that hole.
|
|
144
|
+
*
|
|
145
|
+
* What stays local-only for the same reason it always was: the git-origin
|
|
146
|
+
* hint (there is no checkout to read for a registry install) and the local
|
|
147
|
+
* source directory walk.
|
|
124
148
|
*/
|
|
125
149
|
export declare function readInstalledRepoEvidence(profile: string, name: string, spec: string, explicitDir?: string): InstalledRepoEvidence;
|
|
126
150
|
/** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
|
package/lib/types/sources.d.ts
CHANGED
|
@@ -177,6 +177,16 @@ export declare function gitUpdateTarget(spec: string): string | null;
|
|
|
177
177
|
* Userinfo is stripped so update checks do not resend embedded credentials.
|
|
178
178
|
*/
|
|
179
179
|
export declare function gitUploadPackUrl(spec: string): string | null;
|
|
180
|
+
/**
|
|
181
|
+
* A `link:` that points into a generation the desktop host materialised
|
|
182
|
+
* (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
|
|
183
|
+
* That is the host's production install, not a developer's checkout — it
|
|
184
|
+
* came from the registry and has releases to compare against. The host
|
|
185
|
+
* recognises its own installs by the `.generations/live/` segment, and no
|
|
186
|
+
* hand-written link ever lands under that directory, so the same test is
|
|
187
|
+
* enough here.
|
|
188
|
+
*/
|
|
189
|
+
export declare function isGenerationLink(spec: string): boolean;
|
|
180
190
|
export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.ts';
|
|
181
191
|
/**
|
|
182
192
|
* pnpm add target for restoring a local checkout onto a catalog entry.
|
package/lib/types/updates.d.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Channel } from './channels.ts';
|
|
7
7
|
export interface UpdateStatus {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `generation` is a `link:` the desktop host wrote (#497): the host
|
|
10
|
+
* installs it and reconciles it at startup, so the market names a newer
|
|
11
|
+
* release under `latest` and never offers to apply it. For that kind
|
|
12
|
+
* `latest` is null when the installed build is current or nothing newer
|
|
13
|
+
* can be confirmed.
|
|
14
|
+
*/
|
|
15
|
+
kind: 'github' | 'npm' | 'linked' | 'generation';
|
|
9
16
|
version: string | null;
|
|
10
17
|
current: string | null;
|
|
11
18
|
latest: string | null;
|
|
@@ -116,8 +123,9 @@ export declare function checkUpdates(profile: string, force?: boolean, explicitD
|
|
|
116
123
|
*/
|
|
117
124
|
channelFor?: ReadonlyMap<string, Channel>,
|
|
118
125
|
/**
|
|
119
|
-
* Curated npm sources for
|
|
120
|
-
*
|
|
121
|
-
*
|
|
126
|
+
* Curated npm sources for the installs that carry no registry spec of
|
|
127
|
+
* their own: `file:` packages matched to the market catalog (#429) and
|
|
128
|
+
* the generations the desktop host links in (#497). Any other `link:` is
|
|
129
|
+
* a development workspace and is never opted into online updates.
|
|
122
130
|
*/
|
|
123
131
|
onlineSourceFor?: ReadonlyMap<string, string>): Promise<Record<string, UpdateStatus>>;
|
package/lib/updates.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveHeadCommit } from './accelerate.js';
|
|
|
8
8
|
import { marketFetch } from './net.js';
|
|
9
9
|
import { activeRegion } from './regions.js';
|
|
10
10
|
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
|
|
11
|
-
import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
11
|
+
import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
12
12
|
const UPDATES_TTL_MS = 30 * 60 * 1000;
|
|
13
13
|
const GIT_REMOTE_HEAD_TIMEOUT_MS = 6000;
|
|
14
14
|
let updatesCache = null;
|
|
@@ -254,9 +254,10 @@ export async function checkUpdates(profile, force = false, explicitDir,
|
|
|
254
254
|
*/
|
|
255
255
|
channelFor = new Map(),
|
|
256
256
|
/**
|
|
257
|
-
* Curated npm sources for
|
|
258
|
-
*
|
|
259
|
-
*
|
|
257
|
+
* Curated npm sources for the installs that carry no registry spec of
|
|
258
|
+
* their own: `file:` packages matched to the market catalog (#429) and
|
|
259
|
+
* the generations the desktop host links in (#497). Any other `link:` is
|
|
260
|
+
* a development workspace and is never opted into online updates.
|
|
260
261
|
*/
|
|
261
262
|
onlineSourceFor = new Map()) {
|
|
262
263
|
const activeProfileDir = profileDir(profile, explicitDir);
|
|
@@ -289,6 +290,28 @@ onlineSourceFor = new Map()) {
|
|
|
289
290
|
return;
|
|
290
291
|
}
|
|
291
292
|
if (normalizedSpec.startsWith('link:')) {
|
|
293
|
+
if (isGenerationLink(spec)) {
|
|
294
|
+
// The desktop host's production install, not a checkout (#497): it
|
|
295
|
+
// came from the registry and has a release history to compare
|
|
296
|
+
// against. Reported, never offered — the host reconciles `live/`
|
|
297
|
+
// against its own desired.json at startup, so an update applied
|
|
298
|
+
// here would appear to work and silently revert on the next boot.
|
|
299
|
+
// Only a release that is actually newer is named; current and
|
|
300
|
+
// unknown both read as null, so a lagging `latest` tag (#64) can't
|
|
301
|
+
// advertise a downgrade either.
|
|
302
|
+
const onlineSource = onlineSourceFor.get(name);
|
|
303
|
+
const stable = onlineSource === undefined ? null : await fetchNpmLatest(onlineSource);
|
|
304
|
+
const channel = channelFor.get(name);
|
|
305
|
+
const newest = channel === undefined || onlineSource === undefined
|
|
306
|
+
? stable
|
|
307
|
+
: await versionOnChannel(onlineSource, channel, stable);
|
|
308
|
+
result[name] = {
|
|
309
|
+
kind: 'generation', version, current: version,
|
|
310
|
+
latest: isUpgrade(version, newest) ? newest : null,
|
|
311
|
+
updateAvailable: false,
|
|
312
|
+
};
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
292
315
|
result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false };
|
|
293
316
|
return;
|
|
294
317
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.46.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -42,7 +42,7 @@ import type { OperationRecord } from './operations.ts'
|
|
|
42
42
|
import { Diagnostics } from './Diagnostics.tsx'
|
|
43
43
|
import { exportMarketLog } from './self-check.ts'
|
|
44
44
|
import {
|
|
45
|
-
api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isInstalled, looksTerminal, matchInstalledName, orderedCategories, pluginCategories,
|
|
45
|
+
api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isGenerationSpec, isInstalled, looksTerminal, matchInstalledName, orderedCategories, pluginCategories,
|
|
46
46
|
formatCount, pageItems, pluginName, pluginScreenshotCandidates, pluginScreenshots, pluginsForFavorites, rankThemeScreenshots, readSession, rememberGithubRoute, resetScreenshotsCache, resolveCatalogRestore, safeScreenshots, staleFavoriteUrls, themePlugins as themePluginsOf, themeSwatch, TIME_RANGE_DAYS, visiblePlugins,
|
|
47
47
|
} from './market-data.ts'
|
|
48
48
|
import type {
|
|
@@ -1542,6 +1542,14 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
1542
1542
|
// (padding, sticky `top`), which drifts silently whenever that CSS
|
|
1543
1543
|
// changes. The sentinel just reports what's actually true on screen.
|
|
1544
1544
|
const [catsStuck, setCatsStuck] = useState(false)
|
|
1545
|
+
/** While the sticky header is pinned, expansion is this flag — not
|
|
1546
|
+
* `catsOpen`. Becoming stuck collapses on the SAME render (stuckExpanded
|
|
1547
|
+
* starts false) instead of a follow-up `useLayoutEffect` that flipped
|
|
1548
|
+
* `catsOpen` and forced a second commit; that delayed height change is
|
|
1549
|
+
* what lined up with the host Settings dialog hitching after tab 收放.
|
|
1550
|
+
* An explicit chevron click while stuck sets this true and keeps
|
|
1551
|
+
* `catsOpen` in sync so unstuck restores the user's choice. */
|
|
1552
|
+
const [stuckExpanded, setStuckExpanded] = useState(false)
|
|
1545
1553
|
const [catsSentinel, setCatsSentinel] = useState<HTMLDivElement | null>(null)
|
|
1546
1554
|
|
|
1547
1555
|
const refreshInstalled = useCallback((force?: boolean) => {
|
|
@@ -3638,32 +3646,23 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3638
3646
|
const overflow = root.scrollHeight - root.clientHeight
|
|
3639
3647
|
if (overflow <= wrap.offsetHeight) return
|
|
3640
3648
|
}
|
|
3641
|
-
setCatsStuck(leftView)
|
|
3649
|
+
setCatsStuck(prev => (prev === leftView ? prev : leftView))
|
|
3642
3650
|
},
|
|
3643
3651
|
{ root: bodyRef.current, threshold: 0 },
|
|
3644
3652
|
)
|
|
3645
3653
|
observer.observe(catsSentinel)
|
|
3646
3654
|
return () => observer.disconnect()
|
|
3647
3655
|
}, [catsSentinel])
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
* itself true; the chevron's own click handler only ever toggled the real
|
|
3653
|
-
* `catsOpen`, so while stuck it flipped a value the render path had
|
|
3654
|
-
* already stopped consulting — clicking "expand" did nothing visible
|
|
3655
|
-
* (reported: "吸顶滚动了之后,展开没反应了"). Driving the same state the
|
|
3656
|
-
* chevron drives means the chevron always works, stuck or not.
|
|
3657
|
-
*/
|
|
3658
|
-
const catsAutoCollapsedRef = useRef(false)
|
|
3659
|
-
useLayoutEffect(() => {
|
|
3660
|
-
if (catsStuck) {
|
|
3661
|
-
if (catsOpen) { setCatsOpen(false); catsAutoCollapsedRef.current = true }
|
|
3662
|
-
} else if (catsAutoCollapsedRef.current) {
|
|
3663
|
-
setCatsOpen(true)
|
|
3664
|
-
catsAutoCollapsedRef.current = false
|
|
3665
|
-
}
|
|
3656
|
+
// Drop any in-pin expand once the header unpins, so the next pin starts
|
|
3657
|
+
// collapsed without a rising-edge setState in the observer.
|
|
3658
|
+
useEffect(() => {
|
|
3659
|
+
if (!catsStuck) setStuckExpanded(false)
|
|
3666
3660
|
}, [catsStuck])
|
|
3661
|
+
/** Expanded chips + chevron share one value. Stuck uses `stuckExpanded`
|
|
3662
|
+
* so pinning collapses without rewriting `catsOpen` in a layout effect
|
|
3663
|
+
* (see stuckExpanded state). Leaving stuck falls back to `catsOpen`,
|
|
3664
|
+
* which still holds the pre-pin / in-pin user choice. */
|
|
3665
|
+
const catsExpanded = catsStuck ? stuckExpanded : catsOpen
|
|
3667
3666
|
|
|
3668
3667
|
/**
|
|
3669
3668
|
* A fresh install (hotUrls/hotNames) and a toggle/group action
|
|
@@ -4011,7 +4010,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4011
4010
|
<div
|
|
4012
4011
|
className={css.body}
|
|
4013
4012
|
ref={bodyRef}
|
|
4014
|
-
onScroll={e =>
|
|
4013
|
+
onScroll={e => {
|
|
4014
|
+
const show = e.currentTarget.scrollTop > 400
|
|
4015
|
+
setShowTop(prev => (prev === show ? prev : show))
|
|
4016
|
+
}}
|
|
4015
4017
|
>
|
|
4016
4018
|
{tab === 'backup'
|
|
4017
4019
|
? (
|
|
@@ -4168,12 +4170,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4168
4170
|
{(() => {
|
|
4169
4171
|
// Collapsed, the selected category is pulled to the front so it never hides.
|
|
4170
4172
|
// Whenever collapsed (default, or auto-collapsed by the sticky
|
|
4171
|
-
// header going stuck — see
|
|
4173
|
+
// header going stuck — see catsExpanded / stuckExpanded), a stuck
|
|
4172
4174
|
// header uses the one-row budget instead of the two-row one so an
|
|
4173
4175
|
// already-open list that just got pinned shrinks further.
|
|
4174
4176
|
const budget = catsStuck ? visibleCatsOneRow : visibleCats
|
|
4175
|
-
const ordered = orderedCategories(categories, cat,
|
|
4176
|
-
const shown =
|
|
4177
|
+
const ordered = orderedCategories(categories, cat, catsExpanded, budget)
|
|
4178
|
+
const shown = catsExpanded || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1))
|
|
4177
4179
|
return (
|
|
4178
4180
|
<>
|
|
4179
4181
|
<Pill data-chip="1" active={cat === 'all'} onClick={() => setCat('all')}>{t('all') + ' (' + formatCount(data!.count) + ')'}</Pill>
|
|
@@ -4189,13 +4191,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4189
4191
|
variant="ghost"
|
|
4190
4192
|
size="sm"
|
|
4191
4193
|
className={css.catsToggle}
|
|
4192
|
-
icon={
|
|
4193
|
-
aria-label={
|
|
4194
|
+
icon={catsExpanded ? <IconChevronUpOutline14 size={14} /> : <IconChevronDownOutline14 size={14} />}
|
|
4195
|
+
aria-label={catsExpanded ? t('catsLess') : t('catsMore')}
|
|
4194
4196
|
onClick={() => {
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
setCatsOpen(o => !o)
|
|
4197
|
+
const next = !catsExpanded
|
|
4198
|
+
if (catsStuck) setStuckExpanded(next)
|
|
4199
|
+
setCatsOpen(next)
|
|
4199
4200
|
}}
|
|
4200
4201
|
/>
|
|
4201
4202
|
</>
|
|
@@ -4592,7 +4593,13 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4592
4593
|
const missing = pendingBackup !== null && !installedFiles.includes(name)
|
|
4593
4594
|
const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name])
|
|
4594
4595
|
const status = updates[name]
|
|
4595
|
-
|
|
4596
|
+
// A generation is the desktop host's own install (#497):
|
|
4597
|
+
// the host updates it, the market only says a newer
|
|
4598
|
+
// release exists. Not a development checkout, so no
|
|
4599
|
+
// "local" tag and no restore — the host would put the
|
|
4600
|
+
// generation straight back.
|
|
4601
|
+
const generation = status?.kind === 'generation' || isGenerationSpec(String(spec))
|
|
4602
|
+
const localDev = !generation && (/^(?:link|file):/i.test(String(spec)) || status?.kind === 'linked')
|
|
4596
4603
|
const act = activations[name]
|
|
4597
4604
|
const meta = act !== undefined ? activationMeta(act.state, t) : null
|
|
4598
4605
|
const version = status && status.version ? 'v' + status.version : ''
|
|
@@ -4706,7 +4713,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4706
4713
|
one quiet line in the flow the row already
|
|
4707
4714
|
reserves for conditional content, so rows
|
|
4708
4715
|
without it are pixel-identical to before. */}
|
|
4709
|
-
{status !== undefined && status.updateAvailable && (
|
|
4716
|
+
{status !== undefined && (status.updateAvailable || (generation && status.latest != null)) && (
|
|
4710
4717
|
<div className={css.noteRow}>
|
|
4711
4718
|
<button
|
|
4712
4719
|
type="button"
|
|
@@ -4848,6 +4855,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
4848
4855
|
? <span className={`${css.metaTag} ${css.metaTagOk}`}>{act?.state === 'live' ? t('updatedLive') : t('updated')}</span>
|
|
4849
4856
|
: updatingName === name
|
|
4850
4857
|
? <Button variant="primary" size="sm" className={css.warnBtn} disabled>{t('updating')}</Button>
|
|
4858
|
+
: status !== undefined && generation && status.latest != null
|
|
4859
|
+
? <span className={css.metaTag} title={t('hostUpdateHint')}>{t('hostUpdateReady').replace('{0}', status.latest)}</span>
|
|
4851
4860
|
: status && status.updateAvailable
|
|
4852
4861
|
? (
|
|
4853
4862
|
<Button
|
|
@@ -105,12 +105,14 @@ interface SelfUpdate {
|
|
|
105
105
|
channelSwitch: string | null
|
|
106
106
|
/** The current build came from a local package and must switch to the online release. */
|
|
107
107
|
restoreRequired: boolean
|
|
108
|
+
/** A generation the desktop host installed (#497): a newer release is named here, never applied from here. */
|
|
109
|
+
hostManaged: boolean
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
type Phase = 'idle' | 'confirming' | 'working' | 'removed' | 'updated' | 'failed'
|
|
111
113
|
|
|
112
114
|
/** The market's own row as api('/dsh-market/updates') sends it. */
|
|
113
|
-
interface RawUpdate { updateAvailable?: boolean; latest?: string; channelSwitch?: string; restoreRequired?: boolean }
|
|
115
|
+
interface RawUpdate { updateAvailable?: boolean; latest?: string; channelSwitch?: string; restoreRequired?: boolean; kind?: string }
|
|
114
116
|
|
|
115
117
|
const CHANNELS: Channel[] = ['stable', 'beta', 'dev']
|
|
116
118
|
const asChannel = (value: unknown): Channel | null =>
|
|
@@ -168,6 +170,7 @@ function readUpdate(own: RawUpdate): SelfUpdate {
|
|
|
168
170
|
latest: own.latest ?? null,
|
|
169
171
|
channelSwitch: own.channelSwitch ?? null,
|
|
170
172
|
restoreRequired: own.restoreRequired === true,
|
|
173
|
+
hostManaged: own.kind === 'generation',
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -404,7 +407,7 @@ export function SettingsCard({ t, onRemoved }: SettingsCardProps): ReactElement
|
|
|
404
407
|
// An older offer is not an update, and calling it one would have
|
|
405
408
|
// the user click "更新" to go backwards. It IS what picking an
|
|
406
409
|
// earlier channel asked for, so it is offered — under its own name.
|
|
407
|
-
update?.updateAvailable === true && update.latest !== null
|
|
410
|
+
(update?.updateAvailable === true || update?.hostManaged === true) && update.latest !== null
|
|
408
411
|
? `${t('setSelfUpdateReady')} ${update.latest}`
|
|
409
412
|
: update?.channelSwitch != null
|
|
410
413
|
? `${t('setChannelSwitch')} ${update.channelSwitch}`
|
|
@@ -416,7 +419,10 @@ export function SettingsCard({ t, onRemoved }: SettingsCardProps): ReactElement
|
|
|
416
419
|
// Explains what the Update button does — only worth saying
|
|
417
420
|
// when that button is actually on screen. Already up to date,
|
|
418
421
|
// it read as an instruction for an action that wasn't there.
|
|
419
|
-
: update?.updateAvailable === true ? t('setSelfUpdateHint')
|
|
422
|
+
: update?.updateAvailable === true ? t('setSelfUpdateHint')
|
|
423
|
+
// Named, not offered: the desktop host owns this install.
|
|
424
|
+
: update?.hostManaged === true && update.latest !== null ? t('setSelfHostManagedHint')
|
|
425
|
+
: t('setSelfUpToDateHint'),
|
|
420
426
|
phase === 'updated'
|
|
421
427
|
? null
|
|
422
428
|
: update?.updateAvailable === true
|
package/src/client/locales.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const zh = {
|
|
|
7
7
|
setSelfUpdateReady: '有新版本',
|
|
8
8
|
setSelfUpdateHint: '更新会下载新版本,重启后生效。',
|
|
9
9
|
setSelfUpToDateHint: '',
|
|
10
|
+
setSelfHostManagedHint: '这份市场由桌面宿主安装,新版本请在桌面端更新。',
|
|
10
11
|
setSelfUpdate: '更新',
|
|
11
12
|
setSelfUpdatedHint: '已下载完成。重启 DeepSeek Harness 后新版本才会生效——前端页面会立即更新,服务端不会。',
|
|
12
13
|
setRegion: '下载区域',
|
|
@@ -121,6 +122,8 @@ export const zh = {
|
|
|
121
122
|
updateFail: '更新失败',
|
|
122
123
|
upToDate: '已是最新',
|
|
123
124
|
linkedDev: '本地开发',
|
|
125
|
+
hostUpdateReady: '有新版本 {0}',
|
|
126
|
+
hostUpdateHint: '这份由桌面宿主安装和更新,市场只提醒,不在这里更新',
|
|
124
127
|
notesLink: '更新内容',
|
|
125
128
|
notesRelease: '版本说明',
|
|
126
129
|
notesCommits: '提交记录',
|
|
@@ -545,6 +548,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
545
548
|
setSelfUpdateReady: 'New version available:',
|
|
546
549
|
setSelfUpdateHint: 'Updating downloads the new version; it takes effect after a restart.',
|
|
547
550
|
setSelfUpToDateHint: '',
|
|
551
|
+
setSelfHostManagedHint: 'This copy was installed by the desktop host; update it from the desktop app.',
|
|
548
552
|
setSelfUpdate: 'Update',
|
|
549
553
|
setSelfUpdatedHint: 'Downloaded. Restart DeepSeek Harness for it to take effect — the frontend updates at once, the server does not.',
|
|
550
554
|
setRegion: 'Download region',
|
|
@@ -659,6 +663,8 @@ export const en: Record<MarketKey, string> = {
|
|
|
659
663
|
updateFail: 'Update failed',
|
|
660
664
|
upToDate: 'Up to date',
|
|
661
665
|
linkedDev: 'local',
|
|
666
|
+
hostUpdateReady: 'New version {0}',
|
|
667
|
+
hostUpdateHint: 'Installed and updated by the desktop host; the market only reports it',
|
|
662
668
|
notesLink: 'What changed',
|
|
663
669
|
notesRelease: 'Release notes',
|
|
664
670
|
notesCommits: 'Commits',
|
|
@@ -124,10 +124,21 @@ export interface GistExportResult {
|
|
|
124
124
|
gistUrl: string
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A `link:` the desktop host wrote for one of its generations (#497). The
|
|
129
|
+
* test the server applies (`isGenerationLink` in sources.ts), repeated here
|
|
130
|
+
* because the client bundle cannot import server modules.
|
|
131
|
+
*/
|
|
132
|
+
export function isGenerationSpec(spec: string): boolean {
|
|
133
|
+
return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec)
|
|
134
|
+
}
|
|
135
|
+
|
|
127
136
|
/** Per-package update status from /dsh-market/updates. */
|
|
128
137
|
export interface UpdateStatus {
|
|
129
138
|
updateAvailable?: boolean
|
|
130
139
|
version?: string
|
|
140
|
+
/** `github` | `npm` | `linked` | `generation` — the last is a host-managed
|
|
141
|
+
install (#497): `latest` names a newer release, never an offer. */
|
|
131
142
|
kind?: string
|
|
132
143
|
/** What is installed and what the source of truth offers — versions for npm
|
|
133
144
|
packages, commit shas for github installs; the notes dialog (#294) shows
|
|
@@ -79,6 +79,12 @@ function record(value: unknown): Record<string, unknown> | null {
|
|
|
79
79
|
export function manifestFacts(value: unknown): NpmManifestFacts {
|
|
80
80
|
const manifest = record(value) ?? {}
|
|
81
81
|
const engines = record(manifest.engines)
|
|
82
|
+
// #577: the ecosystem declares the host requirement in BOTH shapes —
|
|
83
|
+
// top-level `engines.dsh` and `dsh.engines.dsh` under the manifest's own
|
|
84
|
+
// `dsh` field (the natural home, and what e.g. @linxin666/dsh-web-all
|
|
85
|
+
// publishes). Neither position is authoritative, so read both; when a
|
|
86
|
+
// manifest carries both, the top-level declaration wins.
|
|
87
|
+
const dshEngines = record(record(manifest.dsh)?.engines)
|
|
82
88
|
const peers = record(manifest.peerDependencies)
|
|
83
89
|
const peerDependencies: Record<string, string> = {}
|
|
84
90
|
for (const [name, raw] of Object.entries(peers ?? {})) {
|
|
@@ -88,7 +94,7 @@ export function manifestFacts(value: unknown): NpmManifestFacts {
|
|
|
88
94
|
}
|
|
89
95
|
return {
|
|
90
96
|
version: range(manifest.version),
|
|
91
|
-
enginesDsh: range(engines?.dsh),
|
|
97
|
+
enginesDsh: range(engines?.dsh) ?? range(dshEngines?.dsh),
|
|
92
98
|
peerDependencies,
|
|
93
99
|
}
|
|
94
100
|
}
|
package/src/install.ts
CHANGED
|
@@ -112,7 +112,7 @@ export async function withHoistRecovery(
|
|
|
112
112
|
logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
|
|
113
113
|
result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
|
|
114
114
|
} else if (
|
|
115
|
-
failure?.code === 'fetch-404'
|
|
115
|
+
(failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
|
|
116
116
|
&& isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
|
|
117
117
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
118
118
|
&& !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const HOST_NAMESPACE_RE = /^@deepseek-ai\//
|
|
|
46
46
|
export interface PnpmFailure {
|
|
47
47
|
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation'
|
|
48
48
|
| 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
|
|
49
|
-
| 'fetch-404' | 'transient-network' | 'fetch-timeout'
|
|
49
|
+
| 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
|
|
50
50
|
| 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked'
|
|
51
51
|
| 'pnpm-unusable' | 'missing-local-dependency'
|
|
52
52
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
@@ -378,6 +378,29 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
|
|
378
378
|
message: `有一个依赖在 registry 上不存在${zh},pnpm 因此拒绝任何安装操作。它可能是之前失败操作残留在 profile package.json 里的幽灵依赖(可手动删除该行),也可能是需要登录的私有包 / a dependency cannot be resolved from the registry${en}; pnpm refuses every install while it is present. It may be a ghost entry left in the profile's package.json by an earlier failed operation (remove that line by hand), or a private package needing registry credentials`,
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
|
+
// #569: a host peer that only ever published pre-releases (e.g.
|
|
382
|
+
// `@deepseek-ai/dsh-tools` with nothing above `-rc.*`) resolves to NO
|
|
383
|
+
// stable version, and pnpm reports ERR_PNPM_NO_MATCHING_VERSION — the
|
|
384
|
+
// package exists, the requested range matches nothing. This is the same
|
|
385
|
+
// unpublished-host-peer shape #289 recovers from, just with a different
|
|
386
|
+
// error code, so the classifier names it separately and the #289 retry
|
|
387
|
+
// shares it via install.ts's gate. Real output (pnpm 12.4.1): see
|
|
388
|
+
// tests/pnpm-compat.spec.ts, which pins this wording.
|
|
389
|
+
if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
|
|
390
|
+
const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
|
|
391
|
+
?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/')
|
|
392
|
+
const zh = pkg === undefined ? '' : `(${pkg})`
|
|
393
|
+
const en = pkg === undefined ? '' : ` (${pkg})`
|
|
394
|
+
const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg)
|
|
395
|
+
return {
|
|
396
|
+
code: 'no-matching-version',
|
|
397
|
+
recoverable: false,
|
|
398
|
+
pkg,
|
|
399
|
+
message: hostPeer
|
|
400
|
+
? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
|
|
401
|
+
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
381
404
|
// #389 by @qq1054435284: on Windows, pnpm stages the new version in a
|
|
382
405
|
// sibling `<name>_tmp_<pid>_<n>` directory and renames it over the old one.
|
|
383
406
|
// Windows refuses that rename while any file underneath the target is open,
|
package/src/profile.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSy
|
|
|
8
8
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
9
9
|
import { isDeepStrictEqual } from 'node:util'
|
|
10
10
|
import { resolveDshHome } from './home-paths.ts'
|
|
11
|
-
import { githubRemoteIdentities, githubRepoIdentities } from './sources.ts'
|
|
11
|
+
import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec } from './sources.ts'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Whether a profile name follows DSH's own directory-name contract.
|
|
@@ -431,6 +431,30 @@ export interface InstalledRepoEvidence {
|
|
|
431
431
|
* Discover declared repository identities and weaker local-origin hints. A
|
|
432
432
|
* package.json repository declaration is authoritative; Git origin is only a
|
|
433
433
|
* disambiguation hint because a checkout may legitimately point at a fork.
|
|
434
|
+
*
|
|
435
|
+
* Read for local AND registry specs, but never for a spec that already names
|
|
436
|
+
* its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
|
|
437
|
+
*
|
|
438
|
+
* The bug: two same-named catalog entries and an ordinary npm install. The
|
|
439
|
+
* manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
|
|
440
|
+
* — is the one fact that says WHICH of the two is installed, and it sits in
|
|
441
|
+
* the same package.json for an npm install as for a local one. This returned
|
|
442
|
+
* empty for anything not `link:`/`file:`, so the client fell back to name
|
|
443
|
+
* matching, found two candidates, and matched NEITHER: the Discover card kept
|
|
444
|
+
* offering Install on a plugin that was running.
|
|
445
|
+
*
|
|
446
|
+
* Why a `github:`/URL install must NOT be read the same way: its spec already
|
|
447
|
+
* states the source, and the manifest can disagree with it. A fork installed
|
|
448
|
+
* as `github:myfork/plugin` usually still declares the UPSTREAM repository,
|
|
449
|
+
* because almost nobody edits that field when forking. Adding it as an
|
|
450
|
+
* identity made the upstream's card read as installed — measured, and the
|
|
451
|
+
* same mistake as #485: a weaker signal allowed to outvote a definite one.
|
|
452
|
+
* The first version of this fix widened to every spec kind and had exactly
|
|
453
|
+
* that hole.
|
|
454
|
+
*
|
|
455
|
+
* What stays local-only for the same reason it always was: the git-origin
|
|
456
|
+
* hint (there is no checkout to read for a registry install) and the local
|
|
457
|
+
* source directory walk.
|
|
434
458
|
*/
|
|
435
459
|
export function readInstalledRepoEvidence(
|
|
436
460
|
profile: string,
|
|
@@ -438,9 +462,14 @@ export function readInstalledRepoEvidence(
|
|
|
438
462
|
spec: string,
|
|
439
463
|
explicitDir?: string,
|
|
440
464
|
): InstalledRepoEvidence {
|
|
441
|
-
if (!PACKAGE_NAME_RE.test(name)
|
|
465
|
+
if (!PACKAGE_NAME_RE.test(name)) return { identities: [], hints: [] }
|
|
466
|
+
const local = /^(?:link|file):/i.test(spec)
|
|
467
|
+
// A spec that names its own source is the authority on it; see above.
|
|
468
|
+
if (!local && (isGitHostedSpec(spec) || /^https?:/i.test(spec.trim()))) {
|
|
469
|
+
return { identities: [], hints: [] }
|
|
470
|
+
}
|
|
442
471
|
const root = profileDir(profile, explicitDir)
|
|
443
|
-
const sourceDir = localSpecDirectory(root, spec)
|
|
472
|
+
const sourceDir = local ? localSpecDirectory(root, spec) : null
|
|
444
473
|
const installedDir = installedPackageDirectory(root, name)
|
|
445
474
|
const manifestDir = installedDir ?? sourceDir
|
|
446
475
|
const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir)
|
package/src/routes.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
|
|
|
36
36
|
import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.ts'
|
|
37
37
|
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
|
|
38
38
|
import { trialValidate } from './trial.ts'
|
|
39
|
-
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
|
|
39
|
+
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
|
|
40
40
|
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
41
41
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
|
|
42
42
|
import {
|
|
@@ -1156,6 +1156,24 @@ export function mountMarketRoutes(
|
|
|
1156
1156
|
try { return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1' } catch { return false }
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* The npm package an install with no registry spec of its own is compared
|
|
1161
|
+
* against: a `file:` package matched to the catalog (#429), or a
|
|
1162
|
+
* generation the desktop host linked in (#497). Null for everything else
|
|
1163
|
+
* — a developer's own `link:` checkout is never compared online.
|
|
1164
|
+
*/
|
|
1165
|
+
const onlineSourceOf = (
|
|
1166
|
+
plugins: Awaited<ReturnType<typeof loadRegistry>>['plugins'],
|
|
1167
|
+
name: string,
|
|
1168
|
+
spec: string,
|
|
1169
|
+
): string | null => {
|
|
1170
|
+
if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec)) return null
|
|
1171
|
+
const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir)
|
|
1172
|
+
const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints)
|
|
1173
|
+
const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities)
|
|
1174
|
+
return target !== null && NPM_NAME_RE.test(target) ? target : null
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1159
1177
|
const disposers = [
|
|
1160
1178
|
host.webServer.register({
|
|
1161
1179
|
kind: 'exact',
|
|
@@ -1218,7 +1236,20 @@ export function mountMarketRoutes(
|
|
|
1218
1236
|
const force = forceCheckFrom(request)
|
|
1219
1237
|
const channel = activeChannel()
|
|
1220
1238
|
const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined
|
|
1221
|
-
|
|
1239
|
+
// The same source lookup the market page makes, so a generation
|
|
1240
|
+
// (#497) or a catalog-matched local package answers here with the
|
|
1241
|
+
// release it can be compared against rather than with nothing.
|
|
1242
|
+
const spec = readInstalled(config.profile, activeProfileDir)[name]
|
|
1243
|
+
const onlineSourceFor = new Map<string, string>()
|
|
1244
|
+
if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
|
|
1245
|
+
try {
|
|
1246
|
+
const source = onlineSourceOf((await loadRegistry()).plugins, name, spec)
|
|
1247
|
+
if (source !== null) onlineSourceFor.set(name, source)
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name]
|
|
1222
1253
|
if (update === undefined) {
|
|
1223
1254
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
|
|
1224
1255
|
return
|
|
@@ -2470,11 +2501,8 @@ export function mountMarketRoutes(
|
|
|
2470
2501
|
for (const [name, spec] of Object.entries(installed)) {
|
|
2471
2502
|
const migration = findGitToNpmMigration(registry.plugins, spec)
|
|
2472
2503
|
if (migration !== null) sourceMigrationFor.set(name, migration)
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints)
|
|
2476
|
-
const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities)
|
|
2477
|
-
if (target !== null && NPM_NAME_RE.test(target)) onlineSourceFor.set(name, target)
|
|
2504
|
+
const source = onlineSourceOf(registry.plugins, name, spec)
|
|
2505
|
+
if (source !== null) onlineSourceFor.set(name, source)
|
|
2478
2506
|
}
|
|
2479
2507
|
} catch (error) {
|
|
2480
2508
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
|
package/src/sources.ts
CHANGED
|
@@ -463,6 +463,19 @@ export function gitUploadPackUrl(spec: string): string | null {
|
|
|
463
463
|
return `${base}/info/refs?service=git-upload-pack`
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/**
|
|
467
|
+
* A `link:` that points into a generation the desktop host materialised
|
|
468
|
+
* (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
|
|
469
|
+
* That is the host's production install, not a developer's checkout — it
|
|
470
|
+
* came from the registry and has releases to compare against. The host
|
|
471
|
+
* recognises its own installs by the `.generations/live/` segment, and no
|
|
472
|
+
* hand-written link ever lands under that directory, so the same test is
|
|
473
|
+
* enough here.
|
|
474
|
+
*/
|
|
475
|
+
export function isGenerationLink(spec: string): boolean {
|
|
476
|
+
return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec)
|
|
477
|
+
}
|
|
478
|
+
|
|
466
479
|
export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.ts'
|
|
467
480
|
|
|
468
481
|
/**
|
package/src/updates.ts
CHANGED
|
@@ -9,10 +9,17 @@ import { resolveHeadCommit } from './accelerate.ts'
|
|
|
9
9
|
import { marketFetch } from './net.ts'
|
|
10
10
|
import { activeRegion } from './regions.ts'
|
|
11
11
|
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
|
|
12
|
-
import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
12
|
+
import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
13
13
|
|
|
14
14
|
export interface UpdateStatus {
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `generation` is a `link:` the desktop host wrote (#497): the host
|
|
17
|
+
* installs it and reconciles it at startup, so the market names a newer
|
|
18
|
+
* release under `latest` and never offers to apply it. For that kind
|
|
19
|
+
* `latest` is null when the installed build is current or nothing newer
|
|
20
|
+
* can be confirmed.
|
|
21
|
+
*/
|
|
22
|
+
kind: 'github' | 'npm' | 'linked' | 'generation'
|
|
16
23
|
version: string | null
|
|
17
24
|
current: string | null
|
|
18
25
|
latest: string | null
|
|
@@ -293,9 +300,10 @@ export async function checkUpdates(
|
|
|
293
300
|
*/
|
|
294
301
|
channelFor: ReadonlyMap<string, Channel> = new Map(),
|
|
295
302
|
/**
|
|
296
|
-
* Curated npm sources for
|
|
297
|
-
*
|
|
298
|
-
*
|
|
303
|
+
* Curated npm sources for the installs that carry no registry spec of
|
|
304
|
+
* their own: `file:` packages matched to the market catalog (#429) and
|
|
305
|
+
* the generations the desktop host links in (#497). Any other `link:` is
|
|
306
|
+
* a development workspace and is never opted into online updates.
|
|
299
307
|
*/
|
|
300
308
|
onlineSourceFor: ReadonlyMap<string, string> = new Map(),
|
|
301
309
|
): Promise<Record<string, UpdateStatus>> {
|
|
@@ -329,6 +337,28 @@ export async function checkUpdates(
|
|
|
329
337
|
return
|
|
330
338
|
}
|
|
331
339
|
if (normalizedSpec.startsWith('link:')) {
|
|
340
|
+
if (isGenerationLink(spec)) {
|
|
341
|
+
// The desktop host's production install, not a checkout (#497): it
|
|
342
|
+
// came from the registry and has a release history to compare
|
|
343
|
+
// against. Reported, never offered — the host reconciles `live/`
|
|
344
|
+
// against its own desired.json at startup, so an update applied
|
|
345
|
+
// here would appear to work and silently revert on the next boot.
|
|
346
|
+
// Only a release that is actually newer is named; current and
|
|
347
|
+
// unknown both read as null, so a lagging `latest` tag (#64) can't
|
|
348
|
+
// advertise a downgrade either.
|
|
349
|
+
const onlineSource = onlineSourceFor.get(name)
|
|
350
|
+
const stable = onlineSource === undefined ? null : await fetchNpmLatest(onlineSource)
|
|
351
|
+
const channel = channelFor.get(name)
|
|
352
|
+
const newest = channel === undefined || onlineSource === undefined
|
|
353
|
+
? stable
|
|
354
|
+
: await versionOnChannel(onlineSource, channel, stable)
|
|
355
|
+
result[name] = {
|
|
356
|
+
kind: 'generation', version, current: version,
|
|
357
|
+
latest: isUpgrade(version, newest) ? newest : null,
|
|
358
|
+
updateAvailable: false,
|
|
359
|
+
}
|
|
360
|
+
return
|
|
361
|
+
}
|
|
332
362
|
result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
|
|
333
363
|
return
|
|
334
364
|
}
|