dshmarket 1.66.0 → 1.66.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +9 -0
- package/lib/install.js +85 -16
- package/lib/official-desktop.js +14 -1
- package/lib/profile.js +138 -0
- package/lib/routes.js +87 -9
- package/lib/types/dsh-cli.d.ts +13 -0
- package/lib/types/install.d.ts +7 -0
- package/lib/types/profile.d.ts +37 -0
- package/package.json +2 -2
- package/src/client/MarketSection.tsx +9 -0
- package/src/client/index.ts +16 -9
- package/src/client/locales.ts +4 -0
- package/src/dsh-cli.ts +13 -0
- package/src/install.ts +77 -15
- package/src/official-desktop.ts +14 -1
- package/src/profile.ts +138 -0
- package/src/routes.ts +87 -9
package/client/client.js
CHANGED
|
@@ -272,6 +272,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
272
272
|
capRedInstallScriptScripts: "安装时会立刻运行 {0},不需要你打开插件",
|
|
273
273
|
capRedCoreTamper: "会改动 DSH 自带的组件",
|
|
274
274
|
capRedCoreTamperDetail: "会改动 DSH 自带的组件({0})",
|
|
275
|
+
capRedCoreOverride: "会覆盖 DSH 自带的组件({0})",
|
|
276
|
+
capRedCoreDisable: "会禁用 DSH 自带的组件({0})",
|
|
275
277
|
favoriteAdd: "加入收藏",
|
|
276
278
|
favoriteRemove: "取消收藏",
|
|
277
279
|
favoritesEmpty: "还没有收藏。在「发现」或「主题」里点书签图标即可收藏。",
|
|
@@ -920,6 +922,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
920
922
|
capRedInstallScriptScripts: "runs {0} the moment you install — you do not have to open the plugin",
|
|
921
923
|
capRedCoreTamper: "replaces parts of DSH itself",
|
|
922
924
|
capRedCoreTamperDetail: "replaces parts of DSH itself ({0})",
|
|
925
|
+
capRedCoreOverride: "overrides a part of DSH itself ({0})",
|
|
926
|
+
capRedCoreDisable: "disables a part of DSH itself ({0})",
|
|
923
927
|
favoriteAdd: "Add to favorites",
|
|
924
928
|
favoriteRemove: "Remove from favorites",
|
|
925
929
|
favoritesEmpty: "No favorites yet. Click the bookmark icon in Discover or Themes to add one.",
|
|
@@ -9764,6 +9768,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9764
9768
|
if (literalIp !== null) return t("capRedLiteralIp").replace("{0}", literalIp[1]);
|
|
9765
9769
|
const installScript = /^runs code at install time(?: \((.+)\))?$/.exec(line);
|
|
9766
9770
|
if (installScript !== null) return installScript[1] === void 0 ? t("capRedInstallScript") : t("capRedInstallScriptScripts").replace("{0}", installScript[1]);
|
|
9771
|
+
const coreVerb = /^tampers with a core bundle \((overrides|disables) bundle (.+)\)$/.exec(line);
|
|
9772
|
+
if (coreVerb !== null) {
|
|
9773
|
+
const key = coreVerb[1] === "overrides" ? "capRedCoreOverride" : "capRedCoreDisable";
|
|
9774
|
+
return t(key).replace("{0}", coreVerb[2]);
|
|
9775
|
+
}
|
|
9767
9776
|
const coreTamper = /^tampers with a core bundle(?: \((.+)\))?$/.exec(line);
|
|
9768
9777
|
if (coreTamper !== null) return coreTamper[1] === void 0 ? t("capRedCoreTamper") : t("capRedCoreTamperDetail").replace("{0}", coreTamper[1]);
|
|
9769
9778
|
return line;
|
package/lib/install.js
CHANGED
|
@@ -8,7 +8,7 @@ import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmS
|
|
|
8
8
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
9
|
import { findDshInstallDir } from './dsh-install.js';
|
|
10
10
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
11
|
-
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
|
|
11
|
+
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, normalizeReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
|
|
12
12
|
import { logEvent } from './log.js';
|
|
13
13
|
import { cleanOrphanedStore } from './store.js';
|
|
14
14
|
/**
|
|
@@ -108,8 +108,31 @@ export function isUnpublishedHostPeer(pkg, profile, explicitDir) {
|
|
|
108
108
|
* where it is not — there the bypass would be what installs the young
|
|
109
109
|
* version, over a minimumReleaseAge the profile set on purpose — so the
|
|
110
110
|
* install route declines it and falls back to the bare name instead.
|
|
111
|
+
*
|
|
112
|
+
* `marketFlags: false` says the host runs pnpm itself and takes no options
|
|
113
|
+
* from us (the official Desktop bridge, #732). The four recoveries below
|
|
114
|
+
* each decorate a command with an option, so on such a host they are skipped
|
|
115
|
+
* rather than sent and refused — and because their classifier messages say
|
|
116
|
+
* the market retried, the note appended at the end says it did not.
|
|
111
117
|
*/
|
|
112
118
|
export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory, options = {}) {
|
|
119
|
+
const marketFlags = options.marketFlags !== false;
|
|
120
|
+
/** The option a recovery step needed and this host does not accept (#732). */
|
|
121
|
+
let unavailableOption = null;
|
|
122
|
+
// Before the FIRST run, not after a failure: the two shapes pnpm writes into
|
|
123
|
+
// `minimumReleaseAgeExclude` (a shadowed duplicate rule, #732; a version
|
|
124
|
+
// union, #733) hurt pnpm while it RESOLVES the dependency graph, and the
|
|
125
|
+
// union one aborts the process on an 80 GiB allocation with no error output
|
|
126
|
+
// at all — nothing to classify, so a repair that waited for a failure would
|
|
127
|
+
// never fire. Every verb that resolves the graph is covered, not just add and
|
|
128
|
+
// remove: an `install` or an in-place `update` consults the same key.
|
|
129
|
+
const verb = pluginArgs.find(argument => !argument.startsWith('-'));
|
|
130
|
+
if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
|
|
131
|
+
const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory);
|
|
132
|
+
if (normalized.length > 0) {
|
|
133
|
+
logEvent('warn', 'install', `minimumReleaseAgeExclude held a form pnpm cannot read back for ${normalized.join(', ')} (a shadowed duplicate rule, #732, or a version union, which pnpm 12.4.1 aborts on — #733) — rewrote each as one bare package name before running`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
113
136
|
let result = await run(profile, pluginArgs);
|
|
114
137
|
const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
|
|
115
138
|
if (!ok(result) && !result.cancelled) {
|
|
@@ -127,19 +150,45 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
else if (failure?.code === 'hoist-pattern-diff') {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
if (!marketFlags) {
|
|
154
|
+
unavailableOption = '--no-frozen-lockfile';
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
|
|
158
|
+
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
159
|
+
// where a lockfile written by the old major would otherwise be refused.
|
|
160
|
+
const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
|
|
161
|
+
if (ok(rebuild))
|
|
162
|
+
result = await run(profile, pluginArgs);
|
|
163
|
+
}
|
|
136
164
|
}
|
|
137
165
|
else if (failure?.code === 'release-age-violation'
|
|
138
|
-
&&
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
|
|
167
|
+
// The repair ran before this command, so a rewrite here means pnpm wrote
|
|
168
|
+
// one of the two broken shapes DURING it — appending a shadowed rule
|
|
169
|
+
// (#732) or extending a version union (#733). Either way the same
|
|
170
|
+
// rewrite fixes it, needs no option at all, and so also works on the
|
|
171
|
+
// desktop bridge that refuses options. Retry the SAME argv afterwards.
|
|
172
|
+
const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory);
|
|
173
|
+
if (normalized.length > 0) {
|
|
174
|
+
logEvent('warn', 'install', `pnpm wrote a minimumReleaseAgeExclude entry it cannot read back for ${normalized.join(', ')} (#732/#733) — rewrote each as one bare package name and retrying once`);
|
|
175
|
+
result = await run(profile, pluginArgs);
|
|
176
|
+
}
|
|
177
|
+
else if (options.releaseAgeBypass === false) {
|
|
178
|
+
// The caller declined the bypass (#594), and the duplicates were not
|
|
179
|
+
// what failed: this violation is the profile's own policy doing its
|
|
180
|
+
// job, so the command stands as it is.
|
|
181
|
+
}
|
|
182
|
+
else if (!marketFlags) {
|
|
183
|
+
unavailableOption = RELEASE_AGE_OVERRIDE;
|
|
184
|
+
}
|
|
185
|
+
else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
|
|
186
|
+
// Already carrying it (a caller's forced retry): nothing left to add.
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
|
|
190
|
+
result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
|
|
191
|
+
}
|
|
143
192
|
}
|
|
144
193
|
else if ((failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
|
|
145
194
|
&& isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
|
|
@@ -149,8 +198,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
149
198
|
// and npm has never carried. Every fresh profile hits this, whatever
|
|
150
199
|
// the plugin, so failing here would be failing for something the user
|
|
151
200
|
// cannot fix and did not cause.
|
|
152
|
-
|
|
153
|
-
|
|
201
|
+
if (!marketFlags) {
|
|
202
|
+
unavailableOption = AUTO_INSTALL_PEERS_OFF;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`);
|
|
206
|
+
result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)]);
|
|
207
|
+
}
|
|
154
208
|
}
|
|
155
209
|
else if (failure?.code === 'transient-network'
|
|
156
210
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
|
|
@@ -166,8 +220,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
166
220
|
// Large tarball / slow network: pnpm's default 60s per-request limit
|
|
167
221
|
// aborted the download. A plain retry fails again at the same limit,
|
|
168
222
|
// so retry once with a longer fetchTimeout.
|
|
169
|
-
|
|
170
|
-
|
|
223
|
+
if (!marketFlags) {
|
|
224
|
+
unavailableOption = FETCH_TIMEOUT_OVERRIDE;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
|
|
228
|
+
result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
|
|
229
|
+
}
|
|
171
230
|
}
|
|
172
231
|
}
|
|
173
232
|
if (!ok(result) && !result.cancelled) {
|
|
@@ -207,6 +266,16 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
207
266
|
result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` };
|
|
208
267
|
}
|
|
209
268
|
}
|
|
269
|
+
if (unavailableOption !== null && !ok(result) && !result.cancelled) {
|
|
270
|
+
// #732: this step was skipped because the host runs pnpm itself and takes
|
|
271
|
+
// no options from us — but the classified message above says the market
|
|
272
|
+
// retried. Say that it did not, or the user reads a retry that never
|
|
273
|
+
// happened and looks for the wrong cause.
|
|
274
|
+
result = {
|
|
275
|
+
...result,
|
|
276
|
+
stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
210
279
|
return result;
|
|
211
280
|
}
|
|
212
281
|
/**
|
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,141 @@ export function dropUnparseableBuildKeys(profile, explicitDir) {
|
|
|
1198
1198
|
writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch);
|
|
1199
1199
|
return removed;
|
|
1200
1200
|
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Split `name@1.2.3 || 1.4.0` into its package name and selector.
|
|
1203
|
+
*
|
|
1204
|
+
* A scoped name begins with `@`, so the separator is the first `@` AFTER
|
|
1205
|
+
* position 0; an entry with no `@` at all excludes every version of that
|
|
1206
|
+
* name. Anything this cannot read exactly returns null, and the caller then
|
|
1207
|
+
* leaves the file alone rather than rewriting a line it misread.
|
|
1208
|
+
*/
|
|
1209
|
+
function splitReleaseAgeExclude(entry) {
|
|
1210
|
+
let text = entry.trim();
|
|
1211
|
+
if (text.length >= 2
|
|
1212
|
+
&& (text[0] === "'" && text[text.length - 1] === "'" || text[0] === '"' && text[text.length - 1] === '"')) {
|
|
1213
|
+
text = text.slice(1, -1);
|
|
1214
|
+
}
|
|
1215
|
+
const at = text.indexOf('@', text.startsWith('@') ? 1 : 0);
|
|
1216
|
+
if (at === -1)
|
|
1217
|
+
return text === '' ? null : { name: text, selector: null };
|
|
1218
|
+
const name = text.slice(0, at);
|
|
1219
|
+
const selector = text.slice(at + 1).trim();
|
|
1220
|
+
if (name === '' || selector === '')
|
|
1221
|
+
return null;
|
|
1222
|
+
return { name, selector };
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
|
|
1226
|
+
*
|
|
1227
|
+
* pnpm WRITES this key in forms it then mishandles, and two separate defects
|
|
1228
|
+
* come out of that:
|
|
1229
|
+
*
|
|
1230
|
+
* - pnpm 11.7.0 appends a second rule for a package that already has one,
|
|
1231
|
+
* while its `evaluateVersionPolicy` honours only the FIRST rule per name.
|
|
1232
|
+
* Its own new entry is therefore dead, and every later command in that
|
|
1233
|
+
* profile fails lockfile verification with
|
|
1234
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
|
|
1235
|
+
* nothing to do with that package (#732).
|
|
1236
|
+
* - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
|
|
1237
|
+
* union, a form its OWN validator rejects ("Invalid versions union … Use
|
|
1238
|
+
* exact versions only"), and evaluating one aborts the process on a single
|
|
1239
|
+
* 80 GiB allocation that takes the machine down for minutes (#733). The
|
|
1240
|
+
* entries are evaluated lazily, so this fires on any later install that
|
|
1241
|
+
* age-checks that package, with no pnpm error output to explain it.
|
|
1242
|
+
*
|
|
1243
|
+
* Both are avoided by writing each such package as ONE BARE NAME. A bare name
|
|
1244
|
+
* cannot be shadowed (the first rule for it is already all of it), it stops
|
|
1245
|
+
* pnpm's auto-collect for that package outright, and it carries no union for
|
|
1246
|
+
* the parser to evaluate. It is also a wider statement than a version list —
|
|
1247
|
+
* the package stops being age-gated at all — so it is spent only on entries
|
|
1248
|
+
* pnpm has already written in one of those two broken forms, never on a
|
|
1249
|
+
* package the file lists as a single exact version. A pure duplicate of one
|
|
1250
|
+
* exact version collapses to that one line, which changes no policy at all.
|
|
1251
|
+
*
|
|
1252
|
+
* A file holding nothing but bare names and single exact versions is left
|
|
1253
|
+
* untouched, as is one whose block this cannot read exactly (a flow list, an
|
|
1254
|
+
* inline comment, a line it would have to guess at).
|
|
1255
|
+
*
|
|
1256
|
+
* @returns the package names whose entry was rewritten; empty when the file
|
|
1257
|
+
* needed no repair or could not be repaired, in which case it is left
|
|
1258
|
+
* byte-for-byte as it was.
|
|
1259
|
+
*/
|
|
1260
|
+
export function normalizeReleaseAgeExcludes(profile, explicitDir) {
|
|
1261
|
+
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
|
|
1262
|
+
let yaml;
|
|
1263
|
+
try {
|
|
1264
|
+
yaml = readFileSync(file, 'utf8');
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return [];
|
|
1268
|
+
}
|
|
1269
|
+
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
|
|
1270
|
+
const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
|
|
1271
|
+
const block = blockRe.exec(yaml);
|
|
1272
|
+
if (block === null)
|
|
1273
|
+
return [];
|
|
1274
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
1275
|
+
const indentMatch = /^([ \t]+)-/.exec(block[1]);
|
|
1276
|
+
const indent = indentMatch === null ? ' ' : indentMatch[1];
|
|
1277
|
+
const entries = [];
|
|
1278
|
+
for (const line of block[1].split(/\r?\n/)) {
|
|
1279
|
+
if (line.trim() === '')
|
|
1280
|
+
continue;
|
|
1281
|
+
const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
1282
|
+
// A `#` on the line is a comment this cannot re-emit without losing it.
|
|
1283
|
+
if (m === null || m[1].includes('#'))
|
|
1284
|
+
return [];
|
|
1285
|
+
const rule = splitReleaseAgeExclude(m[1]);
|
|
1286
|
+
if (rule === null)
|
|
1287
|
+
return [];
|
|
1288
|
+
entries.push({ rule, quoted: /^['"]/.test(m[1]), line });
|
|
1289
|
+
}
|
|
1290
|
+
const byName = new Map();
|
|
1291
|
+
const order = [];
|
|
1292
|
+
for (const { rule, quoted, line } of entries) {
|
|
1293
|
+
let group = byName.get(rule.name);
|
|
1294
|
+
if (group === undefined) {
|
|
1295
|
+
group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 };
|
|
1296
|
+
byName.set(rule.name, group);
|
|
1297
|
+
order.push(rule.name);
|
|
1298
|
+
}
|
|
1299
|
+
group.count += 1;
|
|
1300
|
+
group.quoted = group.quoted || quoted;
|
|
1301
|
+
group.originals.push(rule.selector ?? '');
|
|
1302
|
+
group.lines.push(line);
|
|
1303
|
+
if (rule.selector === null)
|
|
1304
|
+
group.allVersions = true;
|
|
1305
|
+
else
|
|
1306
|
+
group.selectors.add(rule.selector);
|
|
1307
|
+
}
|
|
1308
|
+
/** The one line this package's entry is written as. */
|
|
1309
|
+
const produced = (name) => {
|
|
1310
|
+
const group = byName.get(name);
|
|
1311
|
+
if (group === undefined)
|
|
1312
|
+
return '';
|
|
1313
|
+
// A bare name already covers every version, so it stays bare; a union or
|
|
1314
|
+
// several rules for one name cannot both be read, so they become one.
|
|
1315
|
+
const bare = group.allVersions
|
|
1316
|
+
|| group.originals.some(selector => selector.includes('||'))
|
|
1317
|
+
|| group.selectors.size > 1;
|
|
1318
|
+
const text = bare ? name : `${name}@${[...group.selectors][0]}`;
|
|
1319
|
+
// `@` cannot start a plain scalar in YAML, so a scoped name is written
|
|
1320
|
+
// quoted — the way pnpm itself writes one.
|
|
1321
|
+
const quoted = group.quoted || text.startsWith('@');
|
|
1322
|
+
return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`;
|
|
1323
|
+
};
|
|
1324
|
+
const rewritten = order.filter(name => {
|
|
1325
|
+
const group = byName.get(name);
|
|
1326
|
+
if (group === undefined)
|
|
1327
|
+
return false;
|
|
1328
|
+
return group.count > 1 || group.lines[0] !== produced(name);
|
|
1329
|
+
});
|
|
1330
|
+
if (rewritten.length === 0)
|
|
1331
|
+
return [];
|
|
1332
|
+
const lines = order.map(produced);
|
|
1333
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
1334
|
+
// A function replacement: `$&` and friends in a string replacement would
|
|
1335
|
+
// be read as capture references.
|
|
1336
|
+
writeFileSync(file, yaml.replace(blockRe, () => blockText));
|
|
1337
|
+
return rewritten;
|
|
1338
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -259,6 +259,19 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
259
259
|
// re-applies the same choice on every boot (ported from dsh-plugin-hub).
|
|
260
260
|
const userPatchPath = findUserPatchPath(host, activeProfileDir);
|
|
261
261
|
const commands = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive };
|
|
262
|
+
/**
|
|
263
|
+
* Whether this host runs pnpm itself and so takes the market's own options
|
|
264
|
+
* (`--config.*`, `--force`, `--no-frozen-lockfile`).
|
|
265
|
+
*
|
|
266
|
+
* The official Desktop bridge does not (#732): its in-process manager
|
|
267
|
+
* accepts exactly `add <target>` or `remove <target>` and answers anything
|
|
268
|
+
* else with exit 127. The market's recovery steps decorate commands with
|
|
269
|
+
* those options, so on such a host they have to be left out or rewritten as
|
|
270
|
+
* a bare exact target, instead of being sent and refused — which reported a
|
|
271
|
+
* supported operation as unsupported and sent the reporter looking for a
|
|
272
|
+
* broken profile.
|
|
273
|
+
*/
|
|
274
|
+
const marketFlags = commands.acceptsMarketPnpmFlags !== false;
|
|
262
275
|
const supportsExactRollbackTarget = (target) => commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target);
|
|
263
276
|
// Point every plugin build/install spawn at the configured build
|
|
264
277
|
// environment (#336). Read LIVE from `config.buildEnv` because the settings
|
|
@@ -688,9 +701,34 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
688
701
|
}
|
|
689
702
|
}
|
|
690
703
|
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
691
|
-
const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir);
|
|
704
|
+
const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { marketFlags });
|
|
692
705
|
/** The same, minus the release-age bypass: for a fresh install pinned to a young release (#594). */
|
|
693
|
-
const runPluginKeepingReleaseAge = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false });
|
|
706
|
+
const runPluginKeepingReleaseAge = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir, { releaseAgeBypass: false, marketFlags });
|
|
707
|
+
/**
|
|
708
|
+
* The argv that rematerializes a restored manifest's build on this host.
|
|
709
|
+
*
|
|
710
|
+
* On a host that takes the market's options, one `pnpm install` does it. A
|
|
711
|
+
* host that runs pnpm itself — the official Desktop bridge (#732) — accepts
|
|
712
|
+
* only `add <target>` and refuses `install` outright, so there it is an
|
|
713
|
+
* `add` of the exact version the restored manifest pins, which is what that
|
|
714
|
+
* host's own manager pipeline materializes. A range is deliberately not
|
|
715
|
+
* usable: it would re-resolve to whatever is newest and call that the
|
|
716
|
+
* previous build.
|
|
717
|
+
*
|
|
718
|
+
* @returns null when nothing expressible is left, in which case the caller
|
|
719
|
+
* reports that rather than sending a command the host will refuse.
|
|
720
|
+
*/
|
|
721
|
+
function rematerializeArgs(name, pinned) {
|
|
722
|
+
if (marketFlags)
|
|
723
|
+
return ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install'];
|
|
724
|
+
if (pinned !== undefined && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(pinned))
|
|
725
|
+
return ['add', `${name}@${pinned}`];
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
/** Why a rematerialization could not even be attempted here (see above). */
|
|
729
|
+
function cannotRematerializeDetail(name, pinned) {
|
|
730
|
+
return `这台宿主只接受按精确版本重新安装,「${name}」改动前在 profile 里写作 ${pinned ?? '(不在 profile 里)'},市场无法在它上面重建上一版 / this host can only reinstall an exact version, and "${name}" was declared as ${pinned ?? '(not in the profile)'}, so the previous build cannot be rematerialized there`;
|
|
731
|
+
}
|
|
694
732
|
/**
|
|
695
733
|
* Undo a clean-exit update whose new build cannot boot. Restoring only the
|
|
696
734
|
* manifest pin (the original #159 behavior) leaves the bad package files
|
|
@@ -710,7 +748,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
710
748
|
// Flags come BEFORE the command: preparePluginArgs treats the last arg as
|
|
711
749
|
// the package target and rejects a trailing flag, while pnpm accepts the
|
|
712
750
|
// same flags in front of `install`.
|
|
713
|
-
|
|
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.
|
|
@@ -3625,7 +3691,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3625
3691
|
&& capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
|
|
3626
3692
|
? {
|
|
3627
3693
|
available: false,
|
|
3628
|
-
|
|
3694
|
+
// Name all three: the version on disk, the version
|
|
3695
|
+
// the lockfile records, and the one a rollback
|
|
3696
|
+
// would install (#732). The old wording named the
|
|
3697
|
+
// first two only by implication, and a user who
|
|
3698
|
+
// reads "the exact source cannot be proven" has
|
|
3699
|
+
// nothing to act on — the reproducer's way out was
|
|
3700
|
+
// to align package.json with the installed build,
|
|
3701
|
+
// which is what this now says.
|
|
3702
|
+
detail: `更新前 node_modules 里装的是 v${beforeVersion},但 pnpm-lock.yaml 里 ${name} 记的是 ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? '(没有记录)'},两者不一致,无法证明精确来源,因此自动回滚不可用(要回滚的是 ${name}@${beforeVersion})。需要时请手工重新安装 ${name}@${beforeVersion},或先把 package.json 与 pnpm-lock.yaml 对齐到 v${beforeVersion}。 / The build on disk before the update was v${beforeVersion}, but pnpm-lock.yaml records ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? 'nothing'} for ${name}: they disagree, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed, or align package.json and pnpm-lock.yaml with v${beforeVersion} first.`,
|
|
3629
3703
|
lockfileBefore: lockfileCapture.snapshot,
|
|
3630
3704
|
}
|
|
3631
3705
|
: !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
|
|
@@ -5117,7 +5191,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5117
5191
|
// the intent the fresh path otherwise refuses to assume it has
|
|
5118
5192
|
// (#594): the bypass is safe to use HERE because it is no
|
|
5119
5193
|
// longer the market's idea — it is what was clicked (#635).
|
|
5120
|
-
|
|
5194
|
+
// The bypass is an option this host may not accept (#732). Where
|
|
5195
|
+
// it is not expressible the request falls to the same place as
|
|
5196
|
+
// a refused bypass below: the bare name, with `heldByAge` set
|
|
5197
|
+
// so the row says the profile's own age policy is why.
|
|
5198
|
+
const bypass = heldBack && force && marketFlags;
|
|
5121
5199
|
logEvent('warn', 'install', bypass
|
|
5122
5200
|
? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
|
|
5123
5201
|
: heldBack
|
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,40 @@ export declare function setAllowBuilds(profile: string, packages: string[], expl
|
|
|
350
350
|
* file is left untouched.
|
|
351
351
|
*/
|
|
352
352
|
export declare function dropUnparseableBuildKeys(profile: string, explicitDir?: string): string[];
|
|
353
|
+
/**
|
|
354
|
+
* Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
|
|
355
|
+
*
|
|
356
|
+
* pnpm WRITES this key in forms it then mishandles, and two separate defects
|
|
357
|
+
* come out of that:
|
|
358
|
+
*
|
|
359
|
+
* - pnpm 11.7.0 appends a second rule for a package that already has one,
|
|
360
|
+
* while its `evaluateVersionPolicy` honours only the FIRST rule per name.
|
|
361
|
+
* Its own new entry is therefore dead, and every later command in that
|
|
362
|
+
* profile fails lockfile verification with
|
|
363
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
|
|
364
|
+
* nothing to do with that package (#732).
|
|
365
|
+
* - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
|
|
366
|
+
* union, a form its OWN validator rejects ("Invalid versions union … Use
|
|
367
|
+
* exact versions only"), and evaluating one aborts the process on a single
|
|
368
|
+
* 80 GiB allocation that takes the machine down for minutes (#733). The
|
|
369
|
+
* entries are evaluated lazily, so this fires on any later install that
|
|
370
|
+
* age-checks that package, with no pnpm error output to explain it.
|
|
371
|
+
*
|
|
372
|
+
* Both are avoided by writing each such package as ONE BARE NAME. A bare name
|
|
373
|
+
* cannot be shadowed (the first rule for it is already all of it), it stops
|
|
374
|
+
* pnpm's auto-collect for that package outright, and it carries no union for
|
|
375
|
+
* the parser to evaluate. It is also a wider statement than a version list —
|
|
376
|
+
* the package stops being age-gated at all — so it is spent only on entries
|
|
377
|
+
* pnpm has already written in one of those two broken forms, never on a
|
|
378
|
+
* package the file lists as a single exact version. A pure duplicate of one
|
|
379
|
+
* exact version collapses to that one line, which changes no policy at all.
|
|
380
|
+
*
|
|
381
|
+
* A file holding nothing but bare names and single exact versions is left
|
|
382
|
+
* untouched, as is one whose block this cannot read exactly (a flow list, an
|
|
383
|
+
* inline comment, a line it would have to guess at).
|
|
384
|
+
*
|
|
385
|
+
* @returns the package names whose entry was rewritten; empty when the file
|
|
386
|
+
* needed no repair or could not be repaired, in which case it is left
|
|
387
|
+
* byte-for-byte as it was.
|
|
388
|
+
*/
|
|
389
|
+
export declare function normalizeReleaseAgeExcludes(profile: string, explicitDir?: string): string[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.66.
|
|
4
|
+
"version": "1.66.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"prepack": "npm run build && node scripts/preflight.mjs",
|
|
31
31
|
"prepare": "npm run build",
|
|
32
32
|
"validate:registry": "node scripts/validate-registry.mjs",
|
|
33
|
-
"snapshot": "curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json && curl -sf https://awesome-dsh-plugin.com/readmes.json -o data/readmes-snapshot.json",
|
|
33
|
+
"snapshot": "mkdir -p data && curl -sf https://awesome-dsh-plugin.com/plugins.json -o data/registry-snapshot.json && curl -sf https://awesome-dsh-plugin.com/readmes.json -o data/readmes-snapshot.json",
|
|
34
34
|
"check:web-auth-capture": "node scripts/check-web-auth-capture.mjs",
|
|
35
35
|
"test:web": "node scripts/check-web-auth-capture.mjs && vitest run --config vitest.web.config.ts",
|
|
36
36
|
"build:site": "node scripts/build-site.mjs"
|
|
@@ -3980,6 +3980,15 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3980
3980
|
? t('capRedInstallScript')
|
|
3981
3981
|
: t('capRedInstallScriptScripts').replace('{0}', installScript[1])
|
|
3982
3982
|
}
|
|
3983
|
+
// The parenthesised detail is not a name. The scanner emits exactly two
|
|
3984
|
+
// shapes — "overrides bundle <id>" and "disables bundle <id>" — and pasting
|
|
3985
|
+
// either through leaves the verb in English. That verb is the fact: the
|
|
3986
|
+
// install would replace a part of DSH, or switch one off.
|
|
3987
|
+
const coreVerb = /^tampers with a core bundle \((overrides|disables) bundle (.+)\)$/.exec(line)
|
|
3988
|
+
if (coreVerb !== null) {
|
|
3989
|
+
const key = coreVerb[1] === 'overrides' ? 'capRedCoreOverride' : 'capRedCoreDisable'
|
|
3990
|
+
return t(key).replace('{0}', coreVerb[2]!)
|
|
3991
|
+
}
|
|
3983
3992
|
const coreTamper = /^tampers with a core bundle(?: \((.+)\))?$/.exec(line)
|
|
3984
3993
|
if (coreTamper !== null) {
|
|
3985
3994
|
return coreTamper[1] === undefined
|
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/client/locales.ts
CHANGED
|
@@ -81,6 +81,8 @@ export const zh = {
|
|
|
81
81
|
capRedInstallScriptScripts: '安装时会立刻运行 {0},不需要你打开插件',
|
|
82
82
|
capRedCoreTamper: '会改动 DSH 自带的组件',
|
|
83
83
|
capRedCoreTamperDetail: '会改动 DSH 自带的组件({0})',
|
|
84
|
+
capRedCoreOverride: '会覆盖 DSH 自带的组件({0})',
|
|
85
|
+
capRedCoreDisable: '会禁用 DSH 自带的组件({0})',
|
|
84
86
|
favoriteAdd: '加入收藏',
|
|
85
87
|
favoriteRemove: '取消收藏',
|
|
86
88
|
favoritesEmpty: '还没有收藏。在「发现」或「主题」里点书签图标即可收藏。',
|
|
@@ -746,6 +748,8 @@ export const en: Record<MarketKey, string> = {
|
|
|
746
748
|
capRedInstallScriptScripts: 'runs {0} the moment you install — you do not have to open the plugin',
|
|
747
749
|
capRedCoreTamper: 'replaces parts of DSH itself',
|
|
748
750
|
capRedCoreTamperDetail: 'replaces parts of DSH itself ({0})',
|
|
751
|
+
capRedCoreOverride: 'overrides a part of DSH itself ({0})',
|
|
752
|
+
capRedCoreDisable: 'disables a part of DSH itself ({0})',
|
|
749
753
|
favoriteAdd: 'Add to favorites',
|
|
750
754
|
favoriteRemove: 'Remove from favorites',
|
|
751
755
|
favoritesEmpty: 'No favorites yet. Click the bookmark icon in Discover or Themes to add one.',
|
package/src/dsh-cli.ts
CHANGED
|
@@ -531,6 +531,19 @@ export interface PluginCommandRuntime {
|
|
|
531
531
|
cancelActive(): boolean
|
|
532
532
|
/** Whether this host can execute an immutable rollback add target. */
|
|
533
533
|
supportsExactRollbackTarget?(target: string): boolean
|
|
534
|
+
/**
|
|
535
|
+
* Whether this runtime runs pnpm itself and therefore accepts the market's
|
|
536
|
+
* own arguments — `--config.*` overrides, `--force`, `--no-frozen-lockfile`.
|
|
537
|
+
*
|
|
538
|
+
* The official Electron profile does not (#732): its in-process manager
|
|
539
|
+
* takes exactly `add <target>` or `remove <target>` and answers any other
|
|
540
|
+
* argv with exit 127. Every recovery step that decorates a command with an
|
|
541
|
+
* option is impossible there, and sending it anyway turned a pnpm failure
|
|
542
|
+
* into "this desktop operation is not supported" — a message about an
|
|
543
|
+
* operation that is in fact supported, which sent the reporter looking for
|
|
544
|
+
* a broken profile instead of the option. Absent means "accepts".
|
|
545
|
+
*/
|
|
546
|
+
acceptsMarketPnpmFlags?: boolean
|
|
534
547
|
}
|
|
535
548
|
|
|
536
549
|
/** One running package operation, however it was started. */
|
package/src/install.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
|
10
10
|
import type { InstallResult, PluginRunner } from './dsh-cli.ts'
|
|
11
11
|
import { findDshInstallDir } from './dsh-install.ts'
|
|
12
12
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE, isTransientPnpmFailure } from './pnpm-compat.ts'
|
|
13
|
-
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
|
|
13
|
+
import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, normalizeReleaseAgeExcludes, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
|
|
14
14
|
import { logEvent } from './log.ts'
|
|
15
15
|
import { cleanOrphanedStore } from './store.ts'
|
|
16
16
|
|
|
@@ -118,14 +118,37 @@ export function isUnpublishedHostPeer(
|
|
|
118
118
|
* where it is not — there the bypass would be what installs the young
|
|
119
119
|
* version, over a minimumReleaseAge the profile set on purpose — so the
|
|
120
120
|
* install route declines it and falls back to the bare name instead.
|
|
121
|
+
*
|
|
122
|
+
* `marketFlags: false` says the host runs pnpm itself and takes no options
|
|
123
|
+
* from us (the official Desktop bridge, #732). The four recoveries below
|
|
124
|
+
* each decorate a command with an option, so on such a host they are skipped
|
|
125
|
+
* rather than sent and refused — and because their classifier messages say
|
|
126
|
+
* the market retried, the note appended at the end says it did not.
|
|
121
127
|
*/
|
|
122
128
|
export async function withHoistRecovery(
|
|
123
129
|
run: PluginRunner,
|
|
124
130
|
profile: string,
|
|
125
131
|
pluginArgs: string[],
|
|
126
132
|
profileDirectory?: string,
|
|
127
|
-
options: { releaseAgeBypass?: boolean } = {},
|
|
133
|
+
options: { releaseAgeBypass?: boolean; marketFlags?: boolean } = {},
|
|
128
134
|
): Promise<InstallResult> {
|
|
135
|
+
const marketFlags = options.marketFlags !== false
|
|
136
|
+
/** The option a recovery step needed and this host does not accept (#732). */
|
|
137
|
+
let unavailableOption: string | null = null
|
|
138
|
+
// Before the FIRST run, not after a failure: the two shapes pnpm writes into
|
|
139
|
+
// `minimumReleaseAgeExclude` (a shadowed duplicate rule, #732; a version
|
|
140
|
+
// union, #733) hurt pnpm while it RESOLVES the dependency graph, and the
|
|
141
|
+
// union one aborts the process on an 80 GiB allocation with no error output
|
|
142
|
+
// at all — nothing to classify, so a repair that waited for a failure would
|
|
143
|
+
// never fire. Every verb that resolves the graph is covered, not just add and
|
|
144
|
+
// remove: an `install` or an in-place `update` consults the same key.
|
|
145
|
+
const verb = pluginArgs.find(argument => !argument.startsWith('-'))
|
|
146
|
+
if (verb === 'add' || verb === 'remove' || verb === 'install' || verb === 'update') {
|
|
147
|
+
const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory)
|
|
148
|
+
if (normalized.length > 0) {
|
|
149
|
+
logEvent('warn', 'install', `minimumReleaseAgeExclude held a form pnpm cannot read back for ${normalized.join(', ')} (a shadowed duplicate rule, #732, or a version union, which pnpm 12.4.1 aborts on — #733) — rewrote each as one bare package name before running`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
129
152
|
let result = await run(profile, pluginArgs)
|
|
130
153
|
const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
|
|
131
154
|
if (!ok(result) && !result.cancelled) {
|
|
@@ -142,19 +165,40 @@ export async function withHoistRecovery(
|
|
|
142
165
|
result = await run(profile, pluginArgs)
|
|
143
166
|
}
|
|
144
167
|
} else if (failure?.code === 'hoist-pattern-diff') {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
if (!marketFlags) {
|
|
169
|
+
unavailableOption = '--no-frozen-lockfile'
|
|
170
|
+
} else {
|
|
171
|
+
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
|
|
172
|
+
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
173
|
+
// where a lockfile written by the old major would otherwise be refused.
|
|
174
|
+
const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
|
|
175
|
+
if (ok(rebuild)) result = await run(profile, pluginArgs)
|
|
176
|
+
}
|
|
150
177
|
} else if (
|
|
151
178
|
failure?.code === 'release-age-violation'
|
|
152
|
-
&& options.releaseAgeBypass !== false
|
|
153
179
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
154
|
-
&& !pluginArgs.includes(RELEASE_AGE_OVERRIDE)
|
|
155
180
|
) {
|
|
156
|
-
|
|
157
|
-
|
|
181
|
+
// The repair ran before this command, so a rewrite here means pnpm wrote
|
|
182
|
+
// one of the two broken shapes DURING it — appending a shadowed rule
|
|
183
|
+
// (#732) or extending a version union (#733). Either way the same
|
|
184
|
+
// rewrite fixes it, needs no option at all, and so also works on the
|
|
185
|
+
// desktop bridge that refuses options. Retry the SAME argv afterwards.
|
|
186
|
+
const normalized = normalizeReleaseAgeExcludes(profile, profileDirectory)
|
|
187
|
+
if (normalized.length > 0) {
|
|
188
|
+
logEvent('warn', 'install', `pnpm wrote a minimumReleaseAgeExclude entry it cannot read back for ${normalized.join(', ')} (#732/#733) — rewrote each as one bare package name and retrying once`)
|
|
189
|
+
result = await run(profile, pluginArgs)
|
|
190
|
+
} else if (options.releaseAgeBypass === false) {
|
|
191
|
+
// The caller declined the bypass (#594), and the duplicates were not
|
|
192
|
+
// what failed: this violation is the profile's own policy doing its
|
|
193
|
+
// job, so the command stands as it is.
|
|
194
|
+
} else if (!marketFlags) {
|
|
195
|
+
unavailableOption = RELEASE_AGE_OVERRIDE
|
|
196
|
+
} else if (pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
|
|
197
|
+
// Already carrying it (a caller's forced retry): nothing left to add.
|
|
198
|
+
} else {
|
|
199
|
+
logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
|
|
200
|
+
result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
|
|
201
|
+
}
|
|
158
202
|
} else if (
|
|
159
203
|
(failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
|
|
160
204
|
&& isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
|
|
@@ -165,8 +209,12 @@ export async function withHoistRecovery(
|
|
|
165
209
|
// and npm has never carried. Every fresh profile hits this, whatever
|
|
166
210
|
// the plugin, so failing here would be failing for something the user
|
|
167
211
|
// cannot fix and did not cause.
|
|
168
|
-
|
|
169
|
-
|
|
212
|
+
if (!marketFlags) {
|
|
213
|
+
unavailableOption = AUTO_INSTALL_PEERS_OFF
|
|
214
|
+
} else {
|
|
215
|
+
logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`)
|
|
216
|
+
result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)])
|
|
217
|
+
}
|
|
170
218
|
} else if (
|
|
171
219
|
failure?.code === 'transient-network'
|
|
172
220
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
@@ -184,8 +232,12 @@ export async function withHoistRecovery(
|
|
|
184
232
|
// Large tarball / slow network: pnpm's default 60s per-request limit
|
|
185
233
|
// aborted the download. A plain retry fails again at the same limit,
|
|
186
234
|
// so retry once with a longer fetchTimeout.
|
|
187
|
-
|
|
188
|
-
|
|
235
|
+
if (!marketFlags) {
|
|
236
|
+
unavailableOption = FETCH_TIMEOUT_OVERRIDE
|
|
237
|
+
} else {
|
|
238
|
+
logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
|
|
239
|
+
result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
|
|
240
|
+
}
|
|
189
241
|
}
|
|
190
242
|
}
|
|
191
243
|
if (!ok(result) && !result.cancelled) {
|
|
@@ -223,6 +275,16 @@ export async function withHoistRecovery(
|
|
|
223
275
|
result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` }
|
|
224
276
|
}
|
|
225
277
|
}
|
|
278
|
+
if (unavailableOption !== null && !ok(result) && !result.cancelled) {
|
|
279
|
+
// #732: this step was skipped because the host runs pnpm itself and takes
|
|
280
|
+
// no options from us — but the classified message above says the market
|
|
281
|
+
// retried. Say that it did not, or the user reads a retry that never
|
|
282
|
+
// happened and looks for the wrong cause.
|
|
283
|
+
result = {
|
|
284
|
+
...result,
|
|
285
|
+
stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
226
288
|
return result
|
|
227
289
|
}
|
|
228
290
|
|
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,141 @@ export function dropUnparseableBuildKeys(profile: string, explicitDir?: string):
|
|
|
1238
1238
|
writeAllowBuildsMap(file, yaml, map, blockRe, blockMatch)
|
|
1239
1239
|
return removed
|
|
1240
1240
|
}
|
|
1241
|
+
|
|
1242
|
+
/** One `minimumReleaseAgeExclude` entry, split into its name and its versions. */
|
|
1243
|
+
interface ReleaseAgeExcludeRule {
|
|
1244
|
+
name: string
|
|
1245
|
+
/** The version union exactly as written, or null when the entry names no version. */
|
|
1246
|
+
selector: string | null
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Split `name@1.2.3 || 1.4.0` into its package name and selector.
|
|
1251
|
+
*
|
|
1252
|
+
* A scoped name begins with `@`, so the separator is the first `@` AFTER
|
|
1253
|
+
* position 0; an entry with no `@` at all excludes every version of that
|
|
1254
|
+
* name. Anything this cannot read exactly returns null, and the caller then
|
|
1255
|
+
* leaves the file alone rather than rewriting a line it misread.
|
|
1256
|
+
*/
|
|
1257
|
+
function splitReleaseAgeExclude(entry: string): ReleaseAgeExcludeRule | null {
|
|
1258
|
+
let text = entry.trim()
|
|
1259
|
+
if (text.length >= 2
|
|
1260
|
+
&& (text[0] === "'" && text[text.length - 1] === "'" || text[0] === '"' && text[text.length - 1] === '"')) {
|
|
1261
|
+
text = text.slice(1, -1)
|
|
1262
|
+
}
|
|
1263
|
+
const at = text.indexOf('@', text.startsWith('@') ? 1 : 0)
|
|
1264
|
+
if (at === -1) return text === '' ? null : { name: text, selector: null }
|
|
1265
|
+
const name = text.slice(0, at)
|
|
1266
|
+
const selector = text.slice(at + 1).trim()
|
|
1267
|
+
if (name === '' || selector === '') return null
|
|
1268
|
+
return { name, selector }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Make a profile's `minimumReleaseAgeExclude` readable again (#732, #733).
|
|
1273
|
+
*
|
|
1274
|
+
* pnpm WRITES this key in forms it then mishandles, and two separate defects
|
|
1275
|
+
* come out of that:
|
|
1276
|
+
*
|
|
1277
|
+
* - pnpm 11.7.0 appends a second rule for a package that already has one,
|
|
1278
|
+
* while its `evaluateVersionPolicy` honours only the FIRST rule per name.
|
|
1279
|
+
* Its own new entry is therefore dead, and every later command in that
|
|
1280
|
+
* profile fails lockfile verification with
|
|
1281
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION — including commands that have
|
|
1282
|
+
* nothing to do with that package (#732).
|
|
1283
|
+
* - pnpm 12.4.1 folds the versions it approves into a `name@v1 || v2 || …`
|
|
1284
|
+
* union, a form its OWN validator rejects ("Invalid versions union … Use
|
|
1285
|
+
* exact versions only"), and evaluating one aborts the process on a single
|
|
1286
|
+
* 80 GiB allocation that takes the machine down for minutes (#733). The
|
|
1287
|
+
* entries are evaluated lazily, so this fires on any later install that
|
|
1288
|
+
* age-checks that package, with no pnpm error output to explain it.
|
|
1289
|
+
*
|
|
1290
|
+
* Both are avoided by writing each such package as ONE BARE NAME. A bare name
|
|
1291
|
+
* cannot be shadowed (the first rule for it is already all of it), it stops
|
|
1292
|
+
* pnpm's auto-collect for that package outright, and it carries no union for
|
|
1293
|
+
* the parser to evaluate. It is also a wider statement than a version list —
|
|
1294
|
+
* the package stops being age-gated at all — so it is spent only on entries
|
|
1295
|
+
* pnpm has already written in one of those two broken forms, never on a
|
|
1296
|
+
* package the file lists as a single exact version. A pure duplicate of one
|
|
1297
|
+
* exact version collapses to that one line, which changes no policy at all.
|
|
1298
|
+
*
|
|
1299
|
+
* A file holding nothing but bare names and single exact versions is left
|
|
1300
|
+
* untouched, as is one whose block this cannot read exactly (a flow list, an
|
|
1301
|
+
* inline comment, a line it would have to guess at).
|
|
1302
|
+
*
|
|
1303
|
+
* @returns the package names whose entry was rewritten; empty when the file
|
|
1304
|
+
* needed no repair or could not be repaired, in which case it is left
|
|
1305
|
+
* byte-for-byte as it was.
|
|
1306
|
+
*/
|
|
1307
|
+
export function normalizeReleaseAgeExcludes(profile: string, explicitDir?: string): string[] {
|
|
1308
|
+
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
|
|
1309
|
+
let yaml: string
|
|
1310
|
+
try { yaml = readFileSync(file, 'utf8') } catch { return [] }
|
|
1311
|
+
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
|
|
1312
|
+
const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m
|
|
1313
|
+
const block = blockRe.exec(yaml)
|
|
1314
|
+
if (block === null) return []
|
|
1315
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
|
|
1316
|
+
const indentMatch = /^([ \t]+)-/.exec(block[1])
|
|
1317
|
+
const indent = indentMatch === null ? ' ' : indentMatch[1]
|
|
1318
|
+
const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean; line: string }[] = []
|
|
1319
|
+
for (const line of block[1].split(/\r?\n/)) {
|
|
1320
|
+
if (line.trim() === '') continue
|
|
1321
|
+
const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line)
|
|
1322
|
+
// A `#` on the line is a comment this cannot re-emit without losing it.
|
|
1323
|
+
if (m === null || m[1].includes('#')) return []
|
|
1324
|
+
const rule = splitReleaseAgeExclude(m[1])
|
|
1325
|
+
if (rule === null) return []
|
|
1326
|
+
entries.push({ rule, quoted: /^['"]/.test(m[1]), line })
|
|
1327
|
+
}
|
|
1328
|
+
const byName = new Map<string, {
|
|
1329
|
+
selectors: Set<string>
|
|
1330
|
+
originals: string[]
|
|
1331
|
+
lines: string[]
|
|
1332
|
+
allVersions: boolean
|
|
1333
|
+
quoted: boolean
|
|
1334
|
+
count: number
|
|
1335
|
+
}>()
|
|
1336
|
+
const order: string[] = []
|
|
1337
|
+
for (const { rule, quoted, line } of entries) {
|
|
1338
|
+
let group = byName.get(rule.name)
|
|
1339
|
+
if (group === undefined) {
|
|
1340
|
+
group = { selectors: new Set(), originals: [], lines: [], allVersions: false, quoted: false, count: 0 }
|
|
1341
|
+
byName.set(rule.name, group)
|
|
1342
|
+
order.push(rule.name)
|
|
1343
|
+
}
|
|
1344
|
+
group.count += 1
|
|
1345
|
+
group.quoted = group.quoted || quoted
|
|
1346
|
+
group.originals.push(rule.selector ?? '')
|
|
1347
|
+
group.lines.push(line)
|
|
1348
|
+
if (rule.selector === null) group.allVersions = true
|
|
1349
|
+
else group.selectors.add(rule.selector)
|
|
1350
|
+
}
|
|
1351
|
+
/** The one line this package's entry is written as. */
|
|
1352
|
+
const produced = (name: string): string => {
|
|
1353
|
+
const group = byName.get(name)
|
|
1354
|
+
if (group === undefined) return ''
|
|
1355
|
+
// A bare name already covers every version, so it stays bare; a union or
|
|
1356
|
+
// several rules for one name cannot both be read, so they become one.
|
|
1357
|
+
const bare = group.allVersions
|
|
1358
|
+
|| group.originals.some(selector => selector.includes('||'))
|
|
1359
|
+
|| group.selectors.size > 1
|
|
1360
|
+
const text = bare ? name : `${name}@${[...group.selectors][0]}`
|
|
1361
|
+
// `@` cannot start a plain scalar in YAML, so a scoped name is written
|
|
1362
|
+
// quoted — the way pnpm itself writes one.
|
|
1363
|
+
const quoted = group.quoted || text.startsWith('@')
|
|
1364
|
+
return `${indent}- ${quoted ? `'${text.replaceAll("'", "''")}'` : text}`
|
|
1365
|
+
}
|
|
1366
|
+
const rewritten = order.filter(name => {
|
|
1367
|
+
const group = byName.get(name)
|
|
1368
|
+
if (group === undefined) return false
|
|
1369
|
+
return group.count > 1 || group.lines[0] !== produced(name)
|
|
1370
|
+
})
|
|
1371
|
+
if (rewritten.length === 0) return []
|
|
1372
|
+
const lines = order.map(produced)
|
|
1373
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
|
|
1374
|
+
// A function replacement: `$&` and friends in a string replacement would
|
|
1375
|
+
// be read as capture references.
|
|
1376
|
+
writeFileSync(file, yaml.replace(blockRe, () => blockText))
|
|
1377
|
+
return rewritten
|
|
1378
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -357,6 +357,19 @@ export function mountMarketRoutes(
|
|
|
357
357
|
// re-applies the same choice on every boot (ported from dsh-plugin-hub).
|
|
358
358
|
const userPatchPath = findUserPatchPath(host, activeProfileDir)
|
|
359
359
|
const commands: PluginCommandRuntime = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive }
|
|
360
|
+
/**
|
|
361
|
+
* Whether this host runs pnpm itself and so takes the market's own options
|
|
362
|
+
* (`--config.*`, `--force`, `--no-frozen-lockfile`).
|
|
363
|
+
*
|
|
364
|
+
* The official Desktop bridge does not (#732): its in-process manager
|
|
365
|
+
* accepts exactly `add <target>` or `remove <target>` and answers anything
|
|
366
|
+
* else with exit 127. The market's recovery steps decorate commands with
|
|
367
|
+
* those options, so on such a host they have to be left out or rewritten as
|
|
368
|
+
* a bare exact target, instead of being sent and refused — which reported a
|
|
369
|
+
* supported operation as unsupported and sent the reporter looking for a
|
|
370
|
+
* broken profile.
|
|
371
|
+
*/
|
|
372
|
+
const marketFlags = commands.acceptsMarketPnpmFlags !== false
|
|
360
373
|
const supportsExactRollbackTarget = (target: string): boolean =>
|
|
361
374
|
commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target)
|
|
362
375
|
// Point every plugin build/install spawn at the configured build
|
|
@@ -773,10 +786,36 @@ export function mountMarketRoutes(
|
|
|
773
786
|
}
|
|
774
787
|
|
|
775
788
|
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
776
|
-
const runPlugin = (profile: string, args: string[]) =>
|
|
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.
|
|
@@ -3800,7 +3866,15 @@ sendJson(response, 200, { updates })
|
|
|
3800
3866
|
&& capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
|
|
3801
3867
|
? {
|
|
3802
3868
|
available: false,
|
|
3803
|
-
|
|
3869
|
+
// Name all three: the version on disk, the version
|
|
3870
|
+
// the lockfile records, and the one a rollback
|
|
3871
|
+
// would install (#732). The old wording named the
|
|
3872
|
+
// first two only by implication, and a user who
|
|
3873
|
+
// reads "the exact source cannot be proven" has
|
|
3874
|
+
// nothing to act on — the reproducer's way out was
|
|
3875
|
+
// to align package.json with the installed build,
|
|
3876
|
+
// which is what this now says.
|
|
3877
|
+
detail: `更新前 node_modules 里装的是 v${beforeVersion},但 pnpm-lock.yaml 里 ${name} 记的是 ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? '(没有记录)'},两者不一致,无法证明精确来源,因此自动回滚不可用(要回滚的是 ${name}@${beforeVersion})。需要时请手工重新安装 ${name}@${beforeVersion},或先把 package.json 与 pnpm-lock.yaml 对齐到 v${beforeVersion}。 / The build on disk before the update was v${beforeVersion}, but pnpm-lock.yaml records ${capturedNpmVersion(lockfileCapture.snapshot, name) ?? 'nothing'} for ${name}: they disagree, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed, or align package.json and pnpm-lock.yaml with v${beforeVersion} first.`,
|
|
3804
3878
|
lockfileBefore: lockfileCapture.snapshot,
|
|
3805
3879
|
}
|
|
3806
3880
|
: !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
|
|
@@ -5308,7 +5382,11 @@ sendJson(response, 200, { updates })
|
|
|
5308
5382
|
// the intent the fresh path otherwise refuses to assume it has
|
|
5309
5383
|
// (#594): the bypass is safe to use HERE because it is no
|
|
5310
5384
|
// longer the market's idea — it is what was clicked (#635).
|
|
5311
|
-
|
|
5385
|
+
// The bypass is an option this host may not accept (#732). Where
|
|
5386
|
+
// it is not expressible the request falls to the same place as
|
|
5387
|
+
// a refused bypass below: the bare name, with `heldByAge` set
|
|
5388
|
+
// so the row says the profile's own age policy is why.
|
|
5389
|
+
const bypass = heldBack && force && marketFlags
|
|
5312
5390
|
logEvent('warn', 'install', bypass
|
|
5313
5391
|
? `${entry.name}: ${String(registryLatest)} is younger than this profile's minimumReleaseAge — installing it anyway, as asked, with ${RELEASE_AGE_OVERRIDE}`
|
|
5314
5392
|
: heldBack
|