dshmarket 1.66.0 → 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, 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
  /**
@@ -108,8 +108,31 @@ export function isUnpublishedHostPeer(pkg, profile, explicitDir) {
108
108
  * where it is not — there the bypass would be what installs the young
109
109
  * version, over a minimumReleaseAge the profile set on purpose — so the
110
110
  * install route declines it and falls back to the bare name instead.
111
+ *
112
+ * `marketFlags: false` says the host runs pnpm itself and takes no options
113
+ * from us (the official Desktop bridge, #732). The four recoveries below
114
+ * each decorate a command with an option, so on such a host they are skipped
115
+ * rather than sent and refused — and because their classifier messages say
116
+ * the market retried, the note appended at the end says it did not.
111
117
  */
112
118
  export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory, options = {}) {
119
+ const marketFlags = options.marketFlags !== false;
120
+ /** The option a recovery step needed and this host does not accept (#732). */
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
+ }
113
136
  let result = await run(profile, pluginArgs);
114
137
  const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
115
138
  if (!ok(result) && !result.cancelled) {
@@ -127,19 +150,45 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
127
150
  }
128
151
  }
129
152
  else if (failure?.code === 'hoist-pattern-diff') {
130
- logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
131
- // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
132
- // where a lockfile written by the old major would otherwise be refused.
133
- const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
134
- if (ok(rebuild))
135
- result = await run(profile, pluginArgs);
153
+ if (!marketFlags) {
154
+ unavailableOption = '--no-frozen-lockfile';
155
+ }
156
+ else {
157
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
158
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
159
+ // where a lockfile written by the old major would otherwise be refused.
160
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
161
+ if (ok(rebuild))
162
+ result = await run(profile, pluginArgs);
163
+ }
136
164
  }
137
165
  else if (failure?.code === 'release-age-violation'
138
- && options.releaseAgeBypass !== false
139
- && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
140
- && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
141
- logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
142
- result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
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`);
175
+ result = await run(profile, pluginArgs);
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
+ }
182
+ else if (!marketFlags) {
183
+ unavailableOption = RELEASE_AGE_OVERRIDE;
184
+ }
185
+ else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
186
+ // Already carrying it (a caller's forced retry): nothing left to add.
187
+ }
188
+ else {
189
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
190
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
191
+ }
143
192
  }
144
193
  else if ((failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
145
194
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
@@ -149,8 +198,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
149
198
  // and npm has never carried. Every fresh profile hits this, whatever
150
199
  // the plugin, so failing here would be failing for something the user
151
200
  // cannot fix and did not cause.
152
- logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`);
153
- result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)]);
201
+ if (!marketFlags) {
202
+ unavailableOption = AUTO_INSTALL_PEERS_OFF;
203
+ }
204
+ else {
205
+ logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`);
206
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)]);
207
+ }
154
208
  }
155
209
  else if (failure?.code === 'transient-network'
156
210
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
@@ -166,8 +220,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
166
220
  // Large tarball / slow network: pnpm's default 60s per-request limit
167
221
  // aborted the download. A plain retry fails again at the same limit,
168
222
  // so retry once with a longer fetchTimeout.
169
- logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
170
- result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
223
+ if (!marketFlags) {
224
+ unavailableOption = FETCH_TIMEOUT_OVERRIDE;
225
+ }
226
+ else {
227
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
228
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
229
+ }
171
230
  }
172
231
  }
173
232
  if (!ok(result) && !result.cancelled) {
@@ -207,6 +266,16 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
207
266
  result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` };
208
267
  }
209
268
  }
269
+ if (unavailableOption !== null && !ok(result) && !result.cancelled) {
270
+ // #732: this step was skipped because the host runs pnpm itself and takes
271
+ // no options from us — but the classified message above says the market
272
+ // retried. Say that it did not, or the user reads a retry that never
273
+ // happened and looks for the wrong cause.
274
+ result = {
275
+ ...result,
276
+ stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
277
+ };
278
+ }
210
279
  return result;
211
280
  }
212
281
  /**
@@ -41,7 +41,16 @@ export function createOfficialDesktopRuntime(managerLookup, profileName, _profil
41
41
  // manager are accepted; silently dropping pnpm flags would change intent.
42
42
  const [command, target, ...extra] = argv;
43
43
  if (extra.length !== 0 || typeof target !== 'string' || !TARGET_RE.test(target)) {
44
- return Promise.resolve(failure('this desktop operation is not supported by the official plugin manager', 127));
44
+ // The market's own options are what usually lands here, and they are
45
+ // spelled with a leading `-` — and they sit in the TARGET slot, because
46
+ // the market writes `add <option> <target>`. The OPERATION is supported
47
+ // here; only the option is not, and reporting that as "this desktop
48
+ // operation is not supported" is what made #732 read as a broken
49
+ // profile. Name the option and the way out instead.
50
+ const options = argv.slice(1).filter(argument => argument.startsWith('-')).join(' ');
51
+ return Promise.resolve(failure(options === ''
52
+ ? 'this desktop operation is not supported by the official plugin manager'
53
+ : `官方桌面客户端自己执行这次安装,不接受市场附加的参数(${options}),这个选项在它这里用不了。可以改用普通 dsh web 安装,或等这个版本过了新版本等待期再试。 / The official desktop app runs this install itself and takes no options from the market (${options}); that option cannot be used here. Install from plain dsh web, or try again once the release is past the fresh-release wait.`, 127));
45
54
  }
46
55
  const spec = target;
47
56
  // Only `add` and `remove` map onto the manager. `update` reached here
@@ -122,6 +131,10 @@ export function createOfficialDesktopRuntime(managerLookup, profileName, _profil
122
131
  return true;
123
132
  },
124
133
  supportsExactRollbackTarget: target => TARGET_RE.test(target),
134
+ // The reason the flag exists (#732): this manager takes exactly
135
+ // `add <target>` or `remove <target>`, so every recovery step that needs
136
+ // an option has to be left out here rather than sent and refused.
137
+ acceptsMarketPnpmFlags: false,
125
138
  dispose: async () => {
126
139
  disposed = true;
127
140
  if (active?.requestId !== undefined) {
package/lib/profile.js CHANGED
@@ -1198,3 +1198,141 @@ export function dropUnparseableBuildKeys(profile, explicitDir) {
1198
1198
  writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch);
1199
1199
  return removed;
1200
1200
  }
1201
+ /**
1202
+ * Split `name@1.2.3 || 1.4.0` into its package name and selector.
1203
+ *
1204
+ * A scoped name begins with `@`, so the separator is the first `@` AFTER
1205
+ * position 0; an entry with no `@` at all excludes every version of that
1206
+ * name. Anything this cannot read exactly returns null, and the caller then
1207
+ * leaves the file alone rather than rewriting a line it misread.
1208
+ */
1209
+ function splitReleaseAgeExclude(entry) {
1210
+ let text = entry.trim();
1211
+ if (text.length >= 2
1212
+ && (text[0] === "'" && text[text.length - 1] === "'" || text[0] === '"' && text[text.length - 1] === '"')) {
1213
+ text = text.slice(1, -1);
1214
+ }
1215
+ const at = text.indexOf('@', text.startsWith('@') ? 1 : 0);
1216
+ if (at === -1)
1217
+ return text === '' ? null : { name: text, selector: null };
1218
+ const name = text.slice(0, at);
1219
+ const selector = text.slice(at + 1).trim();
1220
+ if (name === '' || selector === '')
1221
+ return null;
1222
+ return { name, selector };
1223
+ }
1224
+ /**
1225
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
1226
+ *
1227
+ * pnpm WRITES this key in forms it then mishandles, and two separate defects
1228
+ * come out of that:
1229
+ *
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.
1242
+ *
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
1257
+ * needed no repair or could not be repaired, in which case it is left
1258
+ * byte-for-byte as it was.
1259
+ */
1260
+ export function normalizeReleaseAgeExcludes(profile, explicitDir) {
1261
+ const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
1262
+ let yaml;
1263
+ try {
1264
+ yaml = readFileSync(file, 'utf8');
1265
+ }
1266
+ catch {
1267
+ return [];
1268
+ }
1269
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1270
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
1271
+ const block = blockRe.exec(yaml);
1272
+ if (block === null)
1273
+ return [];
1274
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
1275
+ const indentMatch = /^([ \t]+)-/.exec(block[1]);
1276
+ const indent = indentMatch === null ? ' ' : indentMatch[1];
1277
+ const entries = [];
1278
+ for (const line of block[1].split(/\r?\n/)) {
1279
+ if (line.trim() === '')
1280
+ continue;
1281
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
1282
+ // A `#` on the line is a comment this cannot re-emit without losing it.
1283
+ if (m === null || m[1].includes('#'))
1284
+ return [];
1285
+ const rule = splitReleaseAgeExclude(m[1]);
1286
+ if (rule === null)
1287
+ return [];
1288
+ entries.push({ rule, quoted: /^['"]/.test(m[1]), line });
1289
+ }
1290
+ const byName = new Map();
1291
+ const order = [];
1292
+ for (const { rule, quoted, line } of entries) {
1293
+ let group = byName.get(rule.name);
1294
+ if (group === undefined) {
1295
+ group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 };
1296
+ byName.set(rule.name, group);
1297
+ order.push(rule.name);
1298
+ }
1299
+ group.count += 1;
1300
+ group.quoted = group.quoted || quoted;
1301
+ group.originals.push(rule.selector ?? '');
1302
+ group.lines.push(line);
1303
+ if (rule.selector === null)
1304
+ group.allVersions = true;
1305
+ else
1306
+ group.selectors.add(rule.selector);
1307
+ }
1308
+ /** The one line this package's entry is written as. */
1309
+ const produced = (name) => {
1310
+ const group = byName.get(name);
1311
+ if (group === undefined)
1312
+ 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]}`;
1319
+ // `@` cannot start a plain scalar in YAML, so a scoped name is written
1320
+ // quoted — the way pnpm itself writes one.
1321
+ const quoted = group.quoted || text.startsWith('@');
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);
1329
+ });
1330
+ if (rewritten.length === 0)
1331
+ return [];
1332
+ const lines = order.map(produced);
1333
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
1334
+ // A function replacement: `$&` and friends in a string replacement would
1335
+ // be read as capture references.
1336
+ writeFileSync(file, yaml.replace(blockRe, () => blockText));
1337
+ return rewritten;
1338
+ }
package/lib/routes.js CHANGED
@@ -259,6 +259,19 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
259
259
  // re-applies the same choice on every boot (ported from dsh-plugin-hub).
260
260
  const userPatchPath = findUserPatchPath(host, activeProfileDir);
261
261
  const commands = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive };
262
+ /**
263
+ * Whether this host runs pnpm itself and so takes the market's own options
264
+ * (`--config.*`, `--force`, `--no-frozen-lockfile`).
265
+ *
266
+ * The official Desktop bridge does not (#732): its in-process manager
267
+ * accepts exactly `add <target>` or `remove <target>` and answers anything
268
+ * else with exit 127. The market's recovery steps decorate commands with
269
+ * those options, so on such a host they have to be left out or rewritten as
270
+ * a bare exact target, instead of being sent and refused — which reported a
271
+ * supported operation as unsupported and sent the reporter looking for a
272
+ * broken profile.
273
+ */
274
+ const marketFlags = commands.acceptsMarketPnpmFlags !== false;
262
275
  const supportsExactRollbackTarget = (target) => commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target);
263
276
  // Point every plugin build/install spawn at the configured build
264
277
  // environment (#336). Read LIVE from `config.buildEnv` because the settings
@@ -688,9 +701,34 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
688
701
  }
689
702
  }
690
703
  /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
691
- const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir);
704
+ const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { marketFlags });
692
705
  /** The same, minus the release-age bypass: for a fresh install pinned to a young release (#594). */
693
- const runPluginKeepingReleaseAge = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false });
706
+ const runPluginKeepingReleaseAge = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false, marketFlags });
707
+ /**
708
+ * The argv that rematerializes a restored manifest's build on this host.
709
+ *
710
+ * On a host that takes the market's options, one `pnpm install` does it. A
711
+ * host that runs pnpm itself — the official Desktop bridge (#732) — accepts
712
+ * only `add <target>` and refuses `install` outright, so there it is an
713
+ * `add` of the exact version the restored manifest pins, which is what that
714
+ * host's own manager pipeline materializes. A range is deliberately not
715
+ * usable: it would re-resolve to whatever is newest and call that the
716
+ * previous build.
717
+ *
718
+ * @returns null when nothing expressible is left, in which case the caller
719
+ * reports that rather than sending a command the host will refuse.
720
+ */
721
+ function rematerializeArgs(name, pinned) {
722
+ if (marketFlags)
723
+ return ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install'];
724
+ if (pinned !== undefined && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(pinned))
725
+ return ['add', `${name}@${pinned}`];
726
+ return null;
727
+ }
728
+ /** Why a rematerialization could not even be attempted here (see above). */
729
+ function cannotRematerializeDetail(name, pinned) {
730
+ return `这台宿主只接受按精确版本重新安装,「${name}」改动前在 profile 里写作 ${pinned ?? '(不在 profile 里)'},市场无法在它上面重建上一版 / this host can only reinstall an exact version, and "${name}" was declared as ${pinned ?? '(not in the profile)'}, so the previous build cannot be rematerialized there`;
731
+ }
694
732
  /**
695
733
  * Undo a clean-exit update whose new build cannot boot. Restoring only the
696
734
  * manifest pin (the original #159 behavior) leaves the bad package files
@@ -710,7 +748,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
710
748
  // Flags come BEFORE the command: preparePluginArgs treats the last arg as
711
749
  // the package target and rejects a trailing flag, while pnpm accepts the
712
750
  // same flags in front of `install`.
713
- const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
751
+ //
752
+ // On a host that runs pnpm itself neither flag exists and `install` is not
753
+ // accepted at all (#732), so there the build is rematerialized through the
754
+ // exact target — see `rematerializeArgs`.
755
+ const pinned = manifestBefore.dependencies[name];
756
+ const args = rematerializeArgs(name, pinned);
757
+ if (args === null)
758
+ return { ok: false, detail: cannotRematerializeDetail(name, pinned) };
759
+ const reinstall = await runPlugin(config.profile, args);
714
760
  const ok = reinstall.exitCode === 0 && !reinstall.timedOut && !reinstall.cancelled;
715
761
  if (ok)
716
762
  logEvent('info', 'update', `${name}: previous build rematerialized (${rolledBack.join(', ')})`);
@@ -842,7 +888,16 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
842
888
  // were replaced before the rejected update failed. A normal exact add is
843
889
  // then an "already up to date" no-op; --force is what rematerializes the
844
890
  // captured version/commit/archive instead of blessing corrupted bytes.
845
- const add = await runPlugin(config.profile, ['add', '--force', RELEASE_AGE_OVERRIDE, target]);
891
+ //
892
+ // `--force` and the age override are market options, and the official
893
+ // Desktop bridge refuses them outright (#732). There the rollback is the
894
+ // bare exact target: the host's own manager pipeline is what installs it,
895
+ // and sending the options anyway failed the rollback with 127 — which
896
+ // read as "the previous build could not be verified" while node_modules
897
+ // still held the bad build.
898
+ const add = await runPlugin(config.profile, marketFlags
899
+ ? ['add', '--force', RELEASE_AGE_OVERRIDE, target]
900
+ : ['add', target]);
846
901
  // Exact recovery targets deliberately pin versions/commits. Keep the
847
902
  // user's durable range, tag, floating github shortcut, or release URL.
848
903
  restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
@@ -3086,7 +3141,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3086
3141
  const prepared = restoreProfileLockfile(lockfileBefore);
3087
3142
  if (!prepared.ok)
3088
3143
  return prepared;
3089
- const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
3144
+ const reinstallArgs = rematerializeArgs(name, manifestBefore.dependencies[name]);
3145
+ if (reinstallArgs === null) {
3146
+ return { ok: false, detail: cannotRematerializeDetail(name, manifestBefore.dependencies[name]) };
3147
+ }
3148
+ const reinstall = await runPlugin(config.profile, reinstallArgs);
3090
3149
  restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
3091
3150
  const finalLock = restoreProfileLockfile(lockfileBefore);
3092
3151
  if (!finalLock.ok)
@@ -3552,9 +3611,16 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3552
3611
  const reresolveInPlace = isGit && !restore && target === spec;
3553
3612
  // force: the user chose to install a fresh release without the
3554
3613
  // default one-day safety wait; scoped to this single command.
3614
+ //
3615
+ // The override is a market option, so a host that runs pnpm itself
3616
+ // does not take it (#732); there the plain form goes out and the
3617
+ // host's own manager pipeline applies its release policy. That is
3618
+ // the same trade the held-back fresh install already makes: the
3619
+ // version the host admits now, with the newer one still offered by
3620
+ // the update check.
3555
3621
  const addArgs = reresolveInPlace
3556
- ? (force ? ['update', RELEASE_AGE_OVERRIDE, name] : ['update', name])
3557
- : (force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target]);
3622
+ ? (force && marketFlags ? ['update', RELEASE_AGE_OVERRIDE, name] : ['update', name])
3623
+ : (force && marketFlags ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target]);
3558
3624
  // Exact manifest snapshot for failure rollback (#65, #339) — the
3559
3625
  // host can write dependencies AND dsh.profile.bundles before a
3560
3626
  // hard-failed add, leaving residue that breaks the next boot.
@@ -3625,7 +3691,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3625
3691
  && capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
3626
3692
  ? {
3627
3693
  available: false,
3628
- 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.`,
3629
3703
  lockfileBefore: lockfileCapture.snapshot,
3630
3704
  }
3631
3705
  : !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
@@ -5117,7 +5191,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5117
5191
  // the intent the fresh path otherwise refuses to assume it has
5118
5192
  // (#594): the bypass is safe to use HERE because it is no
5119
5193
  // longer the market's idea — it is what was clicked (#635).
5120
- const bypass = heldBack && force;
5194
+ // The bypass is an option this host may not accept (#732). Where
5195
+ // it is not expressible the request falls to the same place as
5196
+ // a refused bypass below: the bare name, with `heldByAge` set
5197
+ // so the row says the profile's own age policy is why.
5198
+ const bypass = heldBack && force && marketFlags;
5121
5199
  logEvent('warn', 'install', bypass
5122
5200
  ? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
5123
5201
  : heldBack
@@ -204,6 +204,19 @@ export interface PluginCommandRuntime {
204
204
  cancelActive(): boolean;
205
205
  /** Whether this host can execute an immutable rollback add target. */
206
206
  supportsExactRollbackTarget?(target: string): boolean;
207
+ /**
208
+ * Whether this runtime runs pnpm itself and therefore accepts the market's
209
+ * own arguments — `--config.*` overrides, `--force`, `--no-frozen-lockfile`.
210
+ *
211
+ * The official Electron profile does not (#732): its in-process manager
212
+ * takes exactly `add <target>` or `remove <target>` and answers any other
213
+ * argv with exit 127. Every recovery step that decorates a command with an
214
+ * option is impossible there, and sending it anyway turned a pnpm failure
215
+ * into "this desktop operation is not supported" — a message about an
216
+ * operation that is in fact supported, which sent the reporter looking for
217
+ * a broken profile instead of the option. Absent means "accepts".
218
+ */
219
+ acceptsMarketPnpmFlags?: boolean;
207
220
  }
208
221
  /** One running package operation, however it was started. */
209
222
  export interface DesktopPnpmHandleLike {
@@ -98,9 +98,16 @@ export declare function isUnpublishedHostPeer(pkg: string | undefined, profile:
98
98
  * where it is not — there the bypass would be what installs the young
99
99
  * version, over a minimumReleaseAge the profile set on purpose — so the
100
100
  * install route declines it and falls back to the bare name instead.
101
+ *
102
+ * `marketFlags: false` says the host runs pnpm itself and takes no options
103
+ * from us (the official Desktop bridge, #732). The four recoveries below
104
+ * each decorate a command with an option, so on such a host they are skipped
105
+ * rather than sent and refused — and because their classifier messages say
106
+ * the market retried, the note appended at the end says it did not.
101
107
  */
102
108
  export declare function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[], profileDirectory?: string, options?: {
103
109
  releaseAgeBypass?: boolean;
110
+ marketFlags?: boolean;
104
111
  }): Promise<InstallResult>;
105
112
  /**
106
113
  * The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
@@ -350,3 +350,40 @@ export declare function setAllowBuilds(profile: string, packages: string[], expl
350
350
  * file is left untouched.
351
351
  */
352
352
  export declare function dropUnparseableBuildKeys(profile: string, explicitDir?: string): string[];
353
+ /**
354
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
355
+ *
356
+ * pnpm WRITES this key in forms it then mishandles, and two separate defects
357
+ * come out of that:
358
+ *
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.
371
+ *
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
386
+ * needed no repair or could not be repaired, in which case it is left
387
+ * byte-for-byte as it was.
388
+ */
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.0",
4
+ "version": "1.66.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -30,7 +30,7 @@
30
30
  "prepack": "npm run build && node scripts/preflight.mjs",
31
31
  "prepare": "npm run build",
32
32
  "validate:registry": "node scripts/validate-registry.mjs",
33
- "snapshot": "curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json && curl -sf https://awesome-dsh-plugin.com/readmes.json -o data/readmes-snapshot.json",
33
+ "snapshot": "mkdir -p data && curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json && curl -sf https://awesome-dsh-plugin.com/readmes.json -o data/readmes-snapshot.json",
34
34
  "check:web-auth-capture": "node scripts/check-web-auth-capture.mjs",
35
35
  "test:web": "node scripts/check-web-auth-capture.mjs && vitest run --config vitest.web.config.ts",
36
36
  "build:site": "node scripts/build-site.mjs"
@@ -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
@@ -245,15 +245,22 @@ export function apply(ctx: MarketClientContext): void {
245
245
  ? null
246
246
  : h(SettingsCard, { t, onRemoved: () => { sectionGate.retire() } })))
247
247
 
248
- // The seat on the 0.1.7 line (#722). That release moved a plugin's
249
- // configuration off the settings list and into the Plugins section: the
250
- // `settingsScope` service and the `settings.plugin.item` slot that hung off
251
- // it are gone, and `settings.plugins.tab` is what its slot contract offers —
252
- // "one page inside the Plugins settings section", rendered as a tab beside
253
- // the host's own. Without it the market had NO card on that line at all: the
254
- // `plugins.bundle.config` registration above never fires, because no host
255
- // declares that slot, and a registration into a slot nobody declares is
256
- // silent by construction. Reported as "the settings card disappeared".
248
+ // The seat on the 0.1.7 line (#722). That release renamed the client's
249
+ // `settingsScope` service to `settings`, so the nested inject above stopped
250
+ // firing and `settings.plugin.item` — the settings-list card — went silent
251
+ // with no error anywhere. `settings.plugins.tab` is what that line's slot
252
+ // contract offers instead: "one page inside the Plugins settings section",
253
+ // rendered as a tab beside the host's own. Reported as "the settings card
254
+ // disappeared".
255
+ //
256
+ // This does not replace the `plugins.bundle.config` registration above, and
257
+ // that one did not stop firing: 0.1.7 declares it (`ui-plugin-manager` — "a
258
+ // bundle's own configuration, keyed by the bundle's package name and
259
+ // rendered on the bundle's page"), so the market's card is also on the
260
+ // market bundle's page in the Plugins section. BOTH seats are deliberate:
261
+ // the tab is where a user opens the market's settings from that section, the
262
+ // bundle page is where the plugin manager shows them for the installed
263
+ // bundle. Keep both.
257
264
  //
258
265
  // Detection is the slot itself, as everywhere else here: a host that does
259
266
  // not declare it never runs this, and the older line keeps its card through
@@ -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/dsh-cli.ts CHANGED
@@ -531,6 +531,19 @@ export interface PluginCommandRuntime {
531
531
  cancelActive(): boolean
532
532
  /** Whether this host can execute an immutable rollback add target. */
533
533
  supportsExactRollbackTarget?(target: string): boolean
534
+ /**
535
+ * Whether this runtime runs pnpm itself and therefore accepts the market's
536
+ * own arguments — `--config.*` overrides, `--force`, `--no-frozen-lockfile`.
537
+ *
538
+ * The official Electron profile does not (#732): its in-process manager
539
+ * takes exactly `add <target>` or `remove <target>` and answers any other
540
+ * argv with exit 127. Every recovery step that decorates a command with an
541
+ * option is impossible there, and sending it anyway turned a pnpm failure
542
+ * into "this desktop operation is not supported" — a message about an
543
+ * operation that is in fact supported, which sent the reporter looking for
544
+ * a broken profile instead of the option. Absent means "accepts".
545
+ */
546
+ acceptsMarketPnpmFlags?: boolean
534
547
  }
535
548
 
536
549
  /** One running package operation, however it was started. */
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, 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
 
@@ -118,14 +118,37 @@ export function isUnpublishedHostPeer(
118
118
  * where it is not — there the bypass would be what installs the young
119
119
  * version, over a minimumReleaseAge the profile set on purpose — so the
120
120
  * install route declines it and falls back to the bare name instead.
121
+ *
122
+ * `marketFlags: false` says the host runs pnpm itself and takes no options
123
+ * from us (the official Desktop bridge, #732). The four recoveries below
124
+ * each decorate a command with an option, so on such a host they are skipped
125
+ * rather than sent and refused — and because their classifier messages say
126
+ * the market retried, the note appended at the end says it did not.
121
127
  */
122
128
  export async function withHoistRecovery(
123
129
  run: PluginRunner,
124
130
  profile: string,
125
131
  pluginArgs: string[],
126
132
  profileDirectory?: string,
127
- options: { releaseAgeBypass?: boolean } = {},
133
+ options: { releaseAgeBypass?: boolean; marketFlags?: boolean } = {},
128
134
  ): Promise<InstallResult> {
135
+ const marketFlags = options.marketFlags !== false
136
+ /** The option a recovery step needed and this host does not accept (#732). */
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
+ }
129
152
  let result = await run(profile, pluginArgs)
130
153
  const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
131
154
  if (!ok(result) && !result.cancelled) {
@@ -142,19 +165,40 @@ export async function withHoistRecovery(
142
165
  result = await run(profile, pluginArgs)
143
166
  }
144
167
  } else if (failure?.code === 'hoist-pattern-diff') {
145
- logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
146
- // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
147
- // where a lockfile written by the old major would otherwise be refused.
148
- const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
149
- if (ok(rebuild)) result = await run(profile, pluginArgs)
168
+ if (!marketFlags) {
169
+ unavailableOption = '--no-frozen-lockfile'
170
+ } else {
171
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
172
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
173
+ // where a lockfile written by the old major would otherwise be refused.
174
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
175
+ if (ok(rebuild)) result = await run(profile, pluginArgs)
176
+ }
150
177
  } else if (
151
178
  failure?.code === 'release-age-violation'
152
- && options.releaseAgeBypass !== false
153
179
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
154
- && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)
155
180
  ) {
156
- logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
157
- result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
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`)
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.
194
+ } else if (!marketFlags) {
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.
198
+ } else {
199
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
200
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
201
+ }
158
202
  } else if (
159
203
  (failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
160
204
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
@@ -165,8 +209,12 @@ export async function withHoistRecovery(
165
209
  // and npm has never carried. Every fresh profile hits this, whatever
166
210
  // the plugin, so failing here would be failing for something the user
167
211
  // cannot fix and did not cause.
168
- logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`)
169
- result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)])
212
+ if (!marketFlags) {
213
+ unavailableOption = AUTO_INSTALL_PEERS_OFF
214
+ } else {
215
+ logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`)
216
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)])
217
+ }
170
218
  } else if (
171
219
  failure?.code === 'transient-network'
172
220
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
@@ -184,8 +232,12 @@ export async function withHoistRecovery(
184
232
  // Large tarball / slow network: pnpm's default 60s per-request limit
185
233
  // aborted the download. A plain retry fails again at the same limit,
186
234
  // so retry once with a longer fetchTimeout.
187
- logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
188
- result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
235
+ if (!marketFlags) {
236
+ unavailableOption = FETCH_TIMEOUT_OVERRIDE
237
+ } else {
238
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
239
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
240
+ }
189
241
  }
190
242
  }
191
243
  if (!ok(result) && !result.cancelled) {
@@ -223,6 +275,16 @@ export async function withHoistRecovery(
223
275
  result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` }
224
276
  }
225
277
  }
278
+ if (unavailableOption !== null && !ok(result) && !result.cancelled) {
279
+ // #732: this step was skipped because the host runs pnpm itself and takes
280
+ // no options from us — but the classified message above says the market
281
+ // retried. Say that it did not, or the user reads a retry that never
282
+ // happened and looks for the wrong cause.
283
+ result = {
284
+ ...result,
285
+ stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
286
+ }
287
+ }
226
288
  return result
227
289
  }
228
290
 
@@ -58,7 +58,16 @@ export function createOfficialDesktopRuntime(
58
58
  // manager are accepted; silently dropping pnpm flags would change intent.
59
59
  const [command, target, ...extra] = argv
60
60
  if (extra.length !== 0 || typeof target !== 'string' || !TARGET_RE.test(target)) {
61
- return Promise.resolve(failure('this desktop operation is not supported by the official plugin manager', 127))
61
+ // The market's own options are what usually lands here, and they are
62
+ // spelled with a leading `-` — and they sit in the TARGET slot, because
63
+ // the market writes `add <option> <target>`. The OPERATION is supported
64
+ // here; only the option is not, and reporting that as "this desktop
65
+ // operation is not supported" is what made #732 read as a broken
66
+ // profile. Name the option and the way out instead.
67
+ const options = argv.slice(1).filter(argument => argument.startsWith('-')).join(' ')
68
+ return Promise.resolve(failure(options === ''
69
+ ? 'this desktop operation is not supported by the official plugin manager'
70
+ : `官方桌面客户端自己执行这次安装,不接受市场附加的参数(${options}),这个选项在它这里用不了。可以改用普通 dsh web 安装,或等这个版本过了新版本等待期再试。 / The official desktop app runs this install itself and takes no options from the market (${options}); that option cannot be used here. Install from plain dsh web, or try again once the release is past the fresh-release wait.`, 127))
62
71
  }
63
72
  const spec = target
64
73
  // Only `add` and `remove` map onto the manager. `update` reached here
@@ -136,6 +145,10 @@ export function createOfficialDesktopRuntime(
136
145
  return true
137
146
  },
138
147
  supportsExactRollbackTarget: target => TARGET_RE.test(target),
148
+ // The reason the flag exists (#732): this manager takes exactly
149
+ // `add <target>` or `remove <target>`, so every recovery step that needs
150
+ // an option has to be left out here rather than sent and refused.
151
+ acceptsMarketPnpmFlags: false,
139
152
  dispose: async () => {
140
153
  disposed = true
141
154
  if (active?.requestId !== undefined) {
package/src/profile.ts CHANGED
@@ -1238,3 +1238,141 @@ export function dropUnparseableBuildKeys(profile: string, explicitDir?: string):
1238
1238
  writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch)
1239
1239
  return removed
1240
1240
  }
1241
+
1242
+ /** One `minimumReleaseAgeExclude` entry, split into its name and its versions. */
1243
+ interface ReleaseAgeExcludeRule {
1244
+ name: string
1245
+ /** The version union exactly as written, or null when the entry names no version. */
1246
+ selector: string | null
1247
+ }
1248
+
1249
+ /**
1250
+ * Split `name@1.2.3 || 1.4.0` into its package name and selector.
1251
+ *
1252
+ * A scoped name begins with `@`, so the separator is the first `@` AFTER
1253
+ * position 0; an entry with no `@` at all excludes every version of that
1254
+ * name. Anything this cannot read exactly returns null, and the caller then
1255
+ * leaves the file alone rather than rewriting a line it misread.
1256
+ */
1257
+ function splitReleaseAgeExclude(entry: string): ReleaseAgeExcludeRule | null {
1258
+ let text = entry.trim()
1259
+ if (text.length >= 2
1260
+ && (text[0] === "'" && text[text.length - 1] === "'" || text[0] === '"' && text[text.length - 1] === '"')) {
1261
+ text = text.slice(1, -1)
1262
+ }
1263
+ const at = text.indexOf('@', text.startsWith('@') ? 1 : 0)
1264
+ if (at === -1) return text === '' ? null : { name: text, selector: null }
1265
+ const name = text.slice(0, at)
1266
+ const selector = text.slice(at + 1).trim()
1267
+ if (name === '' || selector === '') return null
1268
+ return { name, selector }
1269
+ }
1270
+
1271
+ /**
1272
+ * Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
1273
+ *
1274
+ * pnpm WRITES this key in forms it then mishandles, and two separate defects
1275
+ * come out of that:
1276
+ *
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.
1289
+ *
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
1304
+ * needed no repair or could not be repaired, in which case it is left
1305
+ * byte-for-byte as it was.
1306
+ */
1307
+ export function normalizeReleaseAgeExcludes(profile: string, explicitDir?: string): string[] {
1308
+ const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
1309
+ let yaml: string
1310
+ try { yaml = readFileSync(file, 'utf8') } catch { return [] }
1311
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1312
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m
1313
+ const block = blockRe.exec(yaml)
1314
+ if (block === null) return []
1315
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
1316
+ const indentMatch = /^([ \t]+)-/.exec(block[1])
1317
+ const indent = indentMatch === null ? ' ' : indentMatch[1]
1318
+ const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean; line: string }[] = []
1319
+ for (const line of block[1].split(/\r?\n/)) {
1320
+ if (line.trim() === '') continue
1321
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line)
1322
+ // A `#` on the line is a comment this cannot re-emit without losing it.
1323
+ if (m === null || m[1].includes('#')) return []
1324
+ const rule = splitReleaseAgeExclude(m[1])
1325
+ if (rule === null) return []
1326
+ entries.push({ rule, quoted: /^['"]/.test(m[1]), line })
1327
+ }
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
+ }>()
1336
+ const order: string[] = []
1337
+ for (const { rule, quoted, line } of entries) {
1338
+ let group = byName.get(rule.name)
1339
+ if (group === undefined) {
1340
+ group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 }
1341
+ byName.set(rule.name, group)
1342
+ order.push(rule.name)
1343
+ }
1344
+ group.count += 1
1345
+ group.quoted = group.quoted || quoted
1346
+ group.originals.push(rule.selector ?? '')
1347
+ group.lines.push(line)
1348
+ if (rule.selector === null) group.allVersions = true
1349
+ else group.selectors.add(rule.selector)
1350
+ }
1351
+ /** The one line this package's entry is written as. */
1352
+ const produced = (name: string): string => {
1353
+ const group = byName.get(name)
1354
+ if (group === undefined) return ''
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]}`
1361
+ // `@` cannot start a plain scalar in YAML, so a scoped name is written
1362
+ // quoted — the way pnpm itself writes one.
1363
+ const quoted = group.quoted || text.startsWith('@')
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)
1370
+ })
1371
+ if (rewritten.length === 0) return []
1372
+ const lines = order.map(produced)
1373
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
1374
+ // A function replacement: `$&` and friends in a string replacement would
1375
+ // be read as capture references.
1376
+ writeFileSync(file, yaml.replace(blockRe, () => blockText))
1377
+ return rewritten
1378
+ }
package/src/routes.ts CHANGED
@@ -357,6 +357,19 @@ export function mountMarketRoutes(
357
357
  // re-applies the same choice on every boot (ported from dsh-plugin-hub).
358
358
  const userPatchPath = findUserPatchPath(host, activeProfileDir)
359
359
  const commands: PluginCommandRuntime = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive }
360
+ /**
361
+ * Whether this host runs pnpm itself and so takes the market's own options
362
+ * (`--config.*`, `--force`, `--no-frozen-lockfile`).
363
+ *
364
+ * The official Desktop bridge does not (#732): its in-process manager
365
+ * accepts exactly `add <target>` or `remove <target>` and answers anything
366
+ * else with exit 127. The market's recovery steps decorate commands with
367
+ * those options, so on such a host they have to be left out or rewritten as
368
+ * a bare exact target, instead of being sent and refused — which reported a
369
+ * supported operation as unsupported and sent the reporter looking for a
370
+ * broken profile.
371
+ */
372
+ const marketFlags = commands.acceptsMarketPnpmFlags !== false
360
373
  const supportsExactRollbackTarget = (target: string): boolean =>
361
374
  commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target)
362
375
  // Point every plugin build/install spawn at the configured build
@@ -773,10 +786,36 @@ export function mountMarketRoutes(
773
786
  }
774
787
 
775
788
  /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
776
- const runPlugin = (profile: string, args: string[]) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir)
789
+ const runPlugin = (profile: string, args: string[]) =>
790
+ withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { marketFlags })
777
791
  /** The same, minus the release-age bypass: for a fresh install pinned to a young release (#594). */
778
792
  const runPluginKeepingReleaseAge = (profile: string, args: string[]) =>
779
- withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false })
793
+ withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false, marketFlags })
794
+
795
+ /**
796
+ * The argv that rematerializes a restored manifest's build on this host.
797
+ *
798
+ * On a host that takes the market's options, one `pnpm install` does it. A
799
+ * host that runs pnpm itself — the official Desktop bridge (#732) — accepts
800
+ * only `add <target>` and refuses `install` outright, so there it is an
801
+ * `add` of the exact version the restored manifest pins, which is what that
802
+ * host's own manager pipeline materializes. A range is deliberately not
803
+ * usable: it would re-resolve to whatever is newest and call that the
804
+ * previous build.
805
+ *
806
+ * @returns null when nothing expressible is left, in which case the caller
807
+ * reports that rather than sending a command the host will refuse.
808
+ */
809
+ function rematerializeArgs(name: string, pinned: string | undefined): string[] | null {
810
+ if (marketFlags) return ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']
811
+ if (pinned !== undefined && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(pinned)) return ['add', `${name}@${pinned}`]
812
+ return null
813
+ }
814
+
815
+ /** Why a rematerialization could not even be attempted here (see above). */
816
+ function cannotRematerializeDetail(name: string, pinned: string | undefined): string {
817
+ return `这台宿主只接受按精确版本重新安装,「${name}」改动前在 profile 里写作 ${pinned ?? '(不在 profile 里)'},市场无法在它上面重建上一版 / this host can only reinstall an exact version, and "${name}" was declared as ${pinned ?? '(not in the profile)'}, so the previous build cannot be rematerialized there`
818
+ }
780
819
 
781
820
  /**
782
821
  * Undo a clean-exit update whose new build cannot boot. Restoring only the
@@ -800,7 +839,14 @@ export function mountMarketRoutes(
800
839
  // Flags come BEFORE the command: preparePluginArgs treats the last arg as
801
840
  // the package target and rejects a trailing flag, while pnpm accepts the
802
841
  // same flags in front of `install`.
803
- const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install'])
842
+ //
843
+ // On a host that runs pnpm itself neither flag exists and `install` is not
844
+ // accepted at all (#732), so there the build is rematerialized through the
845
+ // exact target — see `rematerializeArgs`.
846
+ const pinned = manifestBefore.dependencies[name]
847
+ const args = rematerializeArgs(name, pinned)
848
+ if (args === null) return { ok: false, detail: cannotRematerializeDetail(name, pinned) }
849
+ const reinstall = await runPlugin(config.profile, args)
804
850
  const ok = reinstall.exitCode === 0 && !reinstall.timedOut && !reinstall.cancelled
805
851
  if (ok) logEvent('info', 'update', `${name}: previous build rematerialized (${rolledBack.join(', ')})`)
806
852
  return { ok, detail: ok ? null : failureDetail(reinstall) }
@@ -962,7 +1008,16 @@ export function mountMarketRoutes(
962
1008
  // were replaced before the rejected update failed. A normal exact add is
963
1009
  // then an "already up to date" no-op; --force is what rematerializes the
964
1010
  // captured version/commit/archive instead of blessing corrupted bytes.
965
- const add = await runPlugin(config.profile, ['add', '--force', RELEASE_AGE_OVERRIDE, target])
1011
+ //
1012
+ // `--force` and the age override are market options, and the official
1013
+ // Desktop bridge refuses them outright (#732). There the rollback is the
1014
+ // bare exact target: the host's own manager pipeline is what installs it,
1015
+ // and sending the options anyway failed the rollback with 127 — which
1016
+ // read as "the previous build could not be verified" while node_modules
1017
+ // still held the bad build.
1018
+ const add = await runPlugin(config.profile, marketFlags
1019
+ ? ['add', '--force', RELEASE_AGE_OVERRIDE, target]
1020
+ : ['add', target])
966
1021
  // Exact recovery targets deliberately pin versions/commits. Keep the
967
1022
  // user's durable range, tag, floating github shortcut, or release URL.
968
1023
  restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
@@ -3256,7 +3311,11 @@ sendJson(response, 200, { updates })
3256
3311
  restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
3257
3312
  const prepared = restoreProfileLockfile(lockfileBefore)
3258
3313
  if (!prepared.ok) return prepared
3259
- const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install'])
3314
+ const reinstallArgs = rematerializeArgs(name, manifestBefore.dependencies[name])
3315
+ if (reinstallArgs === null) {
3316
+ return { ok: false, detail: cannotRematerializeDetail(name, manifestBefore.dependencies[name]) }
3317
+ }
3318
+ const reinstall = await runPlugin(config.profile, reinstallArgs)
3260
3319
  restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
3261
3320
  const finalLock = restoreProfileLockfile(lockfileBefore)
3262
3321
  if (!finalLock.ok) return finalLock
@@ -3727,9 +3786,16 @@ sendJson(response, 200, { updates })
3727
3786
  const reresolveInPlace = isGit && !restore && target === spec
3728
3787
  // force: the user chose to install a fresh release without the
3729
3788
  // default one-day safety wait; scoped to this single command.
3789
+ //
3790
+ // The override is a market option, so a host that runs pnpm itself
3791
+ // does not take it (#732); there the plain form goes out and the
3792
+ // host's own manager pipeline applies its release policy. That is
3793
+ // the same trade the held-back fresh install already makes: the
3794
+ // version the host admits now, with the newer one still offered by
3795
+ // the update check.
3730
3796
  const addArgs = reresolveInPlace
3731
- ? (force ? ['update', RELEASE_AGE_OVERRIDE, name] : ['update', name])
3732
- : (force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target])
3797
+ ? (force && marketFlags ? ['update', RELEASE_AGE_OVERRIDE, name] : ['update', name])
3798
+ : (force && marketFlags ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target])
3733
3799
  // Exact manifest snapshot for failure rollback (#65, #339) — the
3734
3800
  // host can write dependencies AND dsh.profile.bundles before a
3735
3801
  // hard-failed add, leaving residue that breaks the next boot.
@@ -3800,7 +3866,15 @@ sendJson(response, 200, { updates })
3800
3866
  && capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
3801
3867
  ? {
3802
3868
  available: false,
3803
- 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.`,
3804
3878
  lockfileBefore: lockfileCapture.snapshot,
3805
3879
  }
3806
3880
  : !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
@@ -5308,7 +5382,11 @@ sendJson(response, 200, { updates })
5308
5382
  // the intent the fresh path otherwise refuses to assume it has
5309
5383
  // (#594): the bypass is safe to use HERE because it is no
5310
5384
  // longer the market's idea — it is what was clicked (#635).
5311
- const bypass = heldBack && force
5385
+ // The bypass is an option this host may not accept (#732). Where
5386
+ // it is not expressible the request falls to the same place as
5387
+ // a refused bypass below: the bare name, with `heldByAge` set
5388
+ // so the row says the profile's own age policy is why.
5389
+ const bypass = heldBack && force && marketFlags
5312
5390
  logEvent('warn', 'install', bypass
5313
5391
  ? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
5314
5392
  : heldBack