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 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 = normalizeGitRepoKey(spec);
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 (normalizeGitRepoKey(repo[1]) !== want)
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
- /** Index of the `- ` line that opens the row holding `from`. */
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 isRowStart = (index) => /^-\s/u.test(rows[index].trimmed) || rows[index].trimmed === '-';
865
- if (isRowStart(from))
866
- return from;
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 0;
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 marker's body
877
- * indentation — rather than only what happened to be written above.
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
- // The row's own keys; a config body sits deeper than these.
887
- if (j > start && row.indent !== markerIndent + 2)
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 `config:` shape carries loader rows: a `group: true` row
916
- // whose config is a LIST, because its children are entries. That is
917
- // the rule userPatchPackageReferences has always applied to this same
918
- // shape — a plain plugin may use a list of `{ name: … }` as ordinary
919
- // options, and those must stay payload. Anything else here is payload
920
- // too, so the block is skipped rather than counted.
921
- if (insertIndent !== null && indent > insertIndent && /^\s*-?\s*config:\s*$/u.test(line)) {
922
- if (!(rowDeclaresGroup(i) && valueIsList(i, indent)))
923
- payloadIndent = indent;
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
- : { git: [githubProxy, null], raw: [githubProxy, null], avatar: [githubProxy, null] };
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
- ' while (Date.now() < upBy) {',
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
- sendJson(response, 200, report);
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 { repoOf } from './sources.js';
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
- const themeRepos = new Set(themeEntries.map(p => repoOf(p.url)).filter((r) => r !== null).map(r => r.toLowerCase()));
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 match = /github:([^#\s]+)/.exec(String(spec).toLowerCase());
63
- if (match !== null && themeRepos.has(match[1]))
64
- names.add(name);
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 */ }
@@ -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. */