dshmarket 1.46.0 → 1.47.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/README.md +4 -0
- package/README.zh.md +4 -0
- package/client/client.js +65 -5
- package/lib/accelerate.js +10 -0
- package/lib/changelog.js +20 -3
- package/lib/hot.js +71 -5
- package/lib/routes.js +42 -6
- package/lib/types/hot.d.ts +31 -0
- package/package.json +1 -1
- package/src/accelerate.ts +9 -0
- package/src/changelog.ts +21 -4
- package/src/client/MarketSection.tsx +1 -1
- package/src/client/market-data.ts +74 -4
- package/src/hot.ts +64 -2
- package/src/routes.ts +41 -6
package/README.md
CHANGED
|
@@ -123,6 +123,10 @@ DSHM_REGISTRY_URL=https://your-mirror.example/plugins.json dsh web
|
|
|
123
123
|
|
|
124
124
|
[local-dsh](https://github.com/liangchen-harold/local-dsh) — a DeepSeek Harness desktop client that can run the model on your own machine: it bundles llama.cpp next to Node, pnpm and DSH, so a downloaded GGUF model answers without any external API. Built on Tauri; Apple Silicon Macs for now. [localdsh.com](https://localdsh.com)
|
|
125
125
|
|
|
126
|
+
### dsh desktop (MochiNek0)
|
|
127
|
+
|
|
128
|
+
[dsh-desktop](https://github.com/MochiNek0/dsh-desktop) — a cross-platform DeepSeek Harness desktop client built on Tauri (Windows, macOS, Linux). It uses the system webview, so the installer is a few megabytes: 2.3 MB on Windows, 5.8 MB on macOS. It starts `dsh web` in the background on launch and embeds it in a native window, sharing sessions and config with the CLI. This market sits first in the recommended list of its built-in plugin panel, one click to install. A Runtime panel enumerates and switches the machine's Node installs and installs or upgrades dsh, no administrator privileges required, and native notifications fire when a turn ends or dsh is waiting on you. [dsh-desktop.cc.cd](https://dsh-desktop.cc.cd/)
|
|
129
|
+
|
|
126
130
|
### DSH Get
|
|
127
131
|
|
|
128
132
|
[DSH Get](https://www.dshget.com/) — a searchable web directory for discovering DeepSeek Harness plugins: category filters, bilingual descriptions, install commands and per-plugin detail pages. Its normalized catalog snapshot is public at [bobby-sheng/dshget-data](https://github.com/bobby-sheng/dshget-data).
|
package/README.zh.md
CHANGED
|
@@ -120,6 +120,10 @@ DSHM_REGISTRY_URL=https://your-mirror.example/plugins.json dsh web
|
|
|
120
120
|
|
|
121
121
|
[local-dsh](https://github.com/liangchen-harold/local-dsh)——可以把模型跑在本机的 DeepSeek Harness 桌面客户端:发行包内置 llama.cpp 与 Node、pnpm、DSH,下载一个 GGUF 模型就能对话,不必接外部 API。基于 Tauri 构建;目前支持 Apple 芯片的 Mac。[localdsh.com](https://localdsh.com)
|
|
122
122
|
|
|
123
|
+
### dsh desktop(MochiNek0)
|
|
124
|
+
|
|
125
|
+
[dsh-desktop](https://github.com/MochiNek0/dsh-desktop)——基于 Tauri 构建的跨平台 DeepSeek Harness 桌面客户端(Windows / macOS / Linux),界面走系统 webview,安装包只有几 MB(Windows 2.3 MB、macOS 5.8 MB)。启动时自动在后台拉起 `dsh web` 并嵌入原生窗口,会话与配置和 CLI 共享;内置插件面板的推荐位第一条就是本市场,点一下即可装上。另有「运行环境」面板枚举与切换本机 Node、安装或升级 dsh,全程无需管理员权限;回合结束或 dsh 等待你确认时发出系统通知。[dsh-desktop.cc.cd](https://dsh-desktop.cc.cd/)
|
|
126
|
+
|
|
123
127
|
### DSH Get
|
|
124
128
|
|
|
125
129
|
[DSH Get](https://www.dshget.com/)——DeepSeek Harness 插件的网页检索目录:分类筛选、中英描述、安装命令与插件详情页;其规范化的目录快照公开在 [bobby-sheng/dshget-data](https://github.com/bobby-sheng/dshget-data)。
|
package/client/client.js
CHANGED
|
@@ -1591,10 +1591,20 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1591
1591
|
* dependency's spec pins a github repo AND the entry states one, the repos
|
|
1592
1592
|
* decide — the loose name/npm identities only apply when at least one side
|
|
1593
1593
|
* carries no repo evidence (npm installs, non-github entries).
|
|
1594
|
+
*
|
|
1595
|
+
* Repo evidence only ever decides by repository ROOT. A monorepo catalog
|
|
1596
|
+
* entry states `owner/repo#path:/pkg` while an npm-installed manifest
|
|
1597
|
+
* usually states the bare `owner/repo` (it rarely declares
|
|
1598
|
+
* `repository.directory`), and reading that asymmetry as a source conflict
|
|
1599
|
+
* kept a genuinely installed subpackage from ever reading as installed.
|
|
1594
1600
|
*/
|
|
1601
|
+
/** Repository root: the part before any `#path:/…` subpath selection. */
|
|
1602
|
+
function repoRoots(ids) {
|
|
1603
|
+
return new Set([...ids].map((id) => id.split("#path:/")[0]));
|
|
1604
|
+
}
|
|
1595
1605
|
function sameSourceConflict(plugin, spec, repoIdentities = []) {
|
|
1596
|
-
const entry = entryRepoIds(plugin);
|
|
1597
|
-
const dep = depRepoIds(spec, repoIdentities);
|
|
1606
|
+
const entry = repoRoots(entryRepoIds(plugin));
|
|
1607
|
+
const dep = repoRoots(depRepoIds(spec, repoIdentities));
|
|
1598
1608
|
if (entry.size === 0 || dep.size === 0) return false;
|
|
1599
1609
|
for (const id of dep) if (entry.has(id)) return false;
|
|
1600
1610
|
return true;
|
|
@@ -1645,6 +1655,56 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1645
1655
|
for (const id of entryIdentities(plugin)) if (dep.has(id)) return true;
|
|
1646
1656
|
return false;
|
|
1647
1657
|
}
|
|
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
|
+
}
|
|
1648
1708
|
/** The installed dependency name a registry entry corresponds to, or null. */
|
|
1649
1709
|
function matchInstalledName(plugin, installed, repoIdentities = {}, plugins, repoHints = {}) {
|
|
1650
1710
|
const ids = entryIdentities(plugin);
|
|
@@ -1653,7 +1713,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1653
1713
|
const repos = repoIdentities[name] ?? [];
|
|
1654
1714
|
if (/^(?:link|file):/i.test(specStr)) {
|
|
1655
1715
|
if (plugins === void 0) continue;
|
|
1656
|
-
const entry =
|
|
1716
|
+
const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
|
|
1657
1717
|
if (entry !== null && entry.url === plugin.url) return name;
|
|
1658
1718
|
continue;
|
|
1659
1719
|
}
|
|
@@ -2055,7 +2115,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
2055
2115
|
}
|
|
2056
2116
|
/** Catalog row for an installed dependency — strict for local link:/file: specs. */
|
|
2057
2117
|
function catalogEntryForInstalled(plugins, name, spec, repoIdentities = [], repoHints = []) {
|
|
2058
|
-
if (/^(?:link|file):/i.test(spec)) return
|
|
2118
|
+
if (/^(?:link|file):/i.test(spec)) return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
|
|
2059
2119
|
return entryForDep(plugins, name, spec, repoIdentities, repoHints);
|
|
2060
2120
|
}
|
|
2061
2121
|
//#endregion
|
|
@@ -8614,7 +8674,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8614
8674
|
children: updatingName === self ? t("updating") : status.restoreRequired === true ? t("restoreOnline") : t("marketUpdate")
|
|
8615
8675
|
});
|
|
8616
8676
|
})(),
|
|
8617
|
-
reminderBatchUpdatableNames.length >=
|
|
8677
|
+
reminderBatchUpdatableNames.length >= 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
8618
8678
|
variant: "primary",
|
|
8619
8679
|
size: "sm",
|
|
8620
8680
|
disabled: updatingAll || updatingName !== null || busyUrl !== null || removingName !== null,
|
package/lib/accelerate.js
CHANGED
|
@@ -112,6 +112,16 @@ async function tryHeadCommit(repo, proxy, signal, ref) {
|
|
|
112
112
|
// metacharacters (`.` is legal in a branch name).
|
|
113
113
|
const quoted = ref.replace(/[.*+?^${}()|[\]\\]/gu, String.raw `\$&`);
|
|
114
114
|
for (const namespace of ['heads', 'tags']) {
|
|
115
|
+
// An annotated tag advertises two refs — the tag object and its
|
|
116
|
+
// peeled commit (`refs/tags/<ref>^{}`). pnpm resolves the tag to the
|
|
117
|
+
// peeled commit and codeload records that sha in the lockfile, so the
|
|
118
|
+
// peeled line is the one that must equal `current`; answering with
|
|
119
|
+
// the tag-object sha made annotated-tag installs report an update
|
|
120
|
+
// forever (#597). Lightweight tags have no peeled line and fall
|
|
121
|
+
// through to the direct one.
|
|
122
|
+
const peeled = new RegExp(String.raw `([0-9a-f]{40}) refs/${namespace}/${quoted}\^\{\}(?![^\s])`, 'u').exec(body);
|
|
123
|
+
if (peeled !== null)
|
|
124
|
+
return { kind: 'valid', sha: peeled[1] };
|
|
115
125
|
const found = new RegExp(String.raw `([0-9a-f]{40}) refs/${namespace}/${quoted}(?![^\s])`, 'u').exec(body);
|
|
116
126
|
if (found !== null)
|
|
117
127
|
return { kind: 'valid', sha: found[1] };
|
package/lib/changelog.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
import { fileFromTarball } from './catalog-npm.js';
|
|
21
21
|
import { marketFetch } from './net.js';
|
|
22
22
|
import { activeRegion, routesFor } from './regions.js';
|
|
23
|
-
import { profileDir, readInstalled, readLockCommits } from './profile.js';
|
|
24
|
-
import { lookupRepoFromUrl, repoOfTarget } from './sources.js';
|
|
23
|
+
import { profileDir, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.js';
|
|
24
|
+
import { lookupRepoFromUrl, repoOf, repoOfTarget } from './sources.js';
|
|
25
25
|
import { checkUpdates } from './updates.js';
|
|
26
26
|
import { loadRegistry } from './registry.js';
|
|
27
27
|
const UPDATES_PACKAGE = 'dsh-plugin-updates';
|
|
@@ -198,7 +198,24 @@ export async function updateNotesFor(profile, explicitDir, name) {
|
|
|
198
198
|
if (key === null) {
|
|
199
199
|
try {
|
|
200
200
|
const registry = await loadRegistry();
|
|
201
|
-
const
|
|
201
|
+
const candidates = registry.plugins.filter(p => p.name === name);
|
|
202
|
+
let plugin;
|
|
203
|
+
if (candidates.length === 1) {
|
|
204
|
+
plugin = candidates[0];
|
|
205
|
+
}
|
|
206
|
+
else if (candidates.length > 1) {
|
|
207
|
+
// Same-named packages exist in the catalog; a bare name match can
|
|
208
|
+
// pick someone else's repo and answer "no notes" for a plugin that
|
|
209
|
+
// ships updates data under its own repository (#598). The installed
|
|
210
|
+
// package declares its repository, so prefer the catalog entry that
|
|
211
|
+
// agrees with it; only a unique agreement is trusted — an ambiguous
|
|
212
|
+
// name falls through to npm publish times, which are honest for any
|
|
213
|
+
// installed npm package.
|
|
214
|
+
const evidence = readInstalledRepoEvidence(profile, name, spec, explicitDir);
|
|
215
|
+
const matches = candidates.filter(p => evidence.identities.some(id => repoOf(p.url)?.toLowerCase() === id.split('#')[0].toLowerCase()));
|
|
216
|
+
if (matches.length === 1)
|
|
217
|
+
plugin = matches[0];
|
|
218
|
+
}
|
|
202
219
|
if (plugin !== undefined) {
|
|
203
220
|
key = plugin.url;
|
|
204
221
|
}
|
package/lib/hot.js
CHANGED
|
@@ -24,11 +24,62 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
24
24
|
return path;
|
|
25
25
|
};
|
|
26
26
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
27
28
|
import { join } from 'node:path';
|
|
28
29
|
import { pathToFileURL } from 'node:url';
|
|
29
30
|
import { asChannel } from './channels.js';
|
|
30
31
|
import { asRegion, normalizeGithubProxy } from './regions.js';
|
|
31
32
|
import { logEvent } from './log.js';
|
|
33
|
+
import { entryArtifactExists } from './profile.js';
|
|
34
|
+
/**
|
|
35
|
+
* Profile-scoped resolution for hot-mount rows: turn a bare package name into
|
|
36
|
+
* the absolute `file://` entry URL of the package just installed into
|
|
37
|
+
* `profileDir`.
|
|
38
|
+
*
|
|
39
|
+
* Include-tree rows reach `Include.import` as BARE names (`name:
|
|
40
|
+
* '@scope/pkg'`), and the base class resolves them against the LOADER's own
|
|
41
|
+
* location — the host closure
|
|
42
|
+
* (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
|
|
43
|
+
* can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
|
|
44
|
+
* loader sits in an immutable dependency closure, EVERY market hot mount dies
|
|
45
|
+
* with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
|
|
46
|
+
* back to "restart required", blaming the plugin for what is a resolution
|
|
47
|
+
* anchor problem.
|
|
48
|
+
*
|
|
49
|
+
* Resolving the row name HERE, against the profile the package was actually
|
|
50
|
+
* installed into, is anchor-independent: `require.resolve` walks
|
|
51
|
+
* `profileDir/node_modules` natively, so the tree hands the loader a
|
|
52
|
+
* `file://` URL needing no further resolution. Non-bare specifiers (relative
|
|
53
|
+
* paths, `file://`, `cordis:` builtins) and names that do not resolve under
|
|
54
|
+
* the profile pass through unchanged, preserving base-class semantics for
|
|
55
|
+
* every shape this fix does not own.
|
|
56
|
+
*
|
|
57
|
+
* The fallback keeps the name bare rather than synthesising a URL: a package
|
|
58
|
+
* whose entry cannot be located via `require.resolve` (no `main`/exports —
|
|
59
|
+
* the market's own `entryArtifactExists` heuristic covers those shapes before
|
|
60
|
+
* an install is accepted) is not something this resolver should guess about.
|
|
61
|
+
* Client-only shims never reach this function (their rows are replaced by a
|
|
62
|
+
* no-op host module before the file is written).
|
|
63
|
+
*/
|
|
64
|
+
export function resolveProfileEntry(profileDir, name) {
|
|
65
|
+
if (!name || name.startsWith('.') || name.startsWith('cordis:') || name.startsWith('file://'))
|
|
66
|
+
return name;
|
|
67
|
+
const packageDir = join(profileDir, 'node_modules', ...name.split('/'));
|
|
68
|
+
try {
|
|
69
|
+
return pathToFileURL(createRequire(join(profileDir, 'package.json')).resolve(name)).href;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// require.resolve needs a resolvable package entry; the market's install
|
|
73
|
+
// validation accepts a broader artifact set (exports objects, index.js).
|
|
74
|
+
// Fall back to the index.js artifact so a valid mount is not refused over
|
|
75
|
+
// resolver strictness — and keep the bare name when no checkable entry
|
|
76
|
+
// exists, letting the loader produce its own (accurate) error.
|
|
77
|
+
if (entryArtifactExists(packageDir)) {
|
|
78
|
+
return pathToFileURL(join(packageDir, 'index.js')).href;
|
|
79
|
+
}
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
32
83
|
const HOT_DIR = '.dsh-market';
|
|
33
84
|
/**
|
|
34
85
|
* Ceiling for one hot-mount activation, env-overridable like the install
|
|
@@ -394,8 +445,14 @@ export async function hotMount(ctx, profileDir, packageName) {
|
|
|
394
445
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
395
446
|
hotSequence += 1;
|
|
396
447
|
const file = join(dir, `hot-${String(hotSequence)}.yml`);
|
|
448
|
+
// Rows carry ABSOLUTE file:// entry URLs, resolved against this profile:
|
|
449
|
+
// the loader's own parent-walk (from the host closure) can never reach
|
|
450
|
+
// `profileDir/node_modules`, so bare names in the file would fail to
|
|
451
|
+
// import on closure-hosted loaders. The file remains a faithful record —
|
|
452
|
+
// `cleanHotDir` wipes it on every boot and the bundle layer owns
|
|
453
|
+
// persistence, so nothing reads these files back.
|
|
397
454
|
const yml = rows
|
|
398
|
-
.map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
|
|
455
|
+
.map(row => `- id: 'mkt-${row.id}'\n name: '${resolveProfileEntry(profileDir, row.name)}'\n`)
|
|
399
456
|
.join('');
|
|
400
457
|
writeFileSync(file, yml);
|
|
401
458
|
const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
|
|
@@ -403,16 +460,25 @@ export async function hotMount(ctx, profileDir, packageName) {
|
|
|
403
460
|
await raceActivationTimeout(handle.await());
|
|
404
461
|
}
|
|
405
462
|
catch (error) {
|
|
463
|
+
// A failed or wedged mount must leave NOTHING behind: the disposed
|
|
464
|
+
// subtree stops retrying the import, and the input file is removed so
|
|
465
|
+
// it cannot be re-imported by a later boot or replay (a leftover file
|
|
466
|
+
// re-throwing the same resolve error on every composition replay
|
|
467
|
+
// produced unbounded error-log growth on a closure-hosted loader).
|
|
468
|
+
try {
|
|
469
|
+
Promise.resolve(handle.dispose()).catch(() => { });
|
|
470
|
+
}
|
|
471
|
+
catch { /* best effort */ }
|
|
472
|
+
try {
|
|
473
|
+
rmSync(file, { force: true });
|
|
474
|
+
}
|
|
475
|
+
catch { /* best effort */ }
|
|
406
476
|
if (error instanceof ActivationTimeout) {
|
|
407
477
|
// A wedged activation would otherwise hold this request open forever:
|
|
408
478
|
// the route's `finally { installing = false }` never runs, so every
|
|
409
479
|
// later install/update/uninstall gets 409'd until a host restart.
|
|
410
480
|
// Unwind the half-mounted subtree best-effort; disposal never blocks
|
|
411
481
|
// the reply, and the caller falls back to restart activation.
|
|
412
|
-
try {
|
|
413
|
-
Promise.resolve(handle.dispose()).catch(() => { });
|
|
414
|
-
}
|
|
415
|
-
catch { /* best effort */ }
|
|
416
482
|
}
|
|
417
483
|
throw error;
|
|
418
484
|
}
|
package/lib/routes.js
CHANGED
|
@@ -433,14 +433,31 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
433
433
|
return { changed: [...changed], partial: changed.size > 0 };
|
|
434
434
|
}
|
|
435
435
|
/**
|
|
436
|
-
* Apply one enable/disable request:
|
|
437
|
-
*
|
|
438
|
-
* client-only shims go through hotUnmount/hotMount, bundle-layer
|
|
439
|
-
* through setEntryDisabled. Enabling a THEME goes through the
|
|
440
|
-
* activateTheme instead so the Themes tab's exclusivity stays
|
|
436
|
+
* Apply one enable/disable request: drive the live composition, then
|
|
437
|
+
* persist the choice in state.json. Covers every mount form — hot mounts
|
|
438
|
+
* and client-only shims go through hotUnmount/hotMount, bundle-layer
|
|
439
|
+
* entries through setEntryDisabled. Enabling a THEME goes through the
|
|
440
|
+
* caller's activateTheme instead so the Themes tab's exclusivity stays
|
|
441
|
+
* intact.
|
|
442
|
+
*
|
|
443
|
+
* A FAILED ENABLE LEAVES EVERYTHING AS IT WAS (#575). The choice used to be
|
|
444
|
+
* recorded before the mount was attempted and persisted whatever happened,
|
|
445
|
+
* so enabling a plugin that crashes on import — deterministically, every
|
|
446
|
+
* time — wrote "enabled" into state.json anyway. The next boot tried the
|
|
447
|
+
* import again and died again; the reporter measured 24 restarts before
|
|
448
|
+
* restoring the disable by hand. The toggle route's patch-layer gate
|
|
449
|
+
* (@JINITAIMEI121 in #584) closed the same hole in cordis.patch.yml; this
|
|
450
|
+
* closes it in the market's own store, which is the ONLY durable state a
|
|
451
|
+
* client-only plugin has — that plugin kind has no bundle rows, so the
|
|
452
|
+
* patch gate never runs for it.
|
|
453
|
+
*
|
|
454
|
+
* A failed DISABLE still persists, and that asymmetry is deliberate: the
|
|
455
|
+
* user asked for OFF, and a failed unmount leaves the plugin live only for
|
|
456
|
+
* this session. There the durable disable is the contract, not an error.
|
|
441
457
|
*/
|
|
442
458
|
async function setPluginEnabled(name, enabled) {
|
|
443
459
|
const dir = activeProfileDir;
|
|
460
|
+
const wasDisabled = disabled.has(name);
|
|
444
461
|
if (enabled)
|
|
445
462
|
disabled.delete(name);
|
|
446
463
|
else
|
|
@@ -474,6 +491,14 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
474
491
|
ok = true;
|
|
475
492
|
}
|
|
476
493
|
}
|
|
494
|
+
if (!ok && enabled) {
|
|
495
|
+
// Put the in-memory view back before persisting: it is the same object
|
|
496
|
+
// the route reports as `disabled`, so restoring it keeps the reply, the
|
|
497
|
+
// store and the patch layer telling one story.
|
|
498
|
+
if (wasDisabled)
|
|
499
|
+
disabled.add(name);
|
|
500
|
+
logEvent('warn', 'toggle', `${name}: enable failed; leaving it disabled rather than persisting a state that crashes at boot (#575)`);
|
|
501
|
+
}
|
|
477
502
|
writeMarketState(dir, { disabled, groups, groupOrder });
|
|
478
503
|
return { ok, reason };
|
|
479
504
|
}
|
|
@@ -2004,7 +2029,18 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2004
2029
|
}
|
|
2005
2030
|
}
|
|
2006
2031
|
let patchWrite = null;
|
|
2007
|
-
|
|
2032
|
+
// #575: a failed ENABLE must not flip the durable patch layer.
|
|
2033
|
+
// The hot-mount failure may be deterministic (a plugin that
|
|
2034
|
+
// crashes on import), and persisting "enabled" turns a transient
|
|
2035
|
+
// in-session error into a boot crash loop — the loader re-applies
|
|
2036
|
+
// the flipped rows on every start. The frontend already shows the
|
|
2037
|
+
// plugin as still disabled, and the next explicit enable retries
|
|
2038
|
+
// cleanly. Disables keep their unconditional write: a failed
|
|
2039
|
+
// unmount leaves the plugin live in-session, and the user asked
|
|
2040
|
+
// for it OFF — the durable disable is then the contract, not an
|
|
2041
|
+
// error.
|
|
2042
|
+
const patchGate = ok || !enabled;
|
|
2043
|
+
if (patchRows.length > 0 && patchGate) {
|
|
2008
2044
|
for (const rowId of patchRows) {
|
|
2009
2045
|
const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId);
|
|
2010
2046
|
if (!result.ok && patchWrite === null)
|
package/lib/types/hot.d.ts
CHANGED
|
@@ -32,6 +32,37 @@ interface HotContext {
|
|
|
32
32
|
warn(message: string): void;
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Profile-scoped resolution for hot-mount rows: turn a bare package name into
|
|
37
|
+
* the absolute `file://` entry URL of the package just installed into
|
|
38
|
+
* `profileDir`.
|
|
39
|
+
*
|
|
40
|
+
* Include-tree rows reach `Include.import` as BARE names (`name:
|
|
41
|
+
* '@scope/pkg'`), and the base class resolves them against the LOADER's own
|
|
42
|
+
* location — the host closure
|
|
43
|
+
* (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
|
|
44
|
+
* can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
|
|
45
|
+
* loader sits in an immutable dependency closure, EVERY market hot mount dies
|
|
46
|
+
* with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
|
|
47
|
+
* back to "restart required", blaming the plugin for what is a resolution
|
|
48
|
+
* anchor problem.
|
|
49
|
+
*
|
|
50
|
+
* Resolving the row name HERE, against the profile the package was actually
|
|
51
|
+
* installed into, is anchor-independent: `require.resolve` walks
|
|
52
|
+
* `profileDir/node_modules` natively, so the tree hands the loader a
|
|
53
|
+
* `file://` URL needing no further resolution. Non-bare specifiers (relative
|
|
54
|
+
* paths, `file://`, `cordis:` builtins) and names that do not resolve under
|
|
55
|
+
* the profile pass through unchanged, preserving base-class semantics for
|
|
56
|
+
* every shape this fix does not own.
|
|
57
|
+
*
|
|
58
|
+
* The fallback keeps the name bare rather than synthesising a URL: a package
|
|
59
|
+
* whose entry cannot be located via `require.resolve` (no `main`/exports —
|
|
60
|
+
* the market's own `entryArtifactExists` heuristic covers those shapes before
|
|
61
|
+
* an install is accepted) is not something this resolver should guess about.
|
|
62
|
+
* Client-only shims never reach this function (their rows are replaced by a
|
|
63
|
+
* no-op host module before the file is written).
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveProfileEntry(profileDir: string, name: string): string;
|
|
35
66
|
/**
|
|
36
67
|
* Insert rows of a plugin's bundle patch, or null when the patch contains
|
|
37
68
|
* anything beyond plain `id`/`name` insert rows (config blocks, disables,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.47.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/accelerate.ts
CHANGED
|
@@ -132,6 +132,15 @@ async function tryHeadCommit(
|
|
|
132
132
|
// metacharacters (`.` is legal in a branch name).
|
|
133
133
|
const quoted = ref.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
|
|
134
134
|
for (const namespace of ['heads', 'tags']) {
|
|
135
|
+
// An annotated tag advertises two refs — the tag object and its
|
|
136
|
+
// peeled commit (`refs/tags/<ref>^{}`). pnpm resolves the tag to the
|
|
137
|
+
// peeled commit and codeload records that sha in the lockfile, so the
|
|
138
|
+
// peeled line is the one that must equal `current`; answering with
|
|
139
|
+
// the tag-object sha made annotated-tag installs report an update
|
|
140
|
+
// forever (#597). Lightweight tags have no peeled line and fall
|
|
141
|
+
// through to the direct one.
|
|
142
|
+
const peeled = new RegExp(String.raw`([0-9a-f]{40}) refs/${namespace}/${quoted}\^\{\}(?![^\s])`, 'u').exec(body)
|
|
143
|
+
if (peeled !== null) return { kind: 'valid', sha: peeled[1]! }
|
|
135
144
|
const found = new RegExp(String.raw`([0-9a-f]{40}) refs/${namespace}/${quoted}(?![^\s])`, 'u').exec(body)
|
|
136
145
|
if (found !== null) return { kind: 'valid', sha: found[1]! }
|
|
137
146
|
}
|
package/src/changelog.ts
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
import { fileFromTarball } from './catalog-npm.ts'
|
|
22
22
|
import { marketFetch } from './net.ts'
|
|
23
23
|
import { activeRegion, routesFor } from './regions.ts'
|
|
24
|
-
import { profileDir, readInstalled, readLockCommits } from './profile.ts'
|
|
25
|
-
import { lookupRepoFromUrl, repoOfTarget } from './sources.ts'
|
|
24
|
+
import { profileDir, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.ts'
|
|
25
|
+
import { lookupRepoFromUrl, repoOf, repoOfTarget } from './sources.ts'
|
|
26
26
|
import { checkUpdates } from './updates.ts'
|
|
27
|
-
import { loadRegistry } from './registry.ts'
|
|
27
|
+
import { loadRegistry, type RegistryPlugin } from './registry.ts'
|
|
28
28
|
|
|
29
29
|
const UPDATES_PACKAGE = 'dsh-plugin-updates'
|
|
30
30
|
const UPDATES_FILE = 'package/updates.json'
|
|
@@ -236,7 +236,24 @@ export async function updateNotesFor(
|
|
|
236
236
|
if (key === null) {
|
|
237
237
|
try {
|
|
238
238
|
const registry = await loadRegistry()
|
|
239
|
-
const
|
|
239
|
+
const candidates = registry.plugins.filter(p => p.name === name)
|
|
240
|
+
let plugin: RegistryPlugin | undefined
|
|
241
|
+
if (candidates.length === 1) {
|
|
242
|
+
plugin = candidates[0]!
|
|
243
|
+
} else if (candidates.length > 1) {
|
|
244
|
+
// Same-named packages exist in the catalog; a bare name match can
|
|
245
|
+
// pick someone else's repo and answer "no notes" for a plugin that
|
|
246
|
+
// ships updates data under its own repository (#598). The installed
|
|
247
|
+
// package declares its repository, so prefer the catalog entry that
|
|
248
|
+
// agrees with it; only a unique agreement is trusted — an ambiguous
|
|
249
|
+
// name falls through to npm publish times, which are honest for any
|
|
250
|
+
// installed npm package.
|
|
251
|
+
const evidence = readInstalledRepoEvidence(profile, name, spec, explicitDir)
|
|
252
|
+
const matches = candidates.filter(p => evidence.identities.some(
|
|
253
|
+
id => repoOf(p.url)?.toLowerCase() === id.split('#')[0]!.toLowerCase(),
|
|
254
|
+
))
|
|
255
|
+
if (matches.length === 1) plugin = matches[0]!
|
|
256
|
+
}
|
|
240
257
|
if (plugin !== undefined) {
|
|
241
258
|
key = plugin.url
|
|
242
259
|
}
|
|
@@ -3745,7 +3745,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3745
3745
|
>{updatingName === self ? t('updating') : status.restoreRequired === true ? t('restoreOnline') : t('marketUpdate')}</Button>
|
|
3746
3746
|
)
|
|
3747
3747
|
})()}
|
|
3748
|
-
{reminderBatchUpdatableNames.length >=
|
|
3748
|
+
{reminderBatchUpdatableNames.length >= 1 && (
|
|
3749
3749
|
<Button
|
|
3750
3750
|
variant="primary"
|
|
3751
3751
|
size="sm"
|
|
@@ -691,10 +691,20 @@ function entryRepoIds(plugin: RegistryPlugin): Set<string> {
|
|
|
691
691
|
* dependency's spec pins a github repo AND the entry states one, the repos
|
|
692
692
|
* decide — the loose name/npm identities only apply when at least one side
|
|
693
693
|
* carries no repo evidence (npm installs, non-github entries).
|
|
694
|
+
*
|
|
695
|
+
* Repo evidence only ever decides by repository ROOT. A monorepo catalog
|
|
696
|
+
* entry states `owner/repo#path:/pkg` while an npm-installed manifest
|
|
697
|
+
* usually states the bare `owner/repo` (it rarely declares
|
|
698
|
+
* `repository.directory`), and reading that asymmetry as a source conflict
|
|
699
|
+
* kept a genuinely installed subpackage from ever reading as installed.
|
|
694
700
|
*/
|
|
701
|
+
/** Repository root: the part before any `#path:/…` subpath selection. */
|
|
702
|
+
function repoRoots(ids: ReadonlySet<string>): Set<string> {
|
|
703
|
+
return new Set([...ids].map(id => id.split('#path:/')[0]!))
|
|
704
|
+
}
|
|
695
705
|
function sameSourceConflict(plugin: RegistryPlugin, spec: string, repoIdentities: readonly string[] = []): boolean {
|
|
696
|
-
const entry = entryRepoIds(plugin)
|
|
697
|
-
const dep = depRepoIds(spec, repoIdentities)
|
|
706
|
+
const entry = repoRoots(entryRepoIds(plugin))
|
|
707
|
+
const dep = repoRoots(depRepoIds(spec, repoIdentities))
|
|
698
708
|
if (entry.size === 0 || dep.size === 0) return false
|
|
699
709
|
for (const id of dep) if (entry.has(id)) return false
|
|
700
710
|
return true
|
|
@@ -754,6 +764,66 @@ function looseMatches(plugin: RegistryPlugin, name: string): boolean {
|
|
|
754
764
|
return false
|
|
755
765
|
}
|
|
756
766
|
|
|
767
|
+
/**
|
|
768
|
+
* The same memo, for the branch #262 left behind (#589).
|
|
769
|
+
*
|
|
770
|
+
* `looseMatchCount` above covers dependencies installed by version. A
|
|
771
|
+
* `link:` or `file:` dependency takes the other branch, into
|
|
772
|
+
* `findCatalogEntryForLocal`, which walks the whole catalog at least twice
|
|
773
|
+
* per call — once to filter by name, once to collect `/tree/` repos — and
|
|
774
|
+
* up to twice more when there are identities to probe. Both callers below
|
|
775
|
+
* run once per rendered card. The reporter profiled ~300ms per repaint at
|
|
776
|
+
* 24 cards against a 3,627-entry catalog where a version-pinned dependency
|
|
777
|
+
* paid 1.1ms; a local benchmark measured ~38ms per render at that shape,
|
|
778
|
+
* and ~1.4s at 96 cards with eight local dependencies.
|
|
779
|
+
*
|
|
780
|
+
* The inner key carries the EVIDENCE, not just the name. Installing a plugin
|
|
781
|
+
* hands the next render a fresh identities array while the catalog array
|
|
782
|
+
* stays the same, so a name-only key would answer the post-install question
|
|
783
|
+
* with the pre-install result — which is the same-named-fork confusion #485
|
|
784
|
+
* asked this matcher to stop making, reintroduced as a cache bug.
|
|
785
|
+
*
|
|
786
|
+
* A miss is cached as `null`, which is why the "not cached yet" sentinel
|
|
787
|
+
* has to be `undefined`: `null` is a real answer here, and it costs the
|
|
788
|
+
* same full scan to establish as a hit does. It is also the common case —
|
|
789
|
+
* a checkout you are developing is usually not in the catalog at all.
|
|
790
|
+
*
|
|
791
|
+
* The invariant this rests on, stated because the WeakMap cannot enforce it:
|
|
792
|
+
* the catalog array and the entries inside it are frozen once handed here. A
|
|
793
|
+
* refetch replaces the array — which is what the outer key is for — but an
|
|
794
|
+
* in-place `push`, `sort` or `reverse`, or editing a row's `url`, would keep
|
|
795
|
+
* the key and change the answer. Order is load-bearing too: the matcher
|
|
796
|
+
* returns the FIRST row that fits. Nothing in the client does any of this
|
|
797
|
+
* today; `visiblePlugins` and `themePlugins` both sort copies.
|
|
798
|
+
*/
|
|
799
|
+
const localMatchCache = new WeakMap<RegistryPlugin[], Map<string, RegistryPlugin | null>>()
|
|
800
|
+
|
|
801
|
+
function cachedEntryForLocal(
|
|
802
|
+
plugins: RegistryPlugin[],
|
|
803
|
+
name: string,
|
|
804
|
+
identities: readonly string[],
|
|
805
|
+
hints: readonly string[],
|
|
806
|
+
): RegistryPlugin | null {
|
|
807
|
+
let byKey = localMatchCache.get(plugins)
|
|
808
|
+
if (byKey === undefined) {
|
|
809
|
+
byKey = new Map<string, RegistryPlugin | null>()
|
|
810
|
+
localMatchCache.set(plugins, byKey)
|
|
811
|
+
}
|
|
812
|
+
// JSON, not a delimiter-joined string. `[]` and `['']` join to the same
|
|
813
|
+
// thing and they are NOT the same question: an empty-but-present identity
|
|
814
|
+
// list has size 1, so it enters the evidence branch and refuses to guess,
|
|
815
|
+
// while an absent one falls through to the unique-name match. A key that
|
|
816
|
+
// cannot tell those apart lets whichever ran first answer for both, which
|
|
817
|
+
// is the guess this matcher exists to refuse. Stringifying the arrays is
|
|
818
|
+
// injective for free, and its cost is noise beside the scan it replaces.
|
|
819
|
+
const key = JSON.stringify([name, identities, hints])
|
|
820
|
+
const hit = byKey.get(key)
|
|
821
|
+
if (hit !== undefined) return hit
|
|
822
|
+
const entry = findCatalogEntryForLocal(plugins, name, identities, hints)
|
|
823
|
+
byKey.set(key, entry)
|
|
824
|
+
return entry
|
|
825
|
+
}
|
|
826
|
+
|
|
757
827
|
/** The installed dependency name a registry entry corresponds to, or null. */
|
|
758
828
|
export function matchInstalledName(
|
|
759
829
|
plugin: RegistryPlugin,
|
|
@@ -772,7 +842,7 @@ export function matchInstalledName(
|
|
|
772
842
|
// else's fork as installed (#485).
|
|
773
843
|
if (/^(?:link|file):/i.test(specStr)) {
|
|
774
844
|
if (plugins === undefined) continue
|
|
775
|
-
const entry =
|
|
845
|
+
const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? [])
|
|
776
846
|
if (entry !== null && entry.url === plugin.url) return name
|
|
777
847
|
continue
|
|
778
848
|
}
|
|
@@ -1316,7 +1386,7 @@ export function catalogEntryForInstalled(
|
|
|
1316
1386
|
repoHints: readonly string[] = [],
|
|
1317
1387
|
): RegistryPlugin | undefined {
|
|
1318
1388
|
if (/^(?:link|file):/i.test(spec)) {
|
|
1319
|
-
return
|
|
1389
|
+
return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? undefined
|
|
1320
1390
|
}
|
|
1321
1391
|
return entryForDep(plugins, name, spec, repoIdentities, repoHints)
|
|
1322
1392
|
}
|
package/src/hot.ts
CHANGED
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { createRequire } from 'node:module'
|
|
20
21
|
import { join } from 'node:path'
|
|
21
22
|
import { pathToFileURL } from 'node:url'
|
|
22
23
|
import { asChannel, type Channel } from './channels.ts'
|
|
23
24
|
import { asRegion, normalizeGithubProxy, type Region } from './regions.ts'
|
|
24
25
|
import { logEvent } from './log.ts'
|
|
26
|
+
import { entryArtifactExists } from './profile.ts'
|
|
25
27
|
|
|
26
28
|
interface HotRow {
|
|
27
29
|
id: string
|
|
@@ -38,6 +40,54 @@ interface HotContext {
|
|
|
38
40
|
logger?: { info?(message: string): void; warn(message: string): void }
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Profile-scoped resolution for hot-mount rows: turn a bare package name into
|
|
45
|
+
* the absolute `file://` entry URL of the package just installed into
|
|
46
|
+
* `profileDir`.
|
|
47
|
+
*
|
|
48
|
+
* Include-tree rows reach `Include.import` as BARE names (`name:
|
|
49
|
+
* '@scope/pkg'`), and the base class resolves them against the LOADER's own
|
|
50
|
+
* location — the host closure
|
|
51
|
+
* (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
|
|
52
|
+
* can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
|
|
53
|
+
* loader sits in an immutable dependency closure, EVERY market hot mount dies
|
|
54
|
+
* with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
|
|
55
|
+
* back to "restart required", blaming the plugin for what is a resolution
|
|
56
|
+
* anchor problem.
|
|
57
|
+
*
|
|
58
|
+
* Resolving the row name HERE, against the profile the package was actually
|
|
59
|
+
* installed into, is anchor-independent: `require.resolve` walks
|
|
60
|
+
* `profileDir/node_modules` natively, so the tree hands the loader a
|
|
61
|
+
* `file://` URL needing no further resolution. Non-bare specifiers (relative
|
|
62
|
+
* paths, `file://`, `cordis:` builtins) and names that do not resolve under
|
|
63
|
+
* the profile pass through unchanged, preserving base-class semantics for
|
|
64
|
+
* every shape this fix does not own.
|
|
65
|
+
*
|
|
66
|
+
* The fallback keeps the name bare rather than synthesising a URL: a package
|
|
67
|
+
* whose entry cannot be located via `require.resolve` (no `main`/exports —
|
|
68
|
+
* the market's own `entryArtifactExists` heuristic covers those shapes before
|
|
69
|
+
* an install is accepted) is not something this resolver should guess about.
|
|
70
|
+
* Client-only shims never reach this function (their rows are replaced by a
|
|
71
|
+
* no-op host module before the file is written).
|
|
72
|
+
*/
|
|
73
|
+
export function resolveProfileEntry(profileDir: string, name: string): string {
|
|
74
|
+
if (!name || name.startsWith('.') || name.startsWith('cordis:') || name.startsWith('file://')) return name
|
|
75
|
+
const packageDir = join(profileDir, 'node_modules', ...name.split('/'))
|
|
76
|
+
try {
|
|
77
|
+
return pathToFileURL(createRequire(join(profileDir, 'package.json')).resolve(name)).href
|
|
78
|
+
} catch {
|
|
79
|
+
// require.resolve needs a resolvable package entry; the market's install
|
|
80
|
+
// validation accepts a broader artifact set (exports objects, index.js).
|
|
81
|
+
// Fall back to the index.js artifact so a valid mount is not refused over
|
|
82
|
+
// resolver strictness — and keep the bare name when no checkable entry
|
|
83
|
+
// exists, letting the loader produce its own (accurate) error.
|
|
84
|
+
if (entryArtifactExists(packageDir)) {
|
|
85
|
+
return pathToFileURL(join(packageDir, 'index.js')).href
|
|
86
|
+
}
|
|
87
|
+
return name
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
41
91
|
const HOT_DIR = '.dsh-market'
|
|
42
92
|
|
|
43
93
|
/**
|
|
@@ -505,21 +555,33 @@ export async function hotMount(ctx: HotContext, profileDir: string, packageName:
|
|
|
505
555
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
506
556
|
hotSequence += 1
|
|
507
557
|
const file = join(dir, `hot-${String(hotSequence)}.yml`)
|
|
558
|
+
// Rows carry ABSOLUTE file:// entry URLs, resolved against this profile:
|
|
559
|
+
// the loader's own parent-walk (from the host closure) can never reach
|
|
560
|
+
// `profileDir/node_modules`, so bare names in the file would fail to
|
|
561
|
+
// import on closure-hosted loaders. The file remains a faithful record —
|
|
562
|
+
// `cleanHotDir` wipes it on every boot and the bundle layer owns
|
|
563
|
+
// persistence, so nothing reads these files back.
|
|
508
564
|
const yml = rows
|
|
509
|
-
.map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
|
|
565
|
+
.map(row => `- id: 'mkt-${row.id}'\n name: '${resolveProfileEntry(profileDir, row.name)}'\n`)
|
|
510
566
|
.join('')
|
|
511
567
|
writeFileSync(file, yml)
|
|
512
568
|
const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href })
|
|
513
569
|
try {
|
|
514
570
|
await raceActivationTimeout(handle.await())
|
|
515
571
|
} catch (error) {
|
|
572
|
+
// A failed or wedged mount must leave NOTHING behind: the disposed
|
|
573
|
+
// subtree stops retrying the import, and the input file is removed so
|
|
574
|
+
// it cannot be re-imported by a later boot or replay (a leftover file
|
|
575
|
+
// re-throwing the same resolve error on every composition replay
|
|
576
|
+
// produced unbounded error-log growth on a closure-hosted loader).
|
|
577
|
+
try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
|
|
578
|
+
try { rmSync(file, { force: true }) } catch { /* best effort */ }
|
|
516
579
|
if (error instanceof ActivationTimeout) {
|
|
517
580
|
// A wedged activation would otherwise hold this request open forever:
|
|
518
581
|
// the route's `finally { installing = false }` never runs, so every
|
|
519
582
|
// later install/update/uninstall gets 409'd until a host restart.
|
|
520
583
|
// Unwind the half-mounted subtree best-effort; disposal never blocks
|
|
521
584
|
// the reply, and the caller falls back to restart activation.
|
|
522
|
-
try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
|
|
523
585
|
}
|
|
524
586
|
throw error
|
|
525
587
|
}
|
package/src/routes.ts
CHANGED
|
@@ -487,14 +487,31 @@ export function mountMarketRoutes(
|
|
|
487
487
|
}
|
|
488
488
|
|
|
489
489
|
/**
|
|
490
|
-
* Apply one enable/disable request:
|
|
491
|
-
*
|
|
492
|
-
* client-only shims go through hotUnmount/hotMount, bundle-layer
|
|
493
|
-
* through setEntryDisabled. Enabling a THEME goes through the
|
|
494
|
-
* activateTheme instead so the Themes tab's exclusivity stays
|
|
490
|
+
* Apply one enable/disable request: drive the live composition, then
|
|
491
|
+
* persist the choice in state.json. Covers every mount form — hot mounts
|
|
492
|
+
* and client-only shims go through hotUnmount/hotMount, bundle-layer
|
|
493
|
+
* entries through setEntryDisabled. Enabling a THEME goes through the
|
|
494
|
+
* caller's activateTheme instead so the Themes tab's exclusivity stays
|
|
495
|
+
* intact.
|
|
496
|
+
*
|
|
497
|
+
* A FAILED ENABLE LEAVES EVERYTHING AS IT WAS (#575). The choice used to be
|
|
498
|
+
* recorded before the mount was attempted and persisted whatever happened,
|
|
499
|
+
* so enabling a plugin that crashes on import — deterministically, every
|
|
500
|
+
* time — wrote "enabled" into state.json anyway. The next boot tried the
|
|
501
|
+
* import again and died again; the reporter measured 24 restarts before
|
|
502
|
+
* restoring the disable by hand. The toggle route's patch-layer gate
|
|
503
|
+
* (@JINITAIMEI121 in #584) closed the same hole in cordis.patch.yml; this
|
|
504
|
+
* closes it in the market's own store, which is the ONLY durable state a
|
|
505
|
+
* client-only plugin has — that plugin kind has no bundle rows, so the
|
|
506
|
+
* patch gate never runs for it.
|
|
507
|
+
*
|
|
508
|
+
* A failed DISABLE still persists, and that asymmetry is deliberate: the
|
|
509
|
+
* user asked for OFF, and a failed unmount leaves the plugin live only for
|
|
510
|
+
* this session. There the durable disable is the contract, not an error.
|
|
495
511
|
*/
|
|
496
512
|
async function setPluginEnabled(name: string, enabled: boolean): Promise<{ ok: boolean; reason?: string }> {
|
|
497
513
|
const dir = activeProfileDir
|
|
514
|
+
const wasDisabled = disabled.has(name)
|
|
498
515
|
if (enabled) disabled.delete(name)
|
|
499
516
|
else disabled.add(name)
|
|
500
517
|
let ok: boolean
|
|
@@ -522,6 +539,13 @@ export function mountMarketRoutes(
|
|
|
522
539
|
ok = true
|
|
523
540
|
}
|
|
524
541
|
}
|
|
542
|
+
if (!ok && enabled) {
|
|
543
|
+
// Put the in-memory view back before persisting: it is the same object
|
|
544
|
+
// the route reports as `disabled`, so restoring it keeps the reply, the
|
|
545
|
+
// store and the patch layer telling one story.
|
|
546
|
+
if (wasDisabled) disabled.add(name)
|
|
547
|
+
logEvent('warn', 'toggle', `${name}: enable failed; leaving it disabled rather than persisting a state that crashes at boot (#575)`)
|
|
548
|
+
}
|
|
525
549
|
writeMarketState(dir, { disabled, groups, groupOrder })
|
|
526
550
|
return { ok, reason }
|
|
527
551
|
}
|
|
@@ -2109,7 +2133,18 @@ export function mountMarketRoutes(
|
|
|
2109
2133
|
}
|
|
2110
2134
|
}
|
|
2111
2135
|
let patchWrite: { ok: boolean; reason: string | null } | null = null
|
|
2112
|
-
|
|
2136
|
+
// #575: a failed ENABLE must not flip the durable patch layer.
|
|
2137
|
+
// The hot-mount failure may be deterministic (a plugin that
|
|
2138
|
+
// crashes on import), and persisting "enabled" turns a transient
|
|
2139
|
+
// in-session error into a boot crash loop — the loader re-applies
|
|
2140
|
+
// the flipped rows on every start. The frontend already shows the
|
|
2141
|
+
// plugin as still disabled, and the next explicit enable retries
|
|
2142
|
+
// cleanly. Disables keep their unconditional write: a failed
|
|
2143
|
+
// unmount leaves the plugin live in-session, and the user asked
|
|
2144
|
+
// for it OFF — the durable disable is then the contract, not an
|
|
2145
|
+
// error.
|
|
2146
|
+
const patchGate = ok || !enabled
|
|
2147
|
+
if (patchRows.length > 0 && patchGate) {
|
|
2113
2148
|
for (const rowId of patchRows) {
|
|
2114
2149
|
const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId)
|
|
2115
2150
|
if (!result.ok && patchWrite === null) patchWrite = result
|