dsh-plugin-marketplace 0.3.2 → 0.3.3

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/index.js CHANGED
@@ -1,317 +1,317 @@
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
- }),
35
- installState: z.object({
36
- status: z.string().default("idle"), // idle | running | ok | error
37
- message: z.string().default(""),
38
- ts: z.number().default(0),
39
- }),
40
- /** AI-explain request (client writes repo/desc/readme, host answers). */
41
- aiExplain: z.object({
42
- repo: z.string().default(""),
43
- desc: z.string().default(""),
44
- readme: z.string().default(""),
45
- ts: z.number().default(0),
46
- }),
47
- aiExplainResult: z.object({
48
- status: z.string().default("idle"), // idle | running | ok | error
49
- text: z.string().default(""),
50
- /** repo this result belongs to ("owner/name"); client gates display on it. */
51
- repo: z.string().default(""),
52
- ts: z.number().default(0),
53
- }),
54
- });
55
-
56
- /** npm package-name shape (scope/name, no spaces, no path chars). */
57
- const PKG_NAME = /^@?[a-z0-9][a-z0-9-._]*(?:\/[a-z0-9][a-z0-9-._]*)?$/;
58
-
59
- function apply(ctx, config) {
60
- let sourceGetter = null;
61
- let lastTs = 0;
62
- let lastExplainTs = 0;
63
-
64
- // ── settings-backed configuration ─────────────────────────────────────────
65
- // The browser half edits this namespace; every write fires the onChange
66
- // handler below, which is what turns an `install` request into a real
67
- // install.
68
- // Compat shim: dsh-settings 0.1.2-rc.1 removed the module-level
69
- // `installSettingsSection` export (the provider now lives at ctx.settings).
70
- // Inline the same logic via ctx.inject(["settings"]) — works on both
71
- // 0.1.1 (module export wrapper) and 0.1.2 (ctx.settings) hosts.
72
- ctx.inject(["settings"], (sctx) => {
73
- const scope = sctx.settings.register(NS, Config, { base: config });
74
- sourceGetter = () => scope.get();
75
- sctx.effect(() => () => {
76
- sourceGetter = null;
77
- });
78
- // Mirror the old installSettingsSection contract: fire once at attach so a
79
- // request persisted across restarts is consumed, then on every write.
80
- void maybeRunInstall();
81
- void maybeRunExplain();
82
- scope.watch(() => {
83
- void maybeRunInstall();
84
- void maybeRunExplain();
85
- });
86
- });
87
-
88
- // DSH 0.1.0-rc.7+ exposes registered settings namespaces natively, allowing
89
- // the browser client to write install requests without modifying host files.
90
- /** Resolved current config (settings layer over the entry). */
91
- function current() {
92
- return sourceGetter ? sourceGetter() : config;
93
- }
94
-
95
- /** Write the install-state report (and clear the consumed request). */
96
- async function report(status, message, pkg) {
97
- const settings = ctx.get("settings");
98
- if (!settings) return;
99
- const ts = Date.now();
100
- await settings.update(NS, {
101
- installState: { status, message, ts, pkg: pkg || "" },
102
- install: { pkg: "", ts: 0 },
103
- }).catch((error) => {
104
- ctx.logger.warn(`[plugin-marketplace] state write failed: ${String(error)}`);
105
- });
106
- }
107
-
108
- /** Find the active profile name from the process args ("web" default). */
109
- function profileName() {
110
- const argv = process.argv;
111
- const i = argv.indexOf("--profile");
112
- if (i >= 0 && argv[i + 1]) return argv[i + 1];
113
- return "web";
114
- }
115
-
116
- /** The dsh CLI entry used to boot this process (its own bin.js). */
117
- function dshBin() {
118
- return process.argv[1];
119
- }
120
-
121
- /** Read the profile manifest (its `dsh.profile.bundles` list), or null. */
122
- function profileManifest(profile) {
123
- try {
124
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
125
- return JSON.parse(readFileSync(join(dshHome, "profiles", profile, "package.json"), "utf8"));
126
- } catch {
127
- return null;
128
- }
129
- }
130
-
131
- /** Whether the installed package declares dsh.bundle.patch (auto-mounted via its bundle layer). */
132
- function isBundlePackage(pkg, profile) {
133
- try {
134
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
135
- const manifest = JSON.parse(readFileSync(join(dshHome, "profiles", profile, "node_modules", pkg, "package.json"), "utf8"));
136
- return typeof manifest?.dsh?.bundle?.patch === "string" && manifest.dsh.bundle.patch.length > 0;
137
- } catch {
138
- return false;
139
- }
140
- }
141
-
142
- /**
143
- * Append a mount row for `pkg` to cordis.patch.yml (idempotent).
144
- * Bundle plugins (already in `dsh.profile.bundles`, or whose installed
145
- * package declares `dsh.bundle.patch`) are mounted by their own bundle
146
- * layer — adding a manual row would create a duplicate loader entry and
147
- * abort boot. A STALE manual row left by an earlier non-bundle version is
148
- * removed instead, so reinstalling a bundle plugin never breaks boot.
149
- */
150
- function ensureMounted(pkg, profile) {
151
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
152
- const patchPath = join(dshHome, "profiles", profile, "cordis.patch.yml");
153
- if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
154
- const bundles = profileManifest(profile)?.dsh?.profile?.bundles ?? [];
155
- if (bundles.includes(pkg) || isBundlePackage(pkg, profile)) {
156
- // Bundle layer auto-mounts; remove any stale manual row (pre-bundle).
157
- const src = readFileSync(patchPath, "utf8");
158
- const cleaned = src.replace(new RegExp(`[ \\t]*- id: [^\\n]*\\n[ \\t]*name: ['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]\\n?`, "g"), "");
159
- if (cleaned !== src) {
160
- writeFileSync(patchPath, cleaned, "utf8");
161
- return " (removed stale manual mount row; bundle layer auto-mounts)";
162
- }
163
- return bundles.includes(pkg) ? " (already mounted via dsh.profile.bundles)" : " (bundle plugin: auto-mounted via its own dsh.bundle layer)";
164
- }
165
- const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
166
- let src = readFileSync(patchPath, "utf8");
167
- if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
168
- const row = `\n - id: ${id}\n name: '${pkg}'`;
169
- // A flow-style empty array (`[]`, optionally after comment lines) is a
170
- // valid empty patch: replace it with the block-style insert list.
171
- // Appending after `[]` would produce two top-level documents in one
172
- // stream and crash boot with a YAML parse error.
173
- const emptyArray = /^(\s*(?:#[^\n]*\n?)*)\[\s*\]\s*$/.exec(src);
174
- if (emptyArray) {
175
- writeFileSync(patchPath, `${emptyArray[1]}- insert:${row}\n`, "utf8");
176
- return " (mounted in cordis.patch.yml)";
177
- }
178
- // Insert under the first `- insert:` list; match its indentation (4
179
- // spaces is the convention used by dsh profiles).
180
- const insertAt = src.search(/^- insert:\s*$/m);
181
- if (insertAt >= 0) {
182
- src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
183
- } else {
184
- // A non-empty flow-style array cannot be safely extended with string
185
- // surgery — refuse rather than corrupt the file.
186
- if (/^\s*\[/.test(src)) return " (patch file uses a flow-style array; mount manually)";
187
- src += `\n- insert:${row}\n`;
188
- }
189
- writeFileSync(patchPath, src, "utf8");
190
- return " (mounted in cordis.patch.yml)";
191
- }
192
-
193
- /** Run the install; resolve {code, stdout, stderr}. */
194
- function runInstall(pkg, profile) {
195
- const bin = dshBin();
196
- const node = process.execPath;
197
- return new Promise((resolve) => {
198
- execFile(node, [bin, "plugin", "--profile", profile, "add", pkg], {
199
- timeout: 5 * 60 * 1000,
200
- maxBuffer: 8 * 1024 * 1024,
201
- windowsHide: true,
202
- }, (error, stdout, stderr) => {
203
- resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
204
- });
205
- });
206
- }
207
-
208
- async function maybeRunInstall() {
209
- try {
210
- const cfg = current();
211
- const req = cfg?.install;
212
- if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
213
- lastTs = req.ts;
214
- const pkg = String(req.pkg).trim();
215
- if (!PKG_NAME.test(pkg)) {
216
- await report("error", `invalid package name: ${pkg}`, pkg);
217
- return;
218
- }
219
- const profile = profileName();
220
- ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile})…`);
221
- await report("running", `installing ${pkg}…`, pkg);
222
- const result = await runInstall(pkg, profile);
223
- if (result.code !== 0) {
224
- const output = (result.stderr || result.stdout || "").trim();
225
- const tail = output.includes("ERR_PNPM_IGNORED_BUILDS")
226
- ? "dependency build scripts were blocked by pnpm; the plugin author must make native build dependencies opt-in"
227
- : output.split("\n").slice(-3).join(" ");
228
- ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
229
- await report("error", `install failed: ${tail || "unknown error"}`, pkg);
230
- return;
231
- }
232
- const mountNote = ensureMounted(pkg, profile);
233
- ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
234
- await report("ok", `${pkg} installed${mountNote}. Restart dsh web to load it.`, pkg);
235
- } catch (error) {
236
- ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
237
- await report("error", String(error instanceof Error ? error.message : error));
238
- }
239
- }
240
-
241
- /** Write the AI-explain result report (and clear the consumed request). */
242
- async function reportExplain(status, text, repo) {
243
- const settings = ctx.get("settings");
244
- if (!settings) return;
245
- const ts = Date.now();
246
- await settings.update(NS, {
247
- aiExplainResult: { status, text, repo: repo || "", ts },
248
- aiExplain: { repo: "", desc: "", readme: "", ts: 0 },
249
- }).catch((error) => {
250
- ctx.logger.warn(`[plugin-marketplace] explain state write failed: ${String(error)}`);
251
- });
252
- }
253
-
254
- /**
255
- * Answer an aiExplain request with the deployment's default model: tell the
256
- * user in plain Chinese what this plugin roughly does. Uses the same
257
- * settings-backed message channel as the install flow.
258
- */
259
- async function maybeRunExplain() {
260
- let repo = "";
261
- try {
262
- const cfg = current();
263
- const req = cfg?.aiExplain;
264
- if (!req || !req.repo || req.ts === lastExplainTs || req.ts === 0) return;
265
- lastExplainTs = req.ts;
266
- repo = String(req.repo);
267
- const llm = ctx.llm;
268
- if (!llm) {
269
- await reportExplain("error", "LLM service unavailable; configure a model in Settings → Models.", repo);
270
- return;
271
- }
272
- // Route through the deployment's default model when one is configured.
273
- let route = null;
274
- try {
275
- const defaults = ctx.agentDefaultModel?.currentSelection?.();
276
- if (defaults?.provider && defaults?.model) route = defaults;
277
- } catch { /* no default model service; fall back to the adapter default */ }
278
- const desc = String(req.desc || "").trim();
279
- const readme = String(req.readme || "").trim().slice(0, 1500);
280
- const prompt =
281
- `插件仓库:${repo}\n` +
282
- (desc ? `简介:${desc}\n` : "") +
283
- (readme ? `README 摘要:\n${readme}\n` : "") +
284
- `\n请用简体中文、3~5 句话直接告诉我这个插件大概是干嘛的(核心用途、解决什么问题、适合谁)。不要复述仓库名,不要罗列安装步骤。`;
285
- await reportExplain("running", "", repo);
286
- ctx.logger.info(`[plugin-marketplace] explaining ${repo} (model=${route ? `${route.provider}/${route.model}` : "default"})…`);
287
- const messages = [createUserMessage({
288
- content: [{ type: "text", text: prompt }],
289
- source: { kind: "plugin", plugin: "dsh-plugin-marketplace" },
290
- })];
291
- const options = {
292
- messages,
293
- maxTokens: 400,
294
- purpose: "plugin-marketplace-explain",
295
- };
296
- if (route) {
297
- options.provider = route.provider;
298
- options.model = route.model;
299
- }
300
- const assembler = new BlockAssembler();
301
- for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
302
- const blocks = assembler.blocks();
303
- const text = blocks
304
- .filter((block) => block.type === "text")
305
- .map((block) => block.text)
306
- .join(" ")
307
- .trim();
308
- if (!text) throw new Error("model produced no text");
309
- await reportExplain("ok", text, repo);
310
- } catch (error) {
311
- ctx.logger.warn(`[plugin-marketplace] explain flow failed: ${String(error)}`);
312
- await reportExplain("error", String(error instanceof Error ? error.message : error), repo);
313
- }
314
- }
315
- }
316
-
317
- export { Config, apply, inject, name };
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
+ }),
35
+ installState: z.object({
36
+ status: z.string().default("idle"), // idle | running | ok | error
37
+ message: z.string().default(""),
38
+ ts: z.number().default(0),
39
+ }),
40
+ /** AI-explain request (client writes repo/desc/readme, host answers). */
41
+ aiExplain: z.object({
42
+ repo: z.string().default(""),
43
+ desc: z.string().default(""),
44
+ readme: z.string().default(""),
45
+ ts: z.number().default(0),
46
+ }),
47
+ aiExplainResult: z.object({
48
+ status: z.string().default("idle"), // idle | running | ok | error
49
+ text: z.string().default(""),
50
+ /** repo this result belongs to ("owner/name"); client gates display on it. */
51
+ repo: z.string().default(""),
52
+ ts: z.number().default(0),
53
+ }),
54
+ });
55
+
56
+ /** npm package-name shape (scope/name, no spaces, no path chars). */
57
+ const PKG_NAME = /^@?[a-z0-9][a-z0-9-._]*(?:\/[a-z0-9][a-z0-9-._]*)?$/;
58
+
59
+ function apply(ctx, config) {
60
+ let sourceGetter = null;
61
+ let lastTs = 0;
62
+ let lastExplainTs = 0;
63
+
64
+ // ── settings-backed configuration ─────────────────────────────────────────
65
+ // The browser half edits this namespace; every write fires the onChange
66
+ // handler below, which is what turns an `install` request into a real
67
+ // install.
68
+ // Compat shim: dsh-settings 0.1.2-rc.1 removed the module-level
69
+ // `installSettingsSection` export (the provider now lives at ctx.settings).
70
+ // Inline the same logic via ctx.inject(["settings"]) — works on both
71
+ // 0.1.1 (module export wrapper) and 0.1.2 (ctx.settings) hosts.
72
+ ctx.inject(["settings"], (sctx) => {
73
+ const scope = sctx.settings.register(NS, Config, { base: config });
74
+ sourceGetter = () => scope.get();
75
+ sctx.effect(() => () => {
76
+ sourceGetter = null;
77
+ });
78
+ // Mirror the old installSettingsSection contract: fire once at attach so a
79
+ // request persisted across restarts is consumed, then on every write.
80
+ void maybeRunInstall();
81
+ void maybeRunExplain();
82
+ scope.watch(() => {
83
+ void maybeRunInstall();
84
+ void maybeRunExplain();
85
+ });
86
+ });
87
+
88
+ // DSH 0.1.0-rc.7+ exposes registered settings namespaces natively, allowing
89
+ // the browser client to write install requests without modifying host files.
90
+ /** Resolved current config (settings layer over the entry). */
91
+ function current() {
92
+ return sourceGetter ? sourceGetter() : config;
93
+ }
94
+
95
+ /** Write the install-state report (and clear the consumed request). */
96
+ async function report(status, message, pkg) {
97
+ const settings = ctx.get("settings");
98
+ if (!settings) return;
99
+ const ts = Date.now();
100
+ await settings.update(NS, {
101
+ installState: { status, message, ts, pkg: pkg || "" },
102
+ install: { pkg: "", ts: 0 },
103
+ }).catch((error) => {
104
+ ctx.logger.warn(`[plugin-marketplace] state write failed: ${String(error)}`);
105
+ });
106
+ }
107
+
108
+ /** Find the active profile name from the process args ("web" default). */
109
+ function profileName() {
110
+ const argv = process.argv;
111
+ const i = argv.indexOf("--profile");
112
+ if (i >= 0 && argv[i + 1]) return argv[i + 1];
113
+ return "web";
114
+ }
115
+
116
+ /** The dsh CLI entry used to boot this process (its own bin.js). */
117
+ function dshBin() {
118
+ return process.argv[1];
119
+ }
120
+
121
+ /** Read the profile manifest (its `dsh.profile.bundles` list), or null. */
122
+ function profileManifest(profile) {
123
+ try {
124
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
125
+ return JSON.parse(readFileSync(join(dshHome, "profiles", profile, "package.json"), "utf8"));
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Whether the installed package declares dsh.bundle.patch (auto-mounted via its bundle layer). */
132
+ function isBundlePackage(pkg, profile) {
133
+ try {
134
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
135
+ const manifest = JSON.parse(readFileSync(join(dshHome, "profiles", profile, "node_modules", pkg, "package.json"), "utf8"));
136
+ return typeof manifest?.dsh?.bundle?.patch === "string" && manifest.dsh.bundle.patch.length > 0;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Append a mount row for `pkg` to cordis.patch.yml (idempotent).
144
+ * Bundle plugins (already in `dsh.profile.bundles`, or whose installed
145
+ * package declares `dsh.bundle.patch`) are mounted by their own bundle
146
+ * layer — adding a manual row would create a duplicate loader entry and
147
+ * abort boot. A STALE manual row left by an earlier non-bundle version is
148
+ * removed instead, so reinstalling a bundle plugin never breaks boot.
149
+ */
150
+ function ensureMounted(pkg, profile) {
151
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
152
+ const patchPath = join(dshHome, "profiles", profile, "cordis.patch.yml");
153
+ if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
154
+ const bundles = profileManifest(profile)?.dsh?.profile?.bundles ?? [];
155
+ if (bundles.includes(pkg) || isBundlePackage(pkg, profile)) {
156
+ // Bundle layer auto-mounts; remove any stale manual row (pre-bundle).
157
+ const src = readFileSync(patchPath, "utf8");
158
+ const cleaned = src.replace(new RegExp(`[ \\t]*- id: [^\\n]*\\n[ \\t]*name: ['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]\\n?`, "g"), "");
159
+ if (cleaned !== src) {
160
+ writeFileSync(patchPath, cleaned, "utf8");
161
+ return " (removed stale manual mount row; bundle layer auto-mounts)";
162
+ }
163
+ return bundles.includes(pkg) ? " (already mounted via dsh.profile.bundles)" : " (bundle plugin: auto-mounted via its own dsh.bundle layer)";
164
+ }
165
+ const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
166
+ let src = readFileSync(patchPath, "utf8");
167
+ if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
168
+ const row = `\n - id: ${id}\n name: '${pkg}'`;
169
+ // A flow-style empty array (`[]`, optionally after comment lines) is a
170
+ // valid empty patch: replace it with the block-style insert list.
171
+ // Appending after `[]` would produce two top-level documents in one
172
+ // stream and crash boot with a YAML parse error.
173
+ const emptyArray = /^(\s*(?:#[^\n]*\n?)*)\[\s*\]\s*$/.exec(src);
174
+ if (emptyArray) {
175
+ writeFileSync(patchPath, `${emptyArray[1]}- insert:${row}\n`, "utf8");
176
+ return " (mounted in cordis.patch.yml)";
177
+ }
178
+ // Insert under the first `- insert:` list; match its indentation (4
179
+ // spaces is the convention used by dsh profiles).
180
+ const insertAt = src.search(/^- insert:\s*$/m);
181
+ if (insertAt >= 0) {
182
+ src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
183
+ } else {
184
+ // A non-empty flow-style array cannot be safely extended with string
185
+ // surgery — refuse rather than corrupt the file.
186
+ if (/^\s*\[/.test(src)) return " (patch file uses a flow-style array; mount manually)";
187
+ src += `\n- insert:${row}\n`;
188
+ }
189
+ writeFileSync(patchPath, src, "utf8");
190
+ return " (mounted in cordis.patch.yml)";
191
+ }
192
+
193
+ /** Run the install; resolve {code, stdout, stderr}. */
194
+ function runInstall(pkg, profile) {
195
+ const bin = dshBin();
196
+ const node = process.execPath;
197
+ return new Promise((resolve) => {
198
+ execFile(node, [bin, "plugin", "--profile", profile, "add", pkg], {
199
+ timeout: 5 * 60 * 1000,
200
+ maxBuffer: 8 * 1024 * 1024,
201
+ windowsHide: true,
202
+ }, (error, stdout, stderr) => {
203
+ resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
204
+ });
205
+ });
206
+ }
207
+
208
+ async function maybeRunInstall() {
209
+ try {
210
+ const cfg = current();
211
+ const req = cfg?.install;
212
+ if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
213
+ lastTs = req.ts;
214
+ const pkg = String(req.pkg).trim();
215
+ if (!PKG_NAME.test(pkg)) {
216
+ await report("error", `invalid package name: ${pkg}`, pkg);
217
+ return;
218
+ }
219
+ const profile = profileName();
220
+ ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile})…`);
221
+ await report("running", `installing ${pkg}…`, pkg);
222
+ const result = await runInstall(pkg, profile);
223
+ if (result.code !== 0) {
224
+ const output = (result.stderr || result.stdout || "").trim();
225
+ const tail = output.includes("ERR_PNPM_IGNORED_BUILDS")
226
+ ? "dependency build scripts were blocked by pnpm; the plugin author must make native build dependencies opt-in"
227
+ : output.split("\n").slice(-3).join(" ");
228
+ ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
229
+ await report("error", `install failed: ${tail || "unknown error"}`, pkg);
230
+ return;
231
+ }
232
+ const mountNote = ensureMounted(pkg, profile);
233
+ ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
234
+ await report("ok", `${pkg} installed${mountNote}. Restart dsh web to load it.`, pkg);
235
+ } catch (error) {
236
+ ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
237
+ await report("error", String(error instanceof Error ? error.message : error));
238
+ }
239
+ }
240
+
241
+ /** Write the AI-explain result report (and clear the consumed request). */
242
+ async function reportExplain(status, text, repo) {
243
+ const settings = ctx.get("settings");
244
+ if (!settings) return;
245
+ const ts = Date.now();
246
+ await settings.update(NS, {
247
+ aiExplainResult: { status, text, repo: repo || "", ts },
248
+ aiExplain: { repo: "", desc: "", readme: "", ts: 0 },
249
+ }).catch((error) => {
250
+ ctx.logger.warn(`[plugin-marketplace] explain state write failed: ${String(error)}`);
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Answer an aiExplain request with the deployment's default model: tell the
256
+ * user in plain Chinese what this plugin roughly does. Uses the same
257
+ * settings-backed message channel as the install flow.
258
+ */
259
+ async function maybeRunExplain() {
260
+ let repo = "";
261
+ try {
262
+ const cfg = current();
263
+ const req = cfg?.aiExplain;
264
+ if (!req || !req.repo || req.ts === lastExplainTs || req.ts === 0) return;
265
+ lastExplainTs = req.ts;
266
+ repo = String(req.repo);
267
+ const llm = ctx.llm;
268
+ if (!llm) {
269
+ await reportExplain("error", "LLM service unavailable; configure a model in Settings → Models.", repo);
270
+ return;
271
+ }
272
+ // Route through the deployment's default model when one is configured.
273
+ let route = null;
274
+ try {
275
+ const defaults = ctx.agentDefaultModel?.currentSelection?.();
276
+ if (defaults?.provider && defaults?.model) route = defaults;
277
+ } catch { /* no default model service; fall back to the adapter default */ }
278
+ const desc = String(req.desc || "").trim();
279
+ const readme = String(req.readme || "").trim().slice(0, 1500);
280
+ const prompt =
281
+ `插件仓库:${repo}\n` +
282
+ (desc ? `简介:${desc}\n` : "") +
283
+ (readme ? `README 摘要:\n${readme}\n` : "") +
284
+ `\n请用简体中文、3~5 句话直接告诉我这个插件大概是干嘛的(核心用途、解决什么问题、适合谁)。不要复述仓库名,不要罗列安装步骤。`;
285
+ await reportExplain("running", "", repo);
286
+ ctx.logger.info(`[plugin-marketplace] explaining ${repo} (model=${route ? `${route.provider}/${route.model}` : "default"})…`);
287
+ const messages = [createUserMessage({
288
+ content: [{ type: "text", text: prompt }],
289
+ source: { kind: "plugin", plugin: "dsh-plugin-marketplace" },
290
+ })];
291
+ const options = {
292
+ messages,
293
+ maxTokens: 400,
294
+ purpose: "plugin-marketplace-explain",
295
+ };
296
+ if (route) {
297
+ options.provider = route.provider;
298
+ options.model = route.model;
299
+ }
300
+ const assembler = new BlockAssembler();
301
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
302
+ const blocks = assembler.blocks();
303
+ const text = blocks
304
+ .filter((block) => block.type === "text")
305
+ .map((block) => block.text)
306
+ .join(" ")
307
+ .trim();
308
+ if (!text) throw new Error("model produced no text");
309
+ await reportExplain("ok", text, repo);
310
+ } catch (error) {
311
+ ctx.logger.warn(`[plugin-marketplace] explain flow failed: ${String(error)}`);
312
+ await reportExplain("error", String(error instanceof Error ? error.message : error), repo);
313
+ }
314
+ }
315
+ }
316
+
317
+ export { Config, apply, inject, name };