dshmarket 1.66.19 → 1.66.21
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/profile.js +61 -40
- package/lib/recovery.js +10 -0
- package/lib/regions.js +27 -1
- package/lib/restart.js +22 -2
- package/lib/routes.js +78 -2
- package/lib/themes.js +59 -5
- package/lib/types/profile.d.ts +11 -0
- 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/profile.ts +58 -36
- package/src/recovery.ts +10 -0
- package/src/regions.ts +28 -1
- package/src/restart.ts +22 -2
- 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/profile.js
CHANGED
|
@@ -8,7 +8,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
8
8
|
import { isDeepStrictEqual } from 'node:util';
|
|
9
9
|
import { JSON_SCHEMA, load } from 'js-yaml';
|
|
10
10
|
import { resolveDshHome } from './home-paths.js';
|
|
11
|
-
import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
11
|
+
import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
12
12
|
/**
|
|
13
13
|
* Whether a profile name follows DSH's own directory-name contract.
|
|
14
14
|
*
|
|
@@ -680,9 +680,20 @@ export function readLockCommits(profile, explicitDir) {
|
|
|
680
680
|
* by pnpm's `path:` selector, so the spec's subpath has to match too — and
|
|
681
681
|
* when the spec names no subpath while several entries of that remote do,
|
|
682
682
|
* there is no answer rather than the first sibling's commit (#632).
|
|
683
|
+
*
|
|
684
|
+
* The key on both sides of the comparison is `hostedRepoKey`, the identity
|
|
685
|
+
* the rest of the market already compares specs by (#851). This function
|
|
686
|
+
* used to derive its own, and that second spelling lost every install pnpm
|
|
687
|
+
* writes the shorthand back for: it demanded an `https?://` prefix or a
|
|
688
|
+
* `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
|
|
689
|
+
* the `.git` suffix, so the spec side `https://github.com/o/r` could never
|
|
690
|
+
* equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
|
|
691
|
+
* shorthand therefore had no commit, and with no commit the update route
|
|
692
|
+
* refuses to roll back — the market reports "cannot confirm the commit before
|
|
693
|
+
* the update" for a lockfile that states it plainly.
|
|
683
694
|
*/
|
|
684
695
|
export function readGitResolutionCommit(profile, spec, explicitDir) {
|
|
685
|
-
const want =
|
|
696
|
+
const want = hostedRepoKey(spec);
|
|
686
697
|
if (want === null)
|
|
687
698
|
return null;
|
|
688
699
|
const wantPath = gitSubpathSelector(spec);
|
|
@@ -695,7 +706,7 @@ export function readGitResolutionCommit(profile, spec, explicitDir) {
|
|
|
695
706
|
const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
|
|
696
707
|
if (commit === null || repo === null)
|
|
697
708
|
continue;
|
|
698
|
-
if (
|
|
709
|
+
if (hostedRepoKey(repo[1]) !== want)
|
|
699
710
|
continue;
|
|
700
711
|
const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null;
|
|
701
712
|
if (wantPath !== null) {
|
|
@@ -722,23 +733,6 @@ function gitSubpathSelector(spec) {
|
|
|
722
733
|
const trimmed = selector.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
723
734
|
return trimmed === '' ? null : trimmed;
|
|
724
735
|
}
|
|
725
|
-
/** Lowercased transport-agnostic key for comparing two git remote spellings. */
|
|
726
|
-
function normalizeGitRepoKey(spec) {
|
|
727
|
-
let remote = spec.trim().replace(/^git\+/i, '');
|
|
728
|
-
const scp = /^git@([^:]+):(.+)$/.exec(remote);
|
|
729
|
-
if (scp !== null)
|
|
730
|
-
remote = `https://${scp[1]}/${scp[2].replace(/^\/*/, '')}`;
|
|
731
|
-
const hash = remote.indexOf('#');
|
|
732
|
-
if (hash !== -1)
|
|
733
|
-
remote = remote.slice(0, hash);
|
|
734
|
-
const query = remote.indexOf('?');
|
|
735
|
-
if (query !== -1)
|
|
736
|
-
remote = remote.slice(0, query);
|
|
737
|
-
remote = remote.replace(/\/+$/, '').toLowerCase();
|
|
738
|
-
if (!/^https?:\/\//.test(remote) && !remote.includes('.git'))
|
|
739
|
-
return null;
|
|
740
|
-
return remote;
|
|
741
|
-
}
|
|
742
736
|
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
743
737
|
export function hasDshManifest(dir) {
|
|
744
738
|
try {
|
|
@@ -859,32 +853,52 @@ export function parsePatchRows(text) {
|
|
|
859
853
|
// and the market uninstalled the newcomer over a clash the host does
|
|
860
854
|
// not have.
|
|
861
855
|
let payloadIndent = null;
|
|
862
|
-
|
|
856
|
+
const isDash = (index) => /^-(?:\s|$)/u.test(rows[index].trimmed);
|
|
857
|
+
/**
|
|
858
|
+
* Column where a line's key text starts: its indent plus any `- ` marker.
|
|
859
|
+
* A row's keys share this column whether written on the marker line or
|
|
860
|
+
* below it, however many spaces follow the `-`.
|
|
861
|
+
*/
|
|
862
|
+
const keyCol = (index) => {
|
|
863
|
+
// A bare `-` puts the row's keys on the lines below it.
|
|
864
|
+
if (rows[index].trimmed === '-') {
|
|
865
|
+
const next = rows[index + 1];
|
|
866
|
+
return next !== undefined && next.indent > rows[index].indent ? next.indent : rows[index].indent + 2;
|
|
867
|
+
}
|
|
868
|
+
const dash = /^-\s+/u.exec(rows[index].trimmed);
|
|
869
|
+
return rows[index].indent + (dash === null ? 0 : dash[0].length);
|
|
870
|
+
};
|
|
871
|
+
/** Index of the `- ` line that opens the row holding `from`, or -1. */
|
|
863
872
|
const rowStart = (from) => {
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
for (let j = from - 1; j >= 0; j--) {
|
|
868
|
-
if (isRowStart(j))
|
|
873
|
+
const col = keyCol(from);
|
|
874
|
+
for (let j = from; j >= 0; j--) {
|
|
875
|
+
if (isDash(j) && keyCol(j) === col)
|
|
869
876
|
return j;
|
|
877
|
+
// Shallower than the row's keys: left the row without finding its marker.
|
|
878
|
+
if (rows[j].indent < col)
|
|
879
|
+
return -1;
|
|
870
880
|
}
|
|
871
|
-
return
|
|
881
|
+
return -1;
|
|
872
882
|
};
|
|
873
883
|
/**
|
|
874
884
|
* Whether the insert row holding the `config:` at `from` declares
|
|
875
885
|
* `group: true`. YAML gives a mapping's keys no order, so this reads the
|
|
876
|
-
* whole row — its own `- ` marker plus the key lines at the
|
|
877
|
-
*
|
|
886
|
+
* whole row — its own `- ` marker plus the key lines at the row's key
|
|
887
|
+
* column — rather than only what happened to be written above.
|
|
878
888
|
*/
|
|
879
889
|
const rowDeclaresGroup = (from) => {
|
|
880
890
|
const start = rowStart(from);
|
|
891
|
+
if (start === -1)
|
|
892
|
+
return false;
|
|
881
893
|
const markerIndent = rows[start].indent;
|
|
894
|
+
const col = keyCol(start);
|
|
882
895
|
for (let j = start; j < rows.length; j++) {
|
|
883
896
|
const row = rows[j];
|
|
884
897
|
if (j > start && row.indent <= markerIndent)
|
|
885
898
|
break;
|
|
886
|
-
//
|
|
887
|
-
|
|
899
|
+
// Only the row's own keys: a config body, or a list under another
|
|
900
|
+
// key, is not at the key column or starts with its own `- `.
|
|
901
|
+
if (j > start && (row.indent !== col || isDash(j)))
|
|
888
902
|
continue;
|
|
889
903
|
if (/^\s*-?\s*group:\s*['"]?true['"]?\s*$/u.test(row.line))
|
|
890
904
|
return true;
|
|
@@ -912,15 +926,22 @@ export function parsePatchRows(text) {
|
|
|
912
926
|
payloadIndent = null;
|
|
913
927
|
continue;
|
|
914
928
|
}
|
|
915
|
-
// Exactly one
|
|
916
|
-
// whose config is a LIST, because its children are
|
|
917
|
-
// the rule userPatchPackageReferences has always
|
|
918
|
-
// shape — a plain plugin may use a list of
|
|
919
|
-
// options, and those must stay payload.
|
|
920
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
929
|
+
// Exactly one block under a row carries loader rows: the `config:` of a
|
|
930
|
+
// `group: true` row whose config is a LIST, because its children are
|
|
931
|
+
// entries. That is the rule userPatchPackageReferences has always
|
|
932
|
+
// applied to this same shape — a plain plugin may use a list of
|
|
933
|
+
// `{ name: … }` as ordinary options, and those must stay payload. Any
|
|
934
|
+
// other block-valued key (`config:`, `tags:`, …) holds data too, so the
|
|
935
|
+
// block is skipped rather than counted.
|
|
936
|
+
// Inside a payload, a nested key is more payload: it must not move the
|
|
937
|
+
// payload's edge, or the payload ends early and its ids count.
|
|
938
|
+
const blockKey = /^\s*-?\s*([A-Za-z_][\w.-]*):\s*$/u.exec(line);
|
|
939
|
+
if (payloadIndent === null && insertIndent !== null && indent > insertIndent && blockKey !== null) {
|
|
940
|
+
// The key's own column, not the line's: when the key sits on a `- `
|
|
941
|
+
// line, the row's later keys share its column and are not payload.
|
|
942
|
+
const col = keyCol(i);
|
|
943
|
+
if (!(blockKey[1] === 'config' && rowDeclaresGroup(i) && valueIsList(i, col)))
|
|
944
|
+
payloadIndent = col;
|
|
924
945
|
continue;
|
|
925
946
|
}
|
|
926
947
|
const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line);
|
package/lib/recovery.js
CHANGED
|
@@ -938,6 +938,16 @@ export async function runRecovery(config, facts = { exitCode: null, bound: false
|
|
|
938
938
|
const servePort = options.port ?? config.port;
|
|
939
939
|
/** Write errors from the previous apply; they travel into the next payload. */
|
|
940
940
|
let writeErrors = [];
|
|
941
|
+
// A port that ANSWERS is proof the host came up, and it needs no pid to say
|
|
942
|
+
// so (#854). The helper's pid is the process IT spawned, which on Windows is
|
|
943
|
+
// the `powershell.exe` wrapper rather than the node host that binds — so the
|
|
944
|
+
// guard below cannot be trusted on its own, and a helper that gave up late
|
|
945
|
+
// handed this surface a pid that was already gone while the host was still
|
|
946
|
+
// starting. Asking the socket settles it either way.
|
|
947
|
+
if (servePort !== null && servePort !== 0 && await portListening(servePort)) {
|
|
948
|
+
note(config, `port ${String(servePort)} is already answering — the host came up after all; leaving it alone`);
|
|
949
|
+
return 'booted';
|
|
950
|
+
}
|
|
941
951
|
// The replacement is not necessarily dead just because it has not bound the
|
|
942
952
|
// port: on a slow host it is still starting. It gets the same grace as a
|
|
943
953
|
// released port before the surface takes the port out from under it (#719).
|
package/lib/regions.js
CHANGED
|
@@ -160,6 +160,28 @@ export function rememberGithubRoute(service, route) {
|
|
|
160
160
|
export function resetGithubRoutePreferences() {
|
|
161
161
|
preferredGithubRoutes.clear();
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Put a user's own prefix in front, and keep the region's other routes (#850).
|
|
165
|
+
*
|
|
166
|
+
* Heading rather than replacing, because a public proxy dying is the whole
|
|
167
|
+
* reason the setting exists — the reporter's was one of these mirrors, and it
|
|
168
|
+
* took the rest of the chain with it: `gh-proxy.com` stopped serving on
|
|
169
|
+
* 2026-10-13, and every GitHub-sourced update then failed on a timeout rather
|
|
170
|
+
* than trying the next candidate. Replacing the list buys one attempt per
|
|
171
|
+
* mirror outage; heading it buys one, and then the chain that was already
|
|
172
|
+
* measured to work.
|
|
173
|
+
*
|
|
174
|
+
* The proxy is also dropped from the tail if it is already there, so a user
|
|
175
|
+
* who accepted the value the settings UI pre-suggests gets exactly the
|
|
176
|
+
* region's own table back rather than the same prefix twice.
|
|
177
|
+
*
|
|
178
|
+
* The direct escape is never dropped: `githubRoutes` promises every list ends
|
|
179
|
+
* in one, and dropping it would turn a dead mirror into an outage instead of
|
|
180
|
+
* a slow request.
|
|
181
|
+
*/
|
|
182
|
+
function withCustomProxy(proxy, base) {
|
|
183
|
+
return [proxy, ...base.filter(route => route !== proxy)];
|
|
184
|
+
}
|
|
163
185
|
/**
|
|
164
186
|
* The routes for a region, with environment overrides applied.
|
|
165
187
|
*
|
|
@@ -183,7 +205,11 @@ export function routesFor(region, env = process.env) {
|
|
|
183
205
|
raw: [...base.githubRoutes.raw],
|
|
184
206
|
avatar: [...base.githubRoutes.avatar],
|
|
185
207
|
}
|
|
186
|
-
: {
|
|
208
|
+
: {
|
|
209
|
+
git: withCustomProxy(githubProxy, base.githubRoutes.git),
|
|
210
|
+
raw: withCustomProxy(githubProxy, base.githubRoutes.raw),
|
|
211
|
+
avatar: withCustomProxy(githubProxy, base.githubRoutes.avatar),
|
|
212
|
+
};
|
|
187
213
|
return {
|
|
188
214
|
npmRegistry: registry,
|
|
189
215
|
githubProxy: githubProxy ?? base.githubProxy,
|
package/lib/restart.js
CHANGED
|
@@ -481,19 +481,39 @@ export function restartHelperSource(spawned, launch, logs, port, recovery = null
|
|
|
481
481
|
// seen from the other side. A replacement that EXITS still ends this
|
|
482
482
|
// early (the `exited` break below), so a genuinely failed boot is not
|
|
483
483
|
// made slower by the wider backstop.
|
|
484
|
+
' const startedAt = Date.now()',
|
|
484
485
|
' const upBy = Date.now() + 45000 + SETTLE_MS',
|
|
486
|
+
// #854: the window is a budget for "has it come up", NOT a verdict on
|
|
487
|
+
// "is it dead". A cold start right after the market rewrote node_modules
|
|
488
|
+
// can outlast it (measured on Windows: bind at ~63s with a scanner on the
|
|
489
|
+
// fresh files), and handing the port over while the replacement is still
|
|
490
|
+
// RUNNING is what turns a slow boot into the deadlock #719 removed: the
|
|
491
|
+
// surface takes port 3080, the host reaches its own bind and dies on
|
|
492
|
+
// EADDRINUSE, and nothing can come back. So a replacement that has NOT
|
|
493
|
+
// exited keeps the port, up to a hard cap; one that has exited still ends
|
|
494
|
+
// the wait immediately, which is the failed-boot case.
|
|
495
|
+
' const hardBy = Date.now() + 240000',
|
|
485
496
|
' let steadySince = null',
|
|
486
|
-
'
|
|
497
|
+
' let announcedWait = false',
|
|
498
|
+
' while (true) {',
|
|
487
499
|
' if (await listening()) {',
|
|
488
500
|
' if (steadySince === null) steadySince = Date.now()',
|
|
489
501
|
' else if (Date.now() - steadySince >= SETTLE_MS) return',
|
|
490
502
|
' } else {',
|
|
491
503
|
' steadySince = null',
|
|
492
504
|
' if (exited !== null) break',
|
|
505
|
+
' const now = Date.now()',
|
|
506
|
+
' if (now >= hardBy) break',
|
|
507
|
+
' if (now >= upBy && !announcedWait) {',
|
|
508
|
+
' announcedWait = true',
|
|
509
|
+
// The numbers are computed, not typed twice: a log line that drifts from
|
|
510
|
+
// the constants it describes is worse than no line at all.
|
|
511
|
+
' note(`the replacement is still starting after ${String(Math.round((upBy - startedAt) / 1000))}s and has not exited — waiting up to ${String(Math.round((hardBy - startedAt) / 1000))}s for it rather than taking its port (it would die on EADDRINUSE)`)',
|
|
512
|
+
' }',
|
|
493
513
|
' }',
|
|
494
514
|
' await sleep(500)',
|
|
495
515
|
' }',
|
|
496
|
-
' note(`the replacement never came up on port ${port}${exited === null ? "" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
|
|
516
|
+
' note(`the replacement never came up on port ${port}${exited === null ? " (it is still running, so the recovery surface waits for it to exit before binding)" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
|
|
497
517
|
// The host is not coming back on its own. Everything the user can still
|
|
498
518
|
// do about it lives in the recovery surface, so start it while the
|
|
499
519
|
// browser tab that asked for the restart is still open.
|
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/profile.d.ts
CHANGED
|
@@ -232,6 +232,17 @@ export declare function readLockCommits(profile: string, explicitDir?: string):
|
|
|
232
232
|
* by pnpm's `path:` selector, so the spec's subpath has to match too — and
|
|
233
233
|
* when the spec names no subpath while several entries of that remote do,
|
|
234
234
|
* there is no answer rather than the first sibling's commit (#632).
|
|
235
|
+
*
|
|
236
|
+
* The key on both sides of the comparison is `hostedRepoKey`, the identity
|
|
237
|
+
* the rest of the market already compares specs by (#851). This function
|
|
238
|
+
* used to derive its own, and that second spelling lost every install pnpm
|
|
239
|
+
* writes the shorthand back for: it demanded an `https?://` prefix or a
|
|
240
|
+
* `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
|
|
241
|
+
* the `.git` suffix, so the spec side `https://github.com/o/r` could never
|
|
242
|
+
* equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
|
|
243
|
+
* shorthand therefore had no commit, and with no commit the update route
|
|
244
|
+
* refuses to roll back — the market reports "cannot confirm the commit before
|
|
245
|
+
* the update" for a lockfile that states it plainly.
|
|
235
246
|
*/
|
|
236
247
|
export declare function readGitResolutionCommit(profile: string, spec: string, explicitDir?: string): string | null;
|
|
237
248
|
/** True when the installed package's manifest declares a dsh plugin surface. */
|