dshmarket 1.66.1 → 1.66.2

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/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, mergeDuplicateReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
11
+ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, normalizeReleaseAgeExcludes, 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,6 +119,20 @@ 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.
129
+ const verb = pluginArgs.find(argument => !argument.startsWith('-'));
130
+ 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`);
134
+ }
135
+ }
122
136
  let result = await run(profile, pluginArgs);
123
137
  const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
124
138
  if (!ok(result) && !result.cancelled) {
@@ -149,23 +163,28 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
149
163
  }
150
164
  }
151
165
  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`);
166
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
167
+ // The repair ran before this command, so a rewrite here means pnpm wrote
168
+ // one of the two broken shapes DURING it — appending a shadowed rule
169
+ // (#732) or extending a version union (#733). Either way the same
170
+ // rewrite fixes it, needs no option at all, and so also works on the
171
+ // 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`);
164
175
  result = await run(profile, pluginArgs);
165
176
  }
177
+ else if (options.releaseAgeBypass === false) {
178
+ // The caller declined the bypass (#594), and the duplicates were not
179
+ // what failed: this violation is the profile's own policy doing its
180
+ // job, so the command stands as it is.
181
+ }
166
182
  else if (!marketFlags) {
167
183
  unavailableOption = RELEASE_AGE_OVERRIDE;
168
184
  }
185
+ else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
186
+ // Already carrying it (a caller's forced retry): nothing left to add.
187
+ }
169
188
  else {
170
189
  logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
171
190
  result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
package/lib/profile.js CHANGED
@@ -1222,31 +1222,42 @@ 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, #733).
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 WRITES this key in forms it then mishandles, and two separate defects
1228
+ * come out of that:
1238
1229
  *
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).
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.
1244
1242
  *
1245
- * @returns the package names whose entries were merged; empty when the file
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.
1251
+ *
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).
1255
+ *
1256
+ * @returns the package names whose entry was rewritten; empty when the file
1246
1257
  * needed no repair or could not be repaired, in which case it is left
1247
1258
  * byte-for-byte as it was.
1248
1259
  */
1249
- export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1260
+ export function normalizeReleaseAgeExcludes(profile, explicitDir) {
1250
1261
  const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
1251
1262
  let yaml;
1252
1263
  try {
@@ -1274,40 +1285,54 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1274
1285
  const rule = splitReleaseAgeExclude(m[1]);
1275
1286
  if (rule === null)
1276
1287
  return [];
1277
- entries.push({ rule, quoted: /^['"]/.test(m[1]) });
1288
+ entries.push({ rule, quoted: /^['"]/.test(m[1]), line });
1278
1289
  }
1279
1290
  const byName = new Map();
1280
1291
  const order = [];
1281
- for (const { rule, quoted } of entries) {
1292
+ for (const { rule, quoted, line } of entries) {
1282
1293
  let group = byName.get(rule.name);
1283
1294
  if (group === undefined) {
1284
- group = { selectors: new Set(), allVersions: false, quoted: false, count: 0 };
1295
+ group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 };
1285
1296
  byName.set(rule.name, group);
1286
1297
  order.push(rule.name);
1287
1298
  }
1288
1299
  group.count += 1;
1289
1300
  group.quoted = group.quoted || quoted;
1301
+ group.originals.push(rule.selector ?? '');
1302
+ group.lines.push(line);
1290
1303
  if (rule.selector === null)
1291
1304
  group.allVersions = true;
1292
1305
  else
1293
1306
  group.selectors.add(rule.selector);
1294
1307
  }
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 => {
1308
+ /** The one line this package's entry is written as. */
1309
+ const produced = (name) => {
1299
1310
  const group = byName.get(name);
1300
1311
  if (group === undefined)
1301
1312
  return '';
1302
- const text = group.allVersions ? name : `${name}@${[...group.selectors].join(' || ')}`;
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]}`;
1303
1319
  // `@` cannot start a plain scalar in YAML, so a scoped name is written
1304
1320
  // quoted — the way pnpm itself writes one.
1305
1321
  const quoted = group.quoted || text.startsWith('@');
1306
1322
  return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`;
1323
+ };
1324
+ const rewritten = order.filter(name => {
1325
+ const group = byName.get(name);
1326
+ if (group === undefined)
1327
+ return false;
1328
+ return group.count > 1 || group.lines[0] !== produced(name);
1307
1329
  });
1330
+ if (rewritten.length === 0)
1331
+ return [];
1332
+ const lines = order.map(produced);
1308
1333
  const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
1309
1334
  // A function replacement: `$&` and friends in a string replacement would
1310
1335
  // be read as capture references.
1311
1336
  writeFileSync(file, yaml.replace(blockRe, () => blockText));
1312
- return merged;
1337
+ return rewritten;
1313
1338
  }
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}`)
@@ -351,28 +351,39 @@ export declare function setAllowBuilds(profile: string, packages: string[], expl
351
351
  */
352
352
  export declare function dropUnparseableBuildKeys(profile: string, explicitDir?: string): string[];
353
353
  /**
354
- * Merge one package's several `minimumReleaseAgeExclude` entries into one
355
- * (#732).
354
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
356
355
  *
357
- * pnpm 11.7.0 APPENDS an entry when it lets a version through a profile's
358
- * `minimumReleaseAge` instead of folding it into the rule that already names
359
- * that package — and its own `evaluateVersionPolicy` then honours only the
360
- * FIRST rule per package name. The entry pnpm just wrote is therefore
361
- * shadowed by the older one, the young version stays unexcluded, and pnpm
362
- * fails lockfile verification with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION on
363
- * EVERY later command in that profile: installs, updates and uninstalls
364
- * alike, including ones that have nothing to do with the package. Reported
365
- * as #732, where the market's own self-update planted exactly that pair and
366
- * every plugin operation on the desktop profile stopped working.
356
+ * pnpm WRITES this key in forms it then mishandles, and two separate defects
357
+ * come out of that:
367
358
  *
368
- * Merging keeps the union of what the file already says, so nothing is
369
- * loosened or tightened: the entries pnpm wrote were meant to be read, and
370
- * after the merge they are. A file with no same-name duplicate is untouched,
371
- * as is one whose block this cannot read exactly (a flow list, an inline
372
- * comment, a line it would have to guess at).
359
+ * - pnpm 11.7.0 appends a second rule for a package that already has one,
360
+ * while its `evaluateVersionPolicy` honours only the FIRST rule per name.
361
+ * Its own new entry is therefore dead, and every later command in that
362
+ * profile fails lockfile verification with
363
+ * ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
364
+ * nothing to do with that package (#732).
365
+ * - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
366
+ * union, a form its OWN validator rejects ("Invalid versions union … Use
367
+ * exact versions only"), and evaluating one aborts the process on a single
368
+ * 80 GiB allocation that takes the machine down for minutes (#733). The
369
+ * entries are evaluated lazily, so this fires on any later install that
370
+ * age-checks that package, with no pnpm error output to explain it.
373
371
  *
374
- * @returns the package names whose entries were merged; empty when the file
372
+ * Both are avoided by writing each such package as ONE BARE NAME. A bare name
373
+ * cannot be shadowed (the first rule for it is already all of it), it stops
374
+ * pnpm's auto-collect for that package outright, and it carries no union for
375
+ * the parser to evaluate. It is also a wider statement than a version list —
376
+ * the package stops being age-gated at all — so it is spent only on entries
377
+ * pnpm has already written in one of those two broken forms, never on a
378
+ * package the file lists as a single exact version. A pure duplicate of one
379
+ * exact version collapses to that one line, which changes no policy at all.
380
+ *
381
+ * A file holding nothing but bare names and single exact versions is left
382
+ * untouched, as is one whose block this cannot read exactly (a flow list, an
383
+ * inline comment, a line it would have to guess at).
384
+ *
385
+ * @returns the package names whose entry was rewritten; empty when the file
375
386
  * needed no repair or could not be repaired, in which case it is left
376
387
  * byte-for-byte as it was.
377
388
  */
378
- export declare function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?: string): string[];
389
+ export declare function normalizeReleaseAgeExcludes(profile: string, explicitDir?: string): string[];
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.1",
4
+ "version": "1.66.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -3980,6 +3980,15 @@ export function MarketSection(props: MarketSectionProps) {
3980
3980
  ? t('capRedInstallScript')
3981
3981
  : t('capRedInstallScriptScripts').replace('{0}', installScript[1])
3982
3982
  }
3983
+ // The parenthesised detail is not a name. The scanner emits exactly two
3984
+ // shapes — "overrides bundle <id>" and "disables bundle <id>" — and pasting
3985
+ // either through leaves the verb in English. That verb is the fact: the
3986
+ // install would replace a part of DSH, or switch one off.
3987
+ const coreVerb = /^tampers with a core bundle \((overrides|disables) bundle (.+)\)$/.exec(line)
3988
+ if (coreVerb !== null) {
3989
+ const key = coreVerb[1] === 'overrides' ? 'capRedCoreOverride' : 'capRedCoreDisable'
3990
+ return t(key).replace('{0}', coreVerb[2]!)
3991
+ }
3983
3992
  const coreTamper = /^tampers with a core bundle(?: \((.+)\))?$/.exec(line)
3984
3993
  if (coreTamper !== null) {
3985
3994
  return coreTamper[1] === undefined
@@ -81,6 +81,8 @@ export const zh = {
81
81
  capRedInstallScriptScripts: '安装时会立刻运行 {0},不需要你打开插件',
82
82
  capRedCoreTamper: '会改动 DSH 自带的组件',
83
83
  capRedCoreTamperDetail: '会改动 DSH 自带的组件({0})',
84
+ capRedCoreOverride: '会覆盖 DSH 自带的组件({0})',
85
+ capRedCoreDisable: '会禁用 DSH 自带的组件({0})',
84
86
  favoriteAdd: '加入收藏',
85
87
  favoriteRemove: '取消收藏',
86
88
  favoritesEmpty: '还没有收藏。在「发现」或「主题」里点书签图标即可收藏。',
@@ -746,6 +748,8 @@ export const en: Record<MarketKey, string> = {
746
748
  capRedInstallScriptScripts: 'runs {0} the moment you install — you do not have to open the plugin',
747
749
  capRedCoreTamper: 'replaces parts of DSH itself',
748
750
  capRedCoreTamperDetail: 'replaces parts of DSH itself ({0})',
751
+ capRedCoreOverride: 'overrides a part of DSH itself ({0})',
752
+ capRedCoreDisable: 'disables a part of DSH itself ({0})',
749
753
  favoriteAdd: 'Add to favorites',
750
754
  favoriteRemove: 'Remove from favorites',
751
755
  favoritesEmpty: 'No favorites yet. Click the bookmark icon in Discover or Themes to add one.',
package/src/install.ts CHANGED
@@ -10,7 +10,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
10
10
  import type { InstallResult, PluginRunner } from './dsh-cli.ts'
11
11
  import { findDshInstallDir } from './dsh-install.ts'
12
12
  import { classifyPnpmFailure, HOST_NAMESPACE_RE, isTransientPnpmFailure } from './pnpm-compat.ts'
13
- import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, mergeDuplicateReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
13
+ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, normalizeReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
14
14
  import { logEvent } from './log.ts'
15
15
  import { cleanOrphanedStore } from './store.ts'
16
16
 
@@ -135,6 +135,20 @@ export async function withHoistRecovery(
135
135
  const marketFlags = options.marketFlags !== false
136
136
  /** The option a recovery step needed and this host does not accept (#732). */
137
137
  let unavailableOption: string | null = null
138
+ // Before the FIRST run, not after a failure: the two shapes pnpm writes into
139
+ // `minimumReleaseAgeExclude` (a shadowed duplicate rule, #732; a version
140
+ // union, #733) hurt pnpm while it RESOLVES the dependency graph, and the
141
+ // union one aborts the process on an 80 GiB allocation with no error output
142
+ // at all — nothing to classify, so a repair that waited for a failure would
143
+ // never fire. Every verb that resolves the graph is covered, not just add and
144
+ // remove: an `install` or an in-place `update` consults the same key.
145
+ const verb = pluginArgs.find(argument => !argument.startsWith('-'))
146
+ if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
147
+ const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory)
148
+ if (normalized.length > 0) {
149
+ 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`)
150
+ }
151
+ }
138
152
  let result = await run(profile, pluginArgs)
139
153
  const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
140
154
  if (!ok(result) && !result.cancelled) {
@@ -162,22 +176,25 @@ export async function withHoistRecovery(
162
176
  }
163
177
  } else if (
164
178
  failure?.code === 'release-age-violation'
165
- && options.releaseAgeBypass !== false
166
179
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
167
- && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)
168
180
  ) {
169
- // #732 first, because it is the breakage itself: pnpm appends a second
170
- // `minimumReleaseAgeExclude` rule for a package that already has one and
171
- // then honours only the FIRST per name, so its own new entry is shadowed
172
- // and verification fails for every later command in that profile.
173
- // Merging the duplicates repairs the file and needs no option at all, so
174
- // it is the one recovery that also works on the desktop bridge.
175
- const mergedDuplicates = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory)
176
- if (mergedDuplicates.length > 0) {
177
- 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`)
181
+ // The repair ran before this command, so a rewrite here means pnpm wrote
182
+ // one of the two broken shapes DURING it — appending a shadowed rule
183
+ // (#732) or extending a version union (#733). Either way the same
184
+ // rewrite fixes it, needs no option at all, and so also works on the
185
+ // desktop bridge that refuses options. Retry the SAME argv afterwards.
186
+ const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory)
187
+ if (normalized.length > 0) {
188
+ 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`)
178
189
  result = await run(profile, pluginArgs)
190
+ } else if (options.releaseAgeBypass === false) {
191
+ // The caller declined the bypass (#594), and the duplicates were not
192
+ // what failed: this violation is the profile's own policy doing its
193
+ // job, so the command stands as it is.
179
194
  } else if (!marketFlags) {
180
195
  unavailableOption = RELEASE_AGE_OVERRIDE
196
+ } else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
197
+ // Already carrying it (a caller's forced retry): nothing left to add.
181
198
  } else {
182
199
  logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
183
200
  result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
package/src/profile.ts CHANGED
@@ -1269,31 +1269,42 @@ function splitReleaseAgeExclude(entry: string): ReleaseAgeExcludeRule | null {
1269
1269
  }
1270
1270
 
1271
1271
  /**
1272
- * Merge one package's several `minimumReleaseAgeExclude` entries into one
1273
- * (#732).
1272
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
1274
1273
  *
1275
- * pnpm 11.7.0 APPENDS an entry when it lets a version through a profile's
1276
- * `minimumReleaseAge` instead of folding it into the rule that already names
1277
- * that package — and its own `evaluateVersionPolicy` then honours only the
1278
- * FIRST rule per package name. The entry pnpm just wrote is therefore
1279
- * shadowed by the older one, the young version stays unexcluded, and pnpm
1280
- * fails lockfile verification with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION on
1281
- * EVERY later command in that profile: installs, updates and uninstalls
1282
- * alike, including ones that have nothing to do with the package. Reported
1283
- * as #732, where the market's own self-update planted exactly that pair and
1284
- * every plugin operation on the desktop profile stopped working.
1274
+ * pnpm WRITES this key in forms it then mishandles, and two separate defects
1275
+ * come out of that:
1285
1276
  *
1286
- * Merging keeps the union of what the file already says, so nothing is
1287
- * loosened or tightened: the entries pnpm wrote were meant to be read, and
1288
- * after the merge they are. A file with no same-name duplicate is untouched,
1289
- * as is one whose block this cannot read exactly (a flow list, an inline
1290
- * comment, a line it would have to guess at).
1277
+ * - pnpm 11.7.0 appends a second rule for a package that already has one,
1278
+ * while its `evaluateVersionPolicy` honours only the FIRST rule per name.
1279
+ * Its own new entry is therefore dead, and every later command in that
1280
+ * profile fails lockfile verification with
1281
+ * ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
1282
+ * nothing to do with that package (#732).
1283
+ * - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
1284
+ * union, a form its OWN validator rejects ("Invalid versions union … Use
1285
+ * exact versions only"), and evaluating one aborts the process on a single
1286
+ * 80 GiB allocation that takes the machine down for minutes (#733). The
1287
+ * entries are evaluated lazily, so this fires on any later install that
1288
+ * age-checks that package, with no pnpm error output to explain it.
1291
1289
  *
1292
- * @returns the package names whose entries were merged; empty when the file
1290
+ * Both are avoided by writing each such package as ONE BARE NAME. A bare name
1291
+ * cannot be shadowed (the first rule for it is already all of it), it stops
1292
+ * pnpm's auto-collect for that package outright, and it carries no union for
1293
+ * the parser to evaluate. It is also a wider statement than a version list —
1294
+ * the package stops being age-gated at all — so it is spent only on entries
1295
+ * pnpm has already written in one of those two broken forms, never on a
1296
+ * package the file lists as a single exact version. A pure duplicate of one
1297
+ * exact version collapses to that one line, which changes no policy at all.
1298
+ *
1299
+ * A file holding nothing but bare names and single exact versions is left
1300
+ * untouched, as is one whose block this cannot read exactly (a flow list, an
1301
+ * inline comment, a line it would have to guess at).
1302
+ *
1303
+ * @returns the package names whose entry was rewritten; empty when the file
1293
1304
  * needed no repair or could not be repaired, in which case it is left
1294
1305
  * byte-for-byte as it was.
1295
1306
  */
1296
- export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?: string): string[] {
1307
+ export function normalizeReleaseAgeExcludes(profile: string, explicitDir?: string): string[] {
1297
1308
  const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
1298
1309
  let yaml: string
1299
1310
  try { yaml = readFileSync(file, 'utf8') } catch { return [] }
@@ -1304,7 +1315,7 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
1304
1315
  const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
1305
1316
  const indentMatch = /^([ \t]+)-/.exec(block[1])
1306
1317
  const indent = indentMatch === null ? ' ' : indentMatch[1]
1307
- const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean }[] = []
1318
+ const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean; line: string }[] = []
1308
1319
  for (const line of block[1].split(/\r?\n/)) {
1309
1320
  if (line.trim() === '') continue
1310
1321
  const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line)
@@ -1312,36 +1323,56 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
1312
1323
  if (m === null || m[1].includes('#')) return []
1313
1324
  const rule = splitReleaseAgeExclude(m[1])
1314
1325
  if (rule === null) return []
1315
- entries.push({ rule, quoted: /^['"]/.test(m[1]) })
1326
+ entries.push({ rule, quoted: /^['"]/.test(m[1]), line })
1316
1327
  }
1317
- const byName = new Map<string, { selectors: Set<string>; allVersions: boolean; quoted: boolean; count: number }>()
1328
+ const byName = new Map<string, {
1329
+ selectors: Set<string>
1330
+ originals: string[]
1331
+ lines: string[]
1332
+ allVersions: boolean
1333
+ quoted: boolean
1334
+ count: number
1335
+ }>()
1318
1336
  const order: string[] = []
1319
- for (const { rule, quoted } of entries) {
1337
+ for (const { rule, quoted, line } of entries) {
1320
1338
  let group = byName.get(rule.name)
1321
1339
  if (group === undefined) {
1322
- group = { selectors: new Set(), allVersions: false, quoted: false, count: 0 }
1340
+ group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 }
1323
1341
  byName.set(rule.name, group)
1324
1342
  order.push(rule.name)
1325
1343
  }
1326
1344
  group.count += 1
1327
1345
  group.quoted = group.quoted || quoted
1346
+ group.originals.push(rule.selector ?? '')
1347
+ group.lines.push(line)
1328
1348
  if (rule.selector === null) group.allVersions = true
1329
1349
  else group.selectors.add(rule.selector)
1330
1350
  }
1331
- const merged = order.filter(name => (byName.get(name)?.count ?? 0) > 1)
1332
- if (merged.length === 0) return []
1333
- const lines = order.map(name => {
1351
+ /** The one line this package's entry is written as. */
1352
+ const produced = (name: string): string => {
1334
1353
  const group = byName.get(name)
1335
1354
  if (group === undefined) return ''
1336
- const text = group.allVersions ? name : `${name}@${[...group.selectors].join(' || ')}`
1355
+ // A bare name already covers every version, so it stays bare; a union or
1356
+ // several rules for one name cannot both be read, so they become one.
1357
+ const bare = group.allVersions
1358
+ || group.originals.some(selector => selector.includes('||'))
1359
+ || group.selectors.size > 1
1360
+ const text = bare ? name : `${name}@${[...group.selectors][0]}`
1337
1361
  // `@` cannot start a plain scalar in YAML, so a scoped name is written
1338
1362
  // quoted — the way pnpm itself writes one.
1339
1363
  const quoted = group.quoted || text.startsWith('@')
1340
1364
  return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`
1365
+ }
1366
+ const rewritten = order.filter(name => {
1367
+ const group = byName.get(name)
1368
+ if (group === undefined) return false
1369
+ return group.count > 1 || group.lines[0] !== produced(name)
1341
1370
  })
1371
+ if (rewritten.length === 0) return []
1372
+ const lines = order.map(produced)
1342
1373
  const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
1343
1374
  // A function replacement: `$&` and friends in a string replacement would
1344
1375
  // be read as capture references.
1345
1376
  writeFileSync(file, yaml.replace(blockRe, () => blockText))
1346
- return merged
1377
+ return rewritten
1347
1378
  }
package/src/routes.ts CHANGED
@@ -3866,7 +3866,15 @@ sendJson(response, 200, { updates })
3866
3866
  && capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
3867
3867
  ? {
3868
3868
  available: false,
3869
- 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.`,
3869
+ // Name all three: the version on disk, the version
3870
+ // the lockfile records, and the one a rollback
3871
+ // would install (#732). The old wording named the
3872
+ // first two only by implication, and a user who
3873
+ // reads "the exact source cannot be proven" has
3874
+ // nothing to act on — the reproducer's way out was
3875
+ // to align package.json with the installed build,
3876
+ // which is what this now says.
3877
+ 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.`,
3870
3878
  lockfileBefore: lockfileCapture.snapshot,
3871
3879
  }
3872
3880
  : !supportsExactRollbackTarget(`${name}@${beforeVersion}`)