dshmarket 1.66.14 → 1.66.15
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 +19 -10
- package/lib/index.js +3 -0
- package/lib/pnpm-compat.js +73 -0
- package/lib/profile.js +33 -10
- package/lib/routes.js +23 -6
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/routes.d.ts +9 -0
- package/lib/types/verify.d.ts +16 -0
- package/lib/verify.js +58 -2
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +9 -6
- package/src/client/locales.ts +2 -0
- package/src/client/market-data.ts +9 -3
- package/src/index.ts +3 -0
- package/src/pnpm-compat.ts +74 -0
- package/src/profile.ts +39 -10
- package/src/routes.ts +31 -5
- package/src/verify.ts +64 -2
package/client/client.js
CHANGED
|
@@ -642,6 +642,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
642
642
|
stateIncompatible: "已安装,与当前 dsh 不兼容",
|
|
643
643
|
stateInert: "已安装,未生效",
|
|
644
644
|
stateDependencyLibrary: "{0} 的依赖库",
|
|
645
|
+
stateLoadedByProfile: "由你的配置加载",
|
|
645
646
|
stateBroken: "已安装,校验未通过",
|
|
646
647
|
stateDisabled: "已停用",
|
|
647
648
|
phaseResolving: "准备依赖",
|
|
@@ -1338,6 +1339,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1338
1339
|
stateIncompatible: "Installed — incompatible with this dsh",
|
|
1339
1340
|
stateInert: "Installed, not active",
|
|
1340
1341
|
stateDependencyLibrary: "Library for {0}",
|
|
1342
|
+
stateLoadedByProfile: "Loaded by your setup",
|
|
1341
1343
|
stateBroken: "Installed, verification failed",
|
|
1342
1344
|
stateDisabled: "Disabled",
|
|
1343
1345
|
phaseResolving: "Preparing dependencies",
|
|
@@ -1917,15 +1919,19 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1917
1919
|
return normalized;
|
|
1918
1920
|
}
|
|
1919
1921
|
/**
|
|
1920
|
-
* Relevance within one field. Exact
|
|
1921
|
-
* for a multi-word query every word must
|
|
1922
|
+
* Relevance within one field. Exact matches beat phrase matches, which beat
|
|
1923
|
+
* words scattered through the field; for a multi-word query every word must
|
|
1924
|
+
* occur in the same field. A prefix match is NOT ranked above a phrase match
|
|
1925
|
+
* anywhere else in the field: most catalog names start with `dsh-`, so that
|
|
1926
|
+
* distinction mostly punished the naming convention and overrode the sort
|
|
1927
|
+
* the user picked (#815: `computer-use-vision` with 355 downloads above
|
|
1928
|
+
* `dsh-computer-use` with 3.6k, under "downloads").
|
|
1922
1929
|
*/
|
|
1923
1930
|
function fieldRelevance(plugin, value, query, tokens, weight) {
|
|
1924
1931
|
if (!value) return 0;
|
|
1925
1932
|
const text = cachedPluginSearchText(plugin, value);
|
|
1926
1933
|
if (text === "" || !tokens.every((token) => text.includes(token))) return 0;
|
|
1927
1934
|
if (text === query) return weight + 300;
|
|
1928
|
-
if (text.startsWith(query)) return weight + 250;
|
|
1929
1935
|
if (text.includes(query)) return weight + 200;
|
|
1930
1936
|
return weight + 150;
|
|
1931
1937
|
}
|
|
@@ -6321,7 +6327,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
6321
6327
|
});
|
|
6322
6328
|
}
|
|
6323
6329
|
/** The state label + dot for one activation result (P0-2). */
|
|
6324
|
-
function activationMeta(state, t, dependencyOf) {
|
|
6330
|
+
function activationMeta(state, t, dependencyOf, loadedByProfile) {
|
|
6331
|
+
if (state === "inert" && loadedByProfile === true) return {
|
|
6332
|
+
label: t("stateLoadedByProfile"),
|
|
6333
|
+
dot: "done"
|
|
6334
|
+
};
|
|
6325
6335
|
if (state === "inert" && dependencyOf !== void 0) return {
|
|
6326
6336
|
label: t("stateDependencyLibrary").replace("{0}", dependencyOf),
|
|
6327
6337
|
dot: "done"
|
|
@@ -9104,13 +9114,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9104
9114
|
return;
|
|
9105
9115
|
}
|
|
9106
9116
|
if (status === 200 && body.ok) {
|
|
9107
|
-
sessionStorage.setItem("dshm-tab", "installed");
|
|
9108
9117
|
if (body.activation && typeof body.activation === "object") {
|
|
9109
9118
|
setActivations((prev) => ({
|
|
9110
9119
|
...prev,
|
|
9111
9120
|
...body.activation
|
|
9112
9121
|
}));
|
|
9113
|
-
const warns = Object.entries(body.activation).filter(([, info]) => info.state !== "live" && info.state !== "missing").map(([name, info]) => ({
|
|
9122
|
+
const warns = Object.entries(body.activation).filter(([, info]) => info.state !== "live" && info.state !== "missing" && info.loadedByProfile !== true).map(([name, info]) => ({
|
|
9114
9123
|
name,
|
|
9115
9124
|
info
|
|
9116
9125
|
}));
|
|
@@ -11891,7 +11900,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11891
11900
|
children: activationWarnings.map(({ name, info }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
|
|
11892
11901
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: name }),
|
|
11893
11902
|
" — ",
|
|
11894
|
-
activationMeta(info.state, t, info.dependencyOf).label,
|
|
11903
|
+
activationMeta(info.state, t, info.dependencyOf, info.loadedByProfile).label,
|
|
11895
11904
|
info.reasons.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
11896
11905
|
className: Market_module_css_default.spec,
|
|
11897
11906
|
children: [
|
|
@@ -12845,11 +12854,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
12845
12854
|
const entry = data === null ? void 0 : catalogEntryForInstalled(data.plugins, name, String(installed[name]), repoIdentities[name], repoHints[name]);
|
|
12846
12855
|
const off = effectiveDisabledSet.has(name);
|
|
12847
12856
|
const act = activations[name];
|
|
12848
|
-
const meta = !off && act !== void 0 ? activationMeta(act.state, t, act.dependencyOf) : null;
|
|
12857
|
+
const meta = !off && act !== void 0 ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null;
|
|
12849
12858
|
const note = notes[name];
|
|
12850
12859
|
const authored = entry?.description && (entry.description[lang] || entry.description.en) || "";
|
|
12851
12860
|
const shown = note ?? authored;
|
|
12852
|
-
const stateLabel = off ? t("disabledState") : act?.state === "inert" && act.dependencyOf === void 0 ? t("groupStateInert") : act?.state === "restart" ? t("groupStateRestart") : act?.state === "broken" ? t("groupStateBroken") : meta?.label;
|
|
12861
|
+
const stateLabel = off ? t("disabledState") : act?.state === "inert" && act.dependencyOf === void 0 && act.loadedByProfile !== true ? t("groupStateInert") : act?.state === "restart" ? t("groupStateRestart") : act?.state === "broken" ? t("groupStateBroken") : meta?.label;
|
|
12853
12862
|
const stateDot = off ? "warning" : meta?.dot === "error" ? "error" : meta?.dot === "warning" ? "warning" : "done";
|
|
12854
12863
|
const stateClass = stateDot === "error" ? Market_module_css_default.actBroken : stateDot === "warning" ? Market_module_css_default.actWarn : Market_module_css_default.actLive;
|
|
12855
12864
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -12953,7 +12962,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
12953
12962
|
const generation = status?.kind === "generation" || isGenerationSpec(String(spec));
|
|
12954
12963
|
const localDev = isLocalDev(String(spec), status);
|
|
12955
12964
|
const act = activations[name];
|
|
12956
|
-
const meta = act !== void 0 ? activationMeta(act.state, t, act.dependencyOf) : null;
|
|
12965
|
+
const meta = act !== void 0 ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null;
|
|
12957
12966
|
const version = status && status.version ? "v" + status.version : "";
|
|
12958
12967
|
const specText = String(spec);
|
|
12959
12968
|
const specRedundant = version !== "" && /^[\^~]?\d/.test(specText);
|
package/lib/index.js
CHANGED
|
@@ -300,6 +300,9 @@ export function apply(ctx, config) {
|
|
|
300
300
|
// capability bits report that, and an explicit profile directory no
|
|
301
301
|
// longer implies it (#639).
|
|
302
302
|
desktopHost: true,
|
|
303
|
+
// A shell that cannot be asked to replay still replays on its own,
|
|
304
|
+
// shortly after the install lands (#814): wait for it and adopt.
|
|
305
|
+
...(desktopProfiles.pluginActivation === undefined ? { hostReplayWaitMs: 8000 } : {}),
|
|
303
306
|
// Relaunching a raw Electron process would bypass Desktop's launcher
|
|
304
307
|
// lifecycle. The shell remains responsible for restart in this mode.
|
|
305
308
|
allowRestart: false,
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -147,6 +147,26 @@ function integrityViolators(diagnostic) {
|
|
|
147
147
|
* @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
|
|
148
148
|
*/
|
|
149
149
|
export function classifyPnpmFailure(output, exitCode) {
|
|
150
|
+
// #817: the host's silence watchdog (@deepseek-ai/dsh-plugin-manager,
|
|
151
|
+
// `idleTimeoutMs`, 10 minutes by default) killed pnpm. Checked first: it is
|
|
152
|
+
// the last thing that happened, so whatever pnpm printed before is not the
|
|
153
|
+
// cause. The usual culprit is a dependency's install script downloading a
|
|
154
|
+
// binary from a host this network cannot reach — the reporter's cloudflared
|
|
155
|
+
// postinstall fetching from GitHub Releases — so the last lifecycle line
|
|
156
|
+
// pnpm printed names the package that went quiet.
|
|
157
|
+
const stalled = /printed nothing for (\d+)ms and was terminated/.exec(output);
|
|
158
|
+
if (stalled !== null) {
|
|
159
|
+
const minutes = Math.max(1, Math.round(Number(stalled[1]) / 60000));
|
|
160
|
+
const scripts = [...output.matchAll(/node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s]+) (?:pre|post)?install[:$]/g)];
|
|
161
|
+
const culprit = scripts.at(-1)?.[1]?.replace('\\', '/');
|
|
162
|
+
return {
|
|
163
|
+
code: 'pnpm-stalled',
|
|
164
|
+
recoverable: false,
|
|
165
|
+
message: culprit !== undefined
|
|
166
|
+
? `依赖 ${culprit} 的安装脚本 ${minutes} 分钟没有任何输出,被终止了,什么都没装上。这类脚本通常在从网上下载文件,多半是当前网络连不上下载地址;插件本身没有问题。换个网络或开启代理后重试;仍然不行就联系插件作者。 / the install script of the dependency ${culprit} printed nothing for ${minutes} minutes and was stopped, so nothing was installed. Scripts like this usually download a file, and this network most likely cannot reach where it comes from; the plugin itself is not at fault. Try again on another network or through a proxy, and contact the plugin's author if it still fails.`
|
|
167
|
+
: `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
150
170
|
if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
|
|
151
171
|
|| output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
|
|
152
172
|
return {
|
|
@@ -456,6 +476,59 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
456
476
|
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
457
477
|
};
|
|
458
478
|
}
|
|
479
|
+
// #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
|
|
480
|
+
// two shapes that pnpm reports with two codes and two different repairs, so
|
|
481
|
+
// they are classified apart rather than merged into one answer.
|
|
482
|
+
//
|
|
483
|
+
// `lockfile-outdated` is tested first, because its anchor is a code and
|
|
484
|
+
// nothing else. The sentence pnpm prints here also arrives WRAPPED across
|
|
485
|
+
// lines in the human reporter ("… is not up" / "to date with
|
|
486
|
+
// package.json."), so keying on that sentence instead of the code would miss
|
|
487
|
+
// the form users actually see.
|
|
488
|
+
//
|
|
489
|
+
// The two failures do not happen in a fixed order, so nothing here — and
|
|
490
|
+
// nothing in either message — claims which one a user meets first.
|
|
491
|
+
//
|
|
492
|
+
// `recoverable: false` on both: repeating the same command cannot help, the
|
|
493
|
+
// lockfile has to change first.
|
|
494
|
+
if (output.includes('ERR_PNPM_OUTDATED_LOCKFILE')) {
|
|
495
|
+
return {
|
|
496
|
+
code: 'lockfile-outdated',
|
|
497
|
+
recoverable: false,
|
|
498
|
+
message: 'profile 的 pnpm-lock.yaml 与 package.json 对不上:锁文件里记的 specifier 和 manifest 里声明的不是同一个,pnpm 因此在 frozen-lockfile 这一步直接拒绝,什么都没装。先备份一份 pnpm-lock.yaml,再在 profile 目录里重新生成锁文件(pnpm 最后一行会给出它自己那一版的命令;pnpm install --lockfile-only 也让锁文件与 package.json 对齐)后重试。这与「锁文件里缺条目」是两个不同的报错、对应两种不同的修法,两条命令不要互相套用(#808) / this profile\'s pnpm-lock.yaml does not match package.json: the specifier recorded in the lockfile is not the one the manifest declares, so pnpm refuses at the frozen-lockfile check and installs nothing. Back up pnpm-lock.yaml first, then regenerate the lockfile in the profile directory (pnpm\'s last line names the command for its own version; pnpm install --lockfile-only also realigns the lockfile with package.json) and retry. This is a different error with a different repair from a lockfile that is missing an entry — do not apply one repair to the other',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
// `lockfile-broken` is the lockfile contradicting ITSELF: an importer names
|
|
502
|
+
// a dependency that has no entry under `packages:`. pnpm 11 and 12 word it
|
|
503
|
+
// identically and both carry the code, so the rule keys on the code and on
|
|
504
|
+
// pnpm's own phrase — and deliberately NOT on pnpm's closing `help:` line,
|
|
505
|
+
// which drifts: 11.24.0 offers `pnpm install --no-frozen-lockfile` for this
|
|
506
|
+
// failure while 12.8.1 offers `pnpm install --fix-lockfile` (and for the
|
|
507
|
+
// outdated failure above it offers a third command again, `--lockfile-only`).
|
|
508
|
+
// Measured 2026-10-08 against 11.24.0 and 12.8.1.
|
|
509
|
+
//
|
|
510
|
+
// Both shapes reach here whichever stream pnpm used. Market commands run with
|
|
511
|
+
// `--reporter=ndjson`, and in that same measurement 11.24.0 wrote the error
|
|
512
|
+
// record to stdout as one JSON line while 12.8.1 wrote nothing to stdout at
|
|
513
|
+
// all (0 bytes) and put the error block on stderr as plain text.
|
|
514
|
+
// withDecodedPnpmDiagnostics lifts the message out of the first form, and
|
|
515
|
+
// both forms then carry these two strings verbatim.
|
|
516
|
+
//
|
|
517
|
+
// The missing entry is named in the message but deliberately NOT put in
|
|
518
|
+
// `pkg`: callers read `pkg` as "the package this failure is about" and act on
|
|
519
|
+
// it (the #289 peer retry, the #65 ghost-entry advice), and neither applies —
|
|
520
|
+
// the repair is in the lockfile, not in the manifest entry.
|
|
521
|
+
if (output.includes('ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY') || /Broken lockfile/.test(output)) {
|
|
522
|
+
const broken = /no entry for '?([^'\s]+@[^'\s]+)'? in pnpm-lock\.yaml/i
|
|
523
|
+
.exec(withDecodedPnpmDiagnostics(output))?.[1];
|
|
524
|
+
const zh = broken === undefined ? '' : `(${broken})`;
|
|
525
|
+
const en = broken === undefined ? '' : ` (${broken})`;
|
|
526
|
+
return {
|
|
527
|
+
code: 'lockfile-broken',
|
|
528
|
+
recoverable: false,
|
|
529
|
+
message: `profile 的 pnpm-lock.yaml 自相矛盾:有个依赖在 importers 里记着,packages 里却没有它的条目${zh},pnpm 因此在真正写 node_modules 的那一步拒绝整个命令。先备份一份 pnpm-lock.yaml;在 importers 下删掉这个依赖的那条记录(它的名字、specifier、version 三行),再在 profile 目录里执行 pnpm install --no-frozen-lockfile --lockfile-only,然后重试。不要用 pnpm 提示的 --fix-lockfile:在 12.x 上它修不好这种状态(11.x 上能修,所以那条提示只在 12.x 的 help 里出现,照它走会把 12.x 用户送到一条一定失败的命令上)。不要删掉整个 pnpm-lock.yaml,那会把其余插件的版本一起重新解析(#808) / this profile\'s pnpm-lock.yaml contradicts itself: a dependency is recorded in importers but has no entry under packages${en}, so pnpm refuses the whole command at the step that actually writes node_modules. Back up pnpm-lock.yaml first; remove that dependency\'s record from importers (its name, specifier and version lines); then run pnpm install --no-frozen-lockfile --lockfile-only in the profile directory and retry. Do not use the --fix-lockfile that pnpm itself suggests: on 12.x it does not repair this state (it does on 11.x, which is why that suggestion only appears in 12.x\'s help, and following it sends a 12.x user to a command that always fails). Do not delete the whole pnpm-lock.yaml either: that re-resolves every other plugin\'s version too (#808)`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
459
532
|
// #786 follow-up: pnpm could not replace one of the profile's own files —
|
|
460
533
|
// `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
|
|
461
534
|
// `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
|
package/lib/profile.js
CHANGED
|
@@ -1351,27 +1351,39 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
|
|
|
1351
1351
|
catch {
|
|
1352
1352
|
return [];
|
|
1353
1353
|
}
|
|
1354
|
-
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines
|
|
1355
|
-
|
|
1354
|
+
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines, with
|
|
1355
|
+
// comment lines allowed among them. Users annotate why a version is exempt,
|
|
1356
|
+
// and a comment line used to end the block — or veto the whole repair — so
|
|
1357
|
+
// the very profiles someone had tended by hand stayed broken (#816).
|
|
1358
|
+
const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:(?:[ \t]+-[^\r\n]*|[ \t]*#[^\r\n]*)\r?\n?)*)/m;
|
|
1356
1359
|
const block = blockRe.exec(yaml);
|
|
1357
1360
|
if (block === null)
|
|
1358
1361
|
return [];
|
|
1359
1362
|
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
1360
|
-
const indentMatch = /^([ \t]+)
|
|
1363
|
+
const indentMatch = /^([ \t]+)-/m.exec(block[1]);
|
|
1361
1364
|
const indent = indentMatch === null ? ' ' : indentMatch[1];
|
|
1362
|
-
const
|
|
1365
|
+
const items = [];
|
|
1363
1366
|
for (const line of block[1].split(/\r?\n/)) {
|
|
1364
1367
|
if (line.trim() === '')
|
|
1365
1368
|
continue;
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
+
if (/^[ \t]*#/.test(line)) {
|
|
1370
|
+
items.push({ kind: 'comment', line });
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const m = /^([ \t]+-[ \t]*)(.*?)[ \t]*$/.exec(line);
|
|
1374
|
+
if (m === null)
|
|
1369
1375
|
return [];
|
|
1370
|
-
const
|
|
1376
|
+
const commentAt = /[ \t]+#/.exec(m[2]);
|
|
1377
|
+
const value = commentAt === null ? m[2] : m[2].slice(0, commentAt.index);
|
|
1378
|
+
const comment = commentAt === null ? null : m[2].slice(commentAt.index).trim();
|
|
1379
|
+
if (value.includes('#'))
|
|
1380
|
+
return [];
|
|
1381
|
+
const rule = splitReleaseAgeExclude(value);
|
|
1371
1382
|
if (rule === null)
|
|
1372
1383
|
return [];
|
|
1373
|
-
|
|
1384
|
+
items.push({ kind: 'entry', rule, quoted: /^['"]/.test(value), bare: `${m[1]}${value}`, comment });
|
|
1374
1385
|
}
|
|
1386
|
+
const entries = items.flatMap(item => item.kind === 'entry' ? [{ ...item, line: item.bare }] : []);
|
|
1375
1387
|
const byName = new Map();
|
|
1376
1388
|
const order = [];
|
|
1377
1389
|
for (const { rule, quoted, line } of entries) {
|
|
@@ -1418,7 +1430,18 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
|
|
|
1418
1430
|
});
|
|
1419
1431
|
if (rewritten.length === 0)
|
|
1420
1432
|
return [];
|
|
1421
|
-
|
|
1433
|
+
// Each package's one line goes where its FIRST rule was. A later duplicate
|
|
1434
|
+
// is dropped, but a comment it carried stays, as a comment line in its place.
|
|
1435
|
+
const written = new Set();
|
|
1436
|
+
const lines = items.flatMap(item => {
|
|
1437
|
+
if (item.kind === 'comment')
|
|
1438
|
+
return [item.line];
|
|
1439
|
+
const suffix = item.comment === null ? '' : ` ${item.comment}`;
|
|
1440
|
+
if (written.has(item.rule.name))
|
|
1441
|
+
return item.comment === null ? [] : [`${indent}${item.comment}`];
|
|
1442
|
+
written.add(item.rule.name);
|
|
1443
|
+
return [`${produced(item.rule.name)}${suffix}`];
|
|
1444
|
+
});
|
|
1422
1445
|
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
1423
1446
|
// A function replacement: `$&` and friends in a string replacement would
|
|
1424
1447
|
// be read as capture references.
|
package/lib/routes.js
CHANGED
|
@@ -42,7 +42,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
|
|
|
42
42
|
import { createThemeManager } from './themes.js';
|
|
43
43
|
import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
|
|
44
44
|
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
|
|
45
|
-
import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
45
|
+
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
46
46
|
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
47
47
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
48
48
|
import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
|
|
@@ -2327,7 +2327,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2327
2327
|
if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name))
|
|
2328
2328
|
return false;
|
|
2329
2329
|
const manifest = installedManifests.get(name);
|
|
2330
|
-
|
|
2330
|
+
// A bundle whose own patch is empty loads nothing either way, so
|
|
2331
|
+
// its absence from the list says nothing about a switch (#813).
|
|
2332
|
+
return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
|
|
2333
|
+
&& !bundlePatchIsEmpty(join(activeProfileDir, 'node_modules', name));
|
|
2331
2334
|
});
|
|
2332
2335
|
for (const name of Object.keys(installed)) {
|
|
2333
2336
|
const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)), replacedWhileLive.has(name));
|
|
@@ -5903,10 +5906,24 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5903
5906
|
// inventory (live names and `#<id>`) is the fact
|
|
5904
5907
|
// "already active this session", the same source
|
|
5905
5908
|
// verifyActivation reads below.
|
|
5906
|
-
const
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5909
|
+
const isAdopted = () => {
|
|
5910
|
+
const names = liveNames();
|
|
5911
|
+
return names.has(name)
|
|
5912
|
+
|| names.has(`#${name}`)
|
|
5913
|
+
|| bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
|
|
5914
|
+
.some(id => names.has(`#${id}`));
|
|
5915
|
+
};
|
|
5916
|
+
let adopted = isAdopted();
|
|
5917
|
+
// The replay lands after pnpm returns, not before (#814):
|
|
5918
|
+
// give a host that replays on its own the time to do it.
|
|
5919
|
+
const waitUntil = Date.now() + (config.hostReplayWaitMs ?? 0);
|
|
5920
|
+
while (!adopted && Date.now() < waitUntil) {
|
|
5921
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
5922
|
+
adopted = isAdopted();
|
|
5923
|
+
}
|
|
5924
|
+
if (adopted && config.hostReplayWaitMs !== undefined) {
|
|
5925
|
+
logEvent('info', 'install', `${name}: the host's own replay activated it; adopted instead of mounting a second copy (#814)`);
|
|
5926
|
+
}
|
|
5910
5927
|
let live = adopted;
|
|
5911
5928
|
if (!adopted && !pluginCategories(entry).includes('theme')) {
|
|
5912
5929
|
// #758: this fallback hot-mount bypasses the host's own
|
|
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
|
|
|
33
33
|
*/
|
|
34
34
|
export declare const HOST_NAMESPACE_RE: RegExp;
|
|
35
35
|
export interface PnpmFailure {
|
|
36
|
-
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
|
|
36
|
+
code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed' | 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled';
|
|
37
37
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
38
38
|
message: string;
|
|
39
39
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -82,6 +82,15 @@ export interface MarketConfig {
|
|
|
82
82
|
* no longer tells a desktop shell apart from an ordinary `dsh` run.
|
|
83
83
|
*/
|
|
84
84
|
desktopHost?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* How long to wait for the host's own replay of a fresh install before
|
|
87
|
+
* hot-mounting it (#814). A desktop shell that watches the profile replays
|
|
88
|
+
* the composition itself, about two seconds after the manifest lands; one
|
|
89
|
+
* that does not publish `pluginActivation` gives the market no way to ask,
|
|
90
|
+
* and mounting first made the host's replay fail as a duplicate. Unset
|
|
91
|
+
* means no host replay is expected.
|
|
92
|
+
*/
|
|
93
|
+
hostReplayWaitMs?: number;
|
|
85
94
|
/** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
|
|
86
95
|
allowRestart?: boolean;
|
|
87
96
|
/** Which release channel the market offers ITSELF from; other plugins never follow it. */
|
package/lib/types/verify.d.ts
CHANGED
|
@@ -41,7 +41,23 @@ export interface ActivationResult {
|
|
|
41
41
|
* chose (#634). Only ever set on `inert`.
|
|
42
42
|
*/
|
|
43
43
|
dependencyOf?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Set on `inert` when the package's own bundle patch loads nothing but the
|
|
46
|
+
* user's own patch names it somewhere — an Agent preset's plugin row, say
|
|
47
|
+
* (#813). The market cannot see those trees, so it neither promises a
|
|
48
|
+
* restart nor calls the plugin off; it says where the plugin comes from.
|
|
49
|
+
*/
|
|
50
|
+
loadedByProfile?: true;
|
|
44
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether every bundle patch the package declares parses to an empty list.
|
|
54
|
+
*
|
|
55
|
+
* Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
|
|
56
|
+
* or taking it out changes nothing, so neither "restart to apply" nor the
|
|
57
|
+
* #696 "removed from bundles means off" inference applies to it (#813). A
|
|
58
|
+
* patch that is missing or unreadable is NOT empty — other verdicts own it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bundlePatchIsEmpty(packageDir: string): boolean;
|
|
45
61
|
/**
|
|
46
62
|
* What the running host is, for the #757 boot-gate prediction.
|
|
47
63
|
*
|
package/lib/verify.js
CHANGED
|
@@ -32,9 +32,45 @@ import { join } from 'node:path';
|
|
|
32
32
|
import { listHotMounts, parseSimplePatch } from './hot.js';
|
|
33
33
|
import { userPatchPackageReferences } from './patch.js';
|
|
34
34
|
import { nameMatchesPackage } from './entry-identity.js';
|
|
35
|
-
import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
|
|
36
|
-
import { compareSemver, satisfiesRange } from './check.js';
|
|
35
|
+
import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
|
|
36
|
+
import { compareSemver, parsePatchFile, satisfiesRange } from './check.js';
|
|
37
37
|
import { dshHostInfo } from './dsh-install.js';
|
|
38
|
+
/**
|
|
39
|
+
* Whether every bundle patch the package declares parses to an empty list.
|
|
40
|
+
*
|
|
41
|
+
* Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
|
|
42
|
+
* or taking it out changes nothing, so neither "restart to apply" nor the
|
|
43
|
+
* #696 "removed from bundles means off" inference applies to it (#813). A
|
|
44
|
+
* patch that is missing or unreadable is NOT empty — other verdicts own it.
|
|
45
|
+
*/
|
|
46
|
+
export function bundlePatchIsEmpty(packageDir) {
|
|
47
|
+
const files = declaredBundlePatchFiles(packageDir);
|
|
48
|
+
return files.length > 0 && files.every(file => parsePatchFile(file)?.length === 0);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether the profile's own `cordis.patch.yml` names this package anywhere,
|
|
52
|
+
* at any depth — including rows nested in another plugin's config, such as
|
|
53
|
+
* an Agent preset's `plugins:` list, which never enter the host loader.
|
|
54
|
+
* Evidence of where the plugin comes from, not of whether it is running.
|
|
55
|
+
*/
|
|
56
|
+
function profilePatchMentions(activeProfileDir, name) {
|
|
57
|
+
const rows = parsePatchFile(join(activeProfileDir, 'cordis.patch.yml'));
|
|
58
|
+
if (rows === null)
|
|
59
|
+
return false;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const visit = (value) => {
|
|
62
|
+
if (value === null || typeof value !== 'object' || seen.has(value))
|
|
63
|
+
return false;
|
|
64
|
+
seen.add(value);
|
|
65
|
+
if (Array.isArray(value))
|
|
66
|
+
return value.some(visit);
|
|
67
|
+
const row = value;
|
|
68
|
+
if (nameMatchesPackage(row.name, name))
|
|
69
|
+
return true;
|
|
70
|
+
return Object.values(row).some(visit);
|
|
71
|
+
};
|
|
72
|
+
return rows.some(visit);
|
|
73
|
+
}
|
|
38
74
|
/** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
|
|
39
75
|
function readBundles(profile, explicitDir) {
|
|
40
76
|
try {
|
|
@@ -329,6 +365,26 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
|
|
|
329
365
|
hot: true,
|
|
330
366
|
};
|
|
331
367
|
}
|
|
368
|
+
// An empty bundle patch (#813): the bundle row brings nothing in, so a
|
|
369
|
+
// restart cannot change the verdict and "restart to apply" would stand
|
|
370
|
+
// forever. Say where the plugin comes from instead, when the profile's own
|
|
371
|
+
// patch names it; otherwise nothing loads it, and that is the truth.
|
|
372
|
+
if (dsh.client === undefined && bundlePatchIsEmpty(dir)) {
|
|
373
|
+
return profilePatchMentions(activeProfileDir, name)
|
|
374
|
+
? {
|
|
375
|
+
state: 'inert',
|
|
376
|
+
loadedByProfile: true,
|
|
377
|
+
reasons: ['它自带的 bundle 补丁是空的,由你的配置(例如 Agent 预设)加载;市场看不到它是否正在运行 / its own bundle patch is empty, so your own setup (an Agent preset, for example) loads it; the market cannot see whether it is running'],
|
|
378
|
+
bundle: inBundles,
|
|
379
|
+
hot: false,
|
|
380
|
+
}
|
|
381
|
+
: {
|
|
382
|
+
state: 'inert',
|
|
383
|
+
reasons: ['它自带的 bundle 补丁是空的,你的配置里也没有引用它,所以不会加载 / its own bundle patch is empty and your setup does not reference it, so nothing loads it'],
|
|
384
|
+
bundle: inBundles,
|
|
385
|
+
hot: false,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
332
388
|
if (inBundles) {
|
|
333
389
|
// #757: "restart to apply" is only honest when the next boot would
|
|
334
390
|
// actually load the bundle. The host skips bundles whose dsh peer
|
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.15",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -137,7 +137,11 @@ function activationMeta(
|
|
|
137
137
|
state: ActivationState,
|
|
138
138
|
t: Translate,
|
|
139
139
|
dependencyOf?: string,
|
|
140
|
+
loadedByProfile?: true,
|
|
140
141
|
): { label: string; dot: 'done' | 'warning' | 'error' } {
|
|
142
|
+
// Loaded by the user's own setup, out of the market's sight (#813): a fact
|
|
143
|
+
// about where it comes from, not a warning.
|
|
144
|
+
if (state === 'inert' && loadedByProfile === true) return { label: t('stateLoadedByProfile'), dot: 'done' }
|
|
141
145
|
// A library another plugin pulled in is not a plugin that failed to start,
|
|
142
146
|
// so it gets its own label and no warning dot (#634).
|
|
143
147
|
if (state === 'inert' && dependencyOf !== undefined) {
|
|
@@ -3006,11 +3010,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3006
3010
|
return
|
|
3007
3011
|
}
|
|
3008
3012
|
if (status === 200 && body.ok) {
|
|
3009
|
-
sessionStorage.setItem('dshm-tab', 'installed')
|
|
3010
3013
|
if (body.activation && typeof body.activation === 'object') {
|
|
3011
3014
|
setActivations(prev => ({ ...prev, ...body.activation }))
|
|
3012
3015
|
const warns = Object.entries(body.activation as Record<string, ActivationInfo>)
|
|
3013
|
-
.filter(([, info]) => info.state !== 'live' && info.state !== 'missing')
|
|
3016
|
+
.filter(([, info]) => info.state !== 'live' && info.state !== 'missing' && info.loadedByProfile !== true)
|
|
3014
3017
|
.map(([name, info]) => ({ name, info }))
|
|
3015
3018
|
setActivationWarnings(warns)
|
|
3016
3019
|
}
|
|
@@ -5726,7 +5729,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5726
5729
|
<span className={css.grow}>
|
|
5727
5730
|
{activationWarnings.map(({ name, info }) => (
|
|
5728
5731
|
<div key={name}>
|
|
5729
|
-
<b>{name}</b> — {activationMeta(info.state, t, info.dependencyOf).label}
|
|
5732
|
+
<b>{name}</b> — {activationMeta(info.state, t, info.dependencyOf, info.loadedByProfile).label}
|
|
5730
5733
|
{info.reasons.length > 0 && <span className={css.spec}>({localizeBilingualList(info.reasons, lang)})</span>}
|
|
5731
5734
|
</div>
|
|
5732
5735
|
))}
|
|
@@ -6478,13 +6481,13 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
6478
6481
|
const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(installed[name]), repoIdentities[name], repoHints[name])
|
|
6479
6482
|
const off = effectiveDisabledSet.has(name)
|
|
6480
6483
|
const act = activations[name]
|
|
6481
|
-
const meta = !off && act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null
|
|
6484
|
+
const meta = !off && act !== undefined ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null
|
|
6482
6485
|
const note = notes[name]
|
|
6483
6486
|
const authored = (entry?.description && (entry.description[lang] || entry.description.en)) || ''
|
|
6484
6487
|
const shown = note ?? authored
|
|
6485
6488
|
const stateLabel = off
|
|
6486
6489
|
? t('disabledState')
|
|
6487
|
-
: act?.state === 'inert' && act.dependencyOf === undefined
|
|
6490
|
+
: act?.state === 'inert' && act.dependencyOf === undefined && act.loadedByProfile !== true
|
|
6488
6491
|
? t('groupStateInert')
|
|
6489
6492
|
: act?.state === 'restart'
|
|
6490
6493
|
? t('groupStateRestart')
|
|
@@ -6585,7 +6588,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
6585
6588
|
const generation = status?.kind === 'generation' || isGenerationSpec(String(spec))
|
|
6586
6589
|
const localDev = isLocalDev(String(spec), status)
|
|
6587
6590
|
const act = activations[name]
|
|
6588
|
-
const meta = act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null
|
|
6591
|
+
const meta = act !== undefined ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null
|
|
6589
6592
|
const version = status && status.version ? 'v' + status.version : ''
|
|
6590
6593
|
const specText = String(spec)
|
|
6591
6594
|
// A plain range beside the resolved version says the
|
package/src/client/locales.ts
CHANGED
|
@@ -451,6 +451,7 @@ export const zh = {
|
|
|
451
451
|
stateIncompatible: '已安装,与当前 dsh 不兼容',
|
|
452
452
|
stateInert: '已安装,未生效',
|
|
453
453
|
stateDependencyLibrary: '{0} 的依赖库',
|
|
454
|
+
stateLoadedByProfile: '由你的配置加载',
|
|
454
455
|
stateBroken: '已安装,校验未通过',
|
|
455
456
|
stateDisabled: '已停用',
|
|
456
457
|
phaseResolving: '准备依赖',
|
|
@@ -1161,6 +1162,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
1161
1162
|
stateIncompatible: 'Installed — incompatible with this dsh',
|
|
1162
1163
|
stateInert: 'Installed, not active',
|
|
1163
1164
|
stateDependencyLibrary: 'Library for {0}',
|
|
1165
|
+
stateLoadedByProfile: 'Loaded by your setup',
|
|
1164
1166
|
stateBroken: 'Installed, verification failed',
|
|
1165
1167
|
stateDisabled: 'Disabled',
|
|
1166
1168
|
phaseResolving: 'Preparing dependencies',
|
|
@@ -302,6 +302,8 @@ export interface ActivationInfo {
|
|
|
302
302
|
hot: boolean
|
|
303
303
|
/** Set when this package is a library another installed plugin pulled in (#634). */
|
|
304
304
|
dependencyOf?: string
|
|
305
|
+
/** Loaded by the user's own setup, which the market cannot see (#813). */
|
|
306
|
+
loadedByProfile?: true
|
|
305
307
|
}
|
|
306
308
|
|
|
307
309
|
/** The /dsh-market/installed payload (fields the market UI consumes). */
|
|
@@ -489,8 +491,13 @@ function cachedPluginSearchText(plugin: RegistryPlugin, value: string): string {
|
|
|
489
491
|
}
|
|
490
492
|
|
|
491
493
|
/**
|
|
492
|
-
* Relevance within one field. Exact
|
|
493
|
-
* for a multi-word query every word must
|
|
494
|
+
* Relevance within one field. Exact matches beat phrase matches, which beat
|
|
495
|
+
* words scattered through the field; for a multi-word query every word must
|
|
496
|
+
* occur in the same field. A prefix match is NOT ranked above a phrase match
|
|
497
|
+
* anywhere else in the field: most catalog names start with `dsh-`, so that
|
|
498
|
+
* distinction mostly punished the naming convention and overrode the sort
|
|
499
|
+
* the user picked (#815: `computer-use-vision` with 355 downloads above
|
|
500
|
+
* `dsh-computer-use` with 3.6k, under "downloads").
|
|
494
501
|
*/
|
|
495
502
|
function fieldRelevance(
|
|
496
503
|
plugin: RegistryPlugin,
|
|
@@ -503,7 +510,6 @@ function fieldRelevance(
|
|
|
503
510
|
const text = cachedPluginSearchText(plugin, value)
|
|
504
511
|
if (text === '' || !tokens.every(token => text.includes(token))) return 0
|
|
505
512
|
if (text === query) return weight + 300
|
|
506
|
-
if (text.startsWith(query)) return weight + 250
|
|
507
513
|
if (text.includes(query)) return weight + 200
|
|
508
514
|
return weight + 150
|
|
509
515
|
}
|
package/src/index.ts
CHANGED
|
@@ -348,6 +348,9 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
348
348
|
// capability bits report that, and an explicit profile directory no
|
|
349
349
|
// longer implies it (#639).
|
|
350
350
|
desktopHost: true,
|
|
351
|
+
// A shell that cannot be asked to replay still replays on its own,
|
|
352
|
+
// shortly after the install lands (#814): wait for it and adopt.
|
|
353
|
+
...(desktopProfiles.pluginActivation === undefined ? { hostReplayWaitMs: 8000 } : {}),
|
|
351
354
|
// Relaunching a raw Electron process would bypass Desktop's launcher
|
|
352
355
|
// lifecycle. The shell remains responsible for restart in this mode.
|
|
353
356
|
allowRestart: false,
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface PnpmFailure {
|
|
|
50
50
|
| 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked'
|
|
51
51
|
| 'profile-file-locked'
|
|
52
52
|
| 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
|
|
53
|
+
| 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled'
|
|
53
54
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
54
55
|
message: string
|
|
55
56
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
|
@@ -185,6 +186,26 @@ function integrityViolators(diagnostic: string): string[] {
|
|
|
185
186
|
* @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
|
|
186
187
|
*/
|
|
187
188
|
export function classifyPnpmFailure(output: string, exitCode?: number | null): PnpmFailure | null {
|
|
189
|
+
// #817: the host's silence watchdog (@deepseek-ai/dsh-plugin-manager,
|
|
190
|
+
// `idleTimeoutMs`, 10 minutes by default) killed pnpm. Checked first: it is
|
|
191
|
+
// the last thing that happened, so whatever pnpm printed before is not the
|
|
192
|
+
// cause. The usual culprit is a dependency's install script downloading a
|
|
193
|
+
// binary from a host this network cannot reach — the reporter's cloudflared
|
|
194
|
+
// postinstall fetching from GitHub Releases — so the last lifecycle line
|
|
195
|
+
// pnpm printed names the package that went quiet.
|
|
196
|
+
const stalled = /printed nothing for (\d+)ms and was terminated/.exec(output)
|
|
197
|
+
if (stalled !== null) {
|
|
198
|
+
const minutes = Math.max(1, Math.round(Number(stalled[1]) / 60000))
|
|
199
|
+
const scripts = [...output.matchAll(/node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s]+) (?:pre|post)?install[:$]/g)]
|
|
200
|
+
const culprit = scripts.at(-1)?.[1]?.replace('\\', '/')
|
|
201
|
+
return {
|
|
202
|
+
code: 'pnpm-stalled',
|
|
203
|
+
recoverable: false,
|
|
204
|
+
message: culprit !== undefined
|
|
205
|
+
? `依赖 ${culprit} 的安装脚本 ${minutes} 分钟没有任何输出,被终止了,什么都没装上。这类脚本通常在从网上下载文件,多半是当前网络连不上下载地址;插件本身没有问题。换个网络或开启代理后重试;仍然不行就联系插件作者。 / the install script of the dependency ${culprit} printed nothing for ${minutes} minutes and was stopped, so nothing was installed. Scripts like this usually download a file, and this network most likely cannot reach where it comes from; the plugin itself is not at fault. Try again on another network or through a proxy, and contact the plugin's author if it still fails.`
|
|
206
|
+
: `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
188
209
|
if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
|
|
189
210
|
|| output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
|
|
190
211
|
return {
|
|
@@ -494,6 +515,59 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
|
|
494
515
|
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
495
516
|
}
|
|
496
517
|
}
|
|
518
|
+
// #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
|
|
519
|
+
// two shapes that pnpm reports with two codes and two different repairs, so
|
|
520
|
+
// they are classified apart rather than merged into one answer.
|
|
521
|
+
//
|
|
522
|
+
// `lockfile-outdated` is tested first, because its anchor is a code and
|
|
523
|
+
// nothing else. The sentence pnpm prints here also arrives WRAPPED across
|
|
524
|
+
// lines in the human reporter ("… is not up" / "to date with
|
|
525
|
+
// package.json."), so keying on that sentence instead of the code would miss
|
|
526
|
+
// the form users actually see.
|
|
527
|
+
//
|
|
528
|
+
// The two failures do not happen in a fixed order, so nothing here — and
|
|
529
|
+
// nothing in either message — claims which one a user meets first.
|
|
530
|
+
//
|
|
531
|
+
// `recoverable: false` on both: repeating the same command cannot help, the
|
|
532
|
+
// lockfile has to change first.
|
|
533
|
+
if (output.includes('ERR_PNPM_OUTDATED_LOCKFILE')) {
|
|
534
|
+
return {
|
|
535
|
+
code: 'lockfile-outdated',
|
|
536
|
+
recoverable: false,
|
|
537
|
+
message: 'profile 的 pnpm-lock.yaml 与 package.json 对不上:锁文件里记的 specifier 和 manifest 里声明的不是同一个,pnpm 因此在 frozen-lockfile 这一步直接拒绝,什么都没装。先备份一份 pnpm-lock.yaml,再在 profile 目录里重新生成锁文件(pnpm 最后一行会给出它自己那一版的命令;pnpm install --lockfile-only 也让锁文件与 package.json 对齐)后重试。这与「锁文件里缺条目」是两个不同的报错、对应两种不同的修法,两条命令不要互相套用(#808) / this profile\'s pnpm-lock.yaml does not match package.json: the specifier recorded in the lockfile is not the one the manifest declares, so pnpm refuses at the frozen-lockfile check and installs nothing. Back up pnpm-lock.yaml first, then regenerate the lockfile in the profile directory (pnpm\'s last line names the command for its own version; pnpm install --lockfile-only also realigns the lockfile with package.json) and retry. This is a different error with a different repair from a lockfile that is missing an entry — do not apply one repair to the other',
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// `lockfile-broken` is the lockfile contradicting ITSELF: an importer names
|
|
541
|
+
// a dependency that has no entry under `packages:`. pnpm 11 and 12 word it
|
|
542
|
+
// identically and both carry the code, so the rule keys on the code and on
|
|
543
|
+
// pnpm's own phrase — and deliberately NOT on pnpm's closing `help:` line,
|
|
544
|
+
// which drifts: 11.24.0 offers `pnpm install --no-frozen-lockfile` for this
|
|
545
|
+
// failure while 12.8.1 offers `pnpm install --fix-lockfile` (and for the
|
|
546
|
+
// outdated failure above it offers a third command again, `--lockfile-only`).
|
|
547
|
+
// Measured 2026-10-08 against 11.24.0 and 12.8.1.
|
|
548
|
+
//
|
|
549
|
+
// Both shapes reach here whichever stream pnpm used. Market commands run with
|
|
550
|
+
// `--reporter=ndjson`, and in that same measurement 11.24.0 wrote the error
|
|
551
|
+
// record to stdout as one JSON line while 12.8.1 wrote nothing to stdout at
|
|
552
|
+
// all (0 bytes) and put the error block on stderr as plain text.
|
|
553
|
+
// withDecodedPnpmDiagnostics lifts the message out of the first form, and
|
|
554
|
+
// both forms then carry these two strings verbatim.
|
|
555
|
+
//
|
|
556
|
+
// The missing entry is named in the message but deliberately NOT put in
|
|
557
|
+
// `pkg`: callers read `pkg` as "the package this failure is about" and act on
|
|
558
|
+
// it (the #289 peer retry, the #65 ghost-entry advice), and neither applies —
|
|
559
|
+
// the repair is in the lockfile, not in the manifest entry.
|
|
560
|
+
if (output.includes('ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY') || /Broken lockfile/.test(output)) {
|
|
561
|
+
const broken = /no entry for '?([^'\s]+@[^'\s]+)'? in pnpm-lock\.yaml/i
|
|
562
|
+
.exec(withDecodedPnpmDiagnostics(output))?.[1]
|
|
563
|
+
const zh = broken === undefined ? '' : `(${broken})`
|
|
564
|
+
const en = broken === undefined ? '' : ` (${broken})`
|
|
565
|
+
return {
|
|
566
|
+
code: 'lockfile-broken',
|
|
567
|
+
recoverable: false,
|
|
568
|
+
message: `profile 的 pnpm-lock.yaml 自相矛盾:有个依赖在 importers 里记着,packages 里却没有它的条目${zh},pnpm 因此在真正写 node_modules 的那一步拒绝整个命令。先备份一份 pnpm-lock.yaml;在 importers 下删掉这个依赖的那条记录(它的名字、specifier、version 三行),再在 profile 目录里执行 pnpm install --no-frozen-lockfile --lockfile-only,然后重试。不要用 pnpm 提示的 --fix-lockfile:在 12.x 上它修不好这种状态(11.x 上能修,所以那条提示只在 12.x 的 help 里出现,照它走会把 12.x 用户送到一条一定失败的命令上)。不要删掉整个 pnpm-lock.yaml,那会把其余插件的版本一起重新解析(#808) / this profile\'s pnpm-lock.yaml contradicts itself: a dependency is recorded in importers but has no entry under packages${en}, so pnpm refuses the whole command at the step that actually writes node_modules. Back up pnpm-lock.yaml first; remove that dependency\'s record from importers (its name, specifier and version lines); then run pnpm install --no-frozen-lockfile --lockfile-only in the profile directory and retry. Do not use the --fix-lockfile that pnpm itself suggests: on 12.x it does not repair this state (it does on 11.x, which is why that suggestion only appears in 12.x\'s help, and following it sends a 12.x user to a command that always fails). Do not delete the whole pnpm-lock.yaml either: that re-resolves every other plugin\'s version too (#808)`,
|
|
569
|
+
}
|
|
570
|
+
}
|
|
497
571
|
// #786 follow-up: pnpm could not replace one of the profile's own files —
|
|
498
572
|
// `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
|
|
499
573
|
// `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
|
package/src/profile.ts
CHANGED
|
@@ -1405,23 +1405,43 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
|
|
|
1405
1405
|
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
|
|
1406
1406
|
let yaml: string
|
|
1407
1407
|
try { yaml = readFileSync(file, 'utf8') } catch { return [] }
|
|
1408
|
-
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines
|
|
1409
|
-
|
|
1408
|
+
// Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines, with
|
|
1409
|
+
// comment lines allowed among them. Users annotate why a version is exempt,
|
|
1410
|
+
// and a comment line used to end the block — or veto the whole repair — so
|
|
1411
|
+
// the very profiles someone had tended by hand stayed broken (#816).
|
|
1412
|
+
const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:(?:[ \t]+-[^\r\n]*|[ \t]*#[^\r\n]*)\r?\n?)*)/m
|
|
1410
1413
|
const block = blockRe.exec(yaml)
|
|
1411
1414
|
if (block === null) return []
|
|
1412
1415
|
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
|
|
1413
|
-
const indentMatch = /^([ \t]+)
|
|
1416
|
+
const indentMatch = /^([ \t]+)-/m.exec(block[1])
|
|
1414
1417
|
const indent = indentMatch === null ? ' ' : indentMatch[1]
|
|
1415
|
-
|
|
1418
|
+
/**
|
|
1419
|
+
* The block in its original order. Comment lines are kept verbatim; an
|
|
1420
|
+
* entry keeps its trailing ` # …` comment, which YAML only recognises after
|
|
1421
|
+
* whitespace. A `#` anywhere else in an entry is not something this can
|
|
1422
|
+
* read exactly, so the file is left alone.
|
|
1423
|
+
*/
|
|
1424
|
+
type Item =
|
|
1425
|
+
| { kind: 'comment'; line: string }
|
|
1426
|
+
| { kind: 'entry'; rule: ReleaseAgeExcludeRule; quoted: boolean; bare: string; comment: string | null }
|
|
1427
|
+
const items: Item[] = []
|
|
1416
1428
|
for (const line of block[1].split(/\r?\n/)) {
|
|
1417
1429
|
if (line.trim() === '') continue
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1430
|
+
if (/^[ \t]*#/.test(line)) {
|
|
1431
|
+
items.push({ kind: 'comment', line })
|
|
1432
|
+
continue
|
|
1433
|
+
}
|
|
1434
|
+
const m = /^([ \t]+-[ \t]*)(.*?)[ \t]*$/.exec(line)
|
|
1435
|
+
if (m === null) return []
|
|
1436
|
+
const commentAt = /[ \t]+#/.exec(m[2])
|
|
1437
|
+
const value = commentAt === null ? m[2] : m[2].slice(0, commentAt.index)
|
|
1438
|
+
const comment = commentAt === null ? null : m[2].slice(commentAt.index).trim()
|
|
1439
|
+
if (value.includes('#')) return []
|
|
1440
|
+
const rule = splitReleaseAgeExclude(value)
|
|
1422
1441
|
if (rule === null) return []
|
|
1423
|
-
|
|
1442
|
+
items.push({ kind: 'entry', rule, quoted: /^['"]/.test(value), bare: `${m[1]}${value}`, comment })
|
|
1424
1443
|
}
|
|
1444
|
+
const entries = items.flatMap(item => item.kind === 'entry' ? [{ ...item, line: item.bare }] : [])
|
|
1425
1445
|
const byName = new Map<string, {
|
|
1426
1446
|
selectors: Set<string>
|
|
1427
1447
|
originals: string[]
|
|
@@ -1470,7 +1490,16 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
|
|
|
1470
1490
|
return group.count > 1 || group.lines[0] !== produced(name)
|
|
1471
1491
|
})
|
|
1472
1492
|
if (rewritten.length === 0) return []
|
|
1473
|
-
|
|
1493
|
+
// Each package's one line goes where its FIRST rule was. A later duplicate
|
|
1494
|
+
// is dropped, but a comment it carried stays, as a comment line in its place.
|
|
1495
|
+
const written = new Set<string>()
|
|
1496
|
+
const lines = items.flatMap(item => {
|
|
1497
|
+
if (item.kind === 'comment') return [item.line]
|
|
1498
|
+
const suffix = item.comment === null ? '' : ` ${item.comment}`
|
|
1499
|
+
if (written.has(item.rule.name)) return item.comment === null ? [] : [`${indent}${item.comment}`]
|
|
1500
|
+
written.add(item.rule.name)
|
|
1501
|
+
return [`${produced(item.rule.name)}${suffix}`]
|
|
1502
|
+
})
|
|
1474
1503
|
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
|
|
1475
1504
|
// A function replacement: `$&` and friends in a string replacement would
|
|
1476
1505
|
// be read as capture references.
|
package/src/routes.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { createThemeManager, type LoaderEntry } from './themes.ts'
|
|
|
55
55
|
import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
|
|
56
56
|
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
|
|
57
57
|
import type { RecoveryPlugin } from './recovery.ts'
|
|
58
|
-
import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
58
|
+
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
59
59
|
import {
|
|
60
60
|
carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
|
|
61
61
|
readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
|
|
@@ -145,6 +145,15 @@ export interface MarketConfig {
|
|
|
145
145
|
* no longer tells a desktop shell apart from an ordinary `dsh` run.
|
|
146
146
|
*/
|
|
147
147
|
desktopHost?: boolean
|
|
148
|
+
/**
|
|
149
|
+
* How long to wait for the host's own replay of a fresh install before
|
|
150
|
+
* hot-mounting it (#814). A desktop shell that watches the profile replays
|
|
151
|
+
* the composition itself, about two seconds after the manifest lands; one
|
|
152
|
+
* that does not publish `pluginActivation` gives the market no way to ask,
|
|
153
|
+
* and mounting first made the host's replay fail as a duplicate. Unset
|
|
154
|
+
* means no host replay is expected.
|
|
155
|
+
*/
|
|
156
|
+
hostReplayWaitMs?: number
|
|
148
157
|
/** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
|
|
149
158
|
allowRestart?: boolean
|
|
150
159
|
/** Which release channel the market offers ITSELF from; other plugins never follow it. */
|
|
@@ -2521,7 +2530,10 @@ export function mountMarketRoutes(
|
|
|
2521
2530
|
const unbundled = Object.keys(installed).filter(name => {
|
|
2522
2531
|
if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name)) return false
|
|
2523
2532
|
const manifest = installedManifests.get(name) as { dsh?: { bundle?: unknown } } | null | undefined
|
|
2533
|
+
// A bundle whose own patch is empty loads nothing either way, so
|
|
2534
|
+
// its absence from the list says nothing about a switch (#813).
|
|
2524
2535
|
return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
|
|
2536
|
+
&& !bundlePatchIsEmpty(join(activeProfileDir, 'node_modules', name))
|
|
2525
2537
|
})
|
|
2526
2538
|
for (const name of Object.keys(installed)) {
|
|
2527
2539
|
const result = activationAfterReplace(
|
|
@@ -6114,10 +6126,24 @@ sendJson(response, 200, { updates })
|
|
|
6114
6126
|
// inventory (live names and `#<id>`) is the fact
|
|
6115
6127
|
// "already active this session", the same source
|
|
6116
6128
|
// verifyActivation reads below.
|
|
6117
|
-
const
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6129
|
+
const isAdopted = (): boolean => {
|
|
6130
|
+
const names = liveNames()
|
|
6131
|
+
return names.has(name)
|
|
6132
|
+
|| names.has(`#${name}`)
|
|
6133
|
+
|| bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
|
|
6134
|
+
.some(id => names.has(`#${id}`))
|
|
6135
|
+
}
|
|
6136
|
+
let adopted = isAdopted()
|
|
6137
|
+
// The replay lands after pnpm returns, not before (#814):
|
|
6138
|
+
// give a host that replays on its own the time to do it.
|
|
6139
|
+
const waitUntil = Date.now() + (config.hostReplayWaitMs ?? 0)
|
|
6140
|
+
while (!adopted && Date.now() < waitUntil) {
|
|
6141
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
6142
|
+
adopted = isAdopted()
|
|
6143
|
+
}
|
|
6144
|
+
if (adopted && config.hostReplayWaitMs !== undefined) {
|
|
6145
|
+
logEvent('info', 'install', `${name}: the host's own replay activated it; adopted instead of mounting a second copy (#814)`)
|
|
6146
|
+
}
|
|
6121
6147
|
let live = adopted
|
|
6122
6148
|
if (!adopted && !pluginCategories(entry).includes('theme')) {
|
|
6123
6149
|
// #758: this fallback hot-mount bypasses the host's own
|
package/src/verify.ts
CHANGED
|
@@ -33,8 +33,8 @@ import { join } from 'node:path'
|
|
|
33
33
|
import { listHotMounts, parseSimplePatch } from './hot.ts'
|
|
34
34
|
import { userPatchPackageReferences } from './patch.ts'
|
|
35
35
|
import { nameMatchesPackage } from './entry-identity.ts'
|
|
36
|
-
import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
|
|
37
|
-
import { compareSemver, satisfiesRange } from './check.ts'
|
|
36
|
+
import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
|
|
37
|
+
import { compareSemver, parsePatchFile, satisfiesRange } from './check.ts'
|
|
38
38
|
import { dshHostInfo } from './dsh-install.ts'
|
|
39
39
|
|
|
40
40
|
export type ActivationState = 'live' | 'restart' | 'incompatible' | 'inert' | 'broken' | 'missing' | 'disabled'
|
|
@@ -53,6 +53,47 @@ export interface ActivationResult {
|
|
|
53
53
|
* chose (#634). Only ever set on `inert`.
|
|
54
54
|
*/
|
|
55
55
|
dependencyOf?: string
|
|
56
|
+
/**
|
|
57
|
+
* Set on `inert` when the package's own bundle patch loads nothing but the
|
|
58
|
+
* user's own patch names it somewhere — an Agent preset's plugin row, say
|
|
59
|
+
* (#813). The market cannot see those trees, so it neither promises a
|
|
60
|
+
* restart nor calls the plugin off; it says where the plugin comes from.
|
|
61
|
+
*/
|
|
62
|
+
loadedByProfile?: true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether every bundle patch the package declares parses to an empty list.
|
|
67
|
+
*
|
|
68
|
+
* Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
|
|
69
|
+
* or taking it out changes nothing, so neither "restart to apply" nor the
|
|
70
|
+
* #696 "removed from bundles means off" inference applies to it (#813). A
|
|
71
|
+
* patch that is missing or unreadable is NOT empty — other verdicts own it.
|
|
72
|
+
*/
|
|
73
|
+
export function bundlePatchIsEmpty(packageDir: string): boolean {
|
|
74
|
+
const files = declaredBundlePatchFiles(packageDir)
|
|
75
|
+
return files.length > 0 && files.every(file => parsePatchFile(file)?.length === 0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether the profile's own `cordis.patch.yml` names this package anywhere,
|
|
80
|
+
* at any depth — including rows nested in another plugin's config, such as
|
|
81
|
+
* an Agent preset's `plugins:` list, which never enter the host loader.
|
|
82
|
+
* Evidence of where the plugin comes from, not of whether it is running.
|
|
83
|
+
*/
|
|
84
|
+
function profilePatchMentions(activeProfileDir: string, name: string): boolean {
|
|
85
|
+
const rows = parsePatchFile(join(activeProfileDir, 'cordis.patch.yml'))
|
|
86
|
+
if (rows === null) return false
|
|
87
|
+
const seen = new Set<unknown>()
|
|
88
|
+
const visit = (value: unknown): boolean => {
|
|
89
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return false
|
|
90
|
+
seen.add(value)
|
|
91
|
+
if (Array.isArray(value)) return value.some(visit)
|
|
92
|
+
const row = value as Record<string, unknown>
|
|
93
|
+
if (nameMatchesPackage(row.name, name)) return true
|
|
94
|
+
return Object.values(row).some(visit)
|
|
95
|
+
}
|
|
96
|
+
return rows.some(visit)
|
|
56
97
|
}
|
|
57
98
|
|
|
58
99
|
/** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
|
|
@@ -399,6 +440,27 @@ export function verifyActivation(
|
|
|
399
440
|
}
|
|
400
441
|
}
|
|
401
442
|
|
|
443
|
+
// An empty bundle patch (#813): the bundle row brings nothing in, so a
|
|
444
|
+
// restart cannot change the verdict and "restart to apply" would stand
|
|
445
|
+
// forever. Say where the plugin comes from instead, when the profile's own
|
|
446
|
+
// patch names it; otherwise nothing loads it, and that is the truth.
|
|
447
|
+
if (dsh.client === undefined && bundlePatchIsEmpty(dir)) {
|
|
448
|
+
return profilePatchMentions(activeProfileDir, name)
|
|
449
|
+
? {
|
|
450
|
+
state: 'inert',
|
|
451
|
+
loadedByProfile: true,
|
|
452
|
+
reasons: ['它自带的 bundle 补丁是空的,由你的配置(例如 Agent 预设)加载;市场看不到它是否正在运行 / its own bundle patch is empty, so your own setup (an Agent preset, for example) loads it; the market cannot see whether it is running'],
|
|
453
|
+
bundle: inBundles,
|
|
454
|
+
hot: false,
|
|
455
|
+
}
|
|
456
|
+
: {
|
|
457
|
+
state: 'inert',
|
|
458
|
+
reasons: ['它自带的 bundle 补丁是空的,你的配置里也没有引用它,所以不会加载 / its own bundle patch is empty and your setup does not reference it, so nothing loads it'],
|
|
459
|
+
bundle: inBundles,
|
|
460
|
+
hot: false,
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
402
464
|
if (inBundles) {
|
|
403
465
|
// #757: "restart to apply" is only honest when the next boot would
|
|
404
466
|
// actually load the bundle. The host skips bundles whose dsh peer
|