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 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, and its last segment is the plugin's own directory.
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
- * Unknown version stays fatal: the conservative direction, and the case where
765
- * the evidence to be less strict is missing.
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 === 0)
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
- const newest = versions.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b));
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
- * The ids of OTHER plugins a package's bundle patch DISABLES — top-level
323
- * `- id: X` + `disabled: true` rows targeting plugins it does not own. This is
324
- * the precise marker of a bundle whose toggle-off can brick the boot (#224):
325
- * dsh-postgres-backends disables session-persistence-jsonl, so once the market
326
- * also disables the postgres backends nothing provides sessionPersistence.
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; only a foreign `disabled: true` triggers the bundle removal. Removing
333
- * the bundle still neutralizes any config side effects it carries, since its
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 (a foreign disable declared in the second file is still what
340
- * bricks the boot, #224).
368
+ * is attributed.
341
369
  */
342
- export function carrierDisableIds(profileDirectory, packageName) {
370
+ export function carrierRowIds(profileDirectory, packageName) {
343
371
  const packageDir = join(profileDirectory, 'node_modules', packageName);
344
- const disabled = new Set();
372
+ const rows = new Set();
345
373
  const collectFromFile = (patchPath) => {
346
- const rows = parsePatchFile(patchPath);
347
- if (rows === null)
374
+ const parsed = parsePatchFile(patchPath);
375
+ if (parsed === null)
348
376
  return;
349
- for (const id of foreignDisableIds(rows))
350
- disabled.add(id);
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 [...disabled];
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 carrierDisableIds: the declared
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 manifest.
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 { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
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 disablesOthers = carrierDisableIds(activeProfileDir, name);
859
+ const carrierRows = carrierRowIds(activeProfileDir, name);
860
860
  const foreignRows = foreignRowIds(activeProfileDir, name);
861
- const isCarrier = disablesOthers.length > 0;
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 (disables: ${disablesOthers.join(', ')})`
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, disablesOthers, isCarrier, bundleSwitch };
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 = carrierDisableIds(activeProfileDir, name).length > 0;
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, disablesOthers, isCarrier, bundleSwitch } = layers;
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: disablesOthers,
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)
@@ -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
- * Unknown version stays fatal: the conservative direction, and the case where
306
- * the evidence to be less strict is missing.
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
@@ -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 DISABLES — top-level
89
- * `- id: X` + `disabled: true` rows targeting plugins it does not own. This is
90
- * the precise marker of a bundle whose toggle-off can brick the boot (#224):
91
- * dsh-postgres-backends disables session-persistence-jsonl, so once the market
92
- * also disables the postgres backends nothing provides sessionPersistence.
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; only a foreign `disabled: true` triggers the bundle removal. Removing
99
- * the bundle still neutralizes any config side effects it carries, since its
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 (a foreign disable declared in the second file is still what
106
- * bricks the boot, #224).
111
+ * is attributed.
107
112
  */
108
- export declare function carrierDisableIds(profileDirectory: string, packageName: string): string[];
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 carrierDisableIds: the declared
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.
@@ -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 manifest.
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
@@ -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>): Promise<Record<string, UpdateStatus>>;
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 updateAvailable = isUpgrade(version, latest);
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, updateAvailable,
496
- ...(updateAvailable ? { restoreRequired: true } : {}),
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.44",
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
- * Unknown version stays fatal: the conservative direction, and the case where
983
- * the evidence to be less strict is missing.
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 === 0) return true
988
- const newest = versions.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b))
989
- return compareSemver(newest, '1.0.4') < 0
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)}
@@ -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, and its last segment is the plugin's own directory.
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
- * The ids of OTHER plugins a package's bundle patch DISABLES — top-level
329
- * `- id: X` + `disabled: true` rows targeting plugins it does not own. This is
330
- * the precise marker of a bundle whose toggle-off can brick the boot (#224):
331
- * dsh-postgres-backends disables session-persistence-jsonl, so once the market
332
- * also disables the postgres backends nothing provides sessionPersistence.
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; only a foreign `disabled: true` triggers the bundle removal. Removing
339
- * the bundle still neutralizes any config side effects it carries, since its
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 (a foreign disable declared in the second file is still what
346
- * bricks the boot, #224).
373
+ * is attributed.
347
374
  */
348
- export function carrierDisableIds(profileDirectory: string, packageName: string): string[] {
375
+ export function carrierRowIds(profileDirectory: string, packageName: string): string[] {
349
376
  const packageDir = join(profileDirectory, 'node_modules', packageName)
350
- const disabled = new Set<string>()
377
+ const rows = new Set<string>()
351
378
  const collectFromFile = (patchPath: string): void => {
352
- const rows = parsePatchFile(patchPath)
353
- if (rows === null) return
354
- for (const id of foreignDisableIds(rows)) disabled.add(id)
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 [...disabled]
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 carrierDisableIds: the declared
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 manifest.
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 { dependencies?: unknown; peerDependencies?: unknown }
166
- for (const field of [declared.dependencies, declared.peerDependencies]) {
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
- carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
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
- disablesOthers: string[]
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 disablesOthers = carrierDisableIds(activeProfileDir, name)
983
+ const carrierRows = carrierRowIds(activeProfileDir, name)
983
984
  const foreignRows = foreignRowIds(activeProfileDir, name)
984
- const isCarrier = disablesOthers.length > 0
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 (disables: ${disablesOthers.join(', ')})`
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, disablesOthers, isCarrier, bundleSwitch }
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 = carrierDisableIds(activeProfileDir, name).length > 0
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, disablesOthers, isCarrier, bundleSwitch } = layers
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: disablesOthers,
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 updateAvailable = isUpgrade(version, latest)
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, updateAvailable,
570
- ...(updateAvailable ? { restoreRequired: true } : {}),
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
  }