dshmarket 1.66.2 → 1.66.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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
@@ -8,7 +8,7 @@ import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmS
8
8
  import { dirname, isAbsolute, join, resolve } from 'node:path';
9
9
  import { findDshInstallDir } from './dsh-install.js';
10
10
  import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
11
- import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, normalizeReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
11
+ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, mergeDuplicateReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
12
12
  import { logEvent } from './log.js';
13
13
  import { cleanOrphanedStore } from './store.js';
14
14
  /**
@@ -119,18 +119,16 @@ 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 two shapes pnpm writes into
123
- // `minimumReleaseAgeExclude` (a shadowed duplicate rule, #732; a version
124
- // union, #733) hurt pnpm while it RESOLVES the dependency graph, and the
125
- // union one aborts the process on an 80 GiB allocation with no error output
126
- // at all — nothing to classify, so a repair that waited for a failure would
127
- // never fire. Every verb that resolves the graph is covered, not just add and
128
- // remove: an `install` or an in-place `update` consults the same key.
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.
129
127
  const verb = pluginArgs.find(argument => !argument.startsWith('-'));
130
128
  if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
131
- const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory);
132
- if (normalized.length > 0) {
133
- logEvent('warn', 'install', `minimumReleaseAgeExclude held a form pnpm cannot read back for ${normalized.join(', ')} (a shadowed duplicate rule, #732, or a version union, which pnpm 12.4.1 aborts on — #733) — rewrote each as one bare package name before running`);
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`);
134
132
  }
135
133
  }
136
134
  let result = await run(profile, pluginArgs);
@@ -169,9 +167,9 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
169
167
  // (#732) or extending a version union (#733). Either way the same
170
168
  // rewrite fixes it, needs no option at all, and so also works on the
171
169
  // desktop bridge that refuses options. Retry the SAME argv afterwards.
172
- const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory);
173
- if (normalized.length > 0) {
174
- logEvent('warn', 'install', `pnpm wrote a minimumReleaseAgeExclude entry it cannot read back for ${normalized.join(', ')} (#732/#733) — rewrote each as one bare package name and retrying once`);
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`);
175
173
  result = await run(profile, pluginArgs);
176
174
  }
177
175
  else if (options.releaseAgeBypass === false) {
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,42 +1222,45 @@ function splitReleaseAgeExclude(entry) {
1222
1222
  return { name, selector };
1223
1223
  }
1224
1224
  /**
1225
- * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
1225
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732).
1226
1226
  *
1227
- * pnpm WRITES this key in forms it then mishandles, and two separate defects
1228
- * come out of that:
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.
1229
1233
  *
1230
- * - pnpm 11.7.0 appends a second rule for a package that already has one,
1231
- * while its `evaluateVersionPolicy` honours only the FIRST rule per name.
1232
- * Its own new entry is therefore dead, and every later command in that
1233
- * profile fails lockfile verification with
1234
- * ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
1235
- * nothing to do with that package (#732).
1236
- * - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
1237
- * union, a form its OWN validator rejects ("Invalid versions union … Use
1238
- * exact versions only"), and evaluating one aborts the process on a single
1239
- * 80 GiB allocation that takes the machine down for minutes (#733). The
1240
- * entries are evaluated lazily, so this fires on any later install that
1241
- * age-checks that package, with no pnpm error output to explain it.
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.)
1242
1241
  *
1243
- * Both are avoided by writing each such package as ONE BARE NAME. A bare name
1244
- * cannot be shadowed (the first rule for it is already all of it), it stops
1245
- * pnpm's auto-collect for that package outright, and it carries no union for
1246
- * the parser to evaluate. It is also a wider statement than a version list —
1247
- * the package stops being age-gated at all — so it is spent only on entries
1248
- * pnpm has already written in one of those two broken forms, never on a
1249
- * package the file lists as a single exact version. A pure duplicate of one
1250
- * exact version collapses to that one line, which changes no policy at all.
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.
1251
1250
  *
1252
- * A file holding nothing but bare names and single exact versions is left
1253
- * untouched, as is one whose block this cannot read exactly (a flow list, an
1254
- * inline comment, a line it would have to guess at).
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.
1255
1258
  *
1256
- * @returns the package names whose entry was rewritten; empty when the file
1259
+ * @returns the package names whose rules were merged; empty when the file
1257
1260
  * needed no repair or could not be repaired, in which case it is left
1258
1261
  * byte-for-byte as it was.
1259
1262
  */
1260
- export function normalizeReleaseAgeExcludes(profile, explicitDir) {
1263
+ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1261
1264
  const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
1262
1265
  let yaml;
1263
1266
  try {
@@ -1310,12 +1313,16 @@ export function normalizeReleaseAgeExcludes(profile, explicitDir) {
1310
1313
  const group = byName.get(name);
1311
1314
  if (group === undefined)
1312
1315
  return '';
1313
- // A bare name already covers every version, so it stays bare; a union or
1314
- // several rules for one name cannot both be read, so they become one.
1315
- const bare = group.allVersions
1316
- || group.originals.some(selector => selector.includes('||'))
1317
- || group.selectors.size > 1;
1318
- const text = bare ? name : `${name}@${[...group.selectors][0]}`;
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(' || ')}`;
1319
1326
  // `@` cannot start a plain scalar in YAML, so a scoped name is written
1320
1327
  // quoted — the way pnpm itself writes one.
1321
1328
  const quoted = group.quoted || text.startsWith('@');
@@ -9,23 +9,34 @@
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
32
  /**
23
33
  * The proxy this process would use for the catalog, if any.
24
34
  *
25
35
  * The standard variables mirror `EnvHttpProxyAgent`'s own resolution
26
36
  * deliberately, rather than picking the order that reads best, because the
27
- * same answer does two jobs: it decides whether to route through undici at
28
- * all, and it is what the failure message CLAIMS was tried. A helper that
37
+ * same answer does two jobs: it decides whether the request goes through
38
+ * the proxy agent or the direct one, and it is what the failure message
39
+ * CLAIMS was tried. A helper that
29
40
  * named a proxy undici would not have used would put a false statement in
30
41
  * every bug report. `npm_config_*` is an additional source on top of that:
31
42
  * npm holds its proxy in its own config namespace (a machine set up with
@@ -46,11 +57,9 @@
46
57
  */
47
58
  export declare function configuredProxy(): string | null;
48
59
  /**
49
- * Fetch through the proxy this machine is configured to use.
50
- *
51
- * Falls back to the global fetch when no proxy is set, which keeps the
52
- * ordinary case on the runtime's own path rather than routing it through a
53
- * second HTTP stack for no reason.
60
+ * Fetch through the proxy this machine is configured to use, or directly
61
+ * through this module's own agent when it has none. Both paths pass the
62
+ * dispatcher described on this module.
54
63
  */
55
64
  export declare function marketFetch(url: string, init?: {
56
65
  signal?: AbortSignal;
@@ -22,5 +22,5 @@ export interface OfficialPluginManagerLike {
22
22
  */
23
23
  export declare const OFFICIAL_PAGE_HINT = "\u5B98\u65B9\u684C\u9762\u5BA2\u6237\u7AEF\u7684\u8FD9\u4E2A\u63D2\u4EF6\u64CD\u4F5C\u9700\u8981\u5728\u300C\u8BBE\u7F6E \u2192 \u63D2\u4EF6\u300D\u91CC\u5B8C\u6210\uFF1B\u5E02\u573A\u65E0\u6CD5\u901A\u8FC7\u547D\u4EE4\u884C\u4FEE\u6539\u684C\u9762\u7AEF\u7684 profile\u3002 / On the official desktop app, do this from Settings \u2192 Plugins; the market cannot change the desktop profile through the command line.";
24
24
  /** Never fall back to `dsh plugin --profile desktop`: that CLI is forbidden. */
25
- export declare function createOfficialDesktopRuntime(managerLookup: () => OfficialPluginManagerLike | undefined, profileName: string, _profileDirectory: string): DesktopPluginRuntime;
25
+ export declare function createOfficialDesktopRuntime(managerLookup: () => OfficialPluginManagerLike | undefined, profileName: string, _profileDirectory?: string): DesktopPluginRuntime;
26
26
  export {};
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */