dshmarket 1.66.1 → 1.66.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -272,6 +272,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
272
272
  capRedInstallScriptScripts: "安装时会立刻运行 {0},不需要你打开插件",
273
273
  capRedCoreTamper: "会改动 DSH 自带的组件",
274
274
  capRedCoreTamperDetail: "会改动 DSH 自带的组件({0})",
275
+ capRedCoreOverride: "会覆盖 DSH 自带的组件({0})",
276
+ capRedCoreDisable: "会禁用 DSH 自带的组件({0})",
275
277
  favoriteAdd: "加入收藏",
276
278
  favoriteRemove: "取消收藏",
277
279
  favoritesEmpty: "还没有收藏。在「发现」或「主题」里点书签图标即可收藏。",
@@ -920,6 +922,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
920
922
  capRedInstallScriptScripts: "runs {0} the moment you install — you do not have to open the plugin",
921
923
  capRedCoreTamper: "replaces parts of DSH itself",
922
924
  capRedCoreTamperDetail: "replaces parts of DSH itself ({0})",
925
+ capRedCoreOverride: "overrides a part of DSH itself ({0})",
926
+ capRedCoreDisable: "disables a part of DSH itself ({0})",
923
927
  favoriteAdd: "Add to favorites",
924
928
  favoriteRemove: "Remove from favorites",
925
929
  favoritesEmpty: "No favorites yet. Click the bookmark icon in Discover or Themes to add one.",
@@ -9764,6 +9768,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9764
9768
  if (literalIp !== null) return t("capRedLiteralIp").replace("{0}", literalIp[1]);
9765
9769
  const installScript = /^runs code at install time(?: \((.+)\))?$/.exec(line);
9766
9770
  if (installScript !== null) return installScript[1] === void 0 ? t("capRedInstallScript") : t("capRedInstallScriptScripts").replace("{0}", installScript[1]);
9771
+ const coreVerb = /^tampers with a core bundle \((overrides|disables) bundle (.+)\)$/.exec(line);
9772
+ if (coreVerb !== null) {
9773
+ const key = coreVerb[1] === "overrides" ? "capRedCoreOverride" : "capRedCoreDisable";
9774
+ return t(key).replace("{0}", coreVerb[2]);
9775
+ }
9767
9776
  const coreTamper = /^tampers with a core bundle(?: \((.+)\))?$/.exec(line);
9768
9777
  if (coreTamper !== null) return coreTamper[1] === void 0 ? t("capRedCoreTamper") : t("capRedCoreTamperDetail").replace("{0}", coreTamper[1]);
9769
9778
  return line;
package/lib/changelog.js CHANGED
@@ -21,7 +21,7 @@ import { fileFromTarball } from './catalog-npm.js';
21
21
  import { marketFetch } from './net.js';
22
22
  import { activeRegion, routesFor } from './regions.js';
23
23
  import { profileDir, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.js';
24
- import { lookupRepoFromUrl, repoOf, repoOfTarget } from './sources.js';
24
+ import { lookupRepoFromUrl, parseSourceUrl, repoOf, repoOfTarget } from './sources.js';
25
25
  import { checkUpdates } from './updates.js';
26
26
  import { loadRegistry } from './registry.js';
27
27
  const UPDATES_PACKAGE = 'dsh-plugin-updates';
@@ -170,6 +170,55 @@ export async function npmPublishTimes(name) {
170
170
  timesCache.set(name, { at: Date.now(), data });
171
171
  return data;
172
172
  }
173
+ /** A probe entry the dialog can actually render — an empty object is a miss. */
174
+ function usableNotes(entry) {
175
+ if (entry === undefined)
176
+ return false;
177
+ if (entry.release != null && (entry.release.body !== '' || entry.release.tag !== null))
178
+ return true;
179
+ return (entry.commits?.length ?? 0) > 0;
180
+ }
181
+ /**
182
+ * The catalog row for an npm install.
183
+ *
184
+ * The profile dependency key is the npm package name. The catalog `name`
185
+ * often is not: a monorepo entry is `repo#subpath`, and a scoped package's
186
+ * catalog name drops the scope (#746). `npm` is the identity the install
187
+ * used, so it wins; a bare `name` match remains for entries that publish
188
+ * under their catalog name. Several hits are disambiguated by the installed
189
+ * repository, and an ambiguous set is left unresolved (#598).
190
+ */
191
+ function catalogEntryForNpmInstall(plugins, name, evidence) {
192
+ const byNpm = plugins.filter(p => p.npm === name);
193
+ const candidates = byNpm.length > 0 ? byNpm : plugins.filter(p => p.name === name);
194
+ if (candidates.length <= 1)
195
+ return candidates[0];
196
+ const identities = evidence().identities;
197
+ const matches = candidates.filter(p => identities.some(id => repoOf(p.url)?.toLowerCase() === id.split('#')[0].toLowerCase()));
198
+ return matches.length === 1 ? matches[0] : undefined;
199
+ }
200
+ /**
201
+ * The catalog row for a `github:…#path:` install.
202
+ *
203
+ * `repoKeyOf` keeps only the repository root, while a monorepo entry's notes
204
+ * are stored under its `/tree/<ref>/<subpath>` url (#746). The spec's subpath
205
+ * selects the row. Two rows with one subpath are not guessed between.
206
+ */
207
+ function catalogEntryForGitSubpath(plugins, spec) {
208
+ const identity = repoOfTarget(spec);
209
+ const marker = identity?.indexOf('#path:/') ?? -1;
210
+ if (identity === null || marker === -1)
211
+ return undefined;
212
+ const repo = identity.slice(0, marker);
213
+ const subpath = identity.slice(marker + '#path:/'.length);
214
+ const matches = plugins.filter(p => {
215
+ const source = parseSourceUrl(p.url);
216
+ return source?.subpath != null
217
+ && source.repo.toLowerCase() === repo
218
+ && source.subpath.toLowerCase() === subpath;
219
+ });
220
+ return matches.length === 1 ? matches[0] : undefined;
221
+ }
173
222
  /**
174
223
  * Resolve the notes for one installed plugin, or `{ kind: 'none' }`.
175
224
  *
@@ -185,52 +234,46 @@ export async function updateNotesFor(profile, explicitDir, name) {
185
234
  }
186
235
  try {
187
236
  const payload = await loadUpdateNotes();
188
- let key = repoKeyOf(spec);
189
- // If the spec is a Release asset URL (catalog format), try to extract
190
- // the repo from the URL directly. This handles npm-installed plugins
191
- // whose profile spec is the npm name but the catalog entry uses a
192
- // Release asset tarball URL.
193
- if (key === null) {
194
- key = lookupRepoFromUrl(spec);
195
- }
196
- // If the spec is an npm package name (not a GitHub URL), look up the
197
- // catalog to find the corresponding GitHub repo URL.
198
- if (key === null) {
237
+ // A Release-asset spec carries the repo in the URL itself. An npm
238
+ // version range carries nothing, so `specKey` stays null and the catalog
239
+ // lookup below is what finds the repository.
240
+ const specKey = repoKeyOf(spec) ?? lookupRepoFromUrl(spec);
241
+ let key = specKey;
242
+ let entry = key === null ? undefined : entryForRepo(payload, key);
243
+ // The catalog is how an npm install, or a `#path:` install whose notes
244
+ // live under a /tree/ url, finds a key the spec itself does not name.
245
+ // A root github spec already is that key. Loading the catalog on a root
246
+ // miss would wait out its timeout to answer "no notes".
247
+ const needsCatalog = specKey === null || (repoOfTarget(spec)?.includes('#path:/') ?? false);
248
+ if (!usableNotes(entry) && needsCatalog) {
199
249
  try {
200
250
  const registry = await loadRegistry();
201
- const candidates = registry.plugins.filter(p => p.name === name);
202
- let plugin;
203
- if (candidates.length === 1) {
204
- plugin = candidates[0];
205
- }
206
- else if (candidates.length > 1) {
207
- // Same-named packages exist in the catalog; a bare name match can
208
- // pick someone else's repo and answer "no notes" for a plugin that
209
- // ships updates data under its own repository (#598). The installed
210
- // package declares its repository, so prefer the catalog entry that
211
- // agrees with it; only a unique agreement is trusted — an ambiguous
212
- // name falls through to npm publish times, which are honest for any
213
- // installed npm package.
214
- const evidence = readInstalledRepoEvidence(profile, name, spec, explicitDir);
215
- const matches = candidates.filter(p => evidence.identities.some(id => repoOf(p.url)?.toLowerCase() === id.split('#')[0].toLowerCase()));
216
- if (matches.length === 1)
217
- plugin = matches[0];
218
- }
251
+ const plugin = specKey === null
252
+ ? catalogEntryForNpmInstall(registry.plugins, name, () => readInstalledRepoEvidence(profile, name, spec, explicitDir))
253
+ : catalogEntryForGitSubpath(registry.plugins, spec);
219
254
  if (plugin !== undefined) {
220
- key = plugin.url;
255
+ const catalogEntry = entryForRepo(payload, plugin.url);
256
+ // An empty row does not become the key. Publish times for an npm
257
+ // install follow from `specKey` staying null, and a subpath install
258
+ // keeps the root key so the miss stays `none`.
259
+ if (usableNotes(catalogEntry)) {
260
+ key = plugin.url;
261
+ entry = catalogEntry;
262
+ }
221
263
  }
222
264
  }
223
265
  catch {
224
- // Catalog unavailable; fall through to npm times.
266
+ // Catalog unavailable; the tiers below still answer.
225
267
  }
226
268
  }
227
- const entry = key === null ? undefined : entryForRepo(payload, key);
228
269
  // Both tiers below need the installed sha for github-kind installs.
229
270
  // For Release asset URLs and catalog lookups, checkUpdates doesn't
230
271
  // recognize the spec, so we read the lockfile directly using the repo key.
272
+ // `repoOf` drops a `/tree/<ref>/<subpath>` suffix; slicing the url at `#`
273
+ // would look the commit up under `owner/repo/tree/...` and miss it.
231
274
  let current = null;
232
275
  if (key !== null && key.startsWith('https://github.com/')) {
233
- const repo = key.slice('https://github.com/'.length).split('#')[0].toLowerCase();
276
+ const repo = (repoOf(key) ?? key.slice('https://github.com/'.length).split('#')[0]).toLowerCase();
234
277
  current = readLockCommits(profile, activeProfileDir).get(`github.com/${repo}`) ?? null;
235
278
  }
236
279
  else {
@@ -243,8 +286,9 @@ export async function updateNotesFor(profile, explicitDir, name) {
243
286
  if (entry?.commits !== undefined && entry.commits.length > 0) {
244
287
  return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) };
245
288
  }
246
- if (key === null) {
247
- // Not a github-sourced plugin at all: npm times are the only tier left.
289
+ if (specKey === null) {
290
+ // The install spec did not name a repository. Publish times remain
291
+ // when the catalog row is missing or its probe has no notes.
248
292
  return { kind: 'npm', npmTimes: await npmPublishTimes(name) };
249
293
  }
250
294
  // A github plugin whose repo answered nothing — releases 404 AND the log
package/lib/check.js CHANGED
@@ -1008,7 +1008,21 @@ export function analyzeProfile(profileDirectory, options = {}) {
1008
1008
  if (seenDeps.has(key))
1009
1009
  continue;
1010
1010
  seenDeps.add(key);
1011
- const hoisted = readProfileVisibleVersion(profileDirectory, name);
1011
+ // A HOST-PLANE peer never takes its version from the shared workspace
1012
+ // root: `<profiles>/node_modules` is shared by every profile, and on a
1013
+ // machine that also runs a second DSH installation — a global npm CLI
1014
+ // beside the Desktop app — that tree is the OTHER installation's
1015
+ // closure. Reading a host version from it reported a healthy install as
1016
+ // "introduced host-compatibility risks … vs 0.1.5-rc.2" and offered a
1017
+ // rollback, while the running host was 0.1.7-rc.2 (#726). What the
1018
+ // plugin itself resolves still counts (a nested copy, a profile-local
1019
+ // one), and the located installation is asked as `host` below; with
1020
+ // neither, the version is unknown rather than wrong — the rule #676
1021
+ // settled for bundles. Non-host peers keep the hoisted fallback.
1022
+ const hostPlane = name.startsWith('@deepseek-ai/');
1023
+ const hoisted = hostPlane
1024
+ ? readNodeModulesVersion(profileDirectory, name)
1025
+ : readProfileVisibleVersion(profileDirectory, name);
1012
1026
  const nested = readNodeModulesVersion(pluginDir, name);
1013
1027
  const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null;
1014
1028
  // Node resolves a plugin's peer from its OWN node_modules first
package/lib/index.js CHANGED
@@ -158,12 +158,26 @@ export function apply(ctx, config) {
158
158
  // every install (#702). A third-party shell announces itself through
159
159
  // `desktopProfiles`, and this whole block runs only when that service
160
160
  // is absent, so this never takes a third-party shell's profile.
161
+ const configured = config?.profile;
162
+ const configuredDesktop = configured !== undefined && configured.toLowerCase() === 'desktop';
161
163
  const officialElectron = launched !== undefined && launched.name.toLowerCase() === 'desktop';
162
- if (officialElectron && config?.profile === undefined) {
163
- const runtime = createOfficialDesktopRuntime(() => hostCtx.get('pluginManager'), launched.name, launched.dir);
164
+ // An explicitly configured `profile: desktop` takes this branch too. The
165
+ // CLI refuses that profile by NAME, so routing it there — which is what
166
+ // an operator's own workaround for a host that hides `profileContext`
167
+ // does (#744) — makes every install fail for certain. The configuration
168
+ // still wins over the launcher for the name; it just cannot win a branch
169
+ // that is guaranteed to fail.
170
+ if (configuredDesktop || (configured === undefined && officialElectron)) {
171
+ const profileName = configured ?? launched.name;
172
+ // The launcher owns where a profile lives — and its directory rides
173
+ // along only when the launcher is the one that named this profile.
174
+ const profileDirectory = launched !== undefined && launched.name.toLowerCase() === profileName.toLowerCase()
175
+ ? launched.dir
176
+ : undefined;
177
+ const runtime = createOfficialDesktopRuntime(() => hostCtx.get('pluginManager'), profileName, profileDirectory);
164
178
  const resolved = {
165
- profile: launched.name,
166
- profileDirectory: launched.dir,
179
+ profile: profileName,
180
+ ...(profileDirectory === undefined ? {} : { profileDirectory }),
167
181
  desktopHost: true,
168
182
  allowRestart: false,
169
183
  maxSnapshots: config?.maxSnapshots,
package/lib/install.js CHANGED
@@ -119,6 +119,18 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
119
119
  const marketFlags = options.marketFlags !== false;
120
120
  /** The option a recovery step needed and this host does not accept (#732). */
121
121
  let unavailableOption = null;
122
+ // Before the FIRST run, not after a failure: the shadowed duplicate rule
123
+ // (#732) hurts pnpm while it RESOLVES the dependency graph, so merging it
124
+ // first is what keeps the command from failing at all — and it costs one
125
+ // small file read. Every verb that resolves the graph is covered, not just
126
+ // add and remove: an `install` or an in-place `update` reads the same key.
127
+ const verb = pluginArgs.find(argument => !argument.startsWith('-'));
128
+ if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
129
+ const merged = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
130
+ if (merged.length > 0) {
131
+ logEvent('warn', 'install', `minimumReleaseAgeExclude held several rules for ${merged.join(', ')}, and pnpm honours only the FIRST per name (#732) — merged each package's rules into one, before running`);
132
+ }
133
+ }
122
134
  let result = await run(profile, pluginArgs);
123
135
  const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
124
136
  if (!ok(result) && !result.cancelled) {
@@ -149,23 +161,28 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
149
161
  }
150
162
  }
151
163
  else if (failure?.code === 'release-age-violation'
152
- && options.releaseAgeBypass !== false
153
- && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
154
- && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
155
- // #732 first, because it is the breakage itself: pnpm appends a second
156
- // `minimumReleaseAgeExclude` rule for a package that already has one and
157
- // then honours only the FIRST per name, so its own new entry is shadowed
158
- // and verification fails for every later command in that profile.
159
- // Merging the duplicates repairs the file and needs no option at all, so
160
- // it is the one recovery that also works on the desktop bridge.
161
- const mergedDuplicates = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
162
- if (mergedDuplicates.length > 0) {
163
- logEvent('warn', 'install', `pnpm appended a second minimumReleaseAgeExclude rule for ${mergedDuplicates.join(', ')} and honours only the first, shadowing its own entry (#732) — merged the duplicates and retrying once`);
164
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
165
+ // The repair ran before this command, so a rewrite here means pnpm wrote
166
+ // one of the two broken shapes DURING it — appending a shadowed rule
167
+ // (#732) or extending a version union (#733). Either way the same
168
+ // rewrite fixes it, needs no option at all, and so also works on the
169
+ // desktop bridge that refuses options. Retry the SAME argv afterwards.
170
+ const mergedNow = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
171
+ if (mergedNow.length > 0) {
172
+ logEvent('warn', 'install', `pnpm appended a second minimumReleaseAgeExclude rule for ${mergedNow.join(', ')} and honours only the first, shadowing its own entry (#732) — merged them into one and retrying once`);
164
173
  result = await run(profile, pluginArgs);
165
174
  }
175
+ else if (options.releaseAgeBypass === false) {
176
+ // The caller declined the bypass (#594), and the duplicates were not
177
+ // what failed: this violation is the profile's own policy doing its
178
+ // job, so the command stands as it is.
179
+ }
166
180
  else if (!marketFlags) {
167
181
  unavailableOption = RELEASE_AGE_OVERRIDE;
168
182
  }
183
+ else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
184
+ // Already carrying it (a caller's forced retry): nothing left to add.
185
+ }
169
186
  else {
170
187
  logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
171
188
  result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
package/lib/net.js CHANGED
@@ -9,24 +9,35 @@
9
9
  * took 9.9s direct on a reporter's machine, seconds from the 15s timeout,
10
10
  * while their proxy sat unused a millisecond away.
11
11
  *
12
- * `setGlobalDispatcher` from the `undici` PACKAGE cannot fix this, because
13
- * `globalThis.fetch` runs on Node's INTERNAL copy of undici — a different
14
- * instance. Verified: with a dispatcher installed, a global fetch still
15
- * produced no CONNECT at a local proxy, while undici's own fetch produced
16
- * `CONNECT awesome-dsh-plugin.com:443`.
12
+ * Every market request therefore calls undici's own fetch and carries a
13
+ * dispatcher this module created. Two measurements say why, and they are
14
+ * not the same fact:
17
15
  *
18
- * So the market calls undici's fetch with an explicit dispatcher. The scope
19
- * is deliberate: only requests made by this module change, and the host's
20
- * own networking is left exactly as the host configured it.
16
+ * - On Node 25, `setGlobalDispatcher` from the undici package does not
17
+ * steer global fetch. With a dispatcher installed that way, a global
18
+ * fetch produced no CONNECT at a local proxy, while undici's own fetch
19
+ * produced `CONNECT awesome-dsh-plugin.com:443`.
20
+ * - On Node 22 the two stacks share one symbol (#742). Global fetch reads
21
+ * `Symbol.for('undici.globalDispatcher.1')`. The host's first import of
22
+ * undici 8 (`web_fetch`) finds `.2` empty, installs its dispatcher, and
23
+ * writes a `Dispatcher1Wrapper` onto `.1`. After that, global fetch
24
+ * returns gzip bodies with null headers, and `JSON.parse` fails on the
25
+ * catalog. undici 7's fetch reads `.1` too, so calling it with no
26
+ * dispatcher fails the same way.
27
+ *
28
+ * The dispatcher is `EnvHttpProxyAgent` when a proxy is configured, and a
29
+ * plain `Agent` otherwise. Only requests made here take it. The host's own
30
+ * networking stays as the host configured it.
21
31
  */
22
- import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
32
+ import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
23
33
  /**
24
34
  * The proxy this process would use for the catalog, if any.
25
35
  *
26
36
  * The standard variables mirror `EnvHttpProxyAgent`'s own resolution
27
37
  * deliberately, rather than picking the order that reads best, because the
28
- * same answer does two jobs: it decides whether to route through undici at
29
- * all, and it is what the failure message CLAIMS was tried. A helper that
38
+ * same answer does two jobs: it decides whether the request goes through
39
+ * the proxy agent or the direct one, and it is what the failure message
40
+ * CLAIMS was tried. A helper that
30
41
  * named a proxy undici would not have used would put a false statement in
31
42
  * every bug report. `npm_config_*` is an additional source on top of that:
32
43
  * npm holds its proxy in its own config namespace (a machine set up with
@@ -70,29 +81,30 @@ function proxyFromEnv() {
70
81
  return { http, https };
71
82
  }
72
83
  /**
73
- * Built once and reused: an agent per request would drop connection reuse,
74
- * and this one reads NO_PROXY as well, so a host that excludes its own
84
+ * Built once and reused: an agent per request would drop connection reuse.
85
+ * The proxy agent also reads NO_PROXY, so a host that excludes its own
75
86
  * registry mirror keeps being excluded.
76
87
  */
77
- let agent = null;
88
+ let proxyAgent = null;
89
+ let directAgent = null;
78
90
  /**
79
- * Fetch through the proxy this machine is configured to use.
80
- *
81
- * Falls back to the global fetch when no proxy is set, which keeps the
82
- * ordinary case on the runtime's own path rather than routing it through a
83
- * second HTTP stack for no reason.
91
+ * Fetch through the proxy this machine is configured to use, or directly
92
+ * through this module's own agent when it has none. Both paths pass the
93
+ * dispatcher described on this module.
84
94
  */
85
95
  export async function marketFetch(url, init) {
86
96
  const { http, https } = proxyFromEnv();
87
- if (http === null && https === null)
88
- return await fetch(url, init);
97
+ if (http === null && https === null) {
98
+ directAgent ??= new Agent();
99
+ return await undiciFetch(url, { ...init, dispatcher: directAgent });
100
+ }
89
101
  // Pass the resolved proxies explicitly. EnvHttpProxyAgent itself reads
90
102
  // only http(s)_proxy out of the environment, so a proxy that lives in
91
103
  // npm_config_* must be handed over directly — otherwise the agent would
92
104
  // silently go direct while configuredProxy() claims a proxy was used.
93
- agent ??= new EnvHttpProxyAgent({
105
+ proxyAgent ??= new EnvHttpProxyAgent({
94
106
  httpProxy: http ?? undefined,
95
107
  httpsProxy: https ?? undefined,
96
108
  });
97
- return await undiciFetch(url, { ...init, dispatcher: agent });
109
+ return await undiciFetch(url, { ...init, dispatcher: proxyAgent });
98
110
  }
@@ -20,7 +20,11 @@ function detail(error) {
20
20
  return JSON.stringify(error);
21
21
  }
22
22
  /** Never fall back to `dsh plugin --profile desktop`: that CLI is forbidden. */
23
- export function createOfficialDesktopRuntime(managerLookup, profileName, _profileDirectory) {
23
+ export function createOfficialDesktopRuntime(managerLookup, profileName,
24
+ // Unused by the manager bridge — it takes the profile by NAME — and optional
25
+ // because an explicitly configured `profile: desktop` may be the only thing
26
+ // that names this profile, with no launcher directory to go with it (#744).
27
+ _profileDirectory) {
24
28
  let disposed = false;
25
29
  let active;
26
30
  const runPlugin = (profile, argv) => {
@@ -270,6 +270,34 @@ export function classifyPnpmFailure(output, exitCode) {
270
270
  message: `profile 里的一个 pnpm 补丁打不上了${which}。pnpm 会继续把这个包装上,但装的是没打补丁的原版——通常下次启动才会以「插件加载失败」暴露出来。多半是包升级后挪动了补丁指向的文件(例如补丁改的是 client/client.js,而新版本发的是 lib/client.js)。请更新或删掉这个补丁文件,以及 profile package.json 里 pnpm.patchedDependencies 中对应的那一条 / a pnpm patch in this profile no longer applies${whichEn}. pnpm still installs the package, but unpatched — which usually surfaces at the next boot as "failed to load plugins" rather than here. The usual cause is the package moving the file the patch targets (for example a patch against client/client.js when the release now ships lib/client.js). Update or remove that patch file and its entry under pnpm.patchedDependencies in the profile's package.json`,
271
271
  };
272
272
  }
273
+ // #740 by @lws2004: the other half of the story above. `patchedDependencies`
274
+ // keys on `pkg@exactVersion`, and once the installed version moves past that
275
+ // key pnpm 12 treats the stale patch as a HARD error: it writes NOTHING at
276
+ // all. So an update of that plugin is refused in full — the version stays
277
+ // where it was, the market's own log holds only exit=1, and the user reads
278
+ // "the update did not apply" for what is really a patch that no longer
279
+ // matches. Unlike ERR_PNPM_PATCH_FAILED there is no unpatched install to
280
+ // discover later, which makes this the more confusing of the two. The patch
281
+ // is the user's, so the market names the entries and says where to fix them
282
+ // rather than guessing a retarget.
283
+ if (output.includes('ERR_PNPM_UNUSED_PATCH')) {
284
+ // `[^\n"]+`: the same sentence also arrives inside the ndjson stream,
285
+ // where it ends at a quote rather than at the line — matching to the line
286
+ // end there captured `"}}` as part of the package name.
287
+ const unused = /The following patches were not used:\s*([^\n"]+)/
288
+ .exec(withDecodedPnpmDiagnostics(output))?.[1];
289
+ const named = unused === undefined
290
+ ? []
291
+ : unused.split(',').map(entry => entry.trim()).filter(entry => entry !== '');
292
+ const which = named.length === 0 ? '' : `(${named.join('、')})`;
293
+ const whichEn = named.length === 0 ? '' : ` (${named.join(', ')})`;
294
+ return {
295
+ code: 'unused-patch',
296
+ recoverable: false,
297
+ pkg: named.length === 1 ? named[0] : undefined,
298
+ message: `profile 里的 pnpm 补丁有一条已经用不上了${which}:patchedDependencies 是按「包名@精确版本」钉的,而这次要装的版本已经越过它,pnpm 12 因此判定整条命令失败——它什么都没写,所以看起来像「点了更新没反应」。要么把补丁更新到新版本、并把 profile package.json 里 pnpm.patchedDependencies 的键改成「包名@新版本」,要么在新版本已经自带这个修复时直接删掉这一条和对应的补丁文件 / a pnpm patch in this profile is no longer used${whichEn}: patchedDependencies keys on "package@exactVersion", and the version being installed has moved past that key, so pnpm 12 fails the WHOLE command and writes nothing at all — which is why the update looks like it simply did not apply. Either retarget the patch to the new version and change its pnpm.patchedDependencies key in the profile's package.json to "package@newVersion", or — when the new release already carries that fix — drop the entry and its patch file`,
299
+ };
300
+ }
273
301
  if (output.includes('ERR_PNPM_ADDING_TO_ROOT')) {
274
302
  return {
275
303
  code: 'adding-to-root',
package/lib/profile.js CHANGED
@@ -1222,27 +1222,41 @@ function splitReleaseAgeExclude(entry) {
1222
1222
  return { name, selector };
1223
1223
  }
1224
1224
  /**
1225
- * Merge one package's several `minimumReleaseAgeExclude` entries into one
1226
- * (#732).
1225
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732).
1227
1226
  *
1228
- * pnpm 11.7.0 APPENDS an entry when it lets a version through a profile's
1229
- * `minimumReleaseAge` instead of folding it into the rule that already names
1230
- * that package — and its own `evaluateVersionPolicy` then honours only the
1231
- * FIRST rule per package name. The entry pnpm just wrote is therefore
1232
- * shadowed by the older one, the young version stays unexcluded, and pnpm
1233
- * fails lockfile verification with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION on
1234
- * EVERY later command in that profile: installs, updates and uninstalls
1235
- * alike, including ones that have nothing to do with the package. Reported
1236
- * as #732, where the market's own self-update planted exactly that pair and
1237
- * every plugin operation on the desktop profile stopped working.
1227
+ * pnpm appends a second rule for a package that already has one, while its
1228
+ * `evaluateVersionPolicy` honours only the FIRST rule per package name — so
1229
+ * its own new entry is dead, the young version stays unexcluded, and every
1230
+ * later command in that profile fails lockfile verification with
1231
+ * ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION, including commands that have nothing
1232
+ * to do with that package.
1238
1233
  *
1239
- * Merging keeps the union of what the file already says, so nothing is
1240
- * loosened or tightened: the entries pnpm wrote were meant to be read, and
1241
- * after the merge they are. A file with no same-name duplicate is untouched,
1242
- * as is one whose block this cannot read exactly (a flow list, an inline
1243
- * comment, a line it would have to guess at).
1234
+ * (#733 reported a separate, unexplained 80 GiB allocation abort on pnpm
1235
+ * 12.4.1 that its author first tied to the union spelling. Review on that
1236
+ * issue — and the reporter's own follow-up, which could no longer reproduce it
1237
+ * — settled that `name@a || b` is a documented pnpm form, that the validator's
1238
+ * "Use exact versions only" is about ranges and name patterns, and that the
1239
+ * abort is not this market's to fix. Do not "repair" a union into a bare name
1240
+ * on the strength of it: see below.)
1244
1241
  *
1245
- * @returns the package names whose entries were merged; empty when the file
1242
+ * pnpm WRITES this key itself, and one of the forms it writes is what breaks a
1243
+ * profile (#732): pnpm 11.7.0 APPENDS a second rule for a package that already
1244
+ * has one, while its `evaluateVersionPolicy` honours only the FIRST rule per
1245
+ * package name. Its own new entry is therefore dead, the young version stays
1246
+ * unexcluded, and every later command in that profile fails lockfile
1247
+ * verification with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — installs, updates
1248
+ * and uninstalls alike, including ones that have nothing to do with that
1249
+ * package. Merging the same-name rules into one makes the file readable again.
1250
+ *
1251
+ * The merge keeps the UNION of the versions the file already lists
1252
+ * (`name@1.2.3 || 1.4.0`), which is a documented pnpm spelling. What this
1253
+ * deliberately does NOT do is collapse a version list to a bare package name:
1254
+ * a bare name exempts EVERY version of that package from the cooldown, which
1255
+ * is wider than what the file says, and the market pins exact versions
1256
+ * precisely so a fresh install cannot silently land on an older release
1257
+ * (#594). A form the file cannot be read exactly from is left alone.
1258
+ *
1259
+ * @returns the package names whose rules were merged; empty when the file
1246
1260
  * needed no repair or could not be repaired, in which case it is left
1247
1261
  * byte-for-byte as it was.
1248
1262
  */
@@ -1274,40 +1288,58 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1274
1288
  const rule = splitReleaseAgeExclude(m[1]);
1275
1289
  if (rule === null)
1276
1290
  return [];
1277
- entries.push({ rule, quoted: /^['"]/.test(m[1]) });
1291
+ entries.push({ rule, quoted: /^['"]/.test(m[1]), line });
1278
1292
  }
1279
1293
  const byName = new Map();
1280
1294
  const order = [];
1281
- for (const { rule, quoted } of entries) {
1295
+ for (const { rule, quoted, line } of entries) {
1282
1296
  let group = byName.get(rule.name);
1283
1297
  if (group === undefined) {
1284
- group = { selectors: new Set(), allVersions: false, quoted: false, count: 0 };
1298
+ group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 };
1285
1299
  byName.set(rule.name, group);
1286
1300
  order.push(rule.name);
1287
1301
  }
1288
1302
  group.count += 1;
1289
1303
  group.quoted = group.quoted || quoted;
1304
+ group.originals.push(rule.selector ?? '');
1305
+ group.lines.push(line);
1290
1306
  if (rule.selector === null)
1291
1307
  group.allVersions = true;
1292
1308
  else
1293
1309
  group.selectors.add(rule.selector);
1294
1310
  }
1295
- const merged = order.filter(name => (byName.get(name)?.count ?? 0) > 1);
1296
- if (merged.length === 0)
1297
- return [];
1298
- const lines = order.map(name => {
1311
+ /** The one line this package's entry is written as. */
1312
+ const produced = (name) => {
1299
1313
  const group = byName.get(name);
1300
1314
  if (group === undefined)
1301
1315
  return '';
1302
- const text = group.allVersions ? name : `${name}@${[...group.selectors].join(' || ')}`;
1316
+ // A bare name already covers every version, so it stays bare and nothing
1317
+ // is widened; one rule stays exactly as it was written, union included.
1318
+ // Several rules for one name are the #732 breakage — only the first is
1319
+ // ever read — so they become one, as the union of the versions the file
1320
+ // already lists.
1321
+ const text = group.allVersions
1322
+ ? name
1323
+ : group.count === 1
1324
+ ? `${name}@${group.originals[0] ?? ''}`
1325
+ : `${name}@${[...group.selectors].join(' || ')}`;
1303
1326
  // `@` cannot start a plain scalar in YAML, so a scoped name is written
1304
1327
  // quoted — the way pnpm itself writes one.
1305
1328
  const quoted = group.quoted || text.startsWith('@');
1306
1329
  return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`;
1330
+ };
1331
+ const rewritten = order.filter(name => {
1332
+ const group = byName.get(name);
1333
+ if (group === undefined)
1334
+ return false;
1335
+ return group.count > 1 || group.lines[0] !== produced(name);
1307
1336
  });
1337
+ if (rewritten.length === 0)
1338
+ return [];
1339
+ const lines = order.map(produced);
1308
1340
  const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
1309
1341
  // A function replacement: `$&` and friends in a string replacement would
1310
1342
  // be read as capture references.
1311
1343
  writeFileSync(file, yaml.replace(blockRe, () => blockText));
1312
- return merged;
1344
+ return rewritten;
1313
1345
  }
package/lib/routes.js CHANGED
@@ -3691,7 +3691,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3691
3691
  && capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
3692
3692
  ? {
3693
3693
  available: false,
3694
- detail: `更新前安装的是 v${beforeVersion},但 pnpm-lock.yaml 中的版本与它不一致,因此无法证明精确来源,自动回滚不可用。需要时请手工重新安装 ${name}@${beforeVersion}。 / The installed version before the update was v${beforeVersion}, but pnpm-lock.yaml does not match it, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed.`,
3694
+ // Name all three: the version on disk, the version
3695
+ // the lockfile records, and the one a rollback
3696
+ // would install (#732). The old wording named the
3697
+ // first two only by implication, and a user who
3698
+ // reads "the exact source cannot be proven" has
3699
+ // nothing to act on — the reproducer's way out was
3700
+ // to align package.json with the installed build,
3701
+ // which is what this now says.
3702
+ detail: `更新前 node_modules 里装的是 v${beforeVersion},但 pnpm-lock.yaml 里 ${name} 记的是 ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? '(没有记录)'},两者不一致,无法证明精确来源,因此自动回滚不可用(要回滚的是 ${name}@${beforeVersion})。需要时请手工重新安装 ${name}@${beforeVersion},或先把 package.json 与 pnpm-lock.yaml 对齐到 v${beforeVersion}。 / The build on disk before the update was v${beforeVersion}, but pnpm-lock.yaml records ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? 'nothing'} for ${name}: they disagree, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed, or align package.json and pnpm-lock.yaml with v${beforeVersion} first.`,
3695
3703
  lockfileBefore: lockfileCapture.snapshot,
3696
3704
  }
3697
3705
  : !supportsExactRollbackTarget(`${name}@${beforeVersion}`)