dsh-plugin-marketplace 0.4.0 → 0.4.1

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # dsh-plugin-marketplace
2
2
 
3
+ ## v0.4.1: desktop request handling
4
+
5
+ - Installation targets the active Host profile and home, resolves the Desktop Host `cli.js`, and runs Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals`.
6
+ - Install and AI-explain requests start outside the settings/HMR transaction. Transient state-write refusals are retried; spawn failures return an installation error.
7
+ - The UI shows progress while awaiting Host acknowledgement, and an error after 15 seconds without acknowledgement. This does not cap installation or model response time.
8
+ - Update to this release and restart DSH. Repositories without a matching npm package still return an explicit installation error.
9
+
3
10
  ## Configuration page (DSH 0.2.0-rc.2 and later)
4
11
 
5
12
  Open **Plugins → Installed → dsh-plugin-marketplace** from the homepage sidebar to configure and save this plugin. The page uses the official `plugins.bundle.config` interface, without a duplicate entry in global Settings. Web and Desktop share the page. This version requires DSH 0.2.0-rc.2 or a later 0.2.x host; existing configuration is retained.
package/README.zh.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # dsh-plugin-marketplace
2
2
 
3
+ ## v0.4.1:桌面端请求闭环
4
+
5
+ - 一键安装使用当前 Host 的 profile 和数据目录,解析桌面 Host 的 `cli.js`;Electron 子进程启用 `ELECTRON_RUN_AS_NODE=1` 与 `--expose-internals`。
6
+ - 安装和 AI 解释请求延后到 settings/HMR 事务之外处理,状态回写遇到暂时拒绝会重试,启动进程失败会返回安装错误。
7
+ - UI 在等待宿主确认时显示进度;15 秒未确认会显示错误。此超时只针对请求确认,不限制实际安装或模型响应时间。
8
+ - 需要更新到本版本并重启 DSH。GitHub 仓库没有同名 npm 包时,安装仍会明确报错。
9
+
3
10
  ## 配置入口(DSH 0.2.0-rc.2 起)
4
11
 
5
12
  在首页侧边栏打开 **插件 → 已安装 → dsh-plugin-marketplace**,直接在插件详情页配置并保存。配置页注册到官方的 `plugins.bundle.config` 接口;全局设置页不再重复显示配置入口。Web 与桌面版使用相同界面,本版要求 DSH 0.2.0-rc.2 或更新的 0.2.x 版本。现有配置无需迁移。
package/client.js CHANGED
@@ -78,6 +78,7 @@ window.__ModuleLoader__.load({
78
78
  installOk: "已安装:{msg}",
79
79
  installErr: "安装失败:{msg}",
80
80
  installIdle: "",
81
+ hostTimeout: "宿主在 15 秒内没有确认请求,请检查插件是否启用并重启 DSH 后重试。",
81
82
  installingHint: "正在后台执行 dsh plugin add,请稍候…",
82
83
  aiExplain: "🤖 AI 解释",
83
84
  aiExplaining: "AI 解释中,请稍候…",
@@ -109,6 +110,7 @@ window.__ModuleLoader__.load({
109
110
  installOk: "Installed: {msg}",
110
111
  installErr: "Install failed: {msg}",
111
112
  installIdle: "",
113
+ hostTimeout: "The Host did not acknowledge the request within 15 seconds. Check that the plugin is enabled and restart DSH before retrying.",
112
114
  installingHint: "Running dsh plugin add in the background…",
113
115
  aiExplain: "🤖 AI Explain",
114
116
  aiExplaining: "AI is explaining…",
@@ -337,6 +339,30 @@ window.__ModuleLoader__.load({
337
339
  var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
338
340
  return function () { alive = false; if (un) un(); if (scope.dispose) scope.dispose(); };
339
341
  }, [scope]);
342
+ var channel = installState && installState.status === "ready" ? installState.value : {};
343
+ var installRequest = channel.install || {};
344
+ var explainRequest = channel.aiExplain || {};
345
+ react.useEffect(function () {
346
+ if (!installRequest.pkg || !installRequest.ts) return;
347
+ var timer = setTimeout(function () {
348
+ set(function (prev) { return Object.assign({}, prev, { installError: t("hostTimeout") }); });
349
+ }, 15000);
350
+ return function () { clearTimeout(timer); };
351
+ }, [installRequest.pkg, installRequest.ts, t]);
352
+ react.useEffect(function () {
353
+ if (!explainRequest.repo || !explainRequest.ts) return;
354
+ var timer = setTimeout(function () {
355
+ set(function (prev) { return Object.assign({}, prev, { explainError: t("hostTimeout") }); });
356
+ }, 15000);
357
+ return function () { clearTimeout(timer); };
358
+ }, [explainRequest.repo, explainRequest.ts, t]);
359
+ // Pending requests immediately show progress, before the Host acknowledges.
360
+ var visibleInstall = installRequest.pkg ? {
361
+ status: s.installError ? "error" : "running", pkg: installRequest.pkg, message: s.installError || ""
362
+ } : channel.installState;
363
+ var visibleExplain = explainRequest.repo ? {
364
+ status: s.explainError ? "error" : "running", repo: explainRequest.repo, text: s.explainError || ""
365
+ } : channel.aiExplainResult;
340
366
  // Older hosts can still return settings-not-exposed. Recommend upgrading
341
367
  // instead of mutating the installed DSH package on disk.
342
368
  var mutateError = function (detail, fallback) {
@@ -425,7 +451,7 @@ window.__ModuleLoader__.load({
425
451
  var open = s.open && s.open.fullName === p.fullName;
426
452
  return h("div", { key: p.fullName, className: "__mp_item" },
427
453
  h(PluginCard, { plugin: p, t: t, onOpen: function () { openDetail(p); } }),
428
- open ? h(DetailPanel, { plugin: s.open, t: t, readme: s.readme, readmeLoading: s.readmeLoading, readmeError: s.readmeError, readmeRateLimited: s.readmeRateLimited, lang: s.open.lang, installState: installState && installState.status === "ready" ? installState.value.installState : null, onInstall: onInstall, ghError: t("ghError"), explainState: installState && installState.status === "ready" ? installState.value.aiExplainResult : null, explainError: s.explainError, onExplain: onExplain, bundleInfo: s.bundleInfo }) : null
454
+ open ? h(DetailPanel, { plugin: s.open, t: t, readme: s.readme, readmeLoading: s.readmeLoading, readmeError: s.readmeError, readmeRateLimited: s.readmeRateLimited, lang: s.open.lang, installState: visibleInstall, onInstall: onInstall, ghError: t("ghError"), explainState: visibleExplain, explainError: s.explainError, onExplain: onExplain, bundleInfo: s.bundleInfo }) : null
429
455
  );
430
456
  })
431
457
  ),
package/index.js CHANGED
@@ -1,314 +1,278 @@
1
- /**
2
- * dsh-plugin-marketplace — node half.
3
- *
4
- * Host side of the marketplace: owns a `plugin-marketplace` settings
5
- * namespace used as a message channel between the browser UI and this
6
- * process. The client writes an `install` request; the settings watch here
7
- * spawns `dsh plugin --profile <p> add <pkg>` (npm install into the profile)
8
- * and appends the mount row to the profile's cordis.patch.yml, then reports
9
- * progress/result back through `installState`.
10
- *
11
- * The settings section UI itself lives in the browser half (exports["./client"]).
12
- */
13
- import { execFile } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { join } from "node:path";
16
- import os from "node:os";
17
- import z from "@deepseek-ai/schemastery";
18
- import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
19
-
20
- /** Cordis plugin name. */
21
- const name = "plugin-marketplace";
22
- /** Services this plugin needs injected from the host tree (ctx.get requires inject). */
23
- const inject = ["llm", "agentDefaultModel"];
24
- /** Settings namespace owned by this plugin (Web UI settings section + install channel). */
25
- const NS = "plugin-marketplace";
26
-
27
- /** Runtime schema: the install request + the install state report. */
28
- const Config = z.object({
29
- install: z.object({
30
- /** npm package name to install ("" = no pending request). */
31
- pkg: z.string().default(""),
32
- /** client-set timestamp; host dedups on it. */
33
- ts: z.number().default(0),
34
- }),
1
+ /**
2
+ * dsh-plugin-marketplace — node half.
3
+ *
4
+ * Host side of the marketplace: owns a `plugin-marketplace` settings
5
+ * namespace used as a message channel between the browser UI and this
6
+ * process. The client writes an `install` request; the settings watch here
7
+ * spawns `dsh plugin --profile <p> add <pkg>` (npm install into the profile)
8
+ * and appends the mount row to the profile's cordis.patch.yml, then reports
9
+ * progress/result back through `installState`.
10
+ *
11
+ * The settings section UI itself lives in the browser half (exports["./client"]).
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { AsyncResource } from "node:async_hooks";
15
+ import { join } from "node:path";
16
+ import { activeProfile, runInstall, writeState } from "./runtime.js";
17
+ import z from "@deepseek-ai/schemastery";
18
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
19
+
20
+ /** Cordis plugin name. */
21
+ const name = "plugin-marketplace";
22
+ /** Services this plugin needs injected from the host tree (ctx.get requires inject). */
23
+ const inject = ["settings", "profileContext", "llm", "agentDefaultModel"];
24
+ /** Settings namespace owned by this plugin (Web UI settings section + install channel). */
25
+ const NS = "plugin-marketplace";
26
+
27
+ /** Runtime schema: the install request + the install state report. */
28
+ const Config = z.object({
29
+ install: z.object({
30
+ /** npm package name to install ("" = no pending request). */
31
+ pkg: z.string().default(""),
32
+ /** client-set timestamp; host dedups on it. */
33
+ ts: z.number().default(0),
34
+ }),
35
35
  installState: z.object({
36
36
  status: z.string().default("idle"), // idle | running | ok | error
37
37
  message: z.string().default(""),
38
38
  pkg: z.string().default(""),
39
39
  ts: z.number().default(0),
40
- }),
41
- /** AI-explain request (client writes repo/desc/readme, host answers). */
42
- aiExplain: z.object({
43
- repo: z.string().default(""),
44
- desc: z.string().default(""),
45
- readme: z.string().default(""),
46
- ts: z.number().default(0),
47
- }),
48
- aiExplainResult: z.object({
49
- status: z.string().default("idle"), // idle | running | ok | error
50
- text: z.string().default(""),
51
- /** repo this result belongs to ("owner/name"); client gates display on it. */
52
- repo: z.string().default(""),
53
- ts: z.number().default(0),
54
- }),
40
+ }),
41
+ /** AI-explain request (client writes repo/desc/readme, host answers). */
42
+ aiExplain: z.object({
43
+ repo: z.string().default(""),
44
+ desc: z.string().default(""),
45
+ readme: z.string().default(""),
46
+ ts: z.number().default(0),
47
+ }),
48
+ aiExplainResult: z.object({
49
+ status: z.string().default("idle"), // idle | running | ok | error
50
+ text: z.string().default(""),
51
+ /** repo this result belongs to ("owner/name"); client gates display on it. */
52
+ repo: z.string().default(""),
53
+ ts: z.number().default(0),
54
+ }),
55
55
  }).volatile();
56
-
57
- /** npm package-name shape (scope/name, no spaces, no path chars). */
58
- const PKG_NAME = /^@?[a-z0-9][a-z0-9-._]*(?:\/[a-z0-9][a-z0-9-._]*)?$/;
59
-
56
+
57
+ /** npm package-name shape (scope/name, no spaces, no path chars). */
58
+ const PKG_NAME = /^@?[a-z0-9][a-z0-9-._]*(?:\/[a-z0-9][a-z0-9-._]*)?$/;
59
+
60
60
  function apply(ctx, config) {
61
61
  const sourceGetter = () => typeof config.get === "function" ? config.get() : config;
62
- let lastTs = 0;
63
- let lastExplainTs = 0;
64
-
65
- // ── settings-backed configuration ─────────────────────────────────────────
66
- // The browser half edits this namespace; every write fires the onChange
67
- // handler below, which is what turns an `install` request into a real
68
- // install.
69
- // Compat shim: dsh-settings 0.1.2-rc.1 removed the module-level
70
- // `installSettingsSection` export (the provider now lives at ctx.settings).
71
- // Inline the same logic via ctx.inject(["settings"]) — works on both
72
- // 0.1.1 (module export wrapper) and 0.1.2 (ctx.settings) hosts.
73
- // DSH 0.1.7 stores live plugin fields in the profile patch. Config is a
74
- // volatile proxy, and the settings service emits after a committed edit.
62
+ let lastTs = 0;
63
+ let lastExplainTs = 0;
64
+
65
+ // settings/document-updated may fire inside the Host's HMR transaction.
66
+ // Start consumption on the next task, and cancel scheduled work on unload.
67
+ let scheduled;
68
+ let disposed = false;
69
+ // Capture the plugin's startup context. A timer created by an HMR event
70
+ // inherits its AsyncLocalStorage transaction even after that transaction
71
+ // settles; consume requests in this resource's context instead.
72
+ const consumer = new AsyncResource("plugin-marketplace-requests");
73
+ function scheduleRequests() {
74
+ if (disposed || scheduled !== undefined) return;
75
+ scheduled = setTimeout(() => {
76
+ scheduled = undefined;
77
+ consumer.runInAsyncScope(() => {
78
+ void maybeRunInstall();
79
+ void maybeRunExplain();
80
+ });
81
+ }, 0);
82
+ }
75
83
  ctx.on("settings/document-updated", (id) => {
76
- if (id !== NS) return;
77
- void maybeRunInstall();
78
- void maybeRunExplain();
79
- });
80
- queueMicrotask(() => {
81
- void maybeRunInstall();
82
- void maybeRunExplain();
84
+ if (id === NS) scheduleRequests();
83
85
  });
84
-
85
- // DSH 0.1.0-rc.7+ exposes registered settings namespaces natively, allowing
86
- // the browser client to write install requests without modifying host files.
87
- /** Resolved current config (settings layer over the entry). */
88
- function current() {
86
+ ctx.effect(() => () => { disposed = true; clearTimeout(scheduled); consumer.emitDestroy(); });
87
+ scheduleRequests();
88
+
89
+ /** Resolved current config (settings layer over the entry). */
90
+ function current() {
89
91
  return sourceGetter();
90
- }
91
-
92
- /** Write the install-state report (and clear the consumed request). */
93
- async function report(status, message, pkg) {
94
- const settings = ctx.get("settings");
95
- if (!settings) return;
96
- const ts = Date.now();
97
- await settings.update(NS, {
98
- installState: { status, message, ts, pkg: pkg || "" },
99
- install: { pkg: "", ts: 0 },
100
- }).catch((error) => {
101
- ctx.logger.warn(`[plugin-marketplace] state write failed: ${String(error)}`);
102
- });
103
- }
104
-
105
- /** Find the active profile name from the process args ("web" default). */
106
- function profileName() {
107
- const argv = process.argv;
108
- const i = argv.indexOf("--profile");
109
- if (i >= 0 && argv[i + 1]) return argv[i + 1];
110
- return "web";
111
- }
112
-
113
- /** The dsh CLI entry used to boot this process (its own bin.js). */
114
- function dshBin() {
115
- return process.argv[1];
116
- }
117
-
118
- /** Read the profile manifest (its `dsh.profile.bundles` list), or null. */
119
- function profileManifest(profile) {
120
- try {
121
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
122
- return JSON.parse(readFileSync(join(dshHome, "profiles", profile, "package.json"), "utf8"));
123
- } catch {
124
- return null;
125
- }
126
- }
127
-
128
- /** Whether the installed package declares dsh.bundle.patch (auto-mounted via its bundle layer). */
129
- function isBundlePackage(pkg, profile) {
130
- try {
131
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
132
- const manifest = JSON.parse(readFileSync(join(dshHome, "profiles", profile, "node_modules", pkg, "package.json"), "utf8"));
133
- return typeof manifest?.dsh?.bundle?.patch === "string" && manifest.dsh.bundle.patch.length > 0;
134
- } catch {
135
- return false;
136
- }
137
- }
138
-
139
- /**
140
- * Append a mount row for `pkg` to cordis.patch.yml (idempotent).
141
- * Bundle plugins (already in `dsh.profile.bundles`, or whose installed
142
- * package declares `dsh.bundle.patch`) are mounted by their own bundle
143
- * layer — adding a manual row would create a duplicate loader entry and
144
- * abort boot. A STALE manual row left by an earlier non-bundle version is
145
- * removed instead, so reinstalling a bundle plugin never breaks boot.
146
- */
147
- function ensureMounted(pkg, profile) {
148
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
149
- const patchPath = join(dshHome, "profiles", profile, "cordis.patch.yml");
150
- if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
151
- const bundles = profileManifest(profile)?.dsh?.profile?.bundles ?? [];
152
- if (bundles.includes(pkg) || isBundlePackage(pkg, profile)) {
153
- // Bundle layer auto-mounts; remove any stale manual row (pre-bundle).
154
- const src = readFileSync(patchPath, "utf8");
155
- const cleaned = src.replace(new RegExp(`[ \\t]*- id: [^\\n]*\\n[ \\t]*name: ['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]\\n?`, "g"), "");
156
- if (cleaned !== src) {
157
- writeFileSync(patchPath, cleaned, "utf8");
158
- return " (removed stale manual mount row; bundle layer auto-mounts)";
159
- }
160
- return bundles.includes(pkg) ? " (already mounted via dsh.profile.bundles)" : " (bundle plugin: auto-mounted via its own dsh.bundle layer)";
161
- }
162
- const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
163
- let src = readFileSync(patchPath, "utf8");
164
- if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
165
- const row = `\n - id: ${id}\n name: '${pkg}'`;
166
- // A flow-style empty array (`[]`, optionally after comment lines) is a
167
- // valid empty patch: replace it with the block-style insert list.
168
- // Appending after `[]` would produce two top-level documents in one
169
- // stream and crash boot with a YAML parse error.
170
- const emptyArray = /^(\s*(?:#[^\n]*\n?)*)\[\s*\]\s*$/.exec(src);
171
- if (emptyArray) {
172
- writeFileSync(patchPath, `${emptyArray[1]}- insert:${row}\n`, "utf8");
173
- return " (mounted in cordis.patch.yml)";
174
- }
175
- // Insert under the first `- insert:` list; match its indentation (4
176
- // spaces is the convention used by dsh profiles).
177
- const insertAt = src.search(/^- insert:\s*$/m);
178
- if (insertAt >= 0) {
179
- src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
180
- } else {
181
- // A non-empty flow-style array cannot be safely extended with string
182
- // surgery — refuse rather than corrupt the file.
183
- if (/^\s*\[/.test(src)) return " (patch file uses a flow-style array; mount manually)";
184
- src += `\n- insert:${row}\n`;
185
- }
186
- writeFileSync(patchPath, src, "utf8");
187
- return " (mounted in cordis.patch.yml)";
188
- }
189
-
190
- /** Run the install; resolve {code, stdout, stderr}. */
191
- function runInstall(pkg, profile) {
192
- const bin = dshBin();
193
- const node = process.execPath;
194
- return new Promise((resolve) => {
195
- execFile(node, [bin, "plugin", "--profile", profile, "add", pkg], {
196
- timeout: 5 * 60 * 1000,
197
- maxBuffer: 8 * 1024 * 1024,
198
- windowsHide: true,
199
- }, (error, stdout, stderr) => {
200
- resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
201
- });
202
- });
203
- }
204
-
205
- async function maybeRunInstall() {
206
- try {
207
- const cfg = current();
208
- const req = cfg?.install;
209
- if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
210
- lastTs = req.ts;
211
- const pkg = String(req.pkg).trim();
212
- if (!PKG_NAME.test(pkg)) {
213
- await report("error", `invalid package name: ${pkg}`, pkg);
214
- return;
215
- }
216
- const profile = profileName();
217
- ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile})…`);
218
- await report("running", `installing ${pkg}…`, pkg);
219
- const result = await runInstall(pkg, profile);
220
- if (result.code !== 0) {
221
- const output = (result.stderr || result.stdout || "").trim();
222
- const tail = output.includes("ERR_PNPM_IGNORED_BUILDS")
223
- ? "dependency build scripts were blocked by pnpm; the plugin author must make native build dependencies opt-in"
224
- : output.split("\n").slice(-3).join(" ");
225
- ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
226
- await report("error", `install failed: ${tail || "unknown error"}`, pkg);
227
- return;
228
- }
229
- const mountNote = ensureMounted(pkg, profile);
230
- ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
231
- await report("ok", `${pkg} installed${mountNote}. Restart dsh web to load it.`, pkg);
232
- } catch (error) {
233
- ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
234
- await report("error", String(error instanceof Error ? error.message : error));
235
- }
236
- }
237
-
238
- /** Write the AI-explain result report (and clear the consumed request). */
239
- async function reportExplain(status, text, repo) {
240
- const settings = ctx.get("settings");
241
- if (!settings) return;
242
- const ts = Date.now();
243
- await settings.update(NS, {
244
- aiExplainResult: { status, text, repo: repo || "", ts },
245
- aiExplain: { repo: "", desc: "", readme: "", ts: 0 },
246
- }).catch((error) => {
247
- ctx.logger.warn(`[plugin-marketplace] explain state write failed: ${String(error)}`);
248
- });
249
- }
250
-
251
- /**
252
- * Answer an aiExplain request with the deployment's default model: tell the
253
- * user in plain Chinese what this plugin roughly does. Uses the same
254
- * settings-backed message channel as the install flow.
255
- */
256
- async function maybeRunExplain() {
257
- let repo = "";
258
- try {
259
- const cfg = current();
260
- const req = cfg?.aiExplain;
261
- if (!req || !req.repo || req.ts === lastExplainTs || req.ts === 0) return;
262
- lastExplainTs = req.ts;
263
- repo = String(req.repo);
264
- const llm = ctx.llm;
265
- if (!llm) {
266
- await reportExplain("error", "LLM service unavailable; configure a model in Settings → Models.", repo);
267
- return;
268
- }
269
- // Route through the deployment's default model when one is configured.
270
- let route = null;
271
- try {
272
- const defaults = ctx.agentDefaultModel?.currentSelection?.();
273
- if (defaults?.provider && defaults?.model) route = defaults;
274
- } catch { /* no default model service; fall back to the adapter default */ }
275
- const desc = String(req.desc || "").trim();
276
- const readme = String(req.readme || "").trim().slice(0, 1500);
277
- const prompt =
278
- `插件仓库:${repo}\n` +
279
- (desc ? `简介:${desc}\n` : "") +
280
- (readme ? `README 摘要:\n${readme}\n` : "") +
281
- `\n请用简体中文、3~5 句话直接告诉我这个插件大概是干嘛的(核心用途、解决什么问题、适合谁)。不要复述仓库名,不要罗列安装步骤。`;
282
- await reportExplain("running", "", repo);
283
- ctx.logger.info(`[plugin-marketplace] explaining ${repo} (model=${route ? `${route.provider}/${route.model}` : "default"})…`);
284
- const messages = [createUserMessage({
285
- content: [{ type: "text", text: prompt }],
286
- source: { kind: "plugin", plugin: "dsh-plugin-marketplace" },
287
- })];
288
- const options = {
289
- messages,
290
- maxTokens: 400,
291
- purpose: "plugin-marketplace-explain",
292
- };
293
- if (route) {
294
- options.provider = route.provider;
295
- options.model = route.model;
296
- }
297
- const assembler = new BlockAssembler();
298
- for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
299
- const blocks = assembler.blocks();
300
- const text = blocks
301
- .filter((block) => block.type === "text")
302
- .map((block) => block.text)
303
- .join(" ")
304
- .trim();
305
- if (!text) throw new Error("model produced no text");
306
- await reportExplain("ok", text, repo);
307
- } catch (error) {
308
- ctx.logger.warn(`[plugin-marketplace] explain flow failed: ${String(error)}`);
309
- await reportExplain("error", String(error instanceof Error ? error.message : error), repo);
310
- }
311
- }
312
- }
313
-
314
- export { Config, apply, inject, name };
92
+ }
93
+
94
+ /** Write the install-state report (and clear the consumed request). */
95
+ async function report(status, message, pkg) {
96
+ await writeState(ctx.get("settings"), NS, {
97
+ installState: { status, message, ts: Date.now(), pkg: pkg || "" },
98
+ install: { pkg: "", ts: 0 },
99
+ });
100
+ }
101
+
102
+ /** Read the profile manifest (its `dsh.profile.bundles` list), or null. */
103
+ function profileManifest(profile) {
104
+ try {
105
+ return JSON.parse(readFileSync(join(profile.dir, "package.json"), "utf8"));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** Whether the installed package declares dsh.bundle.patch (auto-mounted via its bundle layer). */
112
+ function isBundlePackage(pkg, profile) {
113
+ try {
114
+ const manifest = JSON.parse(readFileSync(join(profile.dir, "node_modules", pkg, "package.json"), "utf8"));
115
+ return typeof manifest?.dsh?.bundle?.patch === "string" && manifest.dsh.bundle.patch.length > 0;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Append a mount row for `pkg` to cordis.patch.yml (idempotent).
123
+ * Bundle plugins (already in `dsh.profile.bundles`, or whose installed
124
+ * package declares `dsh.bundle.patch`) are mounted by their own bundle
125
+ * layer — adding a manual row would create a duplicate loader entry and
126
+ * abort boot. A STALE manual row left by an earlier non-bundle version is
127
+ * removed instead, so reinstalling a bundle plugin never breaks boot.
128
+ */
129
+ function ensureMounted(pkg, profile) {
130
+ const patchPath = join(profile.dir, "cordis.patch.yml");
131
+ if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
132
+ const bundles = profileManifest(profile)?.dsh?.profile?.bundles ?? [];
133
+ if (bundles.includes(pkg) || isBundlePackage(pkg, profile)) {
134
+ // Bundle layer auto-mounts; remove any stale manual row (pre-bundle).
135
+ const src = readFileSync(patchPath, "utf8");
136
+ const cleaned = src.replace(new RegExp(`[ \\t]*- id: [^\\n]*\\n[ \\t]*name: ['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]\\n?`, "g"), "");
137
+ if (cleaned !== src) {
138
+ writeFileSync(patchPath, cleaned, "utf8");
139
+ return " (removed stale manual mount row; bundle layer auto-mounts)";
140
+ }
141
+ return bundles.includes(pkg) ? " (already mounted via dsh.profile.bundles)" : " (bundle plugin: auto-mounted via its own dsh.bundle layer)";
142
+ }
143
+ const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
144
+ let src = readFileSync(patchPath, "utf8");
145
+ if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
146
+ const row = `\n - id: ${id}\n name: '${pkg}'`;
147
+ // A flow-style empty array (`[]`, optionally after comment lines) is a
148
+ // valid empty patch: replace it with the block-style insert list.
149
+ // Appending after `[]` would produce two top-level documents in one
150
+ // stream and crash boot with a YAML parse error.
151
+ const emptyArray = /^(\s*(?:#[^\n]*\n?)*)\[\s*\]\s*$/.exec(src);
152
+ if (emptyArray) {
153
+ writeFileSync(patchPath, `${emptyArray[1]}- insert:${row}\n`, "utf8");
154
+ return " (mounted in cordis.patch.yml)";
155
+ }
156
+ // Insert under the first `- insert:` list; match its indentation (4
157
+ // spaces is the convention used by dsh profiles).
158
+ const insertAt = src.search(/^- insert:\s*$/m);
159
+ if (insertAt >= 0) {
160
+ src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
161
+ } else {
162
+ // A non-empty flow-style array cannot be safely extended with string
163
+ // surgery — refuse rather than corrupt the file.
164
+ if (/^\s*\[/.test(src)) return " (patch file uses a flow-style array; mount manually)";
165
+ src += `\n- insert:${row}\n`;
166
+ }
167
+ writeFileSync(patchPath, src, "utf8");
168
+ return " (mounted in cordis.patch.yml)";
169
+ }
170
+
171
+ async function maybeRunInstall() {
172
+ let pkg = "";
173
+ try {
174
+ const cfg = current();
175
+ const req = cfg?.install;
176
+ if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
177
+ lastTs = req.ts;
178
+ pkg = String(req.pkg).trim();
179
+ if (!PKG_NAME.test(pkg)) {
180
+ await report("error", `invalid package name: ${pkg}`, pkg);
181
+ return;
182
+ }
183
+ const profile = activeProfile(ctx);
184
+ ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile.name})…`);
185
+ await report("running", `installing ${pkg}…`, pkg);
186
+ const result = await runInstall(pkg, profile);
187
+ if (result.code !== 0) {
188
+ const output = (result.stderr || result.stdout || "").trim();
189
+ const tail = output.includes("ERR_PNPM_IGNORED_BUILDS")
190
+ ? "dependency build scripts were blocked by pnpm; the plugin author must make native build dependencies opt-in"
191
+ : output.split("\n").slice(-3).join(" ");
192
+ ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
193
+ await report("error", `install failed: ${tail || "unknown error"}`, pkg);
194
+ return;
195
+ }
196
+ const mountNote = ensureMounted(pkg, profile);
197
+ ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
198
+ await report("ok", `${pkg} installed${mountNote}. Restart DSH to load it.`, pkg);
199
+ } catch (error) {
200
+ ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
201
+ await report("error", String(error instanceof Error ? error.message : error), pkg)
202
+ .catch((writeError) => ctx.logger.warn(`[plugin-marketplace] error state write failed: ${String(writeError)}`));
203
+ }
204
+ }
205
+
206
+ /** Write the AI-explain result report (and clear the consumed request). */
207
+ async function reportExplain(status, text, repo) {
208
+ await writeState(ctx.get("settings"), NS, {
209
+ aiExplainResult: { status, text, repo: repo || "", ts: Date.now() },
210
+ aiExplain: { repo: "", desc: "", readme: "", ts: 0 },
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Answer an aiExplain request with the deployment's default model: tell the
216
+ * user in plain Chinese what this plugin roughly does. Uses the same
217
+ * settings-backed message channel as the install flow.
218
+ */
219
+ async function maybeRunExplain() {
220
+ let repo = "";
221
+ try {
222
+ const cfg = current();
223
+ const req = cfg?.aiExplain;
224
+ if (!req || !req.repo || req.ts === lastExplainTs || req.ts === 0) return;
225
+ lastExplainTs = req.ts;
226
+ repo = String(req.repo);
227
+ const llm = ctx.llm;
228
+ if (!llm) {
229
+ await reportExplain("error", "LLM service unavailable; configure a model in Settings → Models.", repo);
230
+ return;
231
+ }
232
+ // Route through the deployment's default model when one is configured.
233
+ let route = null;
234
+ try {
235
+ const defaults = ctx.agentDefaultModel?.currentSelection?.();
236
+ if (defaults?.provider && defaults?.model) route = defaults;
237
+ } catch { /* no default model service; fall back to the adapter default */ }
238
+ const desc = String(req.desc || "").trim();
239
+ const readme = String(req.readme || "").trim().slice(0, 1500);
240
+ const prompt =
241
+ `插件仓库:${repo}\n` +
242
+ (desc ? `简介:${desc}\n` : "") +
243
+ (readme ? `README 摘要:\n${readme}\n` : "") +
244
+ `\n请用简体中文、3~5 句话直接告诉我这个插件大概是干嘛的(核心用途、解决什么问题、适合谁)。不要复述仓库名,不要罗列安装步骤。`;
245
+ await reportExplain("running", "", repo);
246
+ ctx.logger.info(`[plugin-marketplace] explaining ${repo} (model=${route ? `${route.provider}/${route.model}` : "default"})…`);
247
+ const messages = [createUserMessage({
248
+ content: [{ type: "text", text: prompt }],
249
+ source: { kind: "plugin", plugin: "dsh-plugin-marketplace" },
250
+ })];
251
+ const options = {
252
+ messages,
253
+ maxTokens: 400,
254
+ purpose: "plugin-marketplace-explain",
255
+ };
256
+ if (route) {
257
+ options.provider = route.provider;
258
+ options.model = route.model;
259
+ }
260
+ const assembler = new BlockAssembler();
261
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
262
+ const blocks = assembler.blocks();
263
+ const text = blocks
264
+ .filter((block) => block.type === "text")
265
+ .map((block) => block.text)
266
+ .join(" ")
267
+ .trim();
268
+ if (!text) throw new Error("model produced no text");
269
+ await reportExplain("ok", text, repo);
270
+ } catch (error) {
271
+ ctx.logger.warn(`[plugin-marketplace] explain flow failed: ${String(error)}`);
272
+ await reportExplain("error", String(error instanceof Error ? error.message : error), repo)
273
+ .catch((writeError) => ctx.logger.warn(`[plugin-marketplace] explain error state write failed: ${String(writeError)}`));
274
+ }
275
+ }
276
+ }
277
+
278
+ export { Config, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-marketplace",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "DeepSeek Harness Web UI 内置插件市场:在设置页直接浏览 github.com/topics/dsh-plugin,支持搜索、按 Star 排序、查看安装方式。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -37,10 +37,11 @@
37
37
  }
38
38
  },
39
39
  "scripts": {
40
- "test": "node --test tests/settings-write-verify.test.mjs && node --test tests/plugin-page.test.mjs"
40
+ "test": "node --test tests/settings-write-verify.test.mjs tests/runtime.test.mjs && node --test tests/plugin-page.test.mjs"
41
41
  },
42
42
  "files": [
43
43
  "index.js",
44
+ "runtime.js",
44
45
  "client.js",
45
46
  "cordis.patch.yml",
46
47
  "README.md",
@@ -68,11 +69,15 @@
68
69
  "url": "https://github.com/Scorp1o117/dsh-plugin-marketplace/issues"
69
70
  },
70
71
  "peerDependencies": {
71
- "react": "^18.2.0",
72
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.2 <0.3.0",
72
73
  "@deepseek-ai/dsh-settings": ">=0.2.0-rc.2 <0.3.0",
73
- "@deepseek-ai/dsh-llm": ">=0.2.0-rc.2 <0.3.0"
74
+ "react": "^18.2.0"
74
75
  },
75
76
  "dependencies": {
76
77
  "@deepseek-ai/schemastery": "^3.18.4"
78
+ },
79
+ "devDependencies": {
80
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
81
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2"
77
82
  }
78
83
  }
package/runtime.js ADDED
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { basename, dirname, join } from "node:path";
5
+ import os from "node:os";
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ /** Prefer the active Host profile; never infer it from other installed profiles. */
10
+ function activeProfile(ctx, runtime = process) {
11
+ const profile = ctx.get("profileContext");
12
+ const document = ctx.get("settings")?.documentPath;
13
+ const dir = profile?.dir || (document ? dirname(document) : "");
14
+ if (dir) return { name: profile?.name || basename(dir), dir, home: profile?.home || dirname(dirname(dir)) };
15
+ const i = runtime.argv.indexOf("--profile");
16
+ const inline = runtime.argv.find((arg) => arg.startsWith("--profile="));
17
+ const name = (i >= 0 ? runtime.argv[i + 1] : "") || inline?.slice(10)
18
+ || runtime.env.DSH_PROFILE || (runtime.versions?.electron ? "desktop" : "web");
19
+ const home = runtime.env.DSH_HOME?.trim() || join(os.homedir(), ".dsh");
20
+ return { name, home, dir: join(home, "profiles", name) };
21
+ }
22
+
23
+ /** Desktop Host index.js is not a CLI; use its sibling cli.js instead. */
24
+ function cliEntry(runtime = process, exists = existsSync) {
25
+ const candidates = [];
26
+ const boot = runtime.argv[1];
27
+ if (typeof boot === "string") {
28
+ if (/[\\/]dsh-desktop-host[\\/]lib[\\/]index\.js$/.test(boot)) {
29
+ candidates.push(join(dirname(boot), "cli.js"));
30
+ } else if (/[\\/]dsh[\\/]lib[\\/]bin\.js$/.test(boot)
31
+ || /[\\/]dsh-desktop-host[\\/]lib[\\/]cli\.js$/.test(boot)) candidates.push(boot);
32
+ }
33
+ if (runtime.resourcesPath) candidates.push(join(runtime.resourcesPath,
34
+ "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "cli.js"));
35
+ // In Electron's Node child resourcesPath can be absent, but execPath is still
36
+ // the app binary. macOS stores resources next to MacOS; Windows/Linux beside it.
37
+ if (runtime.versions?.electron) {
38
+ for (const resources of [join(dirname(runtime.execPath), "resources"), join(dirname(runtime.execPath), "..", "Resources")]) {
39
+ candidates.push(join(resources, "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "cli.js"));
40
+ }
41
+ }
42
+ if (!runtime.versions?.electron) {
43
+ try { candidates.push(join(dirname(require.resolve("@deepseek-ai/dsh/package.json")), "lib", "bin.js")); } catch { /* optional CLI */ }
44
+ }
45
+ return candidates.find((candidate) => exists(candidate));
46
+ }
47
+
48
+ function runInstall(pkg, profile, runtime = process, execute = execFile, exists = existsSync) {
49
+ const bin = cliEntry(runtime, exists);
50
+ if (!bin) return Promise.resolve({ code: 1, stdout: "", stderr: "cannot locate the dsh CLI entry" });
51
+ const args = [...(runtime.versions?.electron ? ["--expose-internals"] : []),
52
+ bin, "plugin", "--profile", profile.name, "add", pkg];
53
+ return new Promise((resolve) => {
54
+ try {
55
+ execute(runtime.execPath, args, {
56
+ env: { ...runtime.env, DSH_HOME: profile.home, ELECTRON_RUN_AS_NODE: "1" },
57
+ timeout: 5 * 60 * 1000,
58
+ maxBuffer: 8 * 1024 * 1024,
59
+ windowsHide: true,
60
+ }, (error, stdout, stderr) => resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr }));
61
+ } catch (error) {
62
+ resolve({ code: 1, stdout: "", stderr: String(error.message || error) });
63
+ }
64
+ });
65
+ }
66
+
67
+ /** Let the current HMR transaction finish, and retry transient write refusals. */
68
+ async function writeState(settings, ns, patch, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
69
+ if (!settings) throw new Error("settings service unavailable");
70
+ for (let attempt = 1; attempt <= 6; attempt++) {
71
+ try {
72
+ const result = await settings.update(ns, patch);
73
+ if (result === false) throw new Error("settings state write refused");
74
+ return;
75
+ } catch (error) {
76
+ if (attempt === 6) throw error;
77
+ await wait(400 * attempt);
78
+ }
79
+ }
80
+ }
81
+
82
+ export { activeProfile, cliEntry, runInstall, writeState };