dshmarket 1.48.0 → 1.50.0

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
@@ -105,7 +105,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
105
105
  installing: "安装中…",
106
106
  installedBadge: "✓ 已安装",
107
107
  alreadyInstalled: "✓ 已安装",
108
- restartBanner: "项变更完成,重启 DeepSeek Harness 后生效",
108
+ restartBanner: "项变更需重启 DeepSeek Harness 后生效",
109
109
  uninstall: "卸载",
110
110
  confirmRemove: "确认卸载?",
111
111
  uninstalling: "卸载中…",
@@ -638,7 +638,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
638
638
  installing: "Installing…",
639
639
  installedBadge: "✓ Installed",
640
640
  alreadyInstalled: "✓ Installed",
641
- restartBanner: "change(s) done — restart DeepSeek Harness to apply",
641
+ restartBanner: "change(s) pending restart — restart DeepSeek Harness to apply",
642
642
  uninstall: "Uninstall",
643
643
  confirmRemove: "Uninstall?",
644
644
  uninstalling: "Uninstalling…",
@@ -1150,7 +1150,44 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1150
1150
  * rather than guessing; declared repo evidence that matches nothing in the
1151
1151
  * catalog must not fall back to a coincidental unique name.
1152
1152
  */
1153
+ /**
1154
+ * Memo for findCatalogEntryForLocal, keyed on the catalog array identity and
1155
+ * then the full lookup input (#589).
1156
+ *
1157
+ * The market client calls this once per rendered card for every link:/file:
1158
+ * dependency, and each call filtered the whole catalog and walked every URL —
1159
+ * measured ~300ms per render at 24 cards against a 3,627-entry catalog, on
1160
+ * every keystroke of the search box. The result is a pure function of the
1161
+ * catalog, the name, the identities, and the hints, so caching by the array
1162
+ * identity never serves a stale answer: a refetched catalog is a new array,
1163
+ * and the old inner map becomes collectable. Null results are cached too —
1164
+ * they are the common answer for same-named forks without evidence.
1165
+ *
1166
+ * The input key is the JSON form of the whole tuple, not a delimiter join:
1167
+ * an empty array and an array holding one empty string serialize
1168
+ * differently, and the matcher treats them differently (`identitySet.size >
1169
+ * 0` chooses the evidence branch), so the key must not conflate them
1170
+ * (#485's lesson: weak signals must never override strong ones).
1171
+ */
1172
+ const localMatchCache = /* @__PURE__ */ new WeakMap();
1153
1173
  function findCatalogEntryForLocal(plugins, name, identities = [], hints = []) {
1174
+ const cacheKey = JSON.stringify([
1175
+ name,
1176
+ identities,
1177
+ hints
1178
+ ]);
1179
+ let byCatalog = localMatchCache.get(plugins);
1180
+ if (byCatalog === void 0) {
1181
+ byCatalog = /* @__PURE__ */ new Map();
1182
+ localMatchCache.set(plugins, byCatalog);
1183
+ }
1184
+ const hit = byCatalog.get(cacheKey);
1185
+ if (hit !== void 0) return hit;
1186
+ const result = findCatalogEntryForLocalUncached(plugins, name, identities, hints);
1187
+ byCatalog.set(cacheKey, result);
1188
+ return result;
1189
+ }
1190
+ function findCatalogEntryForLocalUncached(plugins, name, identities, hints) {
1154
1191
  const nameKey = name.toLowerCase();
1155
1192
  const byName = plugins.filter((plugin) => plugin.name.toLowerCase() === nameKey || typeof plugin.npm === "string" && plugin.npm.toLowerCase() === nameKey);
1156
1193
  const identitySet = new Set(identities.map((value) => value.toLowerCase()));
@@ -1655,56 +1692,6 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1655
1692
  for (const id of entryIdentities(plugin)) if (dep.has(id)) return true;
1656
1693
  return false;
1657
1694
  }
1658
- /**
1659
- * The same memo, for the branch #262 left behind (#589).
1660
- *
1661
- * `looseMatchCount` above covers dependencies installed by version. A
1662
- * `link:` or `file:` dependency takes the other branch, into
1663
- * `findCatalogEntryForLocal`, which walks the whole catalog at least twice
1664
- * per call — once to filter by name, once to collect `/tree/` repos — and
1665
- * up to twice more when there are identities to probe. Both callers below
1666
- * run once per rendered card. The reporter profiled ~300ms per repaint at
1667
- * 24 cards against a 3,627-entry catalog where a version-pinned dependency
1668
- * paid 1.1ms; a local benchmark measured ~38ms per render at that shape,
1669
- * and ~1.4s at 96 cards with eight local dependencies.
1670
- *
1671
- * The inner key carries the EVIDENCE, not just the name. Installing a plugin
1672
- * hands the next render a fresh identities array while the catalog array
1673
- * stays the same, so a name-only key would answer the post-install question
1674
- * with the pre-install result — which is the same-named-fork confusion #485
1675
- * asked this matcher to stop making, reintroduced as a cache bug.
1676
- *
1677
- * A miss is cached as `null`, which is why the "not cached yet" sentinel
1678
- * has to be `undefined`: `null` is a real answer here, and it costs the
1679
- * same full scan to establish as a hit does. It is also the common case —
1680
- * a checkout you are developing is usually not in the catalog at all.
1681
- *
1682
- * The invariant this rests on, stated because the WeakMap cannot enforce it:
1683
- * the catalog array and the entries inside it are frozen once handed here. A
1684
- * refetch replaces the array — which is what the outer key is for — but an
1685
- * in-place `push`, `sort` or `reverse`, or editing a row's `url`, would keep
1686
- * the key and change the answer. Order is load-bearing too: the matcher
1687
- * returns the FIRST row that fits. Nothing in the client does any of this
1688
- * today; `visiblePlugins` and `themePlugins` both sort copies.
1689
- */
1690
- const localMatchCache = /* @__PURE__ */ new WeakMap();
1691
- function cachedEntryForLocal(plugins, name, identities, hints) {
1692
- let byKey = localMatchCache.get(plugins);
1693
- if (byKey === void 0) {
1694
- byKey = /* @__PURE__ */ new Map();
1695
- localMatchCache.set(plugins, byKey);
1696
- }
1697
- const key = JSON.stringify([
1698
- name,
1699
- identities,
1700
- hints
1701
- ]);
1702
- const hit = byKey.get(key);
1703
- if (hit !== void 0) return hit;
1704
- const entry = findCatalogEntryForLocal(plugins, name, identities, hints);
1705
- byKey.set(key, entry);
1706
- return entry;
1707
- }
1708
1695
  /** The installed dependency name a registry entry corresponds to, or null. */
1709
1696
  function matchInstalledName(plugin, installed, repoIdentities = {}, plugins, repoHints = {}) {
1710
1697
  const ids = entryIdentities(plugin);
@@ -1713,7 +1700,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1713
1700
  const repos = repoIdentities[name] ?? [];
1714
1701
  if (/^(?:link|file):/i.test(specStr)) {
1715
1702
  if (plugins === void 0) continue;
1716
- const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1703
+ const entry = findCatalogEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1717
1704
  if (entry !== null && entry.url === plugin.url) return name;
1718
1705
  continue;
1719
1706
  }
@@ -2115,7 +2102,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2115
2102
  }
2116
2103
  /** Catalog row for an installed dependency — strict for local link:/file: specs. */
2117
2104
  function catalogEntryForInstalled(plugins, name, spec, repoIdentities = [], repoHints = []) {
2118
- if (/^(?:link|file):/i.test(spec)) return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2105
+ if (/^(?:link|file):/i.test(spec)) return findCatalogEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2119
2106
  return entryForDep(plugins, name, spec, repoIdentities, repoHints);
2120
2107
  }
2121
2108
  //#endregion
@@ -6348,6 +6335,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6348
6335
  const [buildsSkipped, setBuildsSkipped] = (0, react.useState)(null);
6349
6336
  const [updatingAll, setUpdatingAll] = (0, react.useState)(false);
6350
6337
  const [updatedNames, setUpdatedNames] = (0, react.useState)([]);
6338
+ const [restartNames, setRestartNames] = (0, react.useState)([]);
6351
6339
  const [hotUrls, setHotUrls] = (0, react.useState)([]);
6352
6340
  const [hotNames, setHotNames] = (0, react.useState)([]);
6353
6341
  const [progressLine, setProgressLine] = (0, react.useState)(null);
@@ -6667,12 +6655,13 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6667
6655
  }
6668
6656
  if (Array.isArray(saved.doneUrls) && saved.doneUrls.length > 0) setDoneUrls(saved.doneUrls);
6669
6657
  if (Array.isArray(saved.updated) && saved.updated.length > 0) setUpdatedNames(saved.updated);
6658
+ if (Array.isArray(saved.restartNames) && saved.restartNames.length > 0) setRestartNames(saved.restartNames);
6670
6659
  if (typeof saved.removed === "number" && saved.removed > 0) setRemovedCount(saved.removed);
6671
6660
  if (typeof saved.toggled === "number" && saved.toggled > 0) setToggleRestart(saved.toggled);
6672
6661
  }, [bootId]);
6673
6662
  (0, react.useEffect)(() => {
6674
6663
  if (bootId === null) return;
6675
- if (doneUrls.length === 0 && updatedNames.length === 0 && removedCount === 0 && toggleRestart === 0) {
6664
+ if (doneUrls.length === 0 && updatedNames.length === 0 && restartNames.length === 0 && removedCount === 0 && toggleRestart === 0) {
6676
6665
  sessionStorage.removeItem("dshm-restart");
6677
6666
  return;
6678
6667
  }
@@ -6680,6 +6669,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6680
6669
  boot: bootId,
6681
6670
  doneUrls,
6682
6671
  updated: updatedNames,
6672
+ restartNames,
6683
6673
  removed: removedCount,
6684
6674
  toggled: toggleRestart
6685
6675
  }));
@@ -6687,6 +6677,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6687
6677
  bootId,
6688
6678
  doneUrls,
6689
6679
  updatedNames,
6680
+ restartNames,
6690
6681
  removedCount,
6691
6682
  toggleRestart
6692
6683
  ]);
@@ -7307,6 +7298,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7307
7298
  if (status === 200 && body.ok) {
7308
7299
  setRecords((list) => patch(list, updateRecordId, { state: "done" }));
7309
7300
  setUpdatedNames((names) => names.concat(name));
7301
+ const activation = body.activation && typeof body.activation === "object" ? body.activation[name] : void 0;
7302
+ if (!activation || activation.state === "restart") setRestartNames((names) => names.includes(name) ? names : names.concat(name));
7310
7303
  if (body.activation && typeof body.activation === "object") setActivations((prev) => ({
7311
7304
  ...prev,
7312
7305
  ...body.activation
@@ -7854,7 +7847,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7854
7847
  });
7855
7848
  }, [doGroupAction, groups]);
7856
7849
  const selfName = installed["dshmarket"] !== void 0 ? "dshmarket" : "dsh-market";
7857
- const updatableNames = Object.keys(installed).filter((name) => name !== selfName && !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable);
7850
+ const updatableNames = Object.keys(installed).filter((name) => name !== selfName && !updatedNames.includes(name) && !effectiveDisabledSet.has(name) && updates[name] && updates[name].updateAvailable);
7858
7851
  const batchUpdatableNames = updatableNames.filter((name) => updates[name]?.restoreRequired !== true);
7859
7852
  const ignoredUpdateSet = (0, react.useMemo)(() => new Set(ignoredUpdateNames), [ignoredUpdateNames]);
7860
7853
  const reminderUpdatableNames = updatableNames.filter((name) => !ignoredUpdateSet.has(name));
@@ -8102,7 +8095,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8102
8095
  webdavUrl,
8103
8096
  webdavUser
8104
8097
  ]);
8105
- const sessionPendingRestart = doneUrls.length + updatedNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
8098
+ const sessionPendingRestart = doneUrls.length + restartNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
8106
8099
  /**
8107
8100
  * Plugins the HOST reports as restart-pending, independent of what this
8108
8101
  * browser session happens to remember. Installing and then reloading the
@@ -41,7 +41,41 @@ function catalogEntryMatchesHints(url, hintSet) {
41
41
  * rather than guessing; declared repo evidence that matches nothing in the
42
42
  * catalog must not fall back to a coincidental unique name.
43
43
  */
44
+ /**
45
+ * Memo for findCatalogEntryForLocal, keyed on the catalog array identity and
46
+ * then the full lookup input (#589).
47
+ *
48
+ * The market client calls this once per rendered card for every link:/file:
49
+ * dependency, and each call filtered the whole catalog and walked every URL —
50
+ * measured ~300ms per render at 24 cards against a 3,627-entry catalog, on
51
+ * every keystroke of the search box. The result is a pure function of the
52
+ * catalog, the name, the identities, and the hints, so caching by the array
53
+ * identity never serves a stale answer: a refetched catalog is a new array,
54
+ * and the old inner map becomes collectable. Null results are cached too —
55
+ * they are the common answer for same-named forks without evidence.
56
+ *
57
+ * The input key is the JSON form of the whole tuple, not a delimiter join:
58
+ * an empty array and an array holding one empty string serialize
59
+ * differently, and the matcher treats them differently (`identitySet.size >
60
+ * 0` chooses the evidence branch), so the key must not conflate them
61
+ * (#485's lesson: weak signals must never override strong ones).
62
+ */
63
+ const localMatchCache = new WeakMap();
44
64
  export function findCatalogEntryForLocal(plugins, name, identities = [], hints = []) {
65
+ const cacheKey = JSON.stringify([name, identities, hints]);
66
+ let byCatalog = localMatchCache.get(plugins);
67
+ if (byCatalog === undefined) {
68
+ byCatalog = new Map();
69
+ localMatchCache.set(plugins, byCatalog);
70
+ }
71
+ const hit = byCatalog.get(cacheKey);
72
+ if (hit !== undefined)
73
+ return hit;
74
+ const result = findCatalogEntryForLocalUncached(plugins, name, identities, hints);
75
+ byCatalog.set(cacheKey, result);
76
+ return result;
77
+ }
78
+ function findCatalogEntryForLocalUncached(plugins, name, identities, hints) {
45
79
  const nameKey = name.toLowerCase();
46
80
  const byName = plugins.filter(plugin => plugin.name.toLowerCase() === nameKey
47
81
  || (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey));
package/lib/dsh-cli.js CHANGED
@@ -230,6 +230,33 @@ export function gitEnvForPnpm(env = process.env) {
230
230
  return {};
231
231
  return { GIT_TERMINAL_PROMPT: '0' };
232
232
  }
233
+ /**
234
+ * Every `--config.<key>=<value>` override in the argv, repeated as the
235
+ * `PNPM_CONFIG_<KEY>` environment variable that pnpm 12 still reads.
236
+ *
237
+ * pnpm 12 ignores some `--config.<key>` overrides on the command line, in
238
+ * either spelling, without a word: `fetchTimeout` (#615; measured on
239
+ * 12.2.1, 12.3.0 and 12.4.1) and `auto-install-peers` (12.4.1 auto-installs
240
+ * the peer with the flag present and not with the variable), so the retries
241
+ * that carry them ran exactly like the first attempt. `PNPM_CONFIG_*` is
242
+ * honoured by 11.8, 11.21 and 12.4 alike, so the argument stays for the
243
+ * versions that read it and the variable carries the same value for the
244
+ * ones that do not. Scoped to the run that carries the flag; nothing is set
245
+ * otherwise, so a user's own values are untouched on every other run. Keys
246
+ * are accepted in either spelling, so respelling a constant (as #600 did
247
+ * for the release-age one) cannot silently drop the variable.
248
+ */
249
+ export function pnpmConfigEnvForArgs(pluginArgs) {
250
+ const env = {};
251
+ for (const arg of pluginArgs) {
252
+ const match = /^--config\.([A-Za-z][A-Za-z0-9-]*)=(\S+)$/.exec(arg);
253
+ if (match === null)
254
+ continue;
255
+ const key = match[1].replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/-/g, '_').toUpperCase();
256
+ env[`PNPM_CONFIG_${key}`] = match[2];
257
+ }
258
+ return env;
259
+ }
233
260
  function spawnEnv() {
234
261
  // pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
235
262
  // CI mode forces it to act or fail instead of asking.
@@ -665,7 +692,7 @@ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
665
692
  */
666
693
  export const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/;
667
694
  /** Mutating pnpm commands get the structured reporter appended. */
668
- const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
695
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install', 'update']);
669
696
  /** Apply profile-specific pnpm compatibility and the structured reporter. */
670
697
  function preparePluginArgs(profileDirectory, pluginArgs) {
671
698
  let args = pluginArgsFor(profileDirectory, [...pluginArgs]);
@@ -739,7 +766,7 @@ export function runDshPlugin(profile, pluginArgs) {
739
766
  // pnpm v10 blocks forever on a silent interactive prompt without a TTY
740
767
  // (observed on re-add over a pinned git spec); CI mode forces it to act
741
768
  // or fail instead of asking.
742
- env: spawnEnv(),
769
+ env: { ...spawnEnv(), ...pnpmConfigEnvForArgs(pluginArgs) },
743
770
  stdio: ['ignore', 'pipe', 'pipe'],
744
771
  viaShell,
745
772
  // Own process group on POSIX so cancel/timeout can kill the whole
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which loader entry belongs to which installed package (#646).
3
+ *
4
+ * The ecosystem does not require a bundle patch's row `name:` to be the
5
+ * package name. Two shapes are ordinary:
6
+ *
7
+ * - id: aegis-method-pack
8
+ * name: aegis/extensions/dsh/index.js ← a SUBPATH inside the package
9
+ * - id: filesystem
10
+ * name: @deepseek-ai/dsh-skill-filesystem ← another package entirely
11
+ *
12
+ * `dsh-chat-import`, `meow-memory` and `dsh-context` all name themselves, so
13
+ * the simple `name === packageName` comparison worked for years. `aegis`
14
+ * names an entry inside itself, and every site that compared the two
15
+ * directly concluded the plugin had no entry — the market reported "no
16
+ * bundle patch … nothing to hot-mount" on enable (#646) and could not see
17
+ * the entry was already live.
18
+ *
19
+ * Three such sites had grown independently (`src/themes.ts`, `src/verify.ts`,
20
+ * `src/routes.ts`), each restating the rule from memory. That is the shape
21
+ * the identity bugs #485→#605 came from: one assumption, several copies,
22
+ * each fixed at a different time. The rule lives here now, once.
23
+ *
24
+ * The carrier case (#156) is NOT decided here. A bundle that mounts someone
25
+ * else's package has no name relation at all, and is recognised by the entry
26
+ * ID its own patch inserts — see `bundlePatchInsertedIds`. The two rules
27
+ * compose; neither replaces the other.
28
+ */
29
+ /**
30
+ * Whether a loader entry name refers to `packageName`.
31
+ *
32
+ * The `/` bound is load-bearing: without it `toolshrink` would match an
33
+ * entry named `toolshrink-extra`, a different installed package.
34
+ *
35
+ * @param name - an entry's `options.name`, in any shape.
36
+ * @param packageName - the installed package name to match against.
37
+ * @returns true for the bare name and for a subpath entry inside it.
38
+ */
39
+ export function nameMatchesPackage(name, packageName) {
40
+ if (typeof name !== 'string' || name === '')
41
+ return false;
42
+ return name === packageName || name.startsWith(`${packageName}/`);
43
+ }
44
+ /**
45
+ * The same rule for a loader entry object.
46
+ *
47
+ * @param entry - anything with `options.name`, as the host's loader stores it.
48
+ * @param packageName - the installed package name to match against.
49
+ * @returns whether this entry is the one `packageName` refers to.
50
+ */
51
+ export function entryMatchesPackage(entry, packageName) {
52
+ return nameMatchesPackage(entry.options?.name, packageName);
53
+ }
54
+ /**
55
+ * The package a subpath entry name belongs to — `aegis/x/y.js` → `aegis`.
56
+ *
57
+ * The inverse of {@link nameMatchesPackage}, for building a set of live
58
+ * packages from a set of live entry names: an entry that is up proves its
59
+ * package is up, and its name alone does not say so.
60
+ *
61
+ * Names that are not npm package names return null rather than a guess: a
62
+ * relative path, an absolute path, a URL or a cordis builtin would otherwise
63
+ * "belong to" a package that does not exist, which is worse than no answer.
64
+ * A scoped name needs three segments (`@scope/pkg/sub`) to have a subpath at
65
+ * all, and a lone `@scope/pkg` has no package to return.
66
+ *
67
+ * @param name - an entry's `options.name`.
68
+ * @returns the package name, or null when the name cannot imply one.
69
+ */
70
+ export function packageOfEntryName(name) {
71
+ if (typeof name !== 'string' || name === '')
72
+ return null;
73
+ if (name.startsWith('.') || name.startsWith('/') || name.startsWith('cordis:'))
74
+ return null;
75
+ if (name.includes('://'))
76
+ return null;
77
+ if (!name.includes('/'))
78
+ return null;
79
+ const parts = name.split('/');
80
+ if (parts[0] === '')
81
+ return null;
82
+ if (parts[0].startsWith('@'))
83
+ return parts.length >= 3 ? `${parts[0]}/${parts[1]}` : null;
84
+ return parts[0];
85
+ }
package/lib/hot.js CHANGED
@@ -30,7 +30,7 @@ import { pathToFileURL } from 'node:url';
30
30
  import { asChannel } from './channels.js';
31
31
  import { asRegion, normalizeGithubProxy } from './regions.js';
32
32
  import { logEvent } from './log.js';
33
- import { entryArtifactExists } from './profile.js';
33
+ import { declaredBundlePatchFile, entryArtifactExists } from './profile.js';
34
34
  /**
35
35
  * Profile-scoped resolution for hot-mount rows: turn a bare package name into
36
36
  * the absolute `file://` entry URL of the package just installed into
@@ -409,12 +409,23 @@ export async function hotMount(ctx, profileDir, packageName) {
409
409
  reason: '宿主不支持热挂载(include 插件不可导入),需重启 / the host cannot hot-mount (include plugin unavailable); restart required',
410
410
  };
411
411
  }
412
- let patchText;
413
- try {
414
- patchText = readFileSync(join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'), 'utf8');
415
- }
416
- catch {
417
- patchText = null;
412
+ // Where the patch lives is the PACKAGE's decision, declared as
413
+ // `dsh.bundle.patch` — `aegis` keeps it in `./extensions/dsh/`. Reading
414
+ // only the package root made a plugin whose patch is declared elsewhere
415
+ // look like one with no bundle at all, and the user was told "no bundle
416
+ // patch … nothing to hot-mount" for a package that plainly has one
417
+ // (#646). The root file stays as the fallback: it is the long-standing
418
+ // convention, and `profile.ts` resolves the declared field for everything
419
+ // else, so the two now agree on where a patch is.
420
+ const packageRoot = join(profileDir, 'node_modules', packageName);
421
+ const declared = declaredBundlePatchFile(packageRoot);
422
+ let patchText = null;
423
+ for (const file of declared !== null ? [declared] : [join(packageRoot, 'cordis.patch.yml')]) {
424
+ try {
425
+ patchText = readFileSync(file, 'utf8');
426
+ break;
427
+ }
428
+ catch { /* try the next location */ }
418
429
  }
419
430
  let rows;
420
431
  if (patchText !== null) {
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createDesktopPluginRuntime } from './dsh-cli.js';
6
6
  import { mountMarketRoutes } from './routes.js';
7
- import { installMarketSettings } from './settings.js';
7
+ import { installDesktopMarketSettings, installMarketSettings } from './settings.js';
8
8
  export const name = 'dsh-market';
9
9
  /**
10
10
  * Register the market against the host context.
@@ -46,10 +46,8 @@ export function apply(ctx, config) {
46
46
  allowRestart: config?.allowRestart,
47
47
  maxSnapshots: config?.maxSnapshots,
48
48
  };
49
- // Offer allowRestart as a switch on the settings page. Deliberately
50
- // NOT in the Desktop branch below: there the shell owns the process
51
- // lifecycle and the value is forced false, so it is not the user's to
52
- // choose. No-ops on a host without a settings service.
49
+ // Web settings may control restart; Desktop only registers the card's
50
+ // namespace below. Both no-op on a host without a settings service.
53
51
  installMarketSettings(ctx, resolved);
54
52
  host.effect(() => mountMarketRoutes(host, resolved, undefined, agentsLookupOf(ctx)), 'dsh-market: http routes');
55
53
  return;
@@ -72,6 +70,7 @@ export function apply(ctx, config) {
72
70
  maxSnapshots: config?.maxSnapshots,
73
71
  };
74
72
  const desktopHost = desktopCtx;
73
+ installDesktopMarketSettings(desktopCtx);
75
74
  desktopHost.effect(() => {
76
75
  const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx));
77
76
  return async () => {
package/lib/install.js CHANGED
@@ -29,6 +29,14 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimum-release-age=0';
29
29
  * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
30
30
  * plain retry fails again at the same limit, so the recovery re-runs with
31
31
  * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
32
+ *
33
+ * pnpm 12 ignores this flag on the command line in either spelling (#615).
34
+ * On the CLI runner, runDshPlugin repeats every `--config.<key>` override
35
+ * as PNPM_CONFIG_<KEY>, which pnpm 11 and 12 both read, so the retried
36
+ * command really does get the longer limit there. The Desktop runtime hands
37
+ * its host argv only, so on a Desktop host the retry still depends on that
38
+ * host's pnpm reading the flag. The flag stays: pnpm 11 and earlier read
39
+ * it, and it costs nothing on the versions that do not.
32
40
  */
33
41
  export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000';
34
42
  /**
@@ -47,6 +55,10 @@ export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000';
47
55
  * Verified against pnpm 10.29.3: `peerDependencyRules.ignoreMissing` does
48
56
  * NOT prevent the fetch (it only silences the warning), so this flag is the
49
57
  * only lever that actually works.
58
+ *
59
+ * pnpm 12 ignores this flag on the command line too (12.4.1 auto-installs
60
+ * the peer regardless); runDshPlugin repeats it as
61
+ * PNPM_CONFIG_AUTO_INSTALL_PEERS, which 12 reads (#615).
50
62
  */
51
63
  export const AUTO_INSTALL_PEERS_OFF = '--config.auto-install-peers=false';
52
64
  /**
package/lib/profile.js CHANGED
@@ -478,11 +478,18 @@ export function readLockCommits(profile, explicitDir) {
478
478
  * Commit recorded for a non-codeload git resolution (`type: git` in pnpm's
479
479
  * lockfile). Matched against the install spec so a Gitea/GitLab URL can
480
480
  * compare HEAD without mistaking a same-named npm package (#525).
481
+ *
482
+ * Two packages of one monorepo resolve from the SAME remote and differ only
483
+ * by pnpm's `path:` selector, so the spec's subpath has to match too — and
484
+ * when the spec names no subpath while several entries of that remote do,
485
+ * there is no answer rather than the first sibling's commit (#632).
481
486
  */
482
487
  export function readGitResolutionCommit(profile, spec, explicitDir) {
483
488
  const want = normalizeGitRepoKey(spec);
484
489
  if (want === null)
485
490
  return null;
491
+ const wantPath = gitSubpathSelector(spec);
492
+ const matches = [];
486
493
  try {
487
494
  const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
488
495
  for (const m of lock.matchAll(/resolution:\s*\{([^}]*)\}/g)) {
@@ -491,12 +498,32 @@ export function readGitResolutionCommit(profile, spec, explicitDir) {
491
498
  const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
492
499
  if (commit === null || repo === null)
493
500
  continue;
494
- if (normalizeGitRepoKey(repo[1]) === want)
495
- return commit[1].toLowerCase();
501
+ if (normalizeGitRepoKey(repo[1]) !== want)
502
+ continue;
503
+ const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null;
504
+ if (wantPath !== null) {
505
+ if (entryPath === wantPath)
506
+ return commit[1].toLowerCase();
507
+ continue;
508
+ }
509
+ matches.push(commit[1].toLowerCase());
496
510
  }
497
511
  }
498
512
  catch { /* no lockfile */ }
499
- return null;
513
+ // Exactly one entry for this remote is an identity; several are siblings
514
+ // of one monorepo and none of them is THIS package's commit.
515
+ return matches.length === 1 ? matches[0] : null;
516
+ }
517
+ /** The `path:` selector of a git spec, normalized for comparison. */
518
+ function gitSubpathSelector(spec) {
519
+ const hash = spec.indexOf('#');
520
+ if (hash === -1)
521
+ return null;
522
+ const selector = /(?:^|&)path:([^&]*)/.exec(spec.slice(hash + 1))?.[1];
523
+ if (selector === undefined)
524
+ return null;
525
+ const trimmed = selector.replace(/^\/+|\/+$/g, '').toLowerCase();
526
+ return trimmed === '' ? null : trimmed;
500
527
  }
501
528
  /** Lowercased transport-agnostic key for comparing two git remote spellings. */
502
529
  function normalizeGitRepoKey(spec) {
@@ -654,14 +681,38 @@ export function parsePatchRows(text) {
654
681
  return { names, ids, insertedIds };
655
682
  }
656
683
  /** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
657
- function readBundlePatchRows(dir) {
658
- const empty = { names: [], ids: [], insertedIds: [] };
684
+ /**
685
+ * Where a package's bundle patch lives, according to the package itself.
686
+ *
687
+ * `dsh.bundle.patch` is the package's own declaration and the only place the
688
+ * answer is written down: the path may be a subdirectory (`aegis` declares
689
+ * `./extensions/dsh/cordis.patch.yml`), not just the package root. Callers
690
+ * that assumed the root file made a plugin with a declared patch look like
691
+ * one with none (#646) — so the resolution rule lives here, once.
692
+ *
693
+ * @param dir - the installed package directory.
694
+ * @returns the declared patch file's path, or null when the manifest names
695
+ * none (or the manifest cannot be read).
696
+ */
697
+ export function declaredBundlePatchFile(dir) {
659
698
  try {
660
699
  const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
661
700
  const declared = manifest.dsh?.bundle?.patch;
662
701
  if (typeof declared !== 'string' || declared === '')
663
- return empty;
664
- return parsePatchRows(readFileSync(join(dir, declared), 'utf8'));
702
+ return null;
703
+ return join(dir, declared);
704
+ }
705
+ catch {
706
+ return null;
707
+ }
708
+ }
709
+ function readBundlePatchRows(dir) {
710
+ const empty = { names: [], ids: [], insertedIds: [] };
711
+ const file = declaredBundlePatchFile(dir);
712
+ if (file === null)
713
+ return empty;
714
+ try {
715
+ return parsePatchRows(readFileSync(file, 'utf8'));
665
716
  }
666
717
  catch {
667
718
  return empty;
package/lib/restart.js CHANGED
@@ -241,6 +241,10 @@ export function restartLaunch() {
241
241
  * DSH sandbox tool runners) pops a visible node window. Wrapping the launch
242
242
  * in `powershell -WindowStyle Hidden` gives the host a HIDDEN console that
243
243
  * children inherit instead. POSIX keeps the plain detached spawn.
244
+ *
245
+ * The helper that runs this invocation spawns it with `windowsHide`, because
246
+ * the helper has no console of its own to hand down and Windows would
247
+ * otherwise create a visible one for PowerShell (#624).
244
248
  */
245
249
  export function respawnInvocation(launch, platform = process.platform) {
246
250
  if (platform !== 'win32') {
@@ -325,7 +329,17 @@ export function restartHelperSource(spawned, launch, logs, port) {
325
329
  ' try {',
326
330
  ' const out = fs.openSync(logOut, "a")',
327
331
  ' const err = fs.openSync(logErr, "a")',
328
- ' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
332
+ // windowsHide (#624 by @davidekingsss): this helper is itself detached,
333
+ // so on Windows it has no console, and a console program spawned from a
334
+ // console-less parent is given a NEW, visible one — the "Windows
335
+ // PowerShell" window that owns the replacement and takes it down when
336
+ // closed. `-WindowStyle Hidden` cannot hide it: that flag governs the
337
+ // window PowerShell would create, not the console the spawn handed it.
338
+ // CREATE_NO_WINDOW keeps the console but never shows it, and the host's
339
+ // own console children inherit that hidden console rather than popping
340
+ // windows of their own (measured by the reporter with a console-less
341
+ // launcher: one PseudoConsoleWindow without the flag, none with it).
342
+ ' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell, windowsHide: true })',
329
343
  // spawn reports a missing or unexecutable file ASYNCHRONOUSLY; the
330
344
  // try/catch below only covers the synchronous throw, so without this
331
345
  // listener that failure is exactly as silent as the bug being fixed.