dshmarket 1.66.44 → 1.66.45
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 +19 -8
- package/lib/check.js +19 -7
- package/lib/patch.js +48 -18
- package/lib/profile.js +7 -3
- package/lib/routes.js +11 -11
- package/lib/trial.js +1 -1
- package/lib/types/check.d.ts +8 -3
- package/lib/types/patch.d.ts +17 -12
- package/lib/types/profile.d.ts +6 -2
- package/lib/types/updates.d.ts +23 -1
- package/lib/updates.js +79 -7
- package/package.json +1 -1
- package/src/check.ts +19 -7
- package/src/client/MarketSection.tsx +17 -6
- package/src/client/locales.ts +4 -0
- package/src/client/market-data.ts +9 -2
- package/src/patch.ts +45 -17
- package/src/profile.ts +12 -4
- package/src/routes.ts +13 -12
- package/src/trial.ts +1 -1
- package/src/updates.ts +85 -6
package/client/client.js
CHANGED
|
@@ -393,6 +393,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
393
393
|
updateNow: "立即更新",
|
|
394
394
|
updateFail: "更新失败",
|
|
395
395
|
upToDate: "已是最新",
|
|
396
|
+
updateHeldTag: "新版等待中",
|
|
397
|
+
updateHeldHint: "这个 profile 要求新版本发布满一段时间才允许安装,大约还要 {1} 小时。",
|
|
396
398
|
newerPrereleaseAvailable: "预发布版 {0} 已发布",
|
|
397
399
|
newerPrereleaseHint: "npm 上的 latest 没有更新;你在跟的这条预发布线发布了更新的版本。",
|
|
398
400
|
unreleasedCommits: "有新提交(未发版){0}",
|
|
@@ -1132,6 +1134,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1132
1134
|
updateNow: "Update now",
|
|
1133
1135
|
updateFail: "Update failed",
|
|
1134
1136
|
upToDate: "Up to date",
|
|
1137
|
+
updateHeldTag: "new version waiting",
|
|
1138
|
+
updateHeldHint: "This profile waits a while before it installs a fresh release; about {1} hour(s) left.",
|
|
1135
1139
|
newerPrereleaseAvailable: "prerelease {0} published",
|
|
1136
1140
|
newerPrereleaseHint: "The package's latest tag has no update; the prerelease line you are on published a newer build.",
|
|
1137
1141
|
unreleasedCommits: "New commits (unreleased) {0}",
|
|
@@ -2892,14 +2896,17 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
2892
2896
|
*
|
|
2893
2897
|
* The repository name IS the plugin name in the ordinary case, because a
|
|
2894
2898
|
* repository holding one plugin is named after it. Only the compound form
|
|
2895
|
-
* needs unpicking
|
|
2899
|
+
* needs unpicking. Prefer its mapped npm package when available: a generic
|
|
2900
|
+
* directory such as `plugin` is not the product name. Without that mapping,
|
|
2901
|
+
* keep the directory-leaf fallback for source-only entries.
|
|
2896
2902
|
*
|
|
2897
2903
|
* Not a substitute for the identity: every key, lookup and install still
|
|
2898
2904
|
* uses `name` unchanged.
|
|
2899
2905
|
*/
|
|
2900
|
-
function pluginName(name) {
|
|
2906
|
+
function pluginName(name, npm) {
|
|
2901
2907
|
const hash = name.indexOf("#");
|
|
2902
2908
|
if (hash === -1) return name;
|
|
2909
|
+
if (typeof npm === "string" && npm.trim() !== "") return npm.trim();
|
|
2903
2910
|
const sub = name.slice(hash + 1);
|
|
2904
2911
|
const leaf = sub.slice(sub.lastIndexOf("/") + 1);
|
|
2905
2912
|
return leaf === "" ? name.slice(0, hash) : leaf;
|
|
@@ -7392,7 +7399,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
7392
7399
|
plugin.url
|
|
7393
7400
|
]);
|
|
7394
7401
|
const visible = (curated.length > 0 ? curated : fallback.shots).filter((src) => !broken.includes(src));
|
|
7395
|
-
const name = pluginName(plugin.name);
|
|
7402
|
+
const name = pluginName(plugin.name, plugin.npm);
|
|
7396
7403
|
if (visible.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
7397
7404
|
ref: setCoverRef,
|
|
7398
7405
|
type: "button",
|
|
@@ -8699,7 +8706,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8699
8706
|
const entry = data?.plugins.find((plugin) => plugin.npm === name || plugin.name === name);
|
|
8700
8707
|
if (entry === void 0) return { title: name };
|
|
8701
8708
|
return {
|
|
8702
|
-
title: pluginName(entry.name),
|
|
8709
|
+
title: pluginName(entry.name, entry.npm),
|
|
8703
8710
|
author: entry.owner === "" ? void 0 : entry.owner,
|
|
8704
8711
|
avatar: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OwnerAvatar, {
|
|
8705
8712
|
name: entry.name,
|
|
@@ -11813,7 +11820,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11813
11820
|
title: p.name,
|
|
11814
11821
|
"aria-label": `${p.name} — ${t("repoLink")}`,
|
|
11815
11822
|
children: [
|
|
11816
|
-
pluginName(p.name),
|
|
11823
|
+
pluginName(p.name, p.npm),
|
|
11817
11824
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(GithubRepoMark, { className: Market_module_css_default.repoMark }),
|
|
11818
11825
|
p.deprecated === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11819
11826
|
className: Market_module_css_default.depBadge,
|
|
@@ -12002,7 +12009,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
12002
12009
|
rel: "noreferrer",
|
|
12003
12010
|
title: p.name,
|
|
12004
12011
|
"aria-label": `${p.name} — ${t("repoLink")}`,
|
|
12005
|
-
children: [pluginName(p.name), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GithubRepoMark, { className: Market_module_css_default.repoMark })]
|
|
12012
|
+
children: [pluginName(p.name, p.npm), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GithubRepoMark, { className: Market_module_css_default.repoMark })]
|
|
12006
12013
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
12007
12014
|
className: Market_module_css_default.byline,
|
|
12008
12015
|
children: [
|
|
@@ -14151,6 +14158,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
14151
14158
|
else doUpdate(name);
|
|
14152
14159
|
},
|
|
14153
14160
|
children: status.restoreRequired === true ? t("restoreOnline") : t("update")
|
|
14161
|
+
}) : status?.heldUpdate !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
14162
|
+
className: Market_module_css_default.metaTag,
|
|
14163
|
+
title: t("updateHeldHint").replace("{1}", String(Math.max(1, Math.ceil((Date.parse(status.heldUpdate.until) - Date.now()) / 36e5)))),
|
|
14164
|
+
children: t("updateHeldTag").replace("{0}", displayLatest(status.heldUpdate.latest))
|
|
14154
14165
|
}) : localDev ? null : status?.unreleasedCommit != null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
14155
14166
|
className: Market_module_css_default.metaTag,
|
|
14156
14167
|
title: t("unreleasedCommitsHint"),
|
|
@@ -14382,7 +14393,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
14382
14393
|
className: Market_module_css_default.confirmModal,
|
|
14383
14394
|
contentClassName: Market_module_css_default.confirmContent,
|
|
14384
14395
|
onClose: () => setConfirming(null),
|
|
14385
|
-
title: t("confirmTitle") + " " + confirming.name + "?",
|
|
14396
|
+
title: t("confirmTitle") + " " + pluginName(confirming.name, confirming.npm) + "?",
|
|
14386
14397
|
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
14387
14398
|
variant: "ghost",
|
|
14388
14399
|
onClick: () => setConfirming(null),
|
|
@@ -14538,7 +14549,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
14538
14549
|
t
|
|
14539
14550
|
}),
|
|
14540
14551
|
commentsFor !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentsModal, {
|
|
14541
|
-
name: pluginName(commentsFor.name),
|
|
14552
|
+
name: pluginName(commentsFor.name, commentsFor.npm),
|
|
14542
14553
|
url: commentsFor.url,
|
|
14543
14554
|
lang,
|
|
14544
14555
|
onClose: () => setCommentsFor(null),
|
package/lib/check.js
CHANGED
|
@@ -761,15 +761,27 @@ export function composeLayers(layers) {
|
|
|
761
761
|
* patch row plus a user-patch row on the same id: fatal forever meant every
|
|
762
762
|
* update of every plugin rolled back.
|
|
763
763
|
*
|
|
764
|
-
*
|
|
765
|
-
* the
|
|
764
|
+
* An empty answer from the PROFILE lockfile is not "unknown" on the official
|
|
765
|
+
* Desktop: that host embeds the loader in `app.asar` and provides it to every
|
|
766
|
+
* profile, so no profile can ever name it (#943). Reading that as an old
|
|
767
|
+
* loader made the gate fatal for every update on 0.2.1-rc.2 — the exact report
|
|
768
|
+
* #853's fix was supposed to close. The host installation is asked before the
|
|
769
|
+
* evidence is treated as missing; only when both are silent does the
|
|
770
|
+
* conservative answer stand.
|
|
766
771
|
*/
|
|
767
|
-
export function duplicateEntryIdsAreFatal(profileDirectory) {
|
|
772
|
+
export function duplicateEntryIdsAreFatal(profileDirectory, dshInstallDir) {
|
|
768
773
|
const versions = lockfileCoreVersions(profileDirectory).get('@deepseek-ai/cordis-plugin-loader') ?? [];
|
|
769
|
-
if (versions.length
|
|
774
|
+
if (versions.length > 0) {
|
|
775
|
+
const newest = versions.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b));
|
|
776
|
+
return compareSemver(newest, '1.0.4') < 0;
|
|
777
|
+
}
|
|
778
|
+
const install = dshInstallDir === undefined ? findDshInstallDir() : dshInstallDir;
|
|
779
|
+
const host = install === null
|
|
780
|
+
? null
|
|
781
|
+
: readNodeModulesVersion(dirname(hostNodeModulesRoot(install)), '@deepseek-ai/cordis-plugin-loader');
|
|
782
|
+
if (host === null)
|
|
770
783
|
return true;
|
|
771
|
-
|
|
772
|
-
return compareSemver(newest, '1.0.4') < 0;
|
|
784
|
+
return compareSemver(host, '1.0.4') < 0;
|
|
773
785
|
}
|
|
774
786
|
/** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
|
|
775
787
|
function lockfileCoreVersions(profileDir) {
|
|
@@ -1349,7 +1361,7 @@ export function analyzeProfile(profileDirectory, options = {}) {
|
|
|
1349
1361
|
// Rows on one id: a boot failure on an old loader, a silent override on a
|
|
1350
1362
|
// new one (see duplicateEntryIdsAreFatal). Refusing an operation over the
|
|
1351
1363
|
// second shape rolled back every update for the reporter's profile.
|
|
1352
|
-
const duplicateFatal = duplicateEntryIdsAreFatal(profileDirectory);
|
|
1364
|
+
const duplicateFatal = duplicateEntryIdsAreFatal(profileDirectory, dshInstall);
|
|
1353
1365
|
for (const dup of composed.duplicates) {
|
|
1354
1366
|
if (duplicateFatal) {
|
|
1355
1367
|
errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`);
|
package/lib/patch.js
CHANGED
|
@@ -319,40 +319,70 @@ function foreignDisableIds(rows) {
|
|
|
319
319
|
return ids;
|
|
320
320
|
}
|
|
321
321
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
322
|
+
* Top-level patch rows that add an `inject:` requirement to another plugin.
|
|
323
|
+
*
|
|
324
|
+
* Same shape rule as foreignDisableIds: anything at the top level with an id
|
|
325
|
+
* is a statement about a plugin this package does not own. An `inject` there
|
|
326
|
+
* makes that foreign row depend on a service — usually one this bundle
|
|
327
|
+
* provides — so switching this package off while leaving its patch in the
|
|
328
|
+
* stack strands the row: #938's billion-context adds
|
|
329
|
+
* `inject: [biliBasicCompactionPolicy]` to the four official agent presets,
|
|
330
|
+
* and a row-level disable left them unable to activate.
|
|
331
|
+
*/
|
|
332
|
+
function foreignInjectIds(rows) {
|
|
333
|
+
const ids = [];
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row))
|
|
336
|
+
continue;
|
|
337
|
+
const record = row;
|
|
338
|
+
const id = record.id;
|
|
339
|
+
if (typeof id === 'string' && record.inject !== undefined && !ids.includes(id))
|
|
340
|
+
ids.push(id);
|
|
341
|
+
}
|
|
342
|
+
return ids;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The ids of OTHER plugins a package's bundle patch rewires — top-level rows
|
|
346
|
+
* that either `disabled: true` a plugin it does not own, or add an `inject:`
|
|
347
|
+
* requirement to one.
|
|
348
|
+
*
|
|
349
|
+
* Both are the marker of a bundle whose toggle-off can brick the boot while
|
|
350
|
+
* its patch keeps applying (#224, #938):
|
|
351
|
+
* - dsh-postgres-backends disables session-persistence-jsonl, so once the
|
|
352
|
+
* market also disables the postgres backends nothing provides
|
|
353
|
+
* sessionPersistence;
|
|
354
|
+
* - billion-context adds `inject: [biliBasicCompactionPolicy]` to the four
|
|
355
|
+
* official agent presets, so disabling only its own row leaves the presets
|
|
356
|
+
* waiting for a service that no longer exists — every preset disappears.
|
|
327
357
|
*
|
|
328
358
|
* A bundle that merely RECONFIGURES a neighbour is deliberately NOT counted:
|
|
329
359
|
* the e2e fixture-cross tweaks dshm-fixture-b's config, and #147 requires
|
|
330
360
|
* disabling it to leave that neighbour live — dropping such a bundle from the
|
|
331
361
|
* stack broke its re-enable. Config-only side effects stay on the normal #147
|
|
332
|
-
* path
|
|
333
|
-
*
|
|
334
|
-
* whole patch stops applying.
|
|
362
|
+
* path. Removing the bundle still neutralizes any config side effects it
|
|
363
|
+
* carries, since its whole patch stops applying.
|
|
335
364
|
*
|
|
336
365
|
* Reads both patch sources like rowIdsForPackage — the declared
|
|
337
366
|
* dsh.bundle.patch (one file or a LIST of them, #792) and the conventional
|
|
338
367
|
* root cordis.patch.yml — so either form is detected, and every declared file
|
|
339
|
-
* is attributed
|
|
340
|
-
* bricks the boot, #224).
|
|
368
|
+
* is attributed.
|
|
341
369
|
*/
|
|
342
|
-
export function
|
|
370
|
+
export function carrierRowIds(profileDirectory, packageName) {
|
|
343
371
|
const packageDir = join(profileDirectory, 'node_modules', packageName);
|
|
344
|
-
const
|
|
372
|
+
const rows = new Set();
|
|
345
373
|
const collectFromFile = (patchPath) => {
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
374
|
+
const parsed = parsePatchFile(patchPath);
|
|
375
|
+
if (parsed === null)
|
|
348
376
|
return;
|
|
349
|
-
for (const id of foreignDisableIds(
|
|
350
|
-
|
|
377
|
+
for (const id of foreignDisableIds(parsed))
|
|
378
|
+
rows.add(id);
|
|
379
|
+
for (const id of foreignInjectIds(parsed))
|
|
380
|
+
rows.add(id);
|
|
351
381
|
};
|
|
352
382
|
for (const file of declaredBundlePatchFiles(packageDir))
|
|
353
383
|
collectFromFile(file);
|
|
354
384
|
collectFromFile(join(packageDir, 'cordis.patch.yml'));
|
|
355
|
-
return [...
|
|
385
|
+
return [...rows];
|
|
356
386
|
}
|
|
357
387
|
/**
|
|
358
388
|
* The ids a package's bundle patch touches WITHOUT inserting them itself.
|
|
@@ -369,7 +399,7 @@ export function carrierDisableIds(profileDirectory, packageName) {
|
|
|
369
399
|
* fixture-cross e2e exist to protect, where the neighbour must keep running
|
|
370
400
|
* and the bundle must keep re-enabling.
|
|
371
401
|
*
|
|
372
|
-
* Reads both patch sources like
|
|
402
|
+
* Reads both patch sources like carrierRowIds: the declared
|
|
373
403
|
* `dsh.bundle.patch` (one file or a LIST, #792) and the conventional root
|
|
374
404
|
* cordis.patch.yml. Ownership is judged per file, because a package may ship
|
|
375
405
|
* both and only the file's own `insert:` block says which ids it brings in.
|
package/lib/profile.js
CHANGED
|
@@ -127,12 +127,16 @@ export function readManifestDeps(profile, explicitDir) {
|
|
|
127
127
|
}
|
|
128
128
|
/**
|
|
129
129
|
* For each installed package, the OTHER installed package that declares it —
|
|
130
|
-
* as a dependency or a peer dependency — in its own
|
|
130
|
+
* as a dependency, an optional dependency, or a peer dependency — in its own
|
|
131
|
+
* manifest.
|
|
131
132
|
*
|
|
132
133
|
* pnpm's auto-install-peers writes a plugin's peers into the profile manifest
|
|
133
134
|
* as direct dependencies, so a native binding a plugin needs shows up in the
|
|
134
135
|
* installed list looking exactly like a plugin the user chose (#634). What
|
|
135
|
-
* separates them is that somebody else asked for it.
|
|
136
|
+
* separates them is that somebody else asked for it. Optional dependencies
|
|
137
|
+
* count for the same reason (#941): a platform-specific binary a plugin lists
|
|
138
|
+
* under `optionalDependencies` is payload, not a plugin the user chose, even
|
|
139
|
+
* when pnpm installed that package and the card rendered.
|
|
136
140
|
*
|
|
137
141
|
* Ownership is decided by the first owner in sorted order, so the answer does
|
|
138
142
|
* not depend on the manifest's key order. A package that declares itself is
|
|
@@ -152,7 +156,7 @@ export function readDependencyOwners(profile, names, explicitDir) {
|
|
|
152
156
|
if (typeof manifest !== 'object' || manifest === null)
|
|
153
157
|
continue;
|
|
154
158
|
const declared = manifest;
|
|
155
|
-
for (const field of [declared.dependencies, declared.peerDependencies]) {
|
|
159
|
+
for (const field of [declared.dependencies, declared.optionalDependencies, declared.peerDependencies]) {
|
|
156
160
|
if (typeof field !== 'object' || field === null)
|
|
157
161
|
continue;
|
|
158
162
|
for (const dependency of Object.keys(field)) {
|
package/lib/routes.js
CHANGED
|
@@ -45,7 +45,7 @@ import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js'
|
|
|
45
45
|
import { resolveDshHome } from './home-paths.js';
|
|
46
46
|
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, trustedSelfUninstallRequest, selfUninstallReachableFrom, restartReachableFrom } from './restart.js';
|
|
47
47
|
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
48
|
-
import {
|
|
48
|
+
import { carrierRowIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
49
49
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
50
50
|
import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
|
|
51
51
|
import { MAX_UPDATE_OPERATIONS_V1, UpdateOperationStoreV1, UPDATE_API_V1_SCHEMA } from './update-api-v1.js';
|
|
@@ -856,9 +856,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
856
856
|
// A bundle that merely reconfigures a neighbour (config without
|
|
857
857
|
// disabled) is NOT dropped: #147 requires disabling it to leave the
|
|
858
858
|
// neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
|
|
859
|
-
const
|
|
859
|
+
const carrierRows = carrierRowIds(activeProfileDir, name);
|
|
860
860
|
const foreignRows = foreignRowIds(activeProfileDir, name);
|
|
861
|
-
const isCarrier =
|
|
861
|
+
const isCarrier = carrierRows.length > 0;
|
|
862
862
|
// Both layers, or neither (#696 B). `dsh.profile.bundles` is the
|
|
863
863
|
// package-level declaration the official plugins page's switch reads
|
|
864
864
|
// and the loader composes; the patch rows above are the runtime
|
|
@@ -888,7 +888,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
888
888
|
? addProfileBundle(activeProfileDir, name)
|
|
889
889
|
: removeProfileBundle(activeProfileDir, name);
|
|
890
890
|
logEvent('info', 'toggle', isCarrier
|
|
891
|
-
? `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (
|
|
891
|
+
? `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (carrier rows: ${carrierRows.join(', ')})`
|
|
892
892
|
: reBundle
|
|
893
893
|
? `${name}: re-added to dsh.profile.bundles, which nothing was composing (#696)`
|
|
894
894
|
: `${name}: dsh.profile.bundles ${enabled ? 're-added' : 'removed'} so the official page's package switch agrees (#696)`);
|
|
@@ -989,7 +989,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
989
989
|
}
|
|
990
990
|
}
|
|
991
991
|
logEvent(ok ? 'info' : 'error', 'toggle', `${name}: ${enabled ? 'on' : 'off'} ok=${String(ok)}`);
|
|
992
|
-
return { ok, reason, patchRows, userRows, patchWrite,
|
|
992
|
+
return { ok, reason, patchRows, userRows, patchWrite, carrierRows, isCarrier, bundleSwitch };
|
|
993
993
|
}
|
|
994
994
|
/**
|
|
995
995
|
* The plugin inventory the recovery surface is allowed to switch.
|
|
@@ -1015,7 +1015,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1015
1015
|
const plugins = [];
|
|
1016
1016
|
for (const name of names) {
|
|
1017
1017
|
const rows = rowIdsForPackage(host, activeProfileDir, name);
|
|
1018
|
-
const carrier =
|
|
1018
|
+
const carrier = carrierRowIds(activeProfileDir, name).length > 0;
|
|
1019
1019
|
if (rows.length === 0 && !carrier)
|
|
1020
1020
|
continue;
|
|
1021
1021
|
const isProtected = isProtectedModule(name);
|
|
@@ -1958,7 +1958,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1958
1958
|
}
|
|
1959
1959
|
try {
|
|
1960
1960
|
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
|
|
1961
|
-
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
|
|
1961
|
+
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true });
|
|
1962
1962
|
// `packages` carries the same objects the single-package endpoint
|
|
1963
1963
|
// returns, so one parser serves both. Only updatable ones: a badge
|
|
1964
1964
|
// wants the count, a panel wants the rows, and neither wants to
|
|
@@ -2005,7 +2005,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2005
2005
|
// or a catalog-matched local package answers here with the
|
|
2006
2006
|
// release it can be compared against rather than with nothing.
|
|
2007
2007
|
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs();
|
|
2008
|
-
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name];
|
|
2008
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true }))[name];
|
|
2009
2009
|
if (update === undefined) {
|
|
2010
2010
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
|
|
2011
2011
|
return;
|
|
@@ -3066,7 +3066,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3066
3066
|
const layers = await switchPluginLayers(name, enabled, ok, reason);
|
|
3067
3067
|
ok = layers.ok;
|
|
3068
3068
|
reason = layers.reason;
|
|
3069
|
-
const { patchRows, userRows, patchWrite,
|
|
3069
|
+
const { patchRows, userRows, patchWrite, carrierRows, isCarrier, bundleSwitch } = layers;
|
|
3070
3070
|
// Activation reads the post-write truth: the switch state OR the
|
|
3071
3071
|
// patch layer, so a disabled plugin never reports "restart to
|
|
3072
3072
|
// apply".
|
|
@@ -3110,7 +3110,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3110
3110
|
reason,
|
|
3111
3111
|
patchRows,
|
|
3112
3112
|
patchWrite: patchWrite ?? { ok: true, reason: null },
|
|
3113
|
-
carrier:
|
|
3113
|
+
carrier: carrierRows,
|
|
3114
3114
|
bundleSwitch,
|
|
3115
3115
|
restart,
|
|
3116
3116
|
refresh,
|
|
@@ -3676,7 +3676,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3676
3676
|
catch (error) {
|
|
3677
3677
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
3678
3678
|
}
|
|
3679
|
-
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo);
|
|
3679
|
+
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true });
|
|
3680
3680
|
for (const [name, migration] of sourceMigrationFor) {
|
|
3681
3681
|
const status = updates[name];
|
|
3682
3682
|
if (status !== undefined)
|
package/lib/trial.js
CHANGED
|
@@ -93,7 +93,7 @@ export function trialValidate(profileDir, newCommunityOrder, options = {}) {
|
|
|
93
93
|
// id into one entry (1.0.4+) this is an override, not a refusal. Making it
|
|
94
94
|
// fatal here is what rolled back every update for a profile that already
|
|
95
95
|
// had a package row and a user-patch row on one id.
|
|
96
|
-
const duplicateFatal = duplicateEntryIdsAreFatal(profileDir);
|
|
96
|
+
const duplicateFatal = duplicateEntryIdsAreFatal(profileDir, dshInstall);
|
|
97
97
|
for (const dup of composed.duplicates) {
|
|
98
98
|
const message = `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}`;
|
|
99
99
|
if (duplicateFatal)
|
package/lib/types/check.d.ts
CHANGED
|
@@ -302,10 +302,15 @@ export declare function composeLayers(layers: LayerInput[]): Composed;
|
|
|
302
302
|
* patch row plus a user-patch row on the same id: fatal forever meant every
|
|
303
303
|
* update of every plugin rolled back.
|
|
304
304
|
*
|
|
305
|
-
*
|
|
306
|
-
* the
|
|
305
|
+
* An empty answer from the PROFILE lockfile is not "unknown" on the official
|
|
306
|
+
* Desktop: that host embeds the loader in `app.asar` and provides it to every
|
|
307
|
+
* profile, so no profile can ever name it (#943). Reading that as an old
|
|
308
|
+
* loader made the gate fatal for every update on 0.2.1-rc.2 — the exact report
|
|
309
|
+
* #853's fix was supposed to close. The host installation is asked before the
|
|
310
|
+
* evidence is treated as missing; only when both are silent does the
|
|
311
|
+
* conservative answer stand.
|
|
307
312
|
*/
|
|
308
|
-
export declare function duplicateEntryIdsAreFatal(profileDirectory: string): boolean;
|
|
313
|
+
export declare function duplicateEntryIdsAreFatal(profileDirectory: string, dshInstallDir?: string | null): boolean;
|
|
309
314
|
/**
|
|
310
315
|
* Build the bundle layer stack for a profile under a GIVEN bundle order —
|
|
311
316
|
* the manifest order for analyzeProfile, or a candidate order for trial
|
package/lib/types/patch.d.ts
CHANGED
|
@@ -85,27 +85,32 @@ export declare function userPatchPackageReferences(patchPath: string, packageNam
|
|
|
85
85
|
*/
|
|
86
86
|
export declare function rowIdsForPackage(host: PatchHost, profileDirectory: string, packageName: string): string[];
|
|
87
87
|
/**
|
|
88
|
-
* The ids of OTHER plugins a package's bundle patch
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
88
|
+
* The ids of OTHER plugins a package's bundle patch rewires — top-level rows
|
|
89
|
+
* that either `disabled: true` a plugin it does not own, or add an `inject:`
|
|
90
|
+
* requirement to one.
|
|
91
|
+
*
|
|
92
|
+
* Both are the marker of a bundle whose toggle-off can brick the boot while
|
|
93
|
+
* its patch keeps applying (#224, #938):
|
|
94
|
+
* - dsh-postgres-backends disables session-persistence-jsonl, so once the
|
|
95
|
+
* market also disables the postgres backends nothing provides
|
|
96
|
+
* sessionPersistence;
|
|
97
|
+
* - billion-context adds `inject: [biliBasicCompactionPolicy]` to the four
|
|
98
|
+
* official agent presets, so disabling only its own row leaves the presets
|
|
99
|
+
* waiting for a service that no longer exists — every preset disappears.
|
|
93
100
|
*
|
|
94
101
|
* A bundle that merely RECONFIGURES a neighbour is deliberately NOT counted:
|
|
95
102
|
* the e2e fixture-cross tweaks dshm-fixture-b's config, and #147 requires
|
|
96
103
|
* disabling it to leave that neighbour live — dropping such a bundle from the
|
|
97
104
|
* stack broke its re-enable. Config-only side effects stay on the normal #147
|
|
98
|
-
* path
|
|
99
|
-
*
|
|
100
|
-
* whole patch stops applying.
|
|
105
|
+
* path. Removing the bundle still neutralizes any config side effects it
|
|
106
|
+
* carries, since its whole patch stops applying.
|
|
101
107
|
*
|
|
102
108
|
* Reads both patch sources like rowIdsForPackage — the declared
|
|
103
109
|
* dsh.bundle.patch (one file or a LIST of them, #792) and the conventional
|
|
104
110
|
* root cordis.patch.yml — so either form is detected, and every declared file
|
|
105
|
-
* is attributed
|
|
106
|
-
* bricks the boot, #224).
|
|
111
|
+
* is attributed.
|
|
107
112
|
*/
|
|
108
|
-
export declare function
|
|
113
|
+
export declare function carrierRowIds(profileDirectory: string, packageName: string): string[];
|
|
109
114
|
/**
|
|
110
115
|
* The ids a package's bundle patch touches WITHOUT inserting them itself.
|
|
111
116
|
*
|
|
@@ -121,7 +126,7 @@ export declare function carrierDisableIds(profileDirectory: string, packageName:
|
|
|
121
126
|
* fixture-cross e2e exist to protect, where the neighbour must keep running
|
|
122
127
|
* and the bundle must keep re-enabling.
|
|
123
128
|
*
|
|
124
|
-
* Reads both patch sources like
|
|
129
|
+
* Reads both patch sources like carrierRowIds: the declared
|
|
125
130
|
* `dsh.bundle.patch` (one file or a LIST, #792) and the conventional root
|
|
126
131
|
* cordis.patch.yml. Ownership is judged per file, because a package may ship
|
|
127
132
|
* both and only the file's own `insert:` block says which ids it brings in.
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -55,12 +55,16 @@ export declare function generationManagedNames(profile: string, explicitDir?: st
|
|
|
55
55
|
export declare function readManifestDeps(profile: string, explicitDir?: string): Record<string, string>;
|
|
56
56
|
/**
|
|
57
57
|
* For each installed package, the OTHER installed package that declares it —
|
|
58
|
-
* as a dependency or a peer dependency — in its own
|
|
58
|
+
* as a dependency, an optional dependency, or a peer dependency — in its own
|
|
59
|
+
* manifest.
|
|
59
60
|
*
|
|
60
61
|
* pnpm's auto-install-peers writes a plugin's peers into the profile manifest
|
|
61
62
|
* as direct dependencies, so a native binding a plugin needs shows up in the
|
|
62
63
|
* installed list looking exactly like a plugin the user chose (#634). What
|
|
63
|
-
* separates them is that somebody else asked for it.
|
|
64
|
+
* separates them is that somebody else asked for it. Optional dependencies
|
|
65
|
+
* count for the same reason (#941): a platform-specific binary a plugin lists
|
|
66
|
+
* under `optionalDependencies` is payload, not a plugin the user chose, even
|
|
67
|
+
* when pnpm installed that package and the card rendered.
|
|
64
68
|
*
|
|
65
69
|
* Ownership is decided by the first owner in sorted order, so the answer does
|
|
66
70
|
* not depend on the manifest's key order. A package that declares itself is
|
package/lib/types/updates.d.ts
CHANGED
|
@@ -63,6 +63,18 @@ export interface UpdateStatus {
|
|
|
63
63
|
repo: string;
|
|
64
64
|
target: string;
|
|
65
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* A newer release exists but this profile's `minimumReleaseAge` will not let
|
|
68
|
+
* pnpm install it yet (#945). The official Desktop takes no one-shot bypass,
|
|
69
|
+
* so offering the update there means offering a button that is guaranteed
|
|
70
|
+
* to fail; the market names the wait instead. `updateAvailable` is false —
|
|
71
|
+
* an update the user cannot apply is not available — and `until` is when
|
|
72
|
+
* pnpm will accept it.
|
|
73
|
+
*/
|
|
74
|
+
heldUpdate?: {
|
|
75
|
+
latest: string;
|
|
76
|
+
until: string;
|
|
77
|
+
};
|
|
66
78
|
/**
|
|
67
79
|
* The version this package's channel points at, when it differs from what
|
|
68
80
|
* is installed and is NOT newer.
|
|
@@ -210,4 +222,14 @@ onlineSourceFor?: ReadonlyMap<string, string>,
|
|
|
210
222
|
* entry that owns both the repo and the name, the npm fallback would
|
|
211
223
|
* compare the install with whatever package merely shares its name.
|
|
212
224
|
*/
|
|
213
|
-
catalogNpmByRepo?: ReadonlyMap<string, string
|
|
225
|
+
catalogNpmByRepo?: ReadonlyMap<string, string>,
|
|
226
|
+
/**
|
|
227
|
+
* Hold back releases that this profile's `minimumReleaseAge` will refuse
|
|
228
|
+
* (#945). Only true where the install path has no bypass: the official
|
|
229
|
+
* Desktop's manager takes no pnpm options, while the CLI path retries once
|
|
230
|
+
* with `--config.minimum-release-age=0` and CAN install a young release.
|
|
231
|
+
* Hiding those on the CLI would suppress updates that work.
|
|
232
|
+
*/
|
|
233
|
+
options?: {
|
|
234
|
+
holdYoungReleases?: boolean;
|
|
235
|
+
}): Promise<Record<string, UpdateStatus>>;
|
package/lib/updates.js
CHANGED
|
@@ -9,6 +9,7 @@ import { DIST_TAG } from './channels.js';
|
|
|
9
9
|
import { resolveHeadCommit } from './accelerate.js';
|
|
10
10
|
import { marketFetch } from './net.js';
|
|
11
11
|
import { activeRegion, githubRoutesFor, rememberGithubRoute, throughProxy } from './regions.js';
|
|
12
|
+
import { readMinimumReleaseAgeMs } from './install.js';
|
|
12
13
|
import { hasDshManifest, profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits, readProfileBundles } from './profile.js';
|
|
13
14
|
import { userPatchPackageReferences } from './patch.js';
|
|
14
15
|
import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hasSemverSelector, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, parseGitHubRemote, repoOfTarget } from './sources.js';
|
|
@@ -235,6 +236,24 @@ export async function latestPublishedRecently(name, windowMs = 26 * 60 * 60 * 10
|
|
|
235
236
|
return null;
|
|
236
237
|
}
|
|
237
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* When a specific version was published, from the packument's own time map
|
|
241
|
+
* (#945). Null when the registry cannot answer — callers must not claim a
|
|
242
|
+
* wait on null.
|
|
243
|
+
*/
|
|
244
|
+
async function npmPublishedAt(name, version) {
|
|
245
|
+
try {
|
|
246
|
+
const doc = (await fetchJson(npmUrl(encodeURIComponent(name))));
|
|
247
|
+
const published = doc.time?.[version];
|
|
248
|
+
if (published === undefined)
|
|
249
|
+
return null;
|
|
250
|
+
const at = Date.parse(published);
|
|
251
|
+
return Number.isFinite(at) ? at : null;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
238
257
|
/** The registry's current `latest` version for a package, or null when it can't be read. */
|
|
239
258
|
/**
|
|
240
259
|
* The version a channel subscriber should be offered: the newest build in
|
|
@@ -448,6 +467,42 @@ async function gitVersionUpdate(spec, installedVersion, region) {
|
|
|
448
467
|
const compared = compareVersions(remoteVersion, installedVersion);
|
|
449
468
|
return compared === null ? null : compared > 0;
|
|
450
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* A held update whose window has passed is available again. The cache lives
|
|
472
|
+
* 30 minutes; without this a user would watch "in about 1 hour" sit there
|
|
473
|
+
* after the hour ended, and could not update until the TTL happened to turn
|
|
474
|
+
* over. Pure: returns the same map when nothing expired.
|
|
475
|
+
*/
|
|
476
|
+
/**
|
|
477
|
+
* When the profile's fresh-release wait ends for `latest`, or null when the
|
|
478
|
+
* release is old enough to install now (or the registry cannot say).
|
|
479
|
+
*/
|
|
480
|
+
async function releaseAgeHold(name, latest, profile, activeProfileDir) {
|
|
481
|
+
if (latest === null)
|
|
482
|
+
return null;
|
|
483
|
+
const windowMs = readMinimumReleaseAgeMs(profile, activeProfileDir);
|
|
484
|
+
if (windowMs === null)
|
|
485
|
+
return null;
|
|
486
|
+
const published = await npmPublishedAt(name, latest);
|
|
487
|
+
if (published === null)
|
|
488
|
+
return null;
|
|
489
|
+
const until = published + windowMs;
|
|
490
|
+
return until > Date.now() ? new Date(until).toISOString() : null;
|
|
491
|
+
}
|
|
492
|
+
function refreshExpiredHolds(data) {
|
|
493
|
+
let changed = false;
|
|
494
|
+
const out = {};
|
|
495
|
+
for (const [name, status] of Object.entries(data)) {
|
|
496
|
+
if (status.heldUpdate === undefined || Date.parse(status.heldUpdate.until) > Date.now()) {
|
|
497
|
+
out[name] = status;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const { heldUpdate: _held, ...rest } = status;
|
|
501
|
+
out[name] = { ...rest, updateAvailable: true };
|
|
502
|
+
changed = true;
|
|
503
|
+
}
|
|
504
|
+
return changed ? out : data;
|
|
505
|
+
}
|
|
451
506
|
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
452
507
|
export async function checkUpdates(profile, force = false, explicitDir,
|
|
453
508
|
/**
|
|
@@ -470,15 +525,23 @@ onlineSourceFor = new Map(),
|
|
|
470
525
|
* entry that owns both the repo and the name, the npm fallback would
|
|
471
526
|
* compare the install with whatever package merely shares its name.
|
|
472
527
|
*/
|
|
473
|
-
catalogNpmByRepo = new Map()
|
|
528
|
+
catalogNpmByRepo = new Map(),
|
|
529
|
+
/**
|
|
530
|
+
* Hold back releases that this profile's `minimumReleaseAge` will refuse
|
|
531
|
+
* (#945). Only true where the install path has no bypass: the official
|
|
532
|
+
* Desktop's manager takes no pnpm options, while the CLI path retries once
|
|
533
|
+
* with `--config.minimum-release-age=0` and CAN install a young release.
|
|
534
|
+
* Hiding those on the CLI would suppress updates that work.
|
|
535
|
+
*/
|
|
536
|
+
options = {}) {
|
|
474
537
|
const activeProfileDir = profileDir(profile, explicitDir);
|
|
475
538
|
// The channel is part of the key: switching to betas has to change the
|
|
476
539
|
// answer immediately, and a cache keyed on the profile alone would serve
|
|
477
540
|
// the stable verdict for the rest of the TTL — reading as "the setting did
|
|
478
541
|
// nothing".
|
|
479
|
-
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
|
|
542
|
+
const cacheKey = `${activeProfileDir}\u0000${options.holdYoungReleases === true ? 'hold' : 'any'}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`;
|
|
480
543
|
if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
|
|
481
|
-
return updatesCache.data;
|
|
544
|
+
return refreshExpiredHolds(updatesCache.data);
|
|
482
545
|
}
|
|
483
546
|
const installed = readInstalled(profile, activeProfileDir);
|
|
484
547
|
const lockCommits = readLockCommits(profile, activeProfileDir);
|
|
@@ -490,10 +553,15 @@ catalogNpmByRepo = new Map()) {
|
|
|
490
553
|
const onlineSource = onlineSourceFor.get(name);
|
|
491
554
|
if (onlineSource !== undefined) {
|
|
492
555
|
const latest = await fetchNpmLatest(onlineSource);
|
|
493
|
-
const
|
|
556
|
+
const upgrade = isUpgrade(version, latest);
|
|
557
|
+
const heldUntil = upgrade && options.holdYoungReleases === true
|
|
558
|
+
? await releaseAgeHold(onlineSource, latest, profile, activeProfileDir)
|
|
559
|
+
: null;
|
|
494
560
|
result[name] = {
|
|
495
|
-
kind: 'linked', version, current: version, latest,
|
|
496
|
-
|
|
561
|
+
kind: 'linked', version, current: version, latest,
|
|
562
|
+
updateAvailable: upgrade && heldUntil === null,
|
|
563
|
+
...(heldUntil === null ? {} : { heldUpdate: { latest: latest, until: heldUntil } }),
|
|
564
|
+
...(upgrade ? { restoreRequired: true } : {}),
|
|
497
565
|
};
|
|
498
566
|
return;
|
|
499
567
|
}
|
|
@@ -619,9 +687,13 @@ catalogNpmByRepo = new Map()) {
|
|
|
619
687
|
const mayFollowATag = !upgrade
|
|
620
688
|
&& (version?.includes('-') === true || (stable !== null && version !== null && isUpgrade(stable, version) === false && stable !== version));
|
|
621
689
|
const newerPrerelease = mayFollowATag ? await newerPrereleaseFor(name, version) : null;
|
|
690
|
+
const heldUntil = upgrade && options.holdYoungReleases === true
|
|
691
|
+
? await releaseAgeHold(name, latest, profile, activeProfileDir)
|
|
692
|
+
: null;
|
|
622
693
|
result[name] = {
|
|
623
694
|
kind: 'npm', version, current: version, latest,
|
|
624
|
-
updateAvailable: upgrade,
|
|
695
|
+
updateAvailable: upgrade && heldUntil === null,
|
|
696
|
+
...(heldUntil === null ? {} : { heldUpdate: { latest: latest, until: heldUntil } }),
|
|
625
697
|
...(newerPrerelease === null ? {} : { newerPrerelease }),
|
|
626
698
|
...(sideways ? { channelSwitch: latest } : {}),
|
|
627
699
|
};
|
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.66.
|
|
4
|
+
"version": "1.66.45",
|
|
5
5
|
"icon": "assets/logo.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/check.ts
CHANGED
|
@@ -979,14 +979,26 @@ export function composeLayers(layers: LayerInput[]): Composed {
|
|
|
979
979
|
* patch row plus a user-patch row on the same id: fatal forever meant every
|
|
980
980
|
* update of every plugin rolled back.
|
|
981
981
|
*
|
|
982
|
-
*
|
|
983
|
-
* the
|
|
982
|
+
* An empty answer from the PROFILE lockfile is not "unknown" on the official
|
|
983
|
+
* Desktop: that host embeds the loader in `app.asar` and provides it to every
|
|
984
|
+
* profile, so no profile can ever name it (#943). Reading that as an old
|
|
985
|
+
* loader made the gate fatal for every update on 0.2.1-rc.2 — the exact report
|
|
986
|
+
* #853's fix was supposed to close. The host installation is asked before the
|
|
987
|
+
* evidence is treated as missing; only when both are silent does the
|
|
988
|
+
* conservative answer stand.
|
|
984
989
|
*/
|
|
985
|
-
export function duplicateEntryIdsAreFatal(profileDirectory: string): boolean {
|
|
990
|
+
export function duplicateEntryIdsAreFatal(profileDirectory: string, dshInstallDir?: string | null): boolean {
|
|
986
991
|
const versions = lockfileCoreVersions(profileDirectory).get('@deepseek-ai/cordis-plugin-loader') ?? []
|
|
987
|
-
if (versions.length
|
|
988
|
-
|
|
989
|
-
|
|
992
|
+
if (versions.length > 0) {
|
|
993
|
+
const newest = versions.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b))
|
|
994
|
+
return compareSemver(newest, '1.0.4') < 0
|
|
995
|
+
}
|
|
996
|
+
const install = dshInstallDir === undefined ? findDshInstallDir() : dshInstallDir
|
|
997
|
+
const host = install === null
|
|
998
|
+
? null
|
|
999
|
+
: readNodeModulesVersion(dirname(hostNodeModulesRoot(install)), '@deepseek-ai/cordis-plugin-loader')
|
|
1000
|
+
if (host === null) return true
|
|
1001
|
+
return compareSemver(host, '1.0.4') < 0
|
|
990
1002
|
}
|
|
991
1003
|
|
|
992
1004
|
/** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
|
|
@@ -1564,7 +1576,7 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
|
|
|
1564
1576
|
// Rows on one id: a boot failure on an old loader, a silent override on a
|
|
1565
1577
|
// new one (see duplicateEntryIdsAreFatal). Refusing an operation over the
|
|
1566
1578
|
// second shape rolled back every update for the reporter's profile.
|
|
1567
|
-
const duplicateFatal = duplicateEntryIdsAreFatal(profileDirectory)
|
|
1579
|
+
const duplicateFatal = duplicateEntryIdsAreFatal(profileDirectory, dshInstall)
|
|
1568
1580
|
for (const dup of composed.duplicates) {
|
|
1569
1581
|
if (duplicateFatal) {
|
|
1570
1582
|
errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`)
|
|
@@ -774,7 +774,7 @@ function ThemeCover({ plugin, onOpen, t }: {
|
|
|
774
774
|
}, [curatedKey, near, plugin.url])
|
|
775
775
|
const shots = curated.length > 0 ? curated : fallback.shots
|
|
776
776
|
const visible = shots.filter(src => !broken.includes(src))
|
|
777
|
-
const name = pluginName(plugin.name)
|
|
777
|
+
const name = pluginName(plugin.name, plugin.npm)
|
|
778
778
|
|
|
779
779
|
if (visible.length === 0) {
|
|
780
780
|
return (
|
|
@@ -2107,7 +2107,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2107
2107
|
const entry = data?.plugins.find(plugin => plugin.npm === name || plugin.name === name)
|
|
2108
2108
|
if (entry === undefined) return { title: name }
|
|
2109
2109
|
return {
|
|
2110
|
-
title: pluginName(entry.name),
|
|
2110
|
+
title: pluginName(entry.name, entry.npm),
|
|
2111
2111
|
author: entry.owner === '' ? undefined : entry.owner,
|
|
2112
2112
|
avatar: <OwnerAvatar name={entry.name} owner={entry.owner || ''} />,
|
|
2113
2113
|
}
|
|
@@ -5421,7 +5421,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5421
5421
|
the same name without either card needing a qualifier. */}
|
|
5422
5422
|
<div style={{ minWidth: 0 }}>
|
|
5423
5423
|
<a className={`${css.nm} ${css.nmLink}`} href={p.url} target="_blank" rel="noreferrer" title={p.name} aria-label={`${p.name} — ${t('repoLink')}`}>
|
|
5424
|
-
{pluginName(p.name)}
|
|
5424
|
+
{pluginName(p.name, p.npm)}
|
|
5425
5425
|
<GithubRepoMark className={css.repoMark} />
|
|
5426
5426
|
{p.deprecated === true && <span className={css.depBadge}>{t('deprecatedBadge')}</span>}
|
|
5427
5427
|
</a>
|
|
@@ -5569,7 +5569,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5569
5569
|
<div className={css.themeCardHead}>
|
|
5570
5570
|
<div className={css.themeIdentity}>
|
|
5571
5571
|
<a className={`${css.nm} ${css.nmLink}`} href={p.url} target="_blank" rel="noreferrer" title={p.name} aria-label={`${p.name} — ${t('repoLink')}`}>
|
|
5572
|
-
{pluginName(p.name)}
|
|
5572
|
+
{pluginName(p.name, p.npm)}
|
|
5573
5573
|
<GithubRepoMark className={css.repoMark} />
|
|
5574
5574
|
</a>
|
|
5575
5575
|
<div className={css.byline}>
|
|
@@ -7358,6 +7358,17 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
7358
7358
|
}}
|
|
7359
7359
|
>{status.restoreRequired === true ? t('restoreOnline') : t('update')}</Button>
|
|
7360
7360
|
)
|
|
7361
|
+
: status?.heldUpdate !== undefined
|
|
7362
|
+
// A newer release exists but pnpm will refuse
|
|
7363
|
+
// it until the profile's fresh-release wait
|
|
7364
|
+
// passes (#945). The official Desktop has no
|
|
7365
|
+
// bypass, so the honest state is "waiting",
|
|
7366
|
+
// not a button that fails by construction.
|
|
7367
|
+
? (
|
|
7368
|
+
<span className={css.metaTag} title={t('updateHeldHint').replace('{1}', String(Math.max(1, Math.ceil((Date.parse(status.heldUpdate.until) - Date.now()) / 3_600_000))))}>
|
|
7369
|
+
{t('updateHeldTag').replace('{0}', displayLatest(status.heldUpdate.latest))}
|
|
7370
|
+
</span>
|
|
7371
|
+
)
|
|
7361
7372
|
: localDev
|
|
7362
7373
|
? null
|
|
7363
7374
|
: status?.unreleasedCommit != null
|
|
@@ -7556,7 +7567,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
7556
7567
|
className={css.confirmModal}
|
|
7557
7568
|
contentClassName={css.confirmContent}
|
|
7558
7569
|
onClose={() => setConfirming(null)}
|
|
7559
|
-
title={t('confirmTitle') + ' ' + confirming.name + '?'}
|
|
7570
|
+
title={t('confirmTitle') + ' ' + pluginName(confirming.name, confirming.npm) + '?'}
|
|
7560
7571
|
footer={(
|
|
7561
7572
|
<>
|
|
7562
7573
|
<Button variant="ghost" onClick={() => setConfirming(null)}>{t('cancel')}</Button>
|
|
@@ -7682,7 +7693,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
7682
7693
|
{commentsFor !== null && (
|
|
7683
7694
|
<CommentsModal
|
|
7684
7695
|
key={commentsFor.url}
|
|
7685
|
-
name={pluginName(commentsFor.name)}
|
|
7696
|
+
name={pluginName(commentsFor.name, commentsFor.npm)}
|
|
7686
7697
|
url={commentsFor.url}
|
|
7687
7698
|
lang={lang}
|
|
7688
7699
|
onClose={() => setCommentsFor(null)}
|
package/src/client/locales.ts
CHANGED
|
@@ -196,6 +196,8 @@ export const zh = {
|
|
|
196
196
|
updateNow: '立即更新',
|
|
197
197
|
updateFail: '更新失败',
|
|
198
198
|
upToDate: '已是最新',
|
|
199
|
+
updateHeldTag: '新版等待中',
|
|
200
|
+
updateHeldHint: '这个 profile 要求新版本发布满一段时间才允许安装,大约还要 {1} 小时。',
|
|
199
201
|
newerPrereleaseAvailable: '预发布版 {0} 已发布',
|
|
200
202
|
newerPrereleaseHint: 'npm 上的 latest 没有更新;你在跟的这条预发布线发布了更新的版本。',
|
|
201
203
|
unreleasedCommits: '有新提交(未发版){0}',
|
|
@@ -959,6 +961,8 @@ export const en: Record<MarketKey, string> = {
|
|
|
959
961
|
updateNow: 'Update now',
|
|
960
962
|
updateFail: 'Update failed',
|
|
961
963
|
upToDate: 'Up to date',
|
|
964
|
+
updateHeldTag: 'new version waiting',
|
|
965
|
+
updateHeldHint: 'This profile waits a while before it installs a fresh release; about {1} hour(s) left.',
|
|
962
966
|
newerPrereleaseAvailable: 'prerelease {0} published',
|
|
963
967
|
newerPrereleaseHint: 'The package\'s latest tag has no update; the prerelease line you are on published a newer build.',
|
|
964
968
|
unreleasedCommits: 'New commits (unreleased) {0}',
|
|
@@ -212,6 +212,10 @@ export interface UpdateStatus {
|
|
|
212
212
|
unreleasedCommit?: string
|
|
213
213
|
/** Updating this local package switches it to its matched online release. */
|
|
214
214
|
restoreRequired?: boolean
|
|
215
|
+
/** A newer release exists but the profile's fresh-release wait has not
|
|
216
|
+
passed yet (#945). `updateAvailable` is false and this names the wait;
|
|
217
|
+
on the official Desktop there is no bypass to offer. */
|
|
218
|
+
heldUpdate?: { latest: string; until: string }
|
|
215
219
|
/** Explicit source migration; never part of update-all. */
|
|
216
220
|
sourceMigration?: { kind: 'git-to-npm'; repo: string; target: string }
|
|
217
221
|
}
|
|
@@ -1610,14 +1614,17 @@ export function localizeBilingualList(parts: string[], lang: 'zh' | 'en'): strin
|
|
|
1610
1614
|
*
|
|
1611
1615
|
* The repository name IS the plugin name in the ordinary case, because a
|
|
1612
1616
|
* repository holding one plugin is named after it. Only the compound form
|
|
1613
|
-
* needs unpicking
|
|
1617
|
+
* needs unpicking. Prefer its mapped npm package when available: a generic
|
|
1618
|
+
* directory such as `plugin` is not the product name. Without that mapping,
|
|
1619
|
+
* keep the directory-leaf fallback for source-only entries.
|
|
1614
1620
|
*
|
|
1615
1621
|
* Not a substitute for the identity: every key, lookup and install still
|
|
1616
1622
|
* uses `name` unchanged.
|
|
1617
1623
|
*/
|
|
1618
|
-
export function pluginName(name: string): string {
|
|
1624
|
+
export function pluginName(name: string, npm?: string | null): string {
|
|
1619
1625
|
const hash = name.indexOf('#')
|
|
1620
1626
|
if (hash === -1) return name
|
|
1627
|
+
if (typeof npm === 'string' && npm.trim() !== '') return npm.trim()
|
|
1621
1628
|
const sub = name.slice(hash + 1)
|
|
1622
1629
|
const leaf = sub.slice(sub.lastIndexOf('/') + 1)
|
|
1623
1630
|
// A sub-path that is empty or trailing-slashed tells us nothing; the
|
package/src/patch.ts
CHANGED
|
@@ -325,37 +325,65 @@ function foreignDisableIds(rows: unknown[]): string[] {
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
328
|
+
* Top-level patch rows that add an `inject:` requirement to another plugin.
|
|
329
|
+
*
|
|
330
|
+
* Same shape rule as foreignDisableIds: anything at the top level with an id
|
|
331
|
+
* is a statement about a plugin this package does not own. An `inject` there
|
|
332
|
+
* makes that foreign row depend on a service — usually one this bundle
|
|
333
|
+
* provides — so switching this package off while leaving its patch in the
|
|
334
|
+
* stack strands the row: #938's billion-context adds
|
|
335
|
+
* `inject: [biliBasicCompactionPolicy]` to the four official agent presets,
|
|
336
|
+
* and a row-level disable left them unable to activate.
|
|
337
|
+
*/
|
|
338
|
+
function foreignInjectIds(rows: unknown[]): string[] {
|
|
339
|
+
const ids: string[] = []
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) continue
|
|
342
|
+
const record = row as Record<string, unknown>
|
|
343
|
+
const id = record.id
|
|
344
|
+
if (typeof id === 'string' && record.inject !== undefined && !ids.includes(id)) ids.push(id)
|
|
345
|
+
}
|
|
346
|
+
return ids
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The ids of OTHER plugins a package's bundle patch rewires — top-level rows
|
|
351
|
+
* that either `disabled: true` a plugin it does not own, or add an `inject:`
|
|
352
|
+
* requirement to one.
|
|
353
|
+
*
|
|
354
|
+
* Both are the marker of a bundle whose toggle-off can brick the boot while
|
|
355
|
+
* its patch keeps applying (#224, #938):
|
|
356
|
+
* - dsh-postgres-backends disables session-persistence-jsonl, so once the
|
|
357
|
+
* market also disables the postgres backends nothing provides
|
|
358
|
+
* sessionPersistence;
|
|
359
|
+
* - billion-context adds `inject: [biliBasicCompactionPolicy]` to the four
|
|
360
|
+
* official agent presets, so disabling only its own row leaves the presets
|
|
361
|
+
* waiting for a service that no longer exists — every preset disappears.
|
|
333
362
|
*
|
|
334
363
|
* A bundle that merely RECONFIGURES a neighbour is deliberately NOT counted:
|
|
335
364
|
* the e2e fixture-cross tweaks dshm-fixture-b's config, and #147 requires
|
|
336
365
|
* disabling it to leave that neighbour live — dropping such a bundle from the
|
|
337
366
|
* stack broke its re-enable. Config-only side effects stay on the normal #147
|
|
338
|
-
* path
|
|
339
|
-
*
|
|
340
|
-
* whole patch stops applying.
|
|
367
|
+
* path. Removing the bundle still neutralizes any config side effects it
|
|
368
|
+
* carries, since its whole patch stops applying.
|
|
341
369
|
*
|
|
342
370
|
* Reads both patch sources like rowIdsForPackage — the declared
|
|
343
371
|
* dsh.bundle.patch (one file or a LIST of them, #792) and the conventional
|
|
344
372
|
* root cordis.patch.yml — so either form is detected, and every declared file
|
|
345
|
-
* is attributed
|
|
346
|
-
* bricks the boot, #224).
|
|
373
|
+
* is attributed.
|
|
347
374
|
*/
|
|
348
|
-
export function
|
|
375
|
+
export function carrierRowIds(profileDirectory: string, packageName: string): string[] {
|
|
349
376
|
const packageDir = join(profileDirectory, 'node_modules', packageName)
|
|
350
|
-
const
|
|
377
|
+
const rows = new Set<string>()
|
|
351
378
|
const collectFromFile = (patchPath: string): void => {
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
for (const id of foreignDisableIds(
|
|
379
|
+
const parsed = parsePatchFile(patchPath)
|
|
380
|
+
if (parsed === null) return
|
|
381
|
+
for (const id of foreignDisableIds(parsed)) rows.add(id)
|
|
382
|
+
for (const id of foreignInjectIds(parsed)) rows.add(id)
|
|
355
383
|
}
|
|
356
384
|
for (const file of declaredBundlePatchFiles(packageDir)) collectFromFile(file)
|
|
357
385
|
collectFromFile(join(packageDir, 'cordis.patch.yml'))
|
|
358
|
-
return [...
|
|
386
|
+
return [...rows]
|
|
359
387
|
}
|
|
360
388
|
|
|
361
389
|
/**
|
|
@@ -373,7 +401,7 @@ export function carrierDisableIds(profileDirectory: string, packageName: string)
|
|
|
373
401
|
* fixture-cross e2e exist to protect, where the neighbour must keep running
|
|
374
402
|
* and the bundle must keep re-enabling.
|
|
375
403
|
*
|
|
376
|
-
* Reads both patch sources like
|
|
404
|
+
* Reads both patch sources like carrierRowIds: the declared
|
|
377
405
|
* `dsh.bundle.patch` (one file or a LIST, #792) and the conventional root
|
|
378
406
|
* cordis.patch.yml. Ownership is judged per file, because a package may ship
|
|
379
407
|
* both and only the file's own `insert:` block says which ids it brings in.
|
package/src/profile.ts
CHANGED
|
@@ -135,12 +135,16 @@ export function readManifestDeps(profile: string, explicitDir?: string): Record<
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* For each installed package, the OTHER installed package that declares it —
|
|
138
|
-
* as a dependency or a peer dependency — in its own
|
|
138
|
+
* as a dependency, an optional dependency, or a peer dependency — in its own
|
|
139
|
+
* manifest.
|
|
139
140
|
*
|
|
140
141
|
* pnpm's auto-install-peers writes a plugin's peers into the profile manifest
|
|
141
142
|
* as direct dependencies, so a native binding a plugin needs shows up in the
|
|
142
143
|
* installed list looking exactly like a plugin the user chose (#634). What
|
|
143
|
-
* separates them is that somebody else asked for it.
|
|
144
|
+
* separates them is that somebody else asked for it. Optional dependencies
|
|
145
|
+
* count for the same reason (#941): a platform-specific binary a plugin lists
|
|
146
|
+
* under `optionalDependencies` is payload, not a plugin the user chose, even
|
|
147
|
+
* when pnpm installed that package and the card rendered.
|
|
144
148
|
*
|
|
145
149
|
* Ownership is decided by the first owner in sorted order, so the answer does
|
|
146
150
|
* not depend on the manifest's key order. A package that declares itself is
|
|
@@ -162,8 +166,12 @@ export function readDependencyOwners(
|
|
|
162
166
|
for (const owner of topLevelPackageNames(profile, names, explicitDir)) {
|
|
163
167
|
const manifest = readInstalledManifest(profile, owner, explicitDir)
|
|
164
168
|
if (typeof manifest !== 'object' || manifest === null) continue
|
|
165
|
-
const declared = manifest as {
|
|
166
|
-
|
|
169
|
+
const declared = manifest as {
|
|
170
|
+
dependencies?: unknown
|
|
171
|
+
optionalDependencies?: unknown
|
|
172
|
+
peerDependencies?: unknown
|
|
173
|
+
}
|
|
174
|
+
for (const field of [declared.dependencies, declared.optionalDependencies, declared.peerDependencies]) {
|
|
167
175
|
if (typeof field !== 'object' || field === null) continue
|
|
168
176
|
for (const dependency of Object.keys(field as Record<string, unknown>)) {
|
|
169
177
|
if (dependency === owner || !installed.has(dependency)) continue
|
package/src/routes.ts
CHANGED
|
@@ -59,7 +59,7 @@ import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart,
|
|
|
59
59
|
import type { RecoveryPlugin } from './recovery.ts'
|
|
60
60
|
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
61
61
|
import {
|
|
62
|
-
|
|
62
|
+
carrierRowIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
|
|
63
63
|
readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
|
|
64
64
|
} from './patch.ts'
|
|
65
65
|
import {
|
|
@@ -946,7 +946,8 @@ export function mountMarketRoutes(
|
|
|
946
946
|
/** The subset a user decision can be read from (#876). */
|
|
947
947
|
userRows: string[]
|
|
948
948
|
patchWrite: { ok: boolean; reason: string | null } | null
|
|
949
|
-
|
|
949
|
+
/** Foreign rows this bundle rewires: disabled or given a new inject (#224, #938). */
|
|
950
|
+
carrierRows: string[]
|
|
950
951
|
isCarrier: boolean
|
|
951
952
|
bundleSwitch: { ok: boolean; reason: string | null }
|
|
952
953
|
}> {
|
|
@@ -979,9 +980,9 @@ export function mountMarketRoutes(
|
|
|
979
980
|
// A bundle that merely reconfigures a neighbour (config without
|
|
980
981
|
// disabled) is NOT dropped: #147 requires disabling it to leave the
|
|
981
982
|
// neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
|
|
982
|
-
const
|
|
983
|
+
const carrierRows = carrierRowIds(activeProfileDir, name)
|
|
983
984
|
const foreignRows = foreignRowIds(activeProfileDir, name)
|
|
984
|
-
const isCarrier =
|
|
985
|
+
const isCarrier = carrierRows.length > 0
|
|
985
986
|
// Both layers, or neither (#696 B). `dsh.profile.bundles` is the
|
|
986
987
|
// package-level declaration the official plugins page's switch reads
|
|
987
988
|
// and the loader composes; the patch rows above are the runtime
|
|
@@ -1011,7 +1012,7 @@ export function mountMarketRoutes(
|
|
|
1011
1012
|
? addProfileBundle(activeProfileDir, name)
|
|
1012
1013
|
: removeProfileBundle(activeProfileDir, name)
|
|
1013
1014
|
logEvent('info', 'toggle', isCarrier
|
|
1014
|
-
? `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (
|
|
1015
|
+
? `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (carrier rows: ${carrierRows.join(', ')})`
|
|
1015
1016
|
: reBundle
|
|
1016
1017
|
? `${name}: re-added to dsh.profile.bundles, which nothing was composing (#696)`
|
|
1017
1018
|
: `${name}: dsh.profile.bundles ${enabled ? 're-added' : 'removed'} so the official page's package switch agrees (#696)`)
|
|
@@ -1106,7 +1107,7 @@ export function mountMarketRoutes(
|
|
|
1106
1107
|
}
|
|
1107
1108
|
}
|
|
1108
1109
|
logEvent(ok ? 'info' : 'error', 'toggle', `${name}: ${enabled ? 'on' : 'off'} ok=${String(ok)}`)
|
|
1109
|
-
return { ok, reason, patchRows, userRows, patchWrite,
|
|
1110
|
+
return { ok, reason, patchRows, userRows, patchWrite, carrierRows, isCarrier, bundleSwitch }
|
|
1110
1111
|
}
|
|
1111
1112
|
|
|
1112
1113
|
/**
|
|
@@ -1133,7 +1134,7 @@ export function mountMarketRoutes(
|
|
|
1133
1134
|
const plugins: RecoveryPlugin[] = []
|
|
1134
1135
|
for (const name of names) {
|
|
1135
1136
|
const rows = rowIdsForPackage(host, activeProfileDir, name)
|
|
1136
|
-
const carrier =
|
|
1137
|
+
const carrier = carrierRowIds(activeProfileDir, name).length > 0
|
|
1137
1138
|
if (rows.length === 0 && !carrier) continue
|
|
1138
1139
|
const isProtected = isProtectedModule(name)
|
|
1139
1140
|
plugins.push({
|
|
@@ -2154,7 +2155,7 @@ export function mountMarketRoutes(
|
|
|
2154
2155
|
}
|
|
2155
2156
|
try {
|
|
2156
2157
|
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
|
|
2157
|
-
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
|
|
2158
|
+
const updates = await checkUpdates(config.profile, forceCheckFrom(request), activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true })
|
|
2158
2159
|
// `packages` carries the same objects the single-package endpoint
|
|
2159
2160
|
// returns, so one parser serves both. Only updatable ones: a badge
|
|
2160
2161
|
// wants the count, a panel wants the rows, and neither wants to
|
|
@@ -2201,7 +2202,7 @@ export function mountMarketRoutes(
|
|
|
2201
2202
|
// or a catalog-matched local package answers here with the
|
|
2202
2203
|
// release it can be compared against rather than with nothing.
|
|
2203
2204
|
const { channelFor, onlineSourceFor, catalogNpmByRepo } = await updateCheckInputs()
|
|
2204
|
-
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo))[name]
|
|
2205
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true }))[name]
|
|
2205
2206
|
if (update === undefined) {
|
|
2206
2207
|
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
|
|
2207
2208
|
return
|
|
@@ -3277,7 +3278,7 @@ export function mountMarketRoutes(
|
|
|
3277
3278
|
const layers = await switchPluginLayers(name, enabled, ok, reason)
|
|
3278
3279
|
ok = layers.ok
|
|
3279
3280
|
reason = layers.reason
|
|
3280
|
-
const { patchRows, userRows, patchWrite,
|
|
3281
|
+
const { patchRows, userRows, patchWrite, carrierRows, isCarrier, bundleSwitch } = layers
|
|
3281
3282
|
// Activation reads the post-write truth: the switch state OR the
|
|
3282
3283
|
// patch layer, so a disabled plugin never reports "restart to
|
|
3283
3284
|
// apply".
|
|
@@ -3324,7 +3325,7 @@ export function mountMarketRoutes(
|
|
|
3324
3325
|
reason,
|
|
3325
3326
|
patchRows,
|
|
3326
3327
|
patchWrite: patchWrite ?? { ok: true, reason: null },
|
|
3327
|
-
carrier:
|
|
3328
|
+
carrier: carrierRows,
|
|
3328
3329
|
bundleSwitch,
|
|
3329
3330
|
restart,
|
|
3330
3331
|
refresh,
|
|
@@ -3879,7 +3880,7 @@ export function mountMarketRoutes(
|
|
|
3879
3880
|
} catch (error) {
|
|
3880
3881
|
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
3881
3882
|
}
|
|
3882
|
-
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo)
|
|
3883
|
+
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor, catalogNpmByRepo, { holdYoungReleases: config.desktopHost === true })
|
|
3883
3884
|
for (const [name, migration] of sourceMigrationFor) {
|
|
3884
3885
|
const status = updates[name]
|
|
3885
3886
|
if (status !== undefined) updates[name] = { ...status, sourceMigration: migration }
|
package/src/trial.ts
CHANGED
|
@@ -130,7 +130,7 @@ export function trialValidate(
|
|
|
130
130
|
// id into one entry (1.0.4+) this is an override, not a refusal. Making it
|
|
131
131
|
// fatal here is what rolled back every update for a profile that already
|
|
132
132
|
// had a package row and a user-patch row on one id.
|
|
133
|
-
const duplicateFatal = duplicateEntryIdsAreFatal(profileDir)
|
|
133
|
+
const duplicateFatal = duplicateEntryIdsAreFatal(profileDir, dshInstall)
|
|
134
134
|
for (const dup of composed.duplicates) {
|
|
135
135
|
const message = `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}`
|
|
136
136
|
if (duplicateFatal) errors.push({ layer: dup.layers.join(' / '), message })
|
package/src/updates.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { DIST_TAG, type Channel } from './channels.ts'
|
|
|
10
10
|
import { resolveHeadCommit } from './accelerate.ts'
|
|
11
11
|
import { marketFetch } from './net.ts'
|
|
12
12
|
import { activeRegion, githubRoutesFor, rememberGithubRoute, throughProxy, type Region } from './regions.ts'
|
|
13
|
+
import { readMinimumReleaseAgeMs } from './install.ts'
|
|
13
14
|
import { hasDshManifest, profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits, readProfileBundles } from './profile.ts'
|
|
14
15
|
import { userPatchPackageReferences } from './patch.ts'
|
|
15
16
|
import { catalogRepoKey, gitCommitOfTarget, gitRefOfTarget, gitUploadPackUrl, hasSemverSelector, hostedRepoKey, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, lookupRepoFromUrl, parseGitHubRemote, repoOfTarget } from './sources.ts'
|
|
@@ -69,6 +70,15 @@ export interface UpdateStatus {
|
|
|
69
70
|
restoreRequired?: boolean
|
|
70
71
|
/** Independent, explicit source switch offered for verified legacy Git installs. */
|
|
71
72
|
sourceMigration?: { kind: 'git-to-npm'; repo: string; target: string }
|
|
73
|
+
/**
|
|
74
|
+
* A newer release exists but this profile's `minimumReleaseAge` will not let
|
|
75
|
+
* pnpm install it yet (#945). The official Desktop takes no one-shot bypass,
|
|
76
|
+
* so offering the update there means offering a button that is guaranteed
|
|
77
|
+
* to fail; the market names the wait instead. `updateAvailable` is false —
|
|
78
|
+
* an update the user cannot apply is not available — and `until` is when
|
|
79
|
+
* pnpm will accept it.
|
|
80
|
+
*/
|
|
81
|
+
heldUpdate?: { latest: string; until: string }
|
|
72
82
|
/**
|
|
73
83
|
* The version this package's channel points at, when it differs from what
|
|
74
84
|
* is installed and is NOT newer.
|
|
@@ -304,6 +314,23 @@ export async function latestPublishedRecently(name: string, windowMs = 26 * 60 *
|
|
|
304
314
|
}
|
|
305
315
|
}
|
|
306
316
|
|
|
317
|
+
/**
|
|
318
|
+
* When a specific version was published, from the packument's own time map
|
|
319
|
+
* (#945). Null when the registry cannot answer — callers must not claim a
|
|
320
|
+
* wait on null.
|
|
321
|
+
*/
|
|
322
|
+
async function npmPublishedAt(name: string, version: string): Promise<number | null> {
|
|
323
|
+
try {
|
|
324
|
+
const doc = (await fetchJson(npmUrl(encodeURIComponent(name)))) as { time?: Record<string, string> }
|
|
325
|
+
const published = doc.time?.[version]
|
|
326
|
+
if (published === undefined) return null
|
|
327
|
+
const at = Date.parse(published)
|
|
328
|
+
return Number.isFinite(at) ? at : null
|
|
329
|
+
} catch {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
/** The registry's current `latest` version for a package, or null when it can't be read. */
|
|
308
335
|
/**
|
|
309
336
|
* The version a channel subscriber should be offered: the newest build in
|
|
@@ -518,6 +545,41 @@ async function gitVersionUpdate(
|
|
|
518
545
|
return compared === null ? null : compared > 0
|
|
519
546
|
}
|
|
520
547
|
|
|
548
|
+
/**
|
|
549
|
+
* A held update whose window has passed is available again. The cache lives
|
|
550
|
+
* 30 minutes; without this a user would watch "in about 1 hour" sit there
|
|
551
|
+
* after the hour ended, and could not update until the TTL happened to turn
|
|
552
|
+
* over. Pure: returns the same map when nothing expired.
|
|
553
|
+
*/
|
|
554
|
+
/**
|
|
555
|
+
* When the profile's fresh-release wait ends for `latest`, or null when the
|
|
556
|
+
* release is old enough to install now (or the registry cannot say).
|
|
557
|
+
*/
|
|
558
|
+
async function releaseAgeHold(name: string, latest: string | null, profile: string, activeProfileDir: string): Promise<string | null> {
|
|
559
|
+
if (latest === null) return null
|
|
560
|
+
const windowMs = readMinimumReleaseAgeMs(profile, activeProfileDir)
|
|
561
|
+
if (windowMs === null) return null
|
|
562
|
+
const published = await npmPublishedAt(name, latest)
|
|
563
|
+
if (published === null) return null
|
|
564
|
+
const until = published + windowMs
|
|
565
|
+
return until > Date.now() ? new Date(until).toISOString() : null
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function refreshExpiredHolds(data: Record<string, UpdateStatus>): Record<string, UpdateStatus> {
|
|
569
|
+
let changed = false
|
|
570
|
+
const out: Record<string, UpdateStatus> = {}
|
|
571
|
+
for (const [name, status] of Object.entries(data)) {
|
|
572
|
+
if (status.heldUpdate === undefined || Date.parse(status.heldUpdate.until) > Date.now()) {
|
|
573
|
+
out[name] = status
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
const { heldUpdate: _held, ...rest } = status
|
|
577
|
+
out[name] = { ...rest, updateAvailable: true }
|
|
578
|
+
changed = true
|
|
579
|
+
}
|
|
580
|
+
return changed ? out : data
|
|
581
|
+
}
|
|
582
|
+
|
|
521
583
|
/** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
|
|
522
584
|
export async function checkUpdates(
|
|
523
585
|
profile: string,
|
|
@@ -544,15 +606,23 @@ export async function checkUpdates(
|
|
|
544
606
|
* compare the install with whatever package merely shares its name.
|
|
545
607
|
*/
|
|
546
608
|
catalogNpmByRepo: ReadonlyMap<string, string> = new Map(),
|
|
609
|
+
/**
|
|
610
|
+
* Hold back releases that this profile's `minimumReleaseAge` will refuse
|
|
611
|
+
* (#945). Only true where the install path has no bypass: the official
|
|
612
|
+
* Desktop's manager takes no pnpm options, while the CLI path retries once
|
|
613
|
+
* with `--config.minimum-release-age=0` and CAN install a young release.
|
|
614
|
+
* Hiding those on the CLI would suppress updates that work.
|
|
615
|
+
*/
|
|
616
|
+
options: { holdYoungReleases?: boolean } = {},
|
|
547
617
|
): Promise<Record<string, UpdateStatus>> {
|
|
548
618
|
const activeProfileDir = profileDir(profile, explicitDir)
|
|
549
619
|
// The channel is part of the key: switching to betas has to change the
|
|
550
620
|
// answer immediately, and a cache keyed on the profile alone would serve
|
|
551
621
|
// the stable verdict for the rest of the TTL — reading as "the setting did
|
|
552
622
|
// nothing".
|
|
553
|
-
const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
|
|
623
|
+
const cacheKey = `${activeProfileDir}\u0000${options.holdYoungReleases === true ? 'hold' : 'any'}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}\u0000${[...catalogNpmByRepo].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
|
|
554
624
|
if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
|
|
555
|
-
return updatesCache.data
|
|
625
|
+
return refreshExpiredHolds(updatesCache.data)
|
|
556
626
|
}
|
|
557
627
|
const installed = readInstalled(profile, activeProfileDir)
|
|
558
628
|
const lockCommits = readLockCommits(profile, activeProfileDir)
|
|
@@ -564,10 +634,15 @@ export async function checkUpdates(
|
|
|
564
634
|
const onlineSource = onlineSourceFor.get(name)
|
|
565
635
|
if (onlineSource !== undefined) {
|
|
566
636
|
const latest = await fetchNpmLatest(onlineSource)
|
|
567
|
-
const
|
|
637
|
+
const upgrade = isUpgrade(version, latest)
|
|
638
|
+
const heldUntil = upgrade && options.holdYoungReleases === true
|
|
639
|
+
? await releaseAgeHold(onlineSource, latest, profile, activeProfileDir)
|
|
640
|
+
: null
|
|
568
641
|
result[name] = {
|
|
569
|
-
kind: 'linked', version, current: version, latest,
|
|
570
|
-
|
|
642
|
+
kind: 'linked', version, current: version, latest,
|
|
643
|
+
updateAvailable: upgrade && heldUntil === null,
|
|
644
|
+
...(heldUntil === null ? {} : { heldUpdate: { latest: latest!, until: heldUntil } }),
|
|
645
|
+
...(upgrade ? { restoreRequired: true } : {}),
|
|
571
646
|
}
|
|
572
647
|
return
|
|
573
648
|
}
|
|
@@ -693,9 +768,13 @@ export async function checkUpdates(
|
|
|
693
768
|
const mayFollowATag = !upgrade
|
|
694
769
|
&& (version?.includes('-') === true || (stable !== null && version !== null && isUpgrade(stable, version) === false && stable !== version))
|
|
695
770
|
const newerPrerelease = mayFollowATag ? await newerPrereleaseFor(name, version) : null
|
|
771
|
+
const heldUntil = upgrade && options.holdYoungReleases === true
|
|
772
|
+
? await releaseAgeHold(name, latest, profile, activeProfileDir)
|
|
773
|
+
: null
|
|
696
774
|
result[name] = {
|
|
697
775
|
kind: 'npm', version, current: version, latest,
|
|
698
|
-
updateAvailable: upgrade,
|
|
776
|
+
updateAvailable: upgrade && heldUntil === null,
|
|
777
|
+
...(heldUntil === null ? {} : { heldUpdate: { latest: latest!, until: heldUntil } }),
|
|
699
778
|
...(newerPrerelease === null ? {} : { newerPrerelease }),
|
|
700
779
|
...(sideways ? { channelSwitch: latest } : {}),
|
|
701
780
|
}
|