dshmarket 1.66.0 → 1.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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, mergeDuplicateReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
12
12
  import { logEvent } from './log.js';
13
13
  import { cleanOrphanedStore } from './store.js';
14
14
  /**
@@ -108,8 +108,17 @@ 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;
113
122
  let result = await run(profile, pluginArgs);
114
123
  const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
115
124
  if (!ok(result) && !result.cancelled) {
@@ -127,19 +136,40 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
127
136
  }
128
137
  }
129
138
  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);
139
+ if (!marketFlags) {
140
+ unavailableOption = '--no-frozen-lockfile';
141
+ }
142
+ else {
143
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
144
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
145
+ // where a lockfile written by the old major would otherwise be refused.
146
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
147
+ if (ok(rebuild))
148
+ result = await run(profile, pluginArgs);
149
+ }
136
150
  }
137
151
  else if (failure?.code === 'release-age-violation'
138
152
  && options.releaseAgeBypass !== false
139
153
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
140
154
  && !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)]);
155
+ // #732 first, because it is the breakage itself: pnpm appends a second
156
+ // `minimumReleaseAgeExclude` rule for a package that already has one and
157
+ // then honours only the FIRST per name, so its own new entry is shadowed
158
+ // and verification fails for every later command in that profile.
159
+ // Merging the duplicates repairs the file and needs no option at all, so
160
+ // it is the one recovery that also works on the desktop bridge.
161
+ const mergedDuplicates = mergeDuplicateReleaseAgeExcludes(profile, profileDirectory);
162
+ if (mergedDuplicates.length > 0) {
163
+ logEvent('warn', 'install', `pnpm appended a second minimumReleaseAgeExclude rule for ${mergedDuplicates.join(', ')} and honours only the first, shadowing its own entry (#732) — merged the duplicates and retrying once`);
164
+ result = await run(profile, pluginArgs);
165
+ }
166
+ else if (!marketFlags) {
167
+ unavailableOption = RELEASE_AGE_OVERRIDE;
168
+ }
169
+ else {
170
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
171
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
172
+ }
143
173
  }
144
174
  else if ((failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
145
175
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
@@ -149,8 +179,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
149
179
  // and npm has never carried. Every fresh profile hits this, whatever
150
180
  // the plugin, so failing here would be failing for something the user
151
181
  // 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)]);
182
+ if (!marketFlags) {
183
+ unavailableOption = AUTO_INSTALL_PEERS_OFF;
184
+ }
185
+ else {
186
+ 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}`);
187
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)]);
188
+ }
154
189
  }
155
190
  else if (failure?.code === 'transient-network'
156
191
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
@@ -166,8 +201,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
166
201
  // Large tarball / slow network: pnpm's default 60s per-request limit
167
202
  // aborted the download. A plain retry fails again at the same limit,
168
203
  // 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)]);
204
+ if (!marketFlags) {
205
+ unavailableOption = FETCH_TIMEOUT_OVERRIDE;
206
+ }
207
+ else {
208
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
209
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
210
+ }
171
211
  }
172
212
  }
173
213
  if (!ok(result) && !result.cancelled) {
@@ -207,6 +247,16 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
207
247
  result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` };
208
248
  }
209
249
  }
250
+ if (unavailableOption !== null && !ok(result) && !result.cancelled) {
251
+ // #732: this step was skipped because the host runs pnpm itself and takes
252
+ // no options from us — but the classified message above says the market
253
+ // retried. Say that it did not, or the user reads a retry that never
254
+ // happened and looks for the wrong cause.
255
+ result = {
256
+ ...result,
257
+ 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.`,
258
+ };
259
+ }
210
260
  return result;
211
261
  }
212
262
  /**
@@ -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,116 @@ 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
+ * Merge one package's several `minimumReleaseAgeExclude` entries into one
1226
+ * (#732).
1227
+ *
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.
1238
+ *
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).
1244
+ *
1245
+ * @returns the package names whose entries were merged; empty when the file
1246
+ * needed no repair or could not be repaired, in which case it is left
1247
+ * byte-for-byte as it was.
1248
+ */
1249
+ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1250
+ const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
1251
+ let yaml;
1252
+ try {
1253
+ yaml = readFileSync(file, 'utf8');
1254
+ }
1255
+ catch {
1256
+ return [];
1257
+ }
1258
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1259
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
1260
+ const block = blockRe.exec(yaml);
1261
+ if (block === null)
1262
+ return [];
1263
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
1264
+ const indentMatch = /^([ \t]+)-/.exec(block[1]);
1265
+ const indent = indentMatch === null ? ' ' : indentMatch[1];
1266
+ const entries = [];
1267
+ for (const line of block[1].split(/\r?\n/)) {
1268
+ if (line.trim() === '')
1269
+ continue;
1270
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
1271
+ // A `#` on the line is a comment this cannot re-emit without losing it.
1272
+ if (m === null || m[1].includes('#'))
1273
+ return [];
1274
+ const rule = splitReleaseAgeExclude(m[1]);
1275
+ if (rule === null)
1276
+ return [];
1277
+ entries.push({ rule, quoted: /^['"]/.test(m[1]) });
1278
+ }
1279
+ const byName = new Map();
1280
+ const order = [];
1281
+ for (const { rule, quoted } of entries) {
1282
+ let group = byName.get(rule.name);
1283
+ if (group === undefined) {
1284
+ group = { selectors: new Set(), allVersions: false, quoted: false, count: 0 };
1285
+ byName.set(rule.name, group);
1286
+ order.push(rule.name);
1287
+ }
1288
+ group.count += 1;
1289
+ group.quoted = group.quoted || quoted;
1290
+ if (rule.selector === null)
1291
+ group.allVersions = true;
1292
+ else
1293
+ group.selectors.add(rule.selector);
1294
+ }
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 => {
1299
+ const group = byName.get(name);
1300
+ if (group === undefined)
1301
+ return '';
1302
+ const text = group.allVersions ? name : `${name}@${[...group.selectors].join(' || ')}`;
1303
+ // `@` cannot start a plain scalar in YAML, so a scoped name is written
1304
+ // quoted — the way pnpm itself writes one.
1305
+ const quoted = group.quoted || text.startsWith('@');
1306
+ return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`;
1307
+ });
1308
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
1309
+ // A function replacement: `$&` and friends in a string replacement would
1310
+ // be read as capture references.
1311
+ writeFileSync(file, yaml.replace(blockRe, () => blockText));
1312
+ return merged;
1313
+ }
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.
@@ -5117,7 +5183,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5117
5183
  // the intent the fresh path otherwise refuses to assume it has
5118
5184
  // (#594): the bypass is safe to use HERE because it is no
5119
5185
  // longer the market's idea — it is what was clicked (#635).
5120
- const bypass = heldBack && force;
5186
+ // The bypass is an option this host may not accept (#732). Where
5187
+ // it is not expressible the request falls to the same place as
5188
+ // a refused bypass below: the bare name, with `heldByAge` set
5189
+ // so the row says the profile's own age policy is why.
5190
+ const bypass = heldBack && force && marketFlags;
5121
5191
  logEvent('warn', 'install', bypass
5122
5192
  ? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
5123
5193
  : 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,29 @@ 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
+ * Merge one package's several `minimumReleaseAgeExclude` entries into one
355
+ * (#732).
356
+ *
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.
367
+ *
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).
373
+ *
374
+ * @returns the package names whose entries were merged; empty when the file
375
+ * needed no repair or could not be repaired, in which case it is left
376
+ * byte-for-byte as it was.
377
+ */
378
+ export declare function mergeDuplicateReleaseAgeExcludes(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.1",
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"
@@ -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
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, mergeDuplicateReleaseAgeExcludes, 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,23 @@ 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
129
138
  let result = await run(profile, pluginArgs)
130
139
  const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
131
140
  if (!ok(result) && !result.cancelled) {
@@ -142,19 +151,37 @@ export async function withHoistRecovery(
142
151
  result = await run(profile, pluginArgs)
143
152
  }
144
153
  } 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)
154
+ if (!marketFlags) {
155
+ unavailableOption = '--no-frozen-lockfile'
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)) result = await run(profile, pluginArgs)
162
+ }
150
163
  } else if (
151
164
  failure?.code === 'release-age-violation'
152
165
  && options.releaseAgeBypass !== false
153
166
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
154
167
  && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)
155
168
  ) {
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)])
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`)
178
+ result = await run(profile, pluginArgs)
179
+ } else if (!marketFlags) {
180
+ unavailableOption = RELEASE_AGE_OVERRIDE
181
+ } else {
182
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
183
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
184
+ }
158
185
  } else if (
159
186
  (failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
160
187
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
@@ -165,8 +192,12 @@ export async function withHoistRecovery(
165
192
  // and npm has never carried. Every fresh profile hits this, whatever
166
193
  // the plugin, so failing here would be failing for something the user
167
194
  // 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)])
195
+ if (!marketFlags) {
196
+ unavailableOption = AUTO_INSTALL_PEERS_OFF
197
+ } else {
198
+ 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}`)
199
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)])
200
+ }
170
201
  } else if (
171
202
  failure?.code === 'transient-network'
172
203
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
@@ -184,8 +215,12 @@ export async function withHoistRecovery(
184
215
  // Large tarball / slow network: pnpm's default 60s per-request limit
185
216
  // aborted the download. A plain retry fails again at the same limit,
186
217
  // 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)])
218
+ if (!marketFlags) {
219
+ unavailableOption = FETCH_TIMEOUT_OVERRIDE
220
+ } else {
221
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
222
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
223
+ }
189
224
  }
190
225
  }
191
226
  if (!ok(result) && !result.cancelled) {
@@ -223,6 +258,16 @@ export async function withHoistRecovery(
223
258
  result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` }
224
259
  }
225
260
  }
261
+ if (unavailableOption !== null && !ok(result) && !result.cancelled) {
262
+ // #732: this step was skipped because the host runs pnpm itself and takes
263
+ // no options from us — but the classified message above says the market
264
+ // retried. Say that it did not, or the user reads a retry that never
265
+ // happened and looks for the wrong cause.
266
+ result = {
267
+ ...result,
268
+ 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.`,
269
+ }
270
+ }
226
271
  return result
227
272
  }
228
273
 
@@ -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,110 @@ 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
+ * Merge one package's several `minimumReleaseAgeExclude` entries into one
1273
+ * (#732).
1274
+ *
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.
1285
+ *
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).
1291
+ *
1292
+ * @returns the package names whose entries were merged; empty when the file
1293
+ * needed no repair or could not be repaired, in which case it is left
1294
+ * byte-for-byte as it was.
1295
+ */
1296
+ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?: string): string[] {
1297
+ const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
1298
+ let yaml: string
1299
+ try { yaml = readFileSync(file, 'utf8') } catch { return [] }
1300
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1301
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m
1302
+ const block = blockRe.exec(yaml)
1303
+ if (block === null) return []
1304
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
1305
+ const indentMatch = /^([ \t]+)-/.exec(block[1])
1306
+ const indent = indentMatch === null ? ' ' : indentMatch[1]
1307
+ const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean }[] = []
1308
+ for (const line of block[1].split(/\r?\n/)) {
1309
+ if (line.trim() === '') continue
1310
+ const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line)
1311
+ // A `#` on the line is a comment this cannot re-emit without losing it.
1312
+ if (m === null || m[1].includes('#')) return []
1313
+ const rule = splitReleaseAgeExclude(m[1])
1314
+ if (rule === null) return []
1315
+ entries.push({ rule, quoted: /^['"]/.test(m[1]) })
1316
+ }
1317
+ const byName = new Map<string, { selectors: Set<string>; allVersions: boolean; quoted: boolean; count: number }>()
1318
+ const order: string[] = []
1319
+ for (const { rule, quoted } of entries) {
1320
+ let group = byName.get(rule.name)
1321
+ if (group === undefined) {
1322
+ group = { selectors: new Set(), allVersions: false, quoted: false, count: 0 }
1323
+ byName.set(rule.name, group)
1324
+ order.push(rule.name)
1325
+ }
1326
+ group.count += 1
1327
+ group.quoted = group.quoted || quoted
1328
+ if (rule.selector === null) group.allVersions = true
1329
+ else group.selectors.add(rule.selector)
1330
+ }
1331
+ const merged = order.filter(name => (byName.get(name)?.count ?? 0) > 1)
1332
+ if (merged.length === 0) return []
1333
+ const lines = order.map(name => {
1334
+ const group = byName.get(name)
1335
+ if (group === undefined) return ''
1336
+ const text = group.allVersions ? name : `${name}@${[...group.selectors].join(' || ')}`
1337
+ // `@` cannot start a plain scalar in YAML, so a scoped name is written
1338
+ // quoted — the way pnpm itself writes one.
1339
+ const quoted = group.quoted || text.startsWith('@')
1340
+ return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`
1341
+ })
1342
+ const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
1343
+ // A function replacement: `$&` and friends in a string replacement would
1344
+ // be read as capture references.
1345
+ writeFileSync(file, yaml.replace(blockRe, () => blockText))
1346
+ return merged
1347
+ }
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.
@@ -5308,7 +5374,11 @@ sendJson(response, 200, { updates })
5308
5374
  // the intent the fresh path otherwise refuses to assume it has
5309
5375
  // (#594): the bypass is safe to use HERE because it is no
5310
5376
  // longer the market's idea — it is what was clicked (#635).
5311
- const bypass = heldBack && force
5377
+ // The bypass is an option this host may not accept (#732). Where
5378
+ // it is not expressible the request falls to the same place as
5379
+ // a refused bypass below: the bare name, with `heldByAge` set
5380
+ // so the row says the profile's own age policy is why.
5381
+ const bypass = heldBack && force && marketFlags
5312
5382
  logEvent('warn', 'install', bypass
5313
5383
  ? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
5314
5384
  : heldBack