dsh-plugin-marketplace 0.2.2 → 0.2.4

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.
@@ -0,0 +1,10 @@
1
+ # dsh-plugin-marketplace — bundle patch layer
2
+ #
3
+ # Declares the marketplace as a Cordis loader entry. Shipping this file
4
+ # (and the `dsh.bundle.patch` manifest field) makes the package a standard
5
+ # profile bundle: `dsh plugin --profile <p> add dsh-plugin-marketplace`
6
+ # (and any other bundle-list registration) loads it without the boot-time
7
+ # "declares no dsh.bundle" failure, and the entry mounts automatically.
8
+ - insert:
9
+ - id: plugin-marketplace
10
+ name: 'dsh-plugin-marketplace'
package/index.js CHANGED
@@ -1,162 +1,295 @@
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 { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
19
- import { ensureSettingsNamespaceExposed } from "./vendor/dsh-settings-expose.js";
20
-
21
- /** Cordis plugin name. */
22
- const name = "plugin-marketplace";
23
- /** Settings namespace owned by this plugin (Web UI settings section + install channel). */
24
- const NS = settingsNamespace("plugin-marketplace");
25
-
26
- /** Runtime schema: the install request + the install state report. */
27
- const Config = z.object({
28
- install: z.object({
29
- /** npm package name to install ("" = no pending request). */
30
- pkg: z.string().default(""),
31
- /** client-set timestamp; host dedups on it. */
32
- ts: z.number().default(0),
33
- }),
34
- installState: z.object({
35
- status: z.string().default("idle"), // idle | running | ok | error
36
- message: z.string().default(""),
37
- ts: z.number().default(0),
38
- }),
39
- });
40
-
41
- /** npm package-name shape (scope/name, no spaces, no path chars). */
42
- const PKG_NAME = /^@?[a-z0-9][a-z0-9-._]*(?:\/[a-z0-9][a-z0-9-._]*)?$/;
43
-
44
- function apply(ctx, config) {
45
- let sourceGetter = null;
46
- let lastTs = 0;
47
-
48
- // ── settings-backed configuration ─────────────────────────────────────────
49
- // The browser half edits this namespace; `onChange` fires on every write,
50
- // which is what turns an `install` request into a real install.
51
- installSettingsSection(ctx, NS, Config, config, {
52
- setSource: (getter) => {
53
- sourceGetter = getter;
54
- },
55
- onChange: () => {
56
- void maybeRunInstall();
57
- },
58
- });
59
-
60
- // dsh-host-apiproxy hard-codes which namespaces the Web client may touch;
61
- // patch the allowlist idempotently so the client can write install requests.
62
- ensureSettingsNamespaceExposed(ctx, "plugin-marketplace", ctx.logger);
63
-
64
- /** Resolved current config (settings layer over the entry). */
65
- function current() {
66
- return sourceGetter ? sourceGetter() : config;
67
- }
68
-
69
- /** Write the install-state report (and clear the consumed request). */
70
- async function report(status, message, pkg) {
71
- const settings = ctx.get("settings");
72
- if (!settings) return;
73
- const ts = Date.now();
74
- await settings.update(NS, {
75
- installState: { status, message, ts, pkg: pkg || "" },
76
- install: { pkg: "", ts: 0 },
77
- }).catch((error) => {
78
- ctx.logger.warn(`[plugin-marketplace] state write failed: ${String(error)}`);
79
- });
80
- }
81
-
82
- /** Find the active profile name from the process args ("web" default). */
83
- function profileName() {
84
- const argv = process.argv;
85
- const i = argv.indexOf("--profile");
86
- if (i >= 0 && argv[i + 1]) return argv[i + 1];
87
- return "web";
88
- }
89
-
90
- /** The dsh CLI entry used to boot this process (its own bin.js). */
91
- function dshBin() {
92
- return process.argv[1];
93
- }
94
-
95
- /** Append a mount row for `pkg` to cordis.patch.yml (idempotent). */
96
- function ensureMounted(pkg, profile) {
97
- const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
98
- const patchPath = join(dshHome, "profiles", profile, "cordis.patch.yml");
99
- if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
100
- const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
101
- let src = readFileSync(patchPath, "utf8");
102
- if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
103
- // Append under the first `- insert:` list; match its indentation (4 spaces
104
- // is the convention used by dsh profiles).
105
- const insertAt = src.search(/^- insert:\s*$/m);
106
- const row = `\n - id: ${id}\n name: '${pkg}'`;
107
- if (insertAt >= 0) {
108
- src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
109
- } else {
110
- src += `\n- insert:${row}\n`;
111
- }
112
- writeFileSync(patchPath, src, "utf8");
113
- return " (mounted in cordis.patch.yml)";
114
- }
115
-
116
- /** Run the install; resolve {code, stdout, stderr}. */
117
- function runInstall(pkg, profile) {
118
- const bin = dshBin();
119
- const node = process.execPath;
120
- return new Promise((resolve) => {
121
- execFile(node, [bin, "plugin", "--profile", profile, "add", pkg], {
122
- timeout: 5 * 60 * 1000,
123
- maxBuffer: 8 * 1024 * 1024,
124
- windowsHide: true,
125
- }, (error, stdout, stderr) => {
126
- resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
127
- });
128
- });
129
- }
130
-
131
- async function maybeRunInstall() {
132
- try {
133
- const cfg = current();
134
- const req = cfg?.install;
135
- if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
136
- lastTs = req.ts;
137
- const pkg = String(req.pkg).trim();
138
- if (!PKG_NAME.test(pkg)) {
139
- await report("error", `invalid package name: ${pkg}`, pkg);
140
- return;
141
- }
142
- const profile = profileName();
143
- ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile})…`);
144
- await report("running", `installing ${pkg}…`, pkg);
145
- const result = await runInstall(pkg, profile);
146
- if (result.code !== 0) {
147
- const tail = (result.stderr || result.stdout || "").trim().split("\n").slice(-3).join(" ");
148
- ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
149
- await report("error", `install failed: ${tail || "unknown error"}`, pkg);
150
- return;
151
- }
152
- const mountNote = ensureMounted(pkg, profile);
153
- ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
154
- await report("ok", `${pkg} installed${mountNote}. Restart dsh web to load it.`, pkg);
155
- } catch (error) {
156
- ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
157
- await report("error", String(error instanceof Error ? error.message : error));
158
- }
159
- }
160
- }
161
-
162
- export { Config, apply, 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
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
20
+ import { ensureSettingsNamespaceExposed } from "./vendor/dsh-settings-expose.js";
21
+
22
+ /** Cordis plugin name. */
23
+ const name = "plugin-marketplace";
24
+ /** Services this plugin needs injected from the host tree (ctx.get requires inject). */
25
+ const inject = ["llm", "agentDefaultModel"];
26
+ /** Settings namespace owned by this plugin (Web UI settings section + install channel). */
27
+ const NS = settingsNamespace("plugin-marketplace");
28
+
29
+ /** Runtime schema: the install request + the install state report. */
30
+ const Config = z.object({
31
+ install: z.object({
32
+ /** npm package name to install ("" = no pending request). */
33
+ pkg: z.string().default(""),
34
+ /** client-set timestamp; host dedups on it. */
35
+ ts: z.number().default(0),
36
+ }),
37
+ installState: z.object({
38
+ status: z.string().default("idle"), // idle | running | ok | error
39
+ message: z.string().default(""),
40
+ ts: z.number().default(0),
41
+ }),
42
+ /** AI-explain request (client writes repo/desc/readme, host answers). */
43
+ aiExplain: z.object({
44
+ repo: z.string().default(""),
45
+ desc: z.string().default(""),
46
+ readme: z.string().default(""),
47
+ ts: z.number().default(0),
48
+ }),
49
+ aiExplainResult: z.object({
50
+ status: z.string().default("idle"), // idle | running | ok | error
51
+ text: 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; `onChange` fires on every write,
66
+ // which is what turns an `install` request into a real install.
67
+ installSettingsSection(ctx, NS, Config, config, {
68
+ setSource: (getter) => {
69
+ sourceGetter = getter;
70
+ },
71
+ onChange: () => {
72
+ void maybeRunInstall();
73
+ void maybeRunExplain();
74
+ },
75
+ });
76
+
77
+ // dsh-host-apiproxy hard-codes which namespaces the Web client may touch;
78
+ // patch the allowlist idempotently so the client can write install requests.
79
+ ensureSettingsNamespaceExposed(ctx, "plugin-marketplace", ctx.logger);
80
+
81
+ /** Resolved current config (settings layer over the entry). */
82
+ function current() {
83
+ return sourceGetter ? sourceGetter() : config;
84
+ }
85
+
86
+ /** Write the install-state report (and clear the consumed request). */
87
+ async function report(status, message, pkg) {
88
+ const settings = ctx.get("settings");
89
+ if (!settings) return;
90
+ const ts = Date.now();
91
+ await settings.update(NS, {
92
+ installState: { status, message, ts, pkg: pkg || "" },
93
+ install: { pkg: "", ts: 0 },
94
+ }).catch((error) => {
95
+ ctx.logger.warn(`[plugin-marketplace] state write failed: ${String(error)}`);
96
+ });
97
+ }
98
+
99
+ /** Find the active profile name from the process args ("web" default). */
100
+ function profileName() {
101
+ const argv = process.argv;
102
+ const i = argv.indexOf("--profile");
103
+ if (i >= 0 && argv[i + 1]) return argv[i + 1];
104
+ return "web";
105
+ }
106
+
107
+ /** The dsh CLI entry used to boot this process (its own bin.js). */
108
+ function dshBin() {
109
+ return process.argv[1];
110
+ }
111
+
112
+ /** Read the profile manifest (its `dsh.profile.bundles` list), or null. */
113
+ function profileManifest(profile) {
114
+ try {
115
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
116
+ return JSON.parse(readFileSync(join(dshHome, "profiles", profile, "package.json"), "utf8"));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /** Whether the installed package declares dsh.bundle.patch (auto-mounted via its bundle layer). */
123
+ function isBundlePackage(pkg, profile) {
124
+ try {
125
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
126
+ const manifest = JSON.parse(readFileSync(join(dshHome, "profiles", profile, "node_modules", pkg, "package.json"), "utf8"));
127
+ return typeof manifest?.dsh?.bundle?.patch === "string" && manifest.dsh.bundle.patch.length > 0;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Append a mount row for `pkg` to cordis.patch.yml (idempotent).
135
+ * Bundle plugins (already in `dsh.profile.bundles`, or whose installed
136
+ * package declares `dsh.bundle.patch`) are mounted by their own bundle
137
+ * layer — adding a manual row would create a duplicate loader entry and
138
+ * abort boot, so they are skipped here.
139
+ */
140
+ function ensureMounted(pkg, profile) {
141
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), ".dsh");
142
+ const patchPath = join(dshHome, "profiles", profile, "cordis.patch.yml");
143
+ if (!existsSync(patchPath)) return " (patch file not found; mount manually)";
144
+ const bundles = profileManifest(profile)?.dsh?.profile?.bundles ?? [];
145
+ if (bundles.includes(pkg)) return " (already mounted via dsh.profile.bundles)";
146
+ if (isBundlePackage(pkg, profile)) return " (bundle plugin: auto-mounted via its own dsh.bundle layer)";
147
+ const id = pkg.replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]/g, "-") || pkg;
148
+ let src = readFileSync(patchPath, "utf8");
149
+ if (src.includes(`name: '${pkg}'`) || src.includes(`name: "${pkg}"`)) return " (already mounted)";
150
+ const row = `\n - id: ${id}\n name: '${pkg}'`;
151
+ // A flow-style empty array (`[]`, optionally after comment lines) is a
152
+ // valid empty patch: replace it with the block-style insert list.
153
+ // Appending after `[]` would produce two top-level documents in one
154
+ // stream and crash boot with a YAML parse error.
155
+ const emptyArray = /^(\s*(?:#[^\n]*\n?)*)\[\s*\]\s*$/.exec(src);
156
+ if (emptyArray) {
157
+ writeFileSync(patchPath, `${emptyArray[1]}- insert:${row}\n`, "utf8");
158
+ return " (mounted in cordis.patch.yml)";
159
+ }
160
+ // Insert under the first `- insert:` list; match its indentation (4
161
+ // spaces is the convention used by dsh profiles).
162
+ const insertAt = src.search(/^- insert:\s*$/m);
163
+ if (insertAt >= 0) {
164
+ src = src.slice(0, insertAt + "- insert:".length) + row + src.slice(insertAt + "- insert:".length);
165
+ } else {
166
+ // A non-empty flow-style array cannot be safely extended with string
167
+ // surgery — refuse rather than corrupt the file.
168
+ if (/^\s*\[/.test(src)) return " (patch file uses a flow-style array; mount manually)";
169
+ src += `\n- insert:${row}\n`;
170
+ }
171
+ writeFileSync(patchPath, src, "utf8");
172
+ return " (mounted in cordis.patch.yml)";
173
+ }
174
+
175
+ /** Run the install; resolve {code, stdout, stderr}. */
176
+ function runInstall(pkg, profile) {
177
+ const bin = dshBin();
178
+ const node = process.execPath;
179
+ return new Promise((resolve) => {
180
+ execFile(node, [bin, "plugin", "--profile", profile, "add", pkg], {
181
+ timeout: 5 * 60 * 1000,
182
+ maxBuffer: 8 * 1024 * 1024,
183
+ windowsHide: true,
184
+ }, (error, stdout, stderr) => {
185
+ resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr });
186
+ });
187
+ });
188
+ }
189
+
190
+ async function maybeRunInstall() {
191
+ try {
192
+ const cfg = current();
193
+ const req = cfg?.install;
194
+ if (!req || !req.pkg || req.ts === lastTs || req.ts === 0) return;
195
+ lastTs = req.ts;
196
+ const pkg = String(req.pkg).trim();
197
+ if (!PKG_NAME.test(pkg)) {
198
+ await report("error", `invalid package name: ${pkg}`, pkg);
199
+ return;
200
+ }
201
+ const profile = profileName();
202
+ ctx.logger.info(`[plugin-marketplace] installing ${pkg} (profile=${profile})…`);
203
+ await report("running", `installing ${pkg}…`, pkg);
204
+ const result = await runInstall(pkg, profile);
205
+ if (result.code !== 0) {
206
+ const tail = (result.stderr || result.stdout || "").trim().split("\n").slice(-3).join(" ");
207
+ ctx.logger.warn(`[plugin-marketplace] install failed (${result.code}): ${tail}`);
208
+ await report("error", `install failed: ${tail || "unknown error"}`, pkg);
209
+ return;
210
+ }
211
+ const mountNote = ensureMounted(pkg, profile);
212
+ ctx.logger.info(`[plugin-marketplace] ${pkg} installed${mountNote}`);
213
+ await report("ok", `${pkg} installed${mountNote}. Restart dsh web to load it.`, pkg);
214
+ } catch (error) {
215
+ ctx.logger.warn(`[plugin-marketplace] install flow failed: ${String(error)}`);
216
+ await report("error", String(error instanceof Error ? error.message : error));
217
+ }
218
+ }
219
+
220
+ /** Write the AI-explain result report (and clear the consumed request). */
221
+ async function reportExplain(status, text) {
222
+ const settings = ctx.get("settings");
223
+ if (!settings) return;
224
+ const ts = Date.now();
225
+ await settings.update(NS, {
226
+ aiExplainResult: { status, text, ts },
227
+ aiExplain: { repo: "", desc: "", readme: "", ts: 0 },
228
+ }).catch((error) => {
229
+ ctx.logger.warn(`[plugin-marketplace] explain state write failed: ${String(error)}`);
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Answer an aiExplain request with the deployment's default model: tell the
235
+ * user in plain Chinese what this plugin roughly does. Uses the same
236
+ * settings-backed message channel as the install flow.
237
+ */
238
+ async function maybeRunExplain() {
239
+ try {
240
+ const cfg = current();
241
+ const req = cfg?.aiExplain;
242
+ if (!req || !req.repo || req.ts === lastExplainTs || req.ts === 0) return;
243
+ lastExplainTs = req.ts;
244
+ const llm = ctx.llm;
245
+ if (!llm) {
246
+ await reportExplain("error", "LLM service unavailable; configure a model in Settings → Models.");
247
+ return;
248
+ }
249
+ // Route through the deployment's default model when one is configured.
250
+ let route = null;
251
+ try {
252
+ const defaults = ctx.agentDefaultModel?.currentSelection?.();
253
+ if (defaults?.provider && defaults?.model) route = defaults;
254
+ } catch { /* no default model service; fall back to the adapter default */ }
255
+ const repo = String(req.repo);
256
+ const desc = String(req.desc || "").trim();
257
+ const readme = String(req.readme || "").trim().slice(0, 1500);
258
+ const prompt =
259
+ `插件仓库:${repo}\n` +
260
+ (desc ? `简介:${desc}\n` : "") +
261
+ (readme ? `README 摘要:\n${readme}\n` : "") +
262
+ `\n请用简体中文、3~5 句话直接告诉我这个插件大概是干嘛的(核心用途、解决什么问题、适合谁)。不要复述仓库名,不要罗列安装步骤。`;
263
+ await reportExplain("running", "");
264
+ ctx.logger.info(`[plugin-marketplace] explaining ${repo} (model=${route ? `${route.provider}/${route.model}` : "default"})…`);
265
+ const messages = [createUserMessage({
266
+ content: [{ type: "text", text: prompt }],
267
+ source: { kind: "plugin", plugin: "dsh-plugin-marketplace" },
268
+ })];
269
+ const options = {
270
+ messages,
271
+ maxTokens: 400,
272
+ purpose: "plugin-marketplace-explain",
273
+ };
274
+ if (route) {
275
+ options.provider = route.provider;
276
+ options.model = route.model;
277
+ }
278
+ const assembler = new BlockAssembler();
279
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
280
+ const blocks = assembler.blocks();
281
+ const text = blocks
282
+ .filter((block) => block.type === "text")
283
+ .map((block) => block.text)
284
+ .join(" ")
285
+ .trim();
286
+ if (!text) throw new Error("model produced no text");
287
+ await reportExplain("ok", text);
288
+ } catch (error) {
289
+ ctx.logger.warn(`[plugin-marketplace] explain flow failed: ${String(error)}`);
290
+ await reportExplain("error", String(error instanceof Error ? error.message : error));
291
+ }
292
+ }
293
+ }
294
+
295
+ export { Config, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-marketplace",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "DeepSeek Harness Web UI 内置插件市场:在设置页直接浏览 github.com/topics/dsh-plugin,支持搜索、按 Star 排序、查看安装方式。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,6 +10,9 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
13
16
  "client": {
14
17
  "platform": "web"
15
18
  }
@@ -17,6 +20,7 @@
17
20
  "files": [
18
21
  "index.js",
19
22
  "client.js",
23
+ "cordis.patch.yml",
20
24
  "README.md",
21
25
  "README.zh.md",
22
26
  "LICENSE",
@@ -43,7 +47,8 @@
43
47
  },
44
48
  "peerDependencies": {
45
49
  "react": "^18.2.0",
46
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6"
50
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
51
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
47
52
  },
48
53
  "dependencies": {
49
54
  "@deepseek-ai/schemastery": "^3.18.1"
@@ -9,6 +9,18 @@
9
9
  * actually loaded by the host process, so the plugin's settings section
10
10
  * becomes visible in the Web UI without manual edits. A dsh update
11
11
  * overwrites the file; the next plugin start re-patches it.
12
+ *
13
+ * NOTE: dsh-host-apiproxy is an ESM package, so it never appears in the
14
+ * CommonJS `Module._cache` (lookup 1 only helps CJS hosts). Lookup 2 anchors
15
+ * on the running dsh bin, which always resolves the apiproxy as the app's
16
+ * own dependency; lookup 3 walks up from `@deepseek-ai/dsh-settings` for
17
+ * other npm layouts. pnpm-installed hosts resolve into an immutable store —
18
+ * the write then fails and the manual instruction below applies.
19
+ *
20
+ * Also note that patching the file only affects the NEXT process start: the
21
+ * running apiproxy already evaluated its allowlist at module load, so a
22
+ * settings channel opened mid-session still answers `settings-not-exposed`
23
+ * until `dsh web` is restarted.
12
24
  */
13
25
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
26
  import { createRequire } from "node:module";
@@ -26,7 +38,7 @@ export function ensureSettingsNamespaceExposed(ctx, nsName, logger) {
26
38
  try {
27
39
  const target = findApiproxyIndex();
28
40
  if (!target) {
29
- logger?.warn?.(`[settings-expose] could not locate dsh-host-apiproxy; add "${nsName}" to WEB_SETTINGS_NAMESPACES in dsh-host-apiproxy/lib/index.js to get a Web settings section`);
41
+ logger?.warn?.(`[settings-expose] could not locate dsh-host-apiproxy in this dsh install; the "${nsName}" settings channel will answer "not exposed". Fix: add "${nsName}" to WEB_SETTINGS_NAMESPACES in <dsh>/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js and restart dsh web`);
30
42
  return;
31
43
  }
32
44
  let src;
@@ -48,15 +60,16 @@ export function ensureSettingsNamespaceExposed(ctx, nsName, logger) {
48
60
  return;
49
61
  }
50
62
  writeFileSync(target, patched, "utf8");
51
- logger?.info?.(`[settings-expose] added "${nsName}" to WEB_SETTINGS_NAMESPACES (${target}) — restart dsh web for the settings section to appear`);
63
+ logger?.info?.(`[settings-expose] added "${nsName}" to WEB_SETTINGS_NAMESPACES (${target}) — restart dsh web: the settings channel opens only in the NEXT process start`);
52
64
  } catch (error) {
53
- logger?.warn?.(`[settings-expose] failed: ${String(error)}`);
65
+ logger?.warn?.(`[settings-expose] failed to expose "${nsName}" (${String(error)}); add it to WEB_SETTINGS_NAMESPACES in dsh-host-apiproxy/lib/index.js manually, then restart dsh web`);
54
66
  }
55
67
  }
56
68
 
57
69
  function findApiproxyIndex() {
58
70
  // 1) The host process has already loaded dsh-host-apiproxy: read the real
59
- // module path from the CommonJS module cache (any install layout).
71
+ // module path from the CommonJS module cache (any install layout; ESM
72
+ // hosts — every modern dsh — skip this).
60
73
  try {
61
74
  const Module = createRequire(import.meta.url)("module");
62
75
  const cache = Module._cache ?? {};
@@ -64,12 +77,30 @@ function findApiproxyIndex() {
64
77
  if (key.includes(`${sep}dsh-host-apiproxy${sep}`) && key.endsWith(`${sep}index.js`)) return key;
65
78
  }
66
79
  } catch { /* fall through */ }
67
- // 2) Fallback: sibling of @deepseek-ai/dsh-settings (dsh's nested layout).
80
+ // 2) Resolve the apiproxy as a dependency of the running dsh app: the bin
81
+ // (`node <dsh>/lib/bin.js`) anchors the app root in both shipped and
82
+ // source layouts, and dsh-host-apiproxy is always one of its deps.
83
+ try {
84
+ const appRoot = dirname(dirname(process.argv[1] ?? ""));
85
+ const requireFromApp = createRequire(join(appRoot, "package.json"));
86
+ const entry = requireFromApp.resolve("@deepseek-ai/dsh-host-apiproxy");
87
+ if (entry.endsWith(`${sep}index.js`)) return entry;
88
+ } catch { /* fall through */ }
89
+ // 3) Walk up from @deepseek-ai/dsh-settings: the apiproxy is a scoped
90
+ // sibling in the same dependency tree (flat/global and nested layouts).
68
91
  try {
69
92
  const require = createRequire(import.meta.url);
70
93
  const settingsEntry = require.resolve("@deepseek-ai/dsh-settings");
71
- const candidate = join(dirname(dirname(dirname(settingsEntry))), "dsh-host-apiproxy", "lib", "index.js");
72
- if (existsSync(candidate)) return candidate;
94
+ let dir = dirname(settingsEntry);
95
+ while (true) {
96
+ const scoped = join(dir, "@deepseek-ai", "dsh-host-apiproxy", "lib", "index.js");
97
+ if (existsSync(scoped)) return scoped;
98
+ const bare = join(dir, "dsh-host-apiproxy", "lib", "index.js");
99
+ if (existsSync(bare)) return bare;
100
+ const parent = dirname(dir);
101
+ if (parent === dir) break;
102
+ dir = parent;
103
+ }
73
104
  } catch { /* fall through */ }
74
105
  return "";
75
106
  }