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 +63 -13
- package/lib/official-desktop.js +14 -1
- package/lib/profile.js +113 -0
- package/lib/routes.js +78 -8
- package/lib/types/dsh-cli.d.ts +13 -0
- package/lib/types/install.d.ts +7 -0
- package/lib/types/profile.d.ts +26 -0
- package/package.json +2 -2
- package/src/client/index.ts +16 -9
- package/src/dsh-cli.ts +13 -0
- package/src/install.ts +58 -13
- package/src/official-desktop.ts +14 -1
- package/src/profile.ts +107 -0
- package/src/routes.ts +78 -8
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
/**
|
package/lib/official-desktop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -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 {
|
package/lib/types/install.d.ts
CHANGED
|
@@ -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
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -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.
|
|
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"
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// "one page inside the Plugins settings section",
|
|
253
|
-
// the host's own.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
package/src/official-desktop.ts
CHANGED
|
@@ -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
|
-
|
|
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[]) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|