dshmarket 1.66.19 → 1.66.20
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 +57 -0
- package/lib/routes.js +78 -2
- package/lib/themes.js +59 -5
- package/lib/types/verify.d.ts +37 -0
- package/lib/verify.js +118 -26
- package/package.json +1 -1
- package/src/client/Diagnostics.tsx +39 -0
- package/src/client/MarketSection.tsx +27 -0
- package/src/client/locales.ts +14 -0
- package/src/routes.ts +86 -2
- package/src/themes.ts +55 -6
- package/src/verify.ts +130 -23
package/client/client.js
CHANGED
|
@@ -685,6 +685,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
685
685
|
toggleFail: "切换失败",
|
|
686
686
|
deprecatedBadge: "已废弃",
|
|
687
687
|
deprecatedWarn: "该插件已被目录标记为废弃,不建议新用户安装。",
|
|
688
|
+
configNamespaceNotice: "{0} 的设置面板读不到配置:它按「{1}」读取,而这个环境里没有这个名字的条目 id。它自己的条目是「{2}」,插件客户端应该改成读那个——这是插件自身的问题,市场无法代改。",
|
|
688
689
|
brokenPluginTitle: "{0} 更新失败后被移除了",
|
|
689
690
|
brokenPluginBody: "DSH 正在运行时无法更新,而更新前的版本也已经损坏。为避免下次启动卡住,市场把它从 profile 里移除。插件目录还在原处,退出 DSH 后重新安装即可。",
|
|
690
691
|
brokenPluginTitleCancelled: "{0} 取消更新后被移除了",
|
|
@@ -788,6 +789,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
788
789
|
checkResidualsHint: "这些目录不被任何插件使用,也不影响启动,是更新中断后留下的。退出 DSH 后可以删除,或重新安装对应的插件。",
|
|
789
790
|
checkResidualTmp: "pnpm 临时目录",
|
|
790
791
|
checkResidualIncomplete: "残缺的包目录(缺少 package.json)",
|
|
792
|
+
checkConfigNamespaces: "设置面板读不到配置的插件",
|
|
793
|
+
checkConfigNamespacesEmpty: "没有发现这类插件。",
|
|
794
|
+
checkConfigNamespacesHint: "插件面板按「条目 id」读自己的配置,写错名字不会报错,只会静默读不到值:面板空白、存不进去,插件会退回它写死的默认值(画布类插件因此白屏)。这是插件客户端里的名字写错了,市场改不了它,需要插件作者改一行代码。",
|
|
795
|
+
checkConfigNamespaceRow: "它按「{0}」读配置,但这不是任何条目 id",
|
|
796
|
+
checkConfigNamespaceOwn: "它自己的条目 id",
|
|
797
|
+
checkConfigNamespaceNone: "它自己没有声明任何条目 id",
|
|
791
798
|
checkOrphans: "无效的配置条目",
|
|
792
799
|
checkOrphansEmpty: "没有无效配置条目",
|
|
793
800
|
orphanInsertNotArray: "格式错误",
|
|
@@ -1394,6 +1401,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1394
1401
|
toggleFail: "Toggle failed",
|
|
1395
1402
|
deprecatedBadge: "Deprecated",
|
|
1396
1403
|
deprecatedWarn: "This plugin is marked as deprecated by the catalog; new users are advised against installing it.",
|
|
1404
|
+
configNamespaceNotice: "{0}'s settings panel cannot read its config: it asks for \"{1}\", and no entry id in this environment has that name. Its own entry is \"{2}\", which is what the plugin's client should ask for — the mistake is in the plugin, and the market cannot change it.",
|
|
1397
1405
|
brokenPluginTitle: "{0} was removed after a failed update",
|
|
1398
1406
|
brokenPluginBody: "The running DSH blocked the update, and the version it was replacing was already damaged. The market removed it from the profile so the next start does not fail. Its directory is untouched — quit DSH and install it again.",
|
|
1399
1407
|
brokenPluginTitleCancelled: "{0} was removed after an update was cancelled",
|
|
@@ -1497,6 +1505,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1497
1505
|
checkResidualsHint: "No plugin uses these directories and they do not stop DSH from starting: an interrupted update left them behind. Quit DSH, then delete them or reinstall the affected plugin.",
|
|
1498
1506
|
checkResidualTmp: "pnpm temp directory",
|
|
1499
1507
|
checkResidualIncomplete: "incomplete package (no package.json)",
|
|
1508
|
+
checkConfigNamespaces: "Plugins whose settings read the wrong name",
|
|
1509
|
+
checkConfigNamespacesEmpty: "No plugin does this.",
|
|
1510
|
+
checkConfigNamespacesHint: "A plugin panel reads its own config by ENTRY ID. A wrong name there reports nothing: the panel is blank and cannot save, and the plugin falls back to whatever default it hardcoded (a canvas panel shows a white screen). The name is wrong inside the plugin's own client bundle — the market cannot fix it, its author has to change one line.",
|
|
1511
|
+
checkConfigNamespaceRow: "it reads its config from \"{0}\", which is not an entry id",
|
|
1512
|
+
checkConfigNamespaceOwn: "its own entry id",
|
|
1513
|
+
checkConfigNamespaceNone: "it declares no entry id of its own",
|
|
1500
1514
|
checkOrphans: "Invalid config entries",
|
|
1501
1515
|
checkOrphansEmpty: "No invalid config entries",
|
|
1502
1516
|
orphanInsertNotArray: "malformed",
|
|
@@ -6012,6 +6026,32 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
6012
6026
|
}, residual.path))
|
|
6013
6027
|
})]
|
|
6014
6028
|
}),
|
|
6029
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
|
|
6030
|
+
title: t("checkConfigNamespaces"),
|
|
6031
|
+
count: report.configNamespaces?.length ?? 0,
|
|
6032
|
+
empty: t("checkConfigNamespacesEmpty"),
|
|
6033
|
+
overview: report.configNamespaces?.[0]?.name,
|
|
6034
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
6035
|
+
className: Market_module_css_default.panelNote,
|
|
6036
|
+
children: t("checkConfigNamespacesHint")
|
|
6037
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
6038
|
+
className: Market_module_css_default.diagList,
|
|
6039
|
+
children: (report.configNamespaces ?? []).map((finding) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
6040
|
+
className: Market_module_css_default.diagRow,
|
|
6041
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
6042
|
+
className: Market_module_css_default.nm,
|
|
6043
|
+
children: finding.name
|
|
6044
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
6045
|
+
className: Market_module_css_default.spec,
|
|
6046
|
+
children: [
|
|
6047
|
+
finding.namespaces.map((namespace) => t("checkConfigNamespaceRow").replace("{0}", namespace)).join(";"),
|
|
6048
|
+
" — ",
|
|
6049
|
+
finding.ownEntryIds.length > 0 ? `${t("checkConfigNamespaceOwn")}: ${finding.ownEntryIds.join(", ")}` : t("checkConfigNamespaceNone")
|
|
6050
|
+
]
|
|
6051
|
+
})]
|
|
6052
|
+
}, finding.name))
|
|
6053
|
+
})]
|
|
6054
|
+
}),
|
|
6015
6055
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(CollapsibleSection, {
|
|
6016
6056
|
title: t("orderSection"),
|
|
6017
6057
|
count: order.length,
|
|
@@ -8415,6 +8455,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8415
8455
|
const catalogVersionTip = (0, react.useMemo)(() => catalogUpdated === null ? t("catalogVersionUndated") : t("catalogVersionDated").replace("{0}", catalogUpdated), [catalogUpdated, t]);
|
|
8416
8456
|
/** Non-live activation results from the last operation, shown as a banner. */
|
|
8417
8457
|
const [activationWarnings, setActivationWarnings] = (0, react.useState)([]);
|
|
8458
|
+
/**
|
|
8459
|
+
* Installed plugins whose client asks for a config namespace nothing serves
|
|
8460
|
+
* (#810). Server-computed, because the answer needs one client bundle read
|
|
8461
|
+
* per plugin and the client cannot see a bundle's contents.
|
|
8462
|
+
*/
|
|
8463
|
+
const [configNamespaceIssues, setConfigNamespaceIssues] = (0, react.useState)([]);
|
|
8418
8464
|
const [hostDependencyFindings, setHostDependencyFindings] = (0, react.useState)([]);
|
|
8419
8465
|
/** Plugin name awaiting uninstall confirmation (Modal). */
|
|
8420
8466
|
const [removeConfirm, setRemoveConfirm] = (0, react.useState)(null);
|
|
@@ -8596,6 +8642,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8596
8642
|
if (body.notes !== null && typeof body.notes === "object" && !Array.isArray(body.notes)) setNotes(body.notes);
|
|
8597
8643
|
if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled);
|
|
8598
8644
|
if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled);
|
|
8645
|
+
setConfigNamespaceIssues(Array.isArray(body.configNamespaces) ? body.configNamespaces.filter((item) => typeof item === "object" && item !== null && typeof item.name === "string" && Array.isArray(item.namespaces)) : []);
|
|
8599
8646
|
if (body.groups && typeof body.groups === "object") setGroups(body.groups);
|
|
8600
8647
|
if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder);
|
|
8601
8648
|
if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url) => typeof url === "string"));
|
|
@@ -12074,6 +12121,16 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
12074
12121
|
] }, name))
|
|
12075
12122
|
})]
|
|
12076
12123
|
}),
|
|
12124
|
+
tab === "installed" && configNamespaceIssues.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
12125
|
+
className: Market_module_css_default.banner,
|
|
12126
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutline16, {
|
|
12127
|
+
size: 14,
|
|
12128
|
+
className: Market_module_css_default.bannerIcon
|
|
12129
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
12130
|
+
className: Market_module_css_default.grow,
|
|
12131
|
+
children: configNamespaceIssues.map((finding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("configNamespaceNotice").replace("{0}", finding.name).replace("{1}", finding.namespaces.join("、")).replace("{2}", finding.ownEntryIds.join("、") || "—") }, finding.name))
|
|
12132
|
+
})]
|
|
12133
|
+
}),
|
|
12077
12134
|
tab === "installed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostDependencyDiagnostics, {
|
|
12078
12135
|
findings: hostDependencyFindings,
|
|
12079
12136
|
t
|
package/lib/routes.js
CHANGED
|
@@ -43,7 +43,7 @@ import { createThemeManager } from './themes.js';
|
|
|
43
43
|
import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
|
|
44
44
|
import { resolveDshHome } from './home-paths.js';
|
|
45
45
|
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
|
|
46
|
-
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
46
|
+
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
47
47
|
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
48
48
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
49
49
|
import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
|
|
@@ -578,6 +578,16 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
578
578
|
* newer decision than the market's list (the market removes a name from
|
|
579
579
|
* its list BEFORE writing its own enable row), so the list follows it.
|
|
580
580
|
*
|
|
581
|
+
* Scoped to THIS profile's layer, deliberately (#519). DSH also composes a
|
|
582
|
+
* home-level layer, and a third-party manager that wrote its switches there
|
|
583
|
+
* asked us to follow that too. We do not: the market writes the profile
|
|
584
|
+
* layer and nothing else, so following a layer it cannot write would give
|
|
585
|
+
* the user a switch that appears to work and silently reverts at the next
|
|
586
|
+
* boot — worse than a boundary they can see. What a home-level decision
|
|
587
|
+
* DOES get is a name in the log when the market overrides it (see
|
|
588
|
+
* `noteHomeLayerConflict`), so nobody has to guess why a plugin came back
|
|
589
|
+
* off.
|
|
590
|
+
*
|
|
581
591
|
* @returns true when the package was re-enabled elsewhere and has been
|
|
582
592
|
* dropped from the market's disable list.
|
|
583
593
|
*/
|
|
@@ -591,6 +601,27 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
591
601
|
logEvent('info', 'toggle', `${name}: its rows were re-enabled outside the market (#696) — following that instead of switching it back off`);
|
|
592
602
|
return true;
|
|
593
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Say so when the market overrides an enable recorded in the HOME patch
|
|
606
|
+
* layer (#519).
|
|
607
|
+
*
|
|
608
|
+
* The market keeps a plugin off from its own record; a decision in the
|
|
609
|
+
* profile layer is followed instead (`followOutsideEnable`), but the home
|
|
610
|
+
* layer is not read for it — see there for why. The cost of that boundary
|
|
611
|
+
* is a plugin that turns off again on every boot while another manager's
|
|
612
|
+
* layer says it should be on: a perfectly reasonable thing for a user to be
|
|
613
|
+
* confused by, and its only explanation today was silence.
|
|
614
|
+
*/
|
|
615
|
+
function noteHomeLayerConflict(name) {
|
|
616
|
+
const homePatchPath = join(resolveDshHome(), 'cordis.patch.yml');
|
|
617
|
+
const home = readUserPatchState(homePatchPath);
|
|
618
|
+
if (home.forced.length === 0)
|
|
619
|
+
return;
|
|
620
|
+
const flags = packagePatchFlags(host, activeProfileDir, [name], home);
|
|
621
|
+
if (!flags.forced.includes(name) || flags.disabled.includes(name))
|
|
622
|
+
return;
|
|
623
|
+
logEvent('warn', 'toggle', `${name}: kept off by this market's own record, while ${homePatchPath} enables it — that layer is not the one the market writes, so the market will not follow it. Move the row into this profile's cordis.patch.yml to make it stick (#519)`);
|
|
624
|
+
}
|
|
594
625
|
// Client-only packages (dsh.client without dsh.bundle) are invisible to the
|
|
595
626
|
// bundle layer in every boot; the market shim-mounts them so their client
|
|
596
627
|
// bundles are actually served.
|
|
@@ -604,6 +635,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
604
635
|
for (const name of [...disabled]) {
|
|
605
636
|
if (followOutsideEnable(name))
|
|
606
637
|
continue;
|
|
638
|
+
noteHomeLayerConflict(name);
|
|
607
639
|
if (await themes.setEntryDisabled(name, true))
|
|
608
640
|
logEvent('info', 'boot', `plugin kept off: ${name}`);
|
|
609
641
|
}
|
|
@@ -2452,6 +2484,34 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2452
2484
|
packageName,
|
|
2453
2485
|
manifest,
|
|
2454
2486
|
})));
|
|
2487
|
+
// #810: a client that reads its config from a name no loader entry
|
|
2488
|
+
// answers to reads NOTHING — blank or unwritable settings, and in the
|
|
2489
|
+
// reported case a panel stuck on a hardcoded fallback port — with no
|
|
2490
|
+
// error anywhere. Judged against everything this profile can be seen
|
|
2491
|
+
// to serve: each installed package's own patch rows (read once, for
|
|
2492
|
+
// the set AND for the "write this instead" value), the user patch's
|
|
2493
|
+
// rows — its inserts, plus the disables and force-enables, which both
|
|
2494
|
+
// name existing entry ids — and whatever the loader is running now.
|
|
2495
|
+
//
|
|
2496
|
+
// The gap: host-layer ids the market never reads a patch for. A plugin
|
|
2497
|
+
// reading a HOST entry's config by literal would be flagged wrongly
|
|
2498
|
+
// here, where the diagnostics page — which composes the host layers
|
|
2499
|
+
// too — can settle that difference.
|
|
2500
|
+
const ownEntryIds = new Map(Object.keys(installed).map(packageName => [packageName, bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', packageName))]));
|
|
2501
|
+
const servedEntryIds = new Set([...patch.inserts, ...patch.disables, ...patch.forced]);
|
|
2502
|
+
for (const ids of ownEntryIds.values())
|
|
2503
|
+
for (const id of ids)
|
|
2504
|
+
servedEntryIds.add(id);
|
|
2505
|
+
for (const entry of host.loader.entries()) {
|
|
2506
|
+
const id = entry.options?.id;
|
|
2507
|
+
if (id === undefined || id === '')
|
|
2508
|
+
continue;
|
|
2509
|
+
servedEntryIds.add(id);
|
|
2510
|
+
const bare = id.split(':').pop();
|
|
2511
|
+
if (bare !== undefined)
|
|
2512
|
+
servedEntryIds.add(bare);
|
|
2513
|
+
}
|
|
2514
|
+
const configNamespaces = findUnservedConfigNamespaces(activeProfileDir, servedEntryIds, name => ownEntryIds.get(name) ?? [], activeProfileDir);
|
|
2455
2515
|
sendJson(response, 200, {
|
|
2456
2516
|
profile: config.profile,
|
|
2457
2517
|
installed,
|
|
@@ -2479,6 +2539,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2479
2539
|
brokenPlugins: marketState.brokenPlugins ?? {},
|
|
2480
2540
|
patchForced: patchFlags.forced,
|
|
2481
2541
|
bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
|
|
2542
|
+
// Only packages with a finding appear, so a healthy profile sends
|
|
2543
|
+
// nothing and the row renders nothing.
|
|
2544
|
+
configNamespaces: configNamespaces.length > 0 ? configNamespaces : undefined,
|
|
2482
2545
|
});
|
|
2483
2546
|
},
|
|
2484
2547
|
}),
|
|
@@ -2503,7 +2566,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
2503
2566
|
? classifyPeer(row.plugin, row.name, row.range, row.resolved, row.optional === true)
|
|
2504
2567
|
: { kind: 'none' };
|
|
2505
2568
|
}
|
|
2506
|
-
|
|
2569
|
+
// #810: a client that reads its config from a name no loader entry
|
|
2570
|
+
// answers to. Attached here rather than computed inside
|
|
2571
|
+
// `analyzeProfile` because that function is documented as safe on
|
|
2572
|
+
// every market open and this reads one bundle per installed plugin —
|
|
2573
|
+
// the diagnostics page is a page the user opened on purpose.
|
|
2574
|
+
//
|
|
2575
|
+
// Judged against the COMPOSITION's ids, not the package's own: a
|
|
2576
|
+
// package may mount another one (carrier form), and judging by own
|
|
2577
|
+
// ids would flag it.
|
|
2578
|
+
const served = new Set(report.rows.map(row => row.id));
|
|
2579
|
+
sendJson(response, 200, {
|
|
2580
|
+
...report,
|
|
2581
|
+
configNamespaces: findUnservedConfigNamespaces(activeProfileDir, served, name => report.rows.filter(row => row.layer === name).map(row => row.id), activeProfileDir),
|
|
2582
|
+
});
|
|
2507
2583
|
}
|
|
2508
2584
|
catch (error) {
|
|
2509
2585
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
package/lib/themes.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* the loader, and keeping exactly one theme active with the choice
|
|
5
5
|
* persisted across restarts.
|
|
6
6
|
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import { loadRegistry, pluginCategories } from './registry.js';
|
|
9
10
|
import { hotMount, hotUnmount, listHotMounts, writeDisabled } from './hot.js';
|
|
@@ -11,7 +12,28 @@ import { logEvent } from './log.js';
|
|
|
11
12
|
import { LifecyclePendingError, LifecycleWaitError, waitForLifecycle } from './lifecycle.js';
|
|
12
13
|
import { nameMatchesPackage } from './entry-identity.js';
|
|
13
14
|
import { bundlePatchInsertedIds, profileDir, readInstalled } from './profile.js';
|
|
14
|
-
import {
|
|
15
|
+
import { parseSourceUrl, repoOfTarget } from './sources.js';
|
|
16
|
+
/**
|
|
17
|
+
* Whether an installed package declares itself a skin.
|
|
18
|
+
*
|
|
19
|
+
* The market's own criterion for "this is a theme" is the catalog, and the
|
|
20
|
+
* catalog does not list every skin a monorepo ships: the reporter's second
|
|
21
|
+
* skin is absent from it entirely, while the MANAGER that ships with them is
|
|
22
|
+
* a package from the same repo. Judging by repo alone made the manager a
|
|
23
|
+
* theme, so activating any other theme switched it off — and with it the
|
|
24
|
+
* user's own skin-management UI (#519). Both skins ship `skin.json` at their
|
|
25
|
+
* root (it names the package and its wiring); the manager ships none, which
|
|
26
|
+
* is the distinction the reporter pointed at.
|
|
27
|
+
*/
|
|
28
|
+
function hasSkinManifest(profileDirectory, name) {
|
|
29
|
+
try {
|
|
30
|
+
const manifest = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', name, 'skin.json'), 'utf8'));
|
|
31
|
+
return typeof manifest.id === 'string' && manifest.id !== '';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
15
37
|
/**
|
|
16
38
|
* Whether a loader entry belongs to `packageName`.
|
|
17
39
|
*
|
|
@@ -53,15 +75,47 @@ export function createThemeManager(host, profile, disabledThemes, explicitDir) {
|
|
|
53
75
|
const registry = await loadRegistry();
|
|
54
76
|
const themeEntries = registry.plugins.filter(p => pluginCategories(p).includes('theme'));
|
|
55
77
|
const themeNames = new Set(themeEntries.map(p => p.name));
|
|
56
|
-
|
|
78
|
+
// Repo → the subdirectories its catalog entries point at, plus whether
|
|
79
|
+
// any entry is the repo itself. One repository can ship several PACKAGES
|
|
80
|
+
// — a skin manager and its skins — and matching by repo alone made every
|
|
81
|
+
// one of them a theme, so activating another theme switched the manager
|
|
82
|
+
// off too, taking the user's own management UI with it (#519).
|
|
83
|
+
const themeRepos = new Map();
|
|
84
|
+
for (const entry of themeEntries) {
|
|
85
|
+
const parsed = parseSourceUrl(entry.url);
|
|
86
|
+
if (parsed === null)
|
|
87
|
+
continue;
|
|
88
|
+
const key = parsed.repo.toLowerCase();
|
|
89
|
+
const record = themeRepos.get(key) ?? { root: false, subpaths: new Set() };
|
|
90
|
+
if (parsed.subpath === null)
|
|
91
|
+
record.root = true;
|
|
92
|
+
else
|
|
93
|
+
record.subpaths.add(parsed.subpath.toLowerCase());
|
|
94
|
+
themeRepos.set(key, record);
|
|
95
|
+
}
|
|
57
96
|
for (const [name, spec] of Object.entries(readInstalled(profile, activeProfileDir))) {
|
|
58
97
|
if (themeNames.has(name)) {
|
|
59
98
|
names.add(name);
|
|
60
99
|
continue;
|
|
61
100
|
}
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
101
|
+
const identity = repoOfTarget(String(spec).toLowerCase());
|
|
102
|
+
if (identity === null)
|
|
103
|
+
continue;
|
|
104
|
+
const at = identity.indexOf('#path:/');
|
|
105
|
+
const repo = at === -1 ? identity : identity.slice(0, at);
|
|
106
|
+
const subpath = at === -1 ? null : identity.slice(at + '#path:/'.length);
|
|
107
|
+
const record = themeRepos.get(repo);
|
|
108
|
+
if (record === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
// Only a repo whose themes ALL live in subdirectories can be told
|
|
111
|
+
// apart this way. A catalog entry at the repo root says nothing about
|
|
112
|
+
// subdirectories, so that shape keeps the permissive match it had —
|
|
113
|
+
// which is also what keeps a monorepo skin working when the entry
|
|
114
|
+
// points at the repo.
|
|
115
|
+
if (subpath !== null && !record.root && record.subpaths.size > 0
|
|
116
|
+
&& !record.subpaths.has(subpath) && !hasSkinManifest(activeProfileDir, name))
|
|
117
|
+
continue;
|
|
118
|
+
names.add(name);
|
|
65
119
|
}
|
|
66
120
|
}
|
|
67
121
|
catch { /* registry unavailable — nothing classifies as a theme */ }
|
package/lib/types/verify.d.ts
CHANGED
|
@@ -180,6 +180,43 @@ export interface BundleCheck {
|
|
|
180
180
|
/** Populated only when the file was found AND failed to parse. */
|
|
181
181
|
reason: string | null;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Every namespace a client bundle asks its config from (#810).
|
|
185
|
+
*
|
|
186
|
+
* One level of constant propagation, as asked for in the report: the plugin
|
|
187
|
+
* that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
|
|
188
|
+
* `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
|
|
189
|
+
* case the check exists for. A name assigned twice is dropped rather than
|
|
190
|
+
* guessed at — which value it holds at the call site is not decidable from
|
|
191
|
+
* text alone, and a wrong guess here would be reported as a plugin defect.
|
|
192
|
+
*/
|
|
193
|
+
export declare function configFormNamespaces(source: string): string[];
|
|
194
|
+
/** A package whose client asks for a namespace nothing in the composition serves (#810). */
|
|
195
|
+
export interface UnservedConfigNamespace {
|
|
196
|
+
/** The installed package whose client bundle asks. */
|
|
197
|
+
name: string;
|
|
198
|
+
/** The namespaces it asks for that no loader entry id answers to. */
|
|
199
|
+
namespaces: string[];
|
|
200
|
+
/** Entry ids this package's own patch declares — the value it meant. */
|
|
201
|
+
ownEntryIds: string[];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Installed plugins whose client reads its config from a name nothing serves
|
|
205
|
+
* (#810).
|
|
206
|
+
*
|
|
207
|
+
* `served` is the entry id set of the WHOLE composition — deliberately not
|
|
208
|
+
* this package's own ids: a package may legitimately mount another one
|
|
209
|
+
* (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
|
|
210
|
+
* answers what the package itself declares, which is the value to write
|
|
211
|
+
* instead — the report asked for the correct spelling to be shown, not just
|
|
212
|
+
* the mistake.
|
|
213
|
+
*
|
|
214
|
+
* The scan reads one bundle per installed plugin. Measured against the same
|
|
215
|
+
* cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
|
|
216
|
+
* bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
|
|
217
|
+
* made, not in a loop.
|
|
218
|
+
*/
|
|
219
|
+
export declare function findUnservedConfigNamespaces(profile: string, served: ReadonlySet<string>, ownIdsOf: (name: string) => string[], explicitDir?: string): UnservedConfigNamespace[];
|
|
183
220
|
/**
|
|
184
221
|
* Every installed plugin whose client bundle will not parse (#222 by
|
|
185
222
|
* @MicroMilo).
|
package/lib/verify.js
CHANGED
|
@@ -532,6 +532,117 @@ export function clientBundlePath(exportsField, depth = 0) {
|
|
|
532
532
|
}
|
|
533
533
|
return null;
|
|
534
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* One assignment of a string literal to a name, and the `configForms.get`
|
|
537
|
+
* call sites (#810).
|
|
538
|
+
*
|
|
539
|
+
* The namespace a client asks its config from is a PROFILE ENTRY ID, not a
|
|
540
|
+
* package name. A client that writes the package name reads nothing: the
|
|
541
|
+
* settings document has no entry under it, the call returns empty, and the
|
|
542
|
+
* plugin degrades silently — blank or unwritable settings forms, and in the
|
|
543
|
+
* reported case a panel stuck on a hardcoded fallback port. Nothing logs it,
|
|
544
|
+
* and nothing on the market side noticed.
|
|
545
|
+
*
|
|
546
|
+
* `configForms.get` is matched by name, so an aliased receiver
|
|
547
|
+
* (`const forms = ctx.configForms; forms.get("x")`) is not seen — the same
|
|
548
|
+
* honesty as the syntax check above: this reports what it can prove, and
|
|
549
|
+
* says nothing otherwise.
|
|
550
|
+
*/
|
|
551
|
+
const STRING_ASSIGNMENT = /(?:^|[^\w$.])(?:var|let|const)?\s*([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"\\\n]*)\2/gu;
|
|
552
|
+
const CONFIG_FORMS_GET = /configForms\s*\.\s*get\s*\(\s*(?:([A-Za-z_$][\w$]*)|(['"])([^'"\\\n]*)\2)\s*\)/gu;
|
|
553
|
+
/** A value that is a plain loader entry id: a backslash means it is not one. */
|
|
554
|
+
function plainNamespace(value) {
|
|
555
|
+
return value !== '' && value.length <= 200 && !value.includes('\\') && /^[\w@/.:-]+$/u.test(value) ? value : null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Every namespace a client bundle asks its config from (#810).
|
|
559
|
+
*
|
|
560
|
+
* One level of constant propagation, as asked for in the report: the plugin
|
|
561
|
+
* that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
|
|
562
|
+
* `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
|
|
563
|
+
* case the check exists for. A name assigned twice is dropped rather than
|
|
564
|
+
* guessed at — which value it holds at the call site is not decidable from
|
|
565
|
+
* text alone, and a wrong guess here would be reported as a plugin defect.
|
|
566
|
+
*/
|
|
567
|
+
export function configFormNamespaces(source) {
|
|
568
|
+
// Measured on a 750KB bundle: these scans cost ~9ms, because both walk
|
|
569
|
+
// every character, and this runs on a request the market polls. Almost no
|
|
570
|
+
// client mentions configForms at all, and — an order of magnitude rarer —
|
|
571
|
+
// almost none of those calls a method on it: for both, this pair of plain
|
|
572
|
+
// substring searches IS the whole cost (0.23ms measured), and only a client
|
|
573
|
+
// that actually asks for a config pays for a scan.
|
|
574
|
+
if (!source.includes('configForms') || !source.includes('configForms.get'))
|
|
575
|
+
return [];
|
|
576
|
+
const constants = new Map();
|
|
577
|
+
for (const match of source.matchAll(STRING_ASSIGNMENT)) {
|
|
578
|
+
const name = match[1];
|
|
579
|
+
const value = plainNamespace(match[3]);
|
|
580
|
+
constants.set(name, constants.has(name) || value === null ? null : value);
|
|
581
|
+
}
|
|
582
|
+
const namespaces = new Set();
|
|
583
|
+
for (const match of source.matchAll(CONFIG_FORMS_GET)) {
|
|
584
|
+
// A namespace is a loader entry id: no escapes, no whitespace runs, no
|
|
585
|
+
// arbitrary length. Anything else is not this pattern and stays out.
|
|
586
|
+
const value = plainNamespace(match[3] ?? '') ?? constants.get(match[1]) ?? null;
|
|
587
|
+
if (value !== null)
|
|
588
|
+
namespaces.add(value);
|
|
589
|
+
}
|
|
590
|
+
return [...namespaces];
|
|
591
|
+
}
|
|
592
|
+
/** The client bundle of an installed package, read; null when it has none to read. */
|
|
593
|
+
function readClientBundle(profile, name, explicitDir) {
|
|
594
|
+
const root = join(profileDir(profile, explicitDir), 'node_modules', name);
|
|
595
|
+
let manifest;
|
|
596
|
+
try {
|
|
597
|
+
manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
if (manifest.dsh?.client === undefined)
|
|
603
|
+
return null;
|
|
604
|
+
const exportsField = manifest.exports;
|
|
605
|
+
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
606
|
+
? clientBundlePath(exportsField['./client'])
|
|
607
|
+
: null;
|
|
608
|
+
if (relative === null)
|
|
609
|
+
return null;
|
|
610
|
+
try {
|
|
611
|
+
return readFileSync(join(root, relative), 'utf8');
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Installed plugins whose client reads its config from a name nothing serves
|
|
619
|
+
* (#810).
|
|
620
|
+
*
|
|
621
|
+
* `served` is the entry id set of the WHOLE composition — deliberately not
|
|
622
|
+
* this package's own ids: a package may legitimately mount another one
|
|
623
|
+
* (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
|
|
624
|
+
* answers what the package itself declares, which is the value to write
|
|
625
|
+
* instead — the report asked for the correct spelling to be shown, not just
|
|
626
|
+
* the mistake.
|
|
627
|
+
*
|
|
628
|
+
* The scan reads one bundle per installed plugin. Measured against the same
|
|
629
|
+
* cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
|
|
630
|
+
* bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
|
|
631
|
+
* made, not in a loop.
|
|
632
|
+
*/
|
|
633
|
+
export function findUnservedConfigNamespaces(profile, served, ownIdsOf, explicitDir) {
|
|
634
|
+
const found = [];
|
|
635
|
+
for (const name of Object.keys(readInstalled(profile, explicitDir))) {
|
|
636
|
+
const source = readClientBundle(profile, name, explicitDir);
|
|
637
|
+
if (source === null)
|
|
638
|
+
continue;
|
|
639
|
+
const unserved = configFormNamespaces(source).filter(namespace => !served.has(namespace));
|
|
640
|
+
if (unserved.length === 0)
|
|
641
|
+
continue;
|
|
642
|
+
found.push({ name, namespaces: unserved, ownEntryIds: ownIdsOf(name) });
|
|
643
|
+
}
|
|
644
|
+
return found;
|
|
645
|
+
}
|
|
535
646
|
/**
|
|
536
647
|
* Whether a package's client bundle still parses as JavaScript (#222).
|
|
537
648
|
*
|
|
@@ -598,34 +709,15 @@ export function newlyBrokenBundles(before, after) {
|
|
|
598
709
|
return after.filter(entry => !seen.has(entry.name));
|
|
599
710
|
}
|
|
600
711
|
export function checkClientBundle(profile, name, explicitDir) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
return { ok: true, reason: null };
|
|
608
|
-
}
|
|
609
|
-
if (manifest.dsh?.client === undefined)
|
|
712
|
+
// Declared but absent is verifyActivation's territory (a missing entry
|
|
713
|
+
// artifact is already `broken` there); duplicating it here would report one
|
|
714
|
+
// problem twice in two different vocabularies — so the reader's null covers
|
|
715
|
+
// both "no client bundle" and "declared but unreadable" with the same ok.
|
|
716
|
+
const source = readClientBundle(profile, name, explicitDir);
|
|
717
|
+
if (source === null)
|
|
610
718
|
return { ok: true, reason: null };
|
|
611
|
-
const exportsField = manifest.exports;
|
|
612
|
-
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
613
|
-
? clientBundlePath(exportsField['./client'])
|
|
614
|
-
: null;
|
|
615
|
-
if (relative === null)
|
|
616
|
-
return { ok: true, reason: null };
|
|
617
|
-
let source;
|
|
618
|
-
try {
|
|
619
|
-
source = readFileSync(join(root, relative), 'utf8');
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
// Declared but absent is verifyActivation's territory (a missing entry
|
|
623
|
-
// artifact is already `broken` there); duplicating it here would report
|
|
624
|
-
// one problem twice in two different vocabularies.
|
|
625
|
-
return { ok: true, reason: null };
|
|
626
|
-
}
|
|
627
719
|
try {
|
|
628
|
-
new Script(source, { filename:
|
|
720
|
+
new Script(source, { filename: `${name}/client` });
|
|
629
721
|
return { ok: true, reason: null };
|
|
630
722
|
}
|
|
631
723
|
catch (error) {
|
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.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -135,6 +135,16 @@ interface CheckReport {
|
|
|
135
135
|
* failure — informational, and the only place that names what is on disk.
|
|
136
136
|
*/
|
|
137
137
|
residuals?: ResidualDirectory[]
|
|
138
|
+
/**
|
|
139
|
+
* Installed plugins whose client reads its config from a name no loader
|
|
140
|
+
* entry serves (#810). Attached by the /check route, which is the only
|
|
141
|
+
* caller that pays for reading one client bundle per installed plugin.
|
|
142
|
+
*/
|
|
143
|
+
configNamespaces?: Array<{
|
|
144
|
+
name: string
|
|
145
|
+
namespaces: string[]
|
|
146
|
+
ownEntryIds: string[]
|
|
147
|
+
}>
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
/**
|
|
@@ -840,6 +850,35 @@ export function Diagnostics(props: { t: Translate }) {
|
|
|
840
850
|
</div>
|
|
841
851
|
</Section>
|
|
842
852
|
|
|
853
|
+
{/* A client that reads its config from a name no loader entry serves
|
|
854
|
+
(#810). This one IS a defect — the plugin's settings are blank or
|
|
855
|
+
unwritable right now — but it is the plugin's own, not the
|
|
856
|
+
profile's, so it stays out of the summary warnings: nothing the user
|
|
857
|
+
can do to this profile fixes it. The section title says whose
|
|
858
|
+
problem it is and the hint says who has to change it. */}
|
|
859
|
+
<Section
|
|
860
|
+
title={t('checkConfigNamespaces')}
|
|
861
|
+
count={report.configNamespaces?.length ?? 0}
|
|
862
|
+
empty={t('checkConfigNamespacesEmpty')}
|
|
863
|
+
overview={report.configNamespaces?.[0]?.name}
|
|
864
|
+
>
|
|
865
|
+
<p className={css.panelNote}>{t('checkConfigNamespacesHint')}</p>
|
|
866
|
+
<div className={css.diagList}>
|
|
867
|
+
{(report.configNamespaces ?? []).map(finding => (
|
|
868
|
+
<div key={finding.name} className={css.diagRow}>
|
|
869
|
+
<span className={css.nm}>{finding.name}</span>
|
|
870
|
+
<span className={css.spec}>
|
|
871
|
+
{finding.namespaces.map(namespace => t('checkConfigNamespaceRow').replace('{0}', namespace)).join(';')}
|
|
872
|
+
{' — '}
|
|
873
|
+
{finding.ownEntryIds.length > 0
|
|
874
|
+
? `${t('checkConfigNamespaceOwn')}: ${finding.ownEntryIds.join(', ')}`
|
|
875
|
+
: t('checkConfigNamespaceNone')}
|
|
876
|
+
</span>
|
|
877
|
+
</div>
|
|
878
|
+
))}
|
|
879
|
+
</div>
|
|
880
|
+
</Section>
|
|
881
|
+
|
|
843
882
|
{/* issue #98 phase 2 (step 1): community-bundle ordering */}
|
|
844
883
|
<CollapsibleSection title={t('orderSection')} count={order.length} open={orderOpen} onToggle={() => setOrderOpen(o => !o)}>
|
|
845
884
|
<p className={css.panelNote}>{t('orderDragHint')}</p>
|
|
@@ -2207,6 +2207,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2207
2207
|
)
|
|
2208
2208
|
/** Non-live activation results from the last operation, shown as a banner. */
|
|
2209
2209
|
const [activationWarnings, setActivationWarnings] = useState<{ name: string; info: ActivationInfo }[]>([])
|
|
2210
|
+
/**
|
|
2211
|
+
* Installed plugins whose client asks for a config namespace nothing serves
|
|
2212
|
+
* (#810). Server-computed, because the answer needs one client bundle read
|
|
2213
|
+
* per plugin and the client cannot see a bundle's contents.
|
|
2214
|
+
*/
|
|
2215
|
+
const [configNamespaceIssues, setConfigNamespaceIssues] = useState<Array<{ name: string; namespaces: string[]; ownEntryIds: string[] }>>([])
|
|
2210
2216
|
const [hostDependencyFindings, setHostDependencyFindings] = useState<SharedHostPackageDependencyFinding[]>([])
|
|
2211
2217
|
/** Plugin name awaiting uninstall confirmation (Modal). */
|
|
2212
2218
|
const [removeConfirm, setRemoveConfirm] = useState<string | null>(null)
|
|
@@ -2398,6 +2404,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2398
2404
|
}
|
|
2399
2405
|
if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled)
|
|
2400
2406
|
if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled)
|
|
2407
|
+
setConfigNamespaceIssues(Array.isArray(body.configNamespaces)
|
|
2408
|
+
? body.configNamespaces.filter((item: unknown): item is { name: string; namespaces: string[]; ownEntryIds: string[] } =>
|
|
2409
|
+
typeof item === 'object' && item !== null
|
|
2410
|
+
&& typeof (item as { name?: unknown }).name === 'string'
|
|
2411
|
+
&& Array.isArray((item as { namespaces?: unknown }).namespaces))
|
|
2412
|
+
: [])
|
|
2401
2413
|
if (body.groups && typeof body.groups === 'object') setGroups(body.groups)
|
|
2402
2414
|
if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder)
|
|
2403
2415
|
if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url: unknown): url is string => typeof url === 'string'))
|
|
@@ -5806,6 +5818,21 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5806
5818
|
</span>
|
|
5807
5819
|
</div>
|
|
5808
5820
|
)}
|
|
5821
|
+
{tab === 'installed' && configNamespaceIssues.length > 0 && (
|
|
5822
|
+
<div className={css.banner}>
|
|
5823
|
+
<IconWarningOutline16 size={14} className={css.bannerIcon} />
|
|
5824
|
+
<span className={css.grow}>
|
|
5825
|
+
{configNamespaceIssues.map(finding => (
|
|
5826
|
+
<div key={finding.name}>
|
|
5827
|
+
{t('configNamespaceNotice')
|
|
5828
|
+
.replace('{0}', finding.name)
|
|
5829
|
+
.replace('{1}', finding.namespaces.join('、'))
|
|
5830
|
+
.replace('{2}', finding.ownEntryIds.join('、') || '—')}
|
|
5831
|
+
</div>
|
|
5832
|
+
))}
|
|
5833
|
+
</span>
|
|
5834
|
+
</div>
|
|
5835
|
+
)}
|
|
5809
5836
|
{tab === 'installed' && <HostDependencyDiagnostics findings={hostDependencyFindings} t={t} />}
|
|
5810
5837
|
{tab === 'installed' && brokenPluginNames.length > 0 && (
|
|
5811
5838
|
<div className={css.brokenPluginNotice}>
|
package/src/client/locales.ts
CHANGED
|
@@ -497,6 +497,7 @@ export const zh = {
|
|
|
497
497
|
// A plugin the market had to stop declaring (#663). Plain about what
|
|
498
498
|
// happened, because the user's plugin is simply gone from the list and
|
|
499
499
|
// nothing else in the UI can explain that.
|
|
500
|
+
configNamespaceNotice: '{0} 的设置面板读不到配置:它按「{1}」读取,而这个环境里没有这个名字的条目 id。它自己的条目是「{2}」,插件客户端应该改成读那个——这是插件自身的问题,市场无法代改。',
|
|
500
501
|
brokenPluginTitle: '{0} 更新失败后被移除了',
|
|
501
502
|
brokenPluginBody: 'DSH 正在运行时无法更新,而更新前的版本也已经损坏。为避免下次启动卡住,市场把它从 profile 里移除。插件目录还在原处,退出 DSH 后重新安装即可。',
|
|
502
503
|
brokenPluginTitleCancelled: '{0} 取消更新后被移除了',
|
|
@@ -603,6 +604,12 @@ export const zh = {
|
|
|
603
604
|
checkResidualsHint: '这些目录不被任何插件使用,也不影响启动,是更新中断后留下的。退出 DSH 后可以删除,或重新安装对应的插件。',
|
|
604
605
|
checkResidualTmp: 'pnpm 临时目录',
|
|
605
606
|
checkResidualIncomplete: '残缺的包目录(缺少 package.json)',
|
|
607
|
+
checkConfigNamespaces: '设置面板读不到配置的插件',
|
|
608
|
+
checkConfigNamespacesEmpty: '没有发现这类插件。',
|
|
609
|
+
checkConfigNamespacesHint: '插件面板按「条目 id」读自己的配置,写错名字不会报错,只会静默读不到值:面板空白、存不进去,插件会退回它写死的默认值(画布类插件因此白屏)。这是插件客户端里的名字写错了,市场改不了它,需要插件作者改一行代码。',
|
|
610
|
+
checkConfigNamespaceRow: '它按「{0}」读配置,但这不是任何条目 id',
|
|
611
|
+
checkConfigNamespaceOwn: '它自己的条目 id',
|
|
612
|
+
checkConfigNamespaceNone: '它自己没有声明任何条目 id',
|
|
606
613
|
checkOrphans: '无效的配置条目',
|
|
607
614
|
checkOrphansEmpty: '没有无效配置条目',
|
|
608
615
|
// Orphan patch reason → badge label (issue #98 supplement).
|
|
@@ -1217,6 +1224,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
1217
1224
|
toggleFail: 'Toggle failed',
|
|
1218
1225
|
deprecatedBadge: 'Deprecated',
|
|
1219
1226
|
deprecatedWarn: 'This plugin is marked as deprecated by the catalog; new users are advised against installing it.',
|
|
1227
|
+
configNamespaceNotice: '{0}\'s settings panel cannot read its config: it asks for "{1}", and no entry id in this environment has that name. Its own entry is "{2}", which is what the plugin\'s client should ask for — the mistake is in the plugin, and the market cannot change it.',
|
|
1220
1228
|
brokenPluginTitle: '{0} was removed after a failed update',
|
|
1221
1229
|
brokenPluginBody: 'The running DSH blocked the update, and the version it was replacing was already damaged. The market removed it from the profile so the next start does not fail. Its directory is untouched — quit DSH and install it again.',
|
|
1222
1230
|
brokenPluginTitleCancelled: '{0} was removed after an update was cancelled',
|
|
@@ -1323,6 +1331,12 @@ export const en: Record<MarketKey, string> = {
|
|
|
1323
1331
|
checkResidualsHint: 'No plugin uses these directories and they do not stop DSH from starting: an interrupted update left them behind. Quit DSH, then delete them or reinstall the affected plugin.',
|
|
1324
1332
|
checkResidualTmp: 'pnpm temp directory',
|
|
1325
1333
|
checkResidualIncomplete: 'incomplete package (no package.json)',
|
|
1334
|
+
checkConfigNamespaces: 'Plugins whose settings read the wrong name',
|
|
1335
|
+
checkConfigNamespacesEmpty: 'No plugin does this.',
|
|
1336
|
+
checkConfigNamespacesHint: 'A plugin panel reads its own config by ENTRY ID. A wrong name there reports nothing: the panel is blank and cannot save, and the plugin falls back to whatever default it hardcoded (a canvas panel shows a white screen). The name is wrong inside the plugin\'s own client bundle — the market cannot fix it, its author has to change one line.',
|
|
1337
|
+
checkConfigNamespaceRow: 'it reads its config from "{0}", which is not an entry id',
|
|
1338
|
+
checkConfigNamespaceOwn: 'its own entry id',
|
|
1339
|
+
checkConfigNamespaceNone: 'it declares no entry id of its own',
|
|
1326
1340
|
checkOrphans: 'Invalid config entries',
|
|
1327
1341
|
checkOrphansEmpty: 'No invalid config entries',
|
|
1328
1342
|
// Orphan patch reason → badge label (issue #98 supplement).
|
package/src/routes.ts
CHANGED
|
@@ -56,7 +56,7 @@ import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
|
|
|
56
56
|
import { resolveDshHome } from './home-paths.ts'
|
|
57
57
|
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
|
|
58
58
|
import type { RecoveryPlugin } from './recovery.ts'
|
|
59
|
-
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
59
|
+
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
60
60
|
import {
|
|
61
61
|
carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
|
|
62
62
|
readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
|
|
@@ -701,6 +701,16 @@ export function mountMarketRoutes(
|
|
|
701
701
|
* newer decision than the market's list (the market removes a name from
|
|
702
702
|
* its list BEFORE writing its own enable row), so the list follows it.
|
|
703
703
|
*
|
|
704
|
+
* Scoped to THIS profile's layer, deliberately (#519). DSH also composes a
|
|
705
|
+
* home-level layer, and a third-party manager that wrote its switches there
|
|
706
|
+
* asked us to follow that too. We do not: the market writes the profile
|
|
707
|
+
* layer and nothing else, so following a layer it cannot write would give
|
|
708
|
+
* the user a switch that appears to work and silently reverts at the next
|
|
709
|
+
* boot — worse than a boundary they can see. What a home-level decision
|
|
710
|
+
* DOES get is a name in the log when the market overrides it (see
|
|
711
|
+
* `noteHomeLayerConflict`), so nobody has to guess why a plugin came back
|
|
712
|
+
* off.
|
|
713
|
+
*
|
|
704
714
|
* @returns true when the package was re-enabled elsewhere and has been
|
|
705
715
|
* dropped from the market's disable list.
|
|
706
716
|
*/
|
|
@@ -714,6 +724,27 @@ export function mountMarketRoutes(
|
|
|
714
724
|
return true
|
|
715
725
|
}
|
|
716
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Say so when the market overrides an enable recorded in the HOME patch
|
|
729
|
+
* layer (#519).
|
|
730
|
+
*
|
|
731
|
+
* The market keeps a plugin off from its own record; a decision in the
|
|
732
|
+
* profile layer is followed instead (`followOutsideEnable`), but the home
|
|
733
|
+
* layer is not read for it — see there for why. The cost of that boundary
|
|
734
|
+
* is a plugin that turns off again on every boot while another manager's
|
|
735
|
+
* layer says it should be on: a perfectly reasonable thing for a user to be
|
|
736
|
+
* confused by, and its only explanation today was silence.
|
|
737
|
+
*/
|
|
738
|
+
function noteHomeLayerConflict(name: string): void {
|
|
739
|
+
const homePatchPath = join(resolveDshHome(), 'cordis.patch.yml')
|
|
740
|
+
const home = readUserPatchState(homePatchPath)
|
|
741
|
+
if (home.forced.length === 0) return
|
|
742
|
+
const flags = packagePatchFlags(host, activeProfileDir, [name], home)
|
|
743
|
+
if (!flags.forced.includes(name) || flags.disabled.includes(name)) return
|
|
744
|
+
logEvent('warn', 'toggle',
|
|
745
|
+
`${name}: kept off by this market's own record, while ${homePatchPath} enables it — that layer is not the one the market writes, so the market will not follow it. Move the row into this profile's cordis.patch.yml to make it stick (#519)`)
|
|
746
|
+
}
|
|
747
|
+
|
|
717
748
|
// Client-only packages (dsh.client without dsh.bundle) are invisible to the
|
|
718
749
|
// bundle layer in every boot; the market shim-mounts them so their client
|
|
719
750
|
// bundles are actually served.
|
|
@@ -725,6 +756,7 @@ export function mountMarketRoutes(
|
|
|
725
756
|
// shims for disabled plugins were already skipped by mountClientOnlyDeps.
|
|
726
757
|
for (const name of [...disabled]) {
|
|
727
758
|
if (followOutsideEnable(name)) continue
|
|
759
|
+
noteHomeLayerConflict(name)
|
|
728
760
|
if (await themes.setEntryDisabled(name, true)) logEvent('info', 'boot', `plugin kept off: ${name}`)
|
|
729
761
|
}
|
|
730
762
|
})
|
|
@@ -2656,6 +2688,37 @@ export function mountMarketRoutes(
|
|
|
2656
2688
|
packageName,
|
|
2657
2689
|
manifest,
|
|
2658
2690
|
})))
|
|
2691
|
+
// #810: a client that reads its config from a name no loader entry
|
|
2692
|
+
// answers to reads NOTHING — blank or unwritable settings, and in the
|
|
2693
|
+
// reported case a panel stuck on a hardcoded fallback port — with no
|
|
2694
|
+
// error anywhere. Judged against everything this profile can be seen
|
|
2695
|
+
// to serve: each installed package's own patch rows (read once, for
|
|
2696
|
+
// the set AND for the "write this instead" value), the user patch's
|
|
2697
|
+
// rows — its inserts, plus the disables and force-enables, which both
|
|
2698
|
+
// name existing entry ids — and whatever the loader is running now.
|
|
2699
|
+
//
|
|
2700
|
+
// The gap: host-layer ids the market never reads a patch for. A plugin
|
|
2701
|
+
// reading a HOST entry's config by literal would be flagged wrongly
|
|
2702
|
+
// here, where the diagnostics page — which composes the host layers
|
|
2703
|
+
// too — can settle that difference.
|
|
2704
|
+
const ownEntryIds = new Map(Object.keys(installed).map(
|
|
2705
|
+
packageName => [packageName, bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', packageName))] as const,
|
|
2706
|
+
))
|
|
2707
|
+
const servedEntryIds = new Set<string>([...patch.inserts, ...patch.disables, ...patch.forced])
|
|
2708
|
+
for (const ids of ownEntryIds.values()) for (const id of ids) servedEntryIds.add(id)
|
|
2709
|
+
for (const entry of host.loader.entries()) {
|
|
2710
|
+
const id = entry.options?.id
|
|
2711
|
+
if (id === undefined || id === '') continue
|
|
2712
|
+
servedEntryIds.add(id)
|
|
2713
|
+
const bare = id.split(':').pop()
|
|
2714
|
+
if (bare !== undefined) servedEntryIds.add(bare)
|
|
2715
|
+
}
|
|
2716
|
+
const configNamespaces = findUnservedConfigNamespaces(
|
|
2717
|
+
activeProfileDir,
|
|
2718
|
+
servedEntryIds,
|
|
2719
|
+
name => ownEntryIds.get(name) ?? [],
|
|
2720
|
+
activeProfileDir,
|
|
2721
|
+
)
|
|
2659
2722
|
sendJson(response, 200, {
|
|
2660
2723
|
profile: config.profile,
|
|
2661
2724
|
installed,
|
|
@@ -2683,6 +2746,9 @@ export function mountMarketRoutes(
|
|
|
2683
2746
|
brokenPlugins: marketState.brokenPlugins ?? {},
|
|
2684
2747
|
patchForced: patchFlags.forced,
|
|
2685
2748
|
bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
|
|
2749
|
+
// Only packages with a finding appear, so a healthy profile sends
|
|
2750
|
+
// nothing and the row renders nothing.
|
|
2751
|
+
configNamespaces: configNamespaces.length > 0 ? configNamespaces : undefined,
|
|
2686
2752
|
})
|
|
2687
2753
|
},
|
|
2688
2754
|
}),
|
|
@@ -2708,7 +2774,25 @@ export function mountMarketRoutes(
|
|
|
2708
2774
|
? classifyPeer(row.plugin, row.name, row.range, row.resolved, row.optional === true)
|
|
2709
2775
|
: { kind: 'none' }
|
|
2710
2776
|
}
|
|
2711
|
-
|
|
2777
|
+
// #810: a client that reads its config from a name no loader entry
|
|
2778
|
+
// answers to. Attached here rather than computed inside
|
|
2779
|
+
// `analyzeProfile` because that function is documented as safe on
|
|
2780
|
+
// every market open and this reads one bundle per installed plugin —
|
|
2781
|
+
// the diagnostics page is a page the user opened on purpose.
|
|
2782
|
+
//
|
|
2783
|
+
// Judged against the COMPOSITION's ids, not the package's own: a
|
|
2784
|
+
// package may mount another one (carrier form), and judging by own
|
|
2785
|
+
// ids would flag it.
|
|
2786
|
+
const served = new Set(report.rows.map(row => row.id))
|
|
2787
|
+
sendJson(response, 200, {
|
|
2788
|
+
...report,
|
|
2789
|
+
configNamespaces: findUnservedConfigNamespaces(
|
|
2790
|
+
activeProfileDir,
|
|
2791
|
+
served,
|
|
2792
|
+
name => report.rows.filter(row => row.layer === name).map(row => row.id),
|
|
2793
|
+
activeProfileDir,
|
|
2794
|
+
),
|
|
2795
|
+
})
|
|
2712
2796
|
} catch (error) {
|
|
2713
2797
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
2714
2798
|
}
|
package/src/themes.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* persisted across restarts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
8
9
|
import { join } from 'node:path'
|
|
9
10
|
import { loadRegistry, pluginCategories } from './registry.ts'
|
|
10
11
|
import { hotMount, hotUnmount, listHotMounts, writeDisabled } from './hot.ts'
|
|
@@ -12,7 +13,7 @@ import { logEvent } from './log.ts'
|
|
|
12
13
|
import { LifecyclePendingError, LifecycleWaitError, waitForLifecycle } from './lifecycle.ts'
|
|
13
14
|
import { nameMatchesPackage } from './entry-identity.ts'
|
|
14
15
|
import { bundlePatchInsertedIds, profileDir, readInstalled } from './profile.ts'
|
|
15
|
-
import {
|
|
16
|
+
import { parseSourceUrl, repoOfTarget } from './sources.ts'
|
|
16
17
|
|
|
17
18
|
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
18
19
|
export interface LoaderEntry {
|
|
@@ -34,6 +35,29 @@ export interface ThemeManager {
|
|
|
34
35
|
activateTheme(name: string): Promise<boolean>
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Whether an installed package declares itself a skin.
|
|
40
|
+
*
|
|
41
|
+
* The market's own criterion for "this is a theme" is the catalog, and the
|
|
42
|
+
* catalog does not list every skin a monorepo ships: the reporter's second
|
|
43
|
+
* skin is absent from it entirely, while the MANAGER that ships with them is
|
|
44
|
+
* a package from the same repo. Judging by repo alone made the manager a
|
|
45
|
+
* theme, so activating any other theme switched it off — and with it the
|
|
46
|
+
* user's own skin-management UI (#519). Both skins ship `skin.json` at their
|
|
47
|
+
* root (it names the package and its wiring); the manager ships none, which
|
|
48
|
+
* is the distinction the reporter pointed at.
|
|
49
|
+
*/
|
|
50
|
+
function hasSkinManifest(profileDirectory: string, name: string): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const manifest = JSON.parse(
|
|
53
|
+
readFileSync(join(profileDirectory, 'node_modules', name, 'skin.json'), 'utf8'),
|
|
54
|
+
) as { id?: unknown }
|
|
55
|
+
return typeof manifest.id === 'string' && manifest.id !== ''
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
/**
|
|
38
62
|
* Whether a loader entry belongs to `packageName`.
|
|
39
63
|
*
|
|
@@ -83,16 +107,41 @@ export function createThemeManager(
|
|
|
83
107
|
const registry = await loadRegistry()
|
|
84
108
|
const themeEntries = registry.plugins.filter(p => pluginCategories(p).includes('theme'))
|
|
85
109
|
const themeNames = new Set(themeEntries.map(p => p.name))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
110
|
+
// Repo → the subdirectories its catalog entries point at, plus whether
|
|
111
|
+
// any entry is the repo itself. One repository can ship several PACKAGES
|
|
112
|
+
// — a skin manager and its skins — and matching by repo alone made every
|
|
113
|
+
// one of them a theme, so activating another theme switched the manager
|
|
114
|
+
// off too, taking the user's own management UI with it (#519).
|
|
115
|
+
const themeRepos = new Map<string, { root: boolean; subpaths: Set<string> }>()
|
|
116
|
+
for (const entry of themeEntries) {
|
|
117
|
+
const parsed = parseSourceUrl(entry.url)
|
|
118
|
+
if (parsed === null) continue
|
|
119
|
+
const key = parsed.repo.toLowerCase()
|
|
120
|
+
const record = themeRepos.get(key) ?? { root: false, subpaths: new Set<string>() }
|
|
121
|
+
if (parsed.subpath === null) record.root = true
|
|
122
|
+
else record.subpaths.add(parsed.subpath.toLowerCase())
|
|
123
|
+
themeRepos.set(key, record)
|
|
124
|
+
}
|
|
89
125
|
for (const [name, spec] of Object.entries(readInstalled(profile, activeProfileDir))) {
|
|
90
126
|
if (themeNames.has(name)) {
|
|
91
127
|
names.add(name)
|
|
92
128
|
continue
|
|
93
129
|
}
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
130
|
+
const identity = repoOfTarget(String(spec).toLowerCase())
|
|
131
|
+
if (identity === null) continue
|
|
132
|
+
const at = identity.indexOf('#path:/')
|
|
133
|
+
const repo = at === -1 ? identity : identity.slice(0, at)
|
|
134
|
+
const subpath = at === -1 ? null : identity.slice(at + '#path:/'.length)
|
|
135
|
+
const record = themeRepos.get(repo)
|
|
136
|
+
if (record === undefined) continue
|
|
137
|
+
// Only a repo whose themes ALL live in subdirectories can be told
|
|
138
|
+
// apart this way. A catalog entry at the repo root says nothing about
|
|
139
|
+
// subdirectories, so that shape keeps the permissive match it had —
|
|
140
|
+
// which is also what keeps a monorepo skin working when the entry
|
|
141
|
+
// points at the repo.
|
|
142
|
+
if (subpath !== null && !record.root && record.subpaths.size > 0
|
|
143
|
+
&& !record.subpaths.has(subpath) && !hasSkinManifest(activeProfileDir, name)) continue
|
|
144
|
+
names.add(name)
|
|
96
145
|
}
|
|
97
146
|
} catch { /* registry unavailable — nothing classifies as a theme */ }
|
|
98
147
|
return names
|
package/src/verify.ts
CHANGED
|
@@ -615,6 +615,129 @@ export interface BundleCheck {
|
|
|
615
615
|
reason: string | null
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
/**
|
|
619
|
+
* One assignment of a string literal to a name, and the `configForms.get`
|
|
620
|
+
* call sites (#810).
|
|
621
|
+
*
|
|
622
|
+
* The namespace a client asks its config from is a PROFILE ENTRY ID, not a
|
|
623
|
+
* package name. A client that writes the package name reads nothing: the
|
|
624
|
+
* settings document has no entry under it, the call returns empty, and the
|
|
625
|
+
* plugin degrades silently — blank or unwritable settings forms, and in the
|
|
626
|
+
* reported case a panel stuck on a hardcoded fallback port. Nothing logs it,
|
|
627
|
+
* and nothing on the market side noticed.
|
|
628
|
+
*
|
|
629
|
+
* `configForms.get` is matched by name, so an aliased receiver
|
|
630
|
+
* (`const forms = ctx.configForms; forms.get("x")`) is not seen — the same
|
|
631
|
+
* honesty as the syntax check above: this reports what it can prove, and
|
|
632
|
+
* says nothing otherwise.
|
|
633
|
+
*/
|
|
634
|
+
const STRING_ASSIGNMENT = /(?:^|[^\w$.])(?:var|let|const)?\s*([A-Za-z_$][\w$]*)\s*=\s*(['"])([^'"\\\n]*)\2/gu
|
|
635
|
+
const CONFIG_FORMS_GET = /configForms\s*\.\s*get\s*\(\s*(?:([A-Za-z_$][\w$]*)|(['"])([^'"\\\n]*)\2)\s*\)/gu
|
|
636
|
+
|
|
637
|
+
/** A value that is a plain loader entry id: a backslash means it is not one. */
|
|
638
|
+
function plainNamespace(value: string): string | null {
|
|
639
|
+
return value !== '' && value.length <= 200 && !value.includes('\\') && /^[\w@/.:-]+$/u.test(value) ? value : null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Every namespace a client bundle asks its config from (#810).
|
|
644
|
+
*
|
|
645
|
+
* One level of constant propagation, as asked for in the report: the plugin
|
|
646
|
+
* that started this writes `var NAMESPACE = "dsh-comfyui-canvas"` and calls
|
|
647
|
+
* `configForms.get(NAMESPACE)`, so a literal-only scan would miss the very
|
|
648
|
+
* case the check exists for. A name assigned twice is dropped rather than
|
|
649
|
+
* guessed at — which value it holds at the call site is not decidable from
|
|
650
|
+
* text alone, and a wrong guess here would be reported as a plugin defect.
|
|
651
|
+
*/
|
|
652
|
+
export function configFormNamespaces(source: string): string[] {
|
|
653
|
+
// Measured on a 750KB bundle: these scans cost ~9ms, because both walk
|
|
654
|
+
// every character, and this runs on a request the market polls. Almost no
|
|
655
|
+
// client mentions configForms at all, and — an order of magnitude rarer —
|
|
656
|
+
// almost none of those calls a method on it: for both, this pair of plain
|
|
657
|
+
// substring searches IS the whole cost (0.23ms measured), and only a client
|
|
658
|
+
// that actually asks for a config pays for a scan.
|
|
659
|
+
if (!source.includes('configForms') || !source.includes('configForms.get')) return []
|
|
660
|
+
const constants = new Map<string, string | null>()
|
|
661
|
+
for (const match of source.matchAll(STRING_ASSIGNMENT)) {
|
|
662
|
+
const name = match[1]!
|
|
663
|
+
const value = plainNamespace(match[3]!)
|
|
664
|
+
constants.set(name, constants.has(name) || value === null ? null : value)
|
|
665
|
+
}
|
|
666
|
+
const namespaces = new Set<string>()
|
|
667
|
+
for (const match of source.matchAll(CONFIG_FORMS_GET)) {
|
|
668
|
+
// A namespace is a loader entry id: no escapes, no whitespace runs, no
|
|
669
|
+
// arbitrary length. Anything else is not this pattern and stays out.
|
|
670
|
+
const value = plainNamespace(match[3] ?? '') ?? constants.get(match[1]!) ?? null
|
|
671
|
+
if (value !== null) namespaces.add(value)
|
|
672
|
+
}
|
|
673
|
+
return [...namespaces]
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** A package whose client asks for a namespace nothing in the composition serves (#810). */
|
|
677
|
+
export interface UnservedConfigNamespace {
|
|
678
|
+
/** The installed package whose client bundle asks. */
|
|
679
|
+
name: string
|
|
680
|
+
/** The namespaces it asks for that no loader entry id answers to. */
|
|
681
|
+
namespaces: string[]
|
|
682
|
+
/** Entry ids this package's own patch declares — the value it meant. */
|
|
683
|
+
ownEntryIds: string[]
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The client bundle of an installed package, read; null when it has none to read. */
|
|
687
|
+
function readClientBundle(profile: string, name: string, explicitDir?: string): string | null {
|
|
688
|
+
const root = join(profileDir(profile, explicitDir), 'node_modules', name)
|
|
689
|
+
let manifest: { dsh?: { client?: unknown }; exports?: unknown }
|
|
690
|
+
try {
|
|
691
|
+
manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as typeof manifest
|
|
692
|
+
} catch {
|
|
693
|
+
return null
|
|
694
|
+
}
|
|
695
|
+
if (manifest.dsh?.client === undefined) return null
|
|
696
|
+
const exportsField = manifest.exports
|
|
697
|
+
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
698
|
+
? clientBundlePath((exportsField as Record<string, unknown>)['./client'])
|
|
699
|
+
: null
|
|
700
|
+
if (relative === null) return null
|
|
701
|
+
try {
|
|
702
|
+
return readFileSync(join(root, relative), 'utf8')
|
|
703
|
+
} catch {
|
|
704
|
+
return null
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Installed plugins whose client reads its config from a name nothing serves
|
|
710
|
+
* (#810).
|
|
711
|
+
*
|
|
712
|
+
* `served` is the entry id set of the WHOLE composition — deliberately not
|
|
713
|
+
* this package's own ids: a package may legitimately mount another one
|
|
714
|
+
* (carrier form), and judging by own ids alone would flag those. `ownIdsOf`
|
|
715
|
+
* answers what the package itself declares, which is the value to write
|
|
716
|
+
* instead — the report asked for the correct spelling to be shown, not just
|
|
717
|
+
* the mistake.
|
|
718
|
+
*
|
|
719
|
+
* The scan reads one bundle per installed plugin. Measured against the same
|
|
720
|
+
* cost the syntax sweep above already pays (0.40ms per plugin on a 385KB
|
|
721
|
+
* bundle), so a 30-plugin profile is ~12ms — this runs on a request a user
|
|
722
|
+
* made, not in a loop.
|
|
723
|
+
*/
|
|
724
|
+
export function findUnservedConfigNamespaces(
|
|
725
|
+
profile: string,
|
|
726
|
+
served: ReadonlySet<string>,
|
|
727
|
+
ownIdsOf: (name: string) => string[],
|
|
728
|
+
explicitDir?: string,
|
|
729
|
+
): UnservedConfigNamespace[] {
|
|
730
|
+
const found: UnservedConfigNamespace[] = []
|
|
731
|
+
for (const name of Object.keys(readInstalled(profile, explicitDir))) {
|
|
732
|
+
const source = readClientBundle(profile, name, explicitDir)
|
|
733
|
+
if (source === null) continue
|
|
734
|
+
const unserved = configFormNamespaces(source).filter(namespace => !served.has(namespace))
|
|
735
|
+
if (unserved.length === 0) continue
|
|
736
|
+
found.push({ name, namespaces: unserved, ownEntryIds: ownIdsOf(name) })
|
|
737
|
+
}
|
|
738
|
+
return found
|
|
739
|
+
}
|
|
740
|
+
|
|
618
741
|
/**
|
|
619
742
|
* Whether a package's client bundle still parses as JavaScript (#222).
|
|
620
743
|
*
|
|
@@ -686,30 +809,14 @@ export function newlyBrokenBundles(
|
|
|
686
809
|
}
|
|
687
810
|
|
|
688
811
|
export function checkClientBundle(profile: string, name: string, explicitDir?: string): BundleCheck {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
if (manifest.dsh?.client === undefined) return { ok: true, reason: null }
|
|
697
|
-
const exportsField = manifest.exports
|
|
698
|
-
const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
|
|
699
|
-
? clientBundlePath((exportsField as Record<string, unknown>)['./client'])
|
|
700
|
-
: null
|
|
701
|
-
if (relative === null) return { ok: true, reason: null }
|
|
702
|
-
let source: string
|
|
703
|
-
try {
|
|
704
|
-
source = readFileSync(join(root, relative), 'utf8')
|
|
705
|
-
} catch {
|
|
706
|
-
// Declared but absent is verifyActivation's territory (a missing entry
|
|
707
|
-
// artifact is already `broken` there); duplicating it here would report
|
|
708
|
-
// one problem twice in two different vocabularies.
|
|
709
|
-
return { ok: true, reason: null }
|
|
710
|
-
}
|
|
812
|
+
// Declared but absent is verifyActivation's territory (a missing entry
|
|
813
|
+
// artifact is already `broken` there); duplicating it here would report one
|
|
814
|
+
// problem twice in two different vocabularies — so the reader's null covers
|
|
815
|
+
// both "no client bundle" and "declared but unreadable" with the same ok.
|
|
816
|
+
const source = readClientBundle(profile, name, explicitDir)
|
|
817
|
+
if (source === null) return { ok: true, reason: null }
|
|
711
818
|
try {
|
|
712
|
-
new Script(source, { filename:
|
|
819
|
+
new Script(source, { filename: `${name}/client` })
|
|
713
820
|
return { ok: true, reason: null }
|
|
714
821
|
} catch (error) {
|
|
715
822
|
const message = error instanceof Error ? error.message : String(error)
|