@1e0zj/dsh-plugin-mall 0.1.2 → 0.1.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.
package/README.md CHANGED
@@ -4,14 +4,15 @@
4
4
 
5
5
  两个入口:
6
6
 
7
- 1. **设置 → 插件 → 插件市场 tab**(浏览器 UI,dsh web 模式):搜索、看详情、点安装、看进度与已装列表
8
- 2. **会话内 4 个 agent 工具**(所有模式可用)
7
+ 1. **设置 → 插件 → 插件市场 tab**(浏览器 UI,dsh web 模式):搜索、看详情、点安装/卸载、看进度与已装列表
8
+ 2. **会话内 5 个 agent 工具**(所有模式可用)
9
9
 
10
10
  | 工具 | 作用 |
11
11
  |---|---|
12
12
  | `market_search` | 搜索 GitHub 上带 `topic:dsh-plugin` 标签的仓库(按 star 排序,可按关键词过滤) |
13
13
  | `market_info` | 查看单个仓库详情:star、license、package.json、是否声明 `dsh.bundle.patch` / `dsh.client` |
14
14
  | `market_install` | 把插件装进某个 profile(后台任务,可用 `job_output` 查看进度) |
15
+ | `market_uninstall` | 把插件从某个 profile 卸载(后台任务:`pnpm remove` + 剔除 bundle 层 + 删除客户端加载行) |
15
16
  | `market_installed` | 列出某 profile 已装的插件及其 bundle 状态 |
16
17
 
17
18
  ## 安装
@@ -36,7 +37,7 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
36
37
  ## 工作原理
37
38
 
38
39
  - 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
39
- 注册 4 个 agent 工具(进 global 层,所有会话可见,与 MCP 工具同理);
40
+ 注册 5 个 agent 工具(进 global 层,所有会话可见,与 MCP 工具同理);
40
41
  `dsh.client` 半边是浏览器插件,往设置页插件区注册「插件市场」tab
41
42
  (`settings.plugins.tab` slot;手写无构建,经 `window.__ModuleLoader__` 加载)。
42
43
  - 浏览器 → 服务端走 Connection 服务的独立 RPC 通道 `/market`
@@ -46,6 +47,10 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
46
47
  `pnpm add <spec>`,成功后把声明了 `dsh.bundle.patch` 的依赖登记进
47
48
  `dsh.profile.bundles`(layer 列表),声明了 `dsh.client` 的依赖自动在
48
49
  profile 的 `cordis.patch.yml` 注册加载行,与官方 reconcile 逻辑一致。
50
+ - `market_uninstall` 复刻官方 `dsh plugin remove` 的流程:在 profile 目录跑
51
+ `pnpm remove <package>`,成功后从 `dsh.profile.bundles` 剔除该依赖的
52
+ bundle 条目,并删掉 `cordis.patch.yml` 里由安装流程注册的客户端加载行
53
+ (文本级精准移除,用户手写的行不受影响)。
49
54
  - GitHub 源的插件安装时要跑 prepare 构建脚本,pnpm 默认拦截;本插件检测到
50
55
  拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的 `allowBuilds`
51
56
  并自动重试一次。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
 
16
16
  // ── styles ──────────────────────────────────────────────────────────────
17
17
  var css = [
18
- ".mkt_root{display:flex;flex-direction:column;gap:12px;max-width:960px}",
18
+ ".mkt_root{display:flex;flex-direction:column;gap:12px;max-width:1040px}",
19
19
  ".mkt_head{display:flex;flex-direction:column;gap:2px}",
20
20
  ".mkt_title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}",
21
21
  ".mkt_sub{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
@@ -28,16 +28,19 @@ window.__ModuleLoader__.load({
28
28
  ".mkt_btnPrimary{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent;color:#fff}",
29
29
  ".mkt_btnPrimary:hover:not(:disabled){color:#fff;opacity:.92}",
30
30
  ".mkt_btnDanger:hover:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
31
+ ".mkt_btnSm{padding:3px 10px;font-size:12px}",
31
32
  ".mkt_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}",
32
33
  ".mkt_columns{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}",
33
34
  "@media (max-width:860px){.mkt_columns{grid-template-columns:1fr}}",
34
- ".mkt_list{display:flex;flex-direction:column;gap:10px;min-width:0}",
35
+ ".mkt_list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:stretch;min-width:0}",
36
+ "@media (max-width:820px){.mkt_list{grid-template-columns:minmax(0,1fr)}}",
37
+ ".mkt_listHead{grid-column:1/-1;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
35
38
  ".mkt_card{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-primary,#fff);display:flex;flex-direction:column;gap:6px;min-width:0}",
36
39
  ".mkt_cardHead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
37
40
  ".mkt_name{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}",
38
41
  ".mkt_meta{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
39
42
  ".mkt_desc{font-size:12.5px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
40
- ".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
43
+ ".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:auto;padding-top:4px}",
41
44
  ".mkt_rail{display:flex;flex-direction:column;gap:10px;min-width:0}",
42
45
  ".mkt_panelTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}",
43
46
  ".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-secondary,#f6f7f8);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
@@ -46,7 +49,8 @@ window.__ModuleLoader__.load({
46
49
  ".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
47
50
  ".mkt_installedHead{display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;padding:0;margin:0;cursor:pointer;font:inherit;text-align:left}",
48
51
  ".mkt_installedHead:hover .mkt_panelTitle{color:var(--dsw-alias-state-business-primary)}",
49
- ".mkt_depList{display:flex;flex-direction:column;gap:6px;margin-top:8px}",
52
+ ".mkt_depList{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px;margin-top:8px}",
53
+ "@media (max-width:820px){.mkt_depList{grid-template-columns:minmax(0,1fr)}}",
50
54
  ".mkt_depRow{display:flex;justify-content:space-between;align-items:center;gap:8px}",
51
55
  ].join("\n");
52
56
  var tagId = "@1e0zj/dsh-plugin-mall/market-tab.css";
@@ -180,7 +184,7 @@ window.__ModuleLoader__.load({
180
184
  var ids = Object.keys(props.jobs);
181
185
  if (ids.length === 0) return null;
182
186
  return h("div", { className: "mkt_card" },
183
- h("p", { className: "mkt_panelTitle" }, "安装任务"),
187
+ h("p", { className: "mkt_panelTitle" }, "任务"),
184
188
  ids.map(function (id) {
185
189
  var job = props.jobs[id];
186
190
  var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
@@ -189,9 +193,9 @@ window.__ModuleLoader__.load({
189
193
  id + " · " + clip(job.spec || "", 40) + " · " + (job.status || "running")),
190
194
  job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
191
195
  done && job.status === "completed"
192
- ? h("div", { className: "mkt_ok" }, "✓ 安装完成 — 重启 dsh 后生效")
196
+ ? h("div", { className: "mkt_ok" }, "✓ 完成 — 重启 dsh 后生效")
193
197
  : job.status === "failed"
194
- ? h("div", { className: "mkt_error" }, "安装失败,见下方输出")
198
+ ? h("div", { className: "mkt_error" }, "失败,见下方输出")
195
199
  : null,
196
200
  job.output ? h("pre", { className: "mkt_pre" }, job.output.slice(-4000)) : null
197
201
  );
@@ -224,9 +228,15 @@ window.__ModuleLoader__.load({
224
228
  : (installed.deps || []).length === 0
225
229
  ? h("div", { className: "mkt_meta" }, "还没有装过插件")
226
230
  : h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
231
+ var busy = (props.removing || {})[dep.name] === true;
227
232
  return h("div", { key: dep.name, className: "mkt_depRow" },
228
233
  h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
229
- h("span", { className: "mkt_badge" }, kindLabel(dep.kind)));
234
+ h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
235
+ h("button", {
236
+ className: "mkt_btn mkt_btnDanger mkt_btnSm",
237
+ disabled: busy,
238
+ onClick: function () { props.onUninstall(dep.name); },
239
+ }, busy ? "卸载中…" : "卸载"));
230
240
  }))
231
241
  );
232
242
  }
@@ -258,6 +268,9 @@ window.__ModuleLoader__.load({
258
268
  var _installing = useState({});
259
269
  var installing = _installing[0];
260
270
  var setInstalling = _installing[1];
271
+ var _removing = useState({});
272
+ var removing = _removing[0];
273
+ var setRemoving = _removing[1];
261
274
 
262
275
  var call = useCallback(function (endpoint, payload) {
263
276
  return rpc.call("/market", endpoint, payload || {}).then(function (res) {
@@ -325,6 +338,25 @@ window.__ModuleLoader__.load({
325
338
  });
326
339
  }, [call, track]);
327
340
 
341
+ var doUninstall = useCallback(function (name) {
342
+ setRemoving(function (prev) {
343
+ var next = Object.assign({}, prev, { [name]: true });
344
+ return next;
345
+ });
346
+ setError(null);
347
+ call("uninstall", { package: name }).then(function (value) {
348
+ track(value.jobId, name);
349
+ }).catch(function (e) {
350
+ setError(errorText(e));
351
+ }).finally(function () {
352
+ setRemoving(function (prev) {
353
+ var next = Object.assign({}, prev);
354
+ delete next[name];
355
+ return next;
356
+ });
357
+ });
358
+ }, [call, track]);
359
+
328
360
  useEffect(function () {
329
361
  refreshInstalled();
330
362
  }, [refreshInstalled]);
@@ -332,7 +364,7 @@ window.__ModuleLoader__.load({
332
364
  return h("div", { className: "mkt_root" },
333
365
  h("div", { className: "mkt_head" },
334
366
  h("div", { className: "mkt_title" }, "插件市场"),
335
- h("div", { className: "mkt_sub" }, "搜索 GitHub dsh-plugin 话题插件 · 安装后需重启 dsh 生效")
367
+ h("div", { className: "mkt_sub" }, "搜索 GitHub dsh-plugin 话题插件 · 装/卸后需重启 dsh 生效")
336
368
  ),
337
369
  h("div", { className: "mkt_row" },
338
370
  h("input", {
@@ -355,14 +387,15 @@ window.__ModuleLoader__.load({
355
387
  h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装")
356
388
  ),
357
389
  error ? h("div", { className: "mkt_error" }, error) : null,
390
+ h(InstalledPanel, { installed: installed, removing: removing, onUninstall: doUninstall }),
358
391
  h("div", { className: "mkt_columns" },
359
392
  h("div", { className: "mkt_list" },
360
393
  results == null
361
- ? h("div", { className: "mkt_meta" }, loading ? "正在加载最热插件…" : "—")
394
+ ? h("div", { className: "mkt_meta mkt_listHead" }, loading ? "正在加载最热插件…" : "—")
362
395
  : results.items.length === 0
363
- ? h("div", { className: "mkt_meta" }, "没有匹配的仓库。")
396
+ ? h("div", { className: "mkt_meta mkt_listHead" }, "没有匹配的仓库。")
364
397
  : h(React.Fragment, null,
365
- h("div", { className: "mkt_meta" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
398
+ h("div", { className: "mkt_meta mkt_listHead" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
366
399
  results.items.map(function (item) {
367
400
  return h(RepoCard, {
368
401
  key: item.fullName,
@@ -375,7 +408,6 @@ window.__ModuleLoader__.load({
375
408
  )
376
409
  ),
377
410
  h("div", { className: "mkt_rail" },
378
- h(InstalledPanel, { installed: installed }),
379
411
  h(InfoPanel, { info: info, onInstall: doInstall }),
380
412
  h(JobsPanel, { jobs: jobs })
381
413
  )
package/src/index.js CHANGED
@@ -2,10 +2,11 @@
2
2
  //
3
3
  // A Cordis plugin mounted at the host plane (profile bundle layer), so its
4
4
  // tools land in the tools registry's global layer and every session sees
5
- // them. It exposes four tools:
5
+ // them. It exposes five tools:
6
6
  // market_search search GitHub repositories tagged topic:dsh-plugin
7
7
  // market_info inspect one repository (stars, license, package.json, dsh.bundle)
8
8
  // market_install install a plugin into a local dsh profile (background job)
9
+ // market_uninstall remove a plugin from a local dsh profile (background job)
9
10
  // market_installed list a profile's installed plugins
10
11
  //
11
12
  // Plugin contract (see @deepseek-ai/cordis-plugin-loader): the loader imports
@@ -18,7 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
18
19
  import { join } from "node:path";
19
20
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
20
21
  import { repoInfo, searchPlugins } from "./github.js";
21
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, createInstallTracker } from "./installer.js";
22
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
22
23
 
23
24
  export const name = "@1e0zj/dsh-plugin-mall";
24
25
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -104,7 +105,7 @@ function renderInstalled(result, profile) {
104
105
  lines.push(` ${dep.name}@${dep.version} ${markers[dep.kind] ?? markers.plain}`);
105
106
  }
106
107
  lines.push("");
107
- lines.push(`Remove with: dsh plugin --profile ${profile} remove <name> (then restart dsh).`);
108
+ lines.push(`Remove with: market_uninstall (package: "<name>"), or dsh plugin --profile ${profile} remove <name>; then restart dsh.`);
108
109
  return lines.join("\n");
109
110
  }
110
111
 
@@ -136,7 +137,7 @@ function rpcFail(error) {
136
137
  * tools keep using ctx.jobs; the browser surface uses `tracker` because the
137
138
  * web host plane has no job controller for ctx.jobs to serve.
138
139
  * @param ctx - plugin context.
139
- * @param endpoint - "search" | "info" | "installed" | "install" | "job" | "jobCancel".
140
+ * @param endpoint - "search" | "info" | "installed" | "install" | "uninstall" | "job" | "jobCancel".
140
141
  * @param payload - endpoint arguments from the browser.
141
142
  * @param config - the row config (defaultProfile, apiBase, perPageMax).
142
143
  * @param token - GitHub token from the environment.
@@ -192,6 +193,25 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
192
193
  return rpcFail(error);
193
194
  }
194
195
  }
196
+ case "uninstall": {
197
+ const profile = String(payload?.profile ?? defaultProfile).trim();
198
+ const packageName = String(payload?.package ?? "").trim();
199
+ if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
200
+ try {
201
+ const profileDir = resolveProfileDir(profile);
202
+ if (!existsSync(join(profileDir, "package.json"))) {
203
+ return rpcFail(new Error(`profile "${profile}" has no package.json — nothing installed to remove`));
204
+ }
205
+ } catch (error) {
206
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
207
+ }
208
+ try {
209
+ const jobId = tracker.start({ profile, spec: packageName, verb: "remove" });
210
+ return rpcOk({ jobId, profile, package: packageName });
211
+ } catch (error) {
212
+ return rpcFail(error);
213
+ }
214
+ }
195
215
  case "job": {
196
216
  try {
197
217
  return rpcOk(tracker.get(payload?.jobId));
@@ -221,7 +241,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
221
241
  * @param token - GitHub token from the environment.
222
242
  */
223
243
  function registerRpcChannel(ctx, config, token) {
224
- const tracker = createInstallTracker();
244
+ const tracker = createJobTracker();
225
245
  ctx.inject(["connection"], (connectionCtx) => {
226
246
  connectionCtx.connection.rpc.handle("/market", (endpoint, payload, signal) => rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker), { authority: "loopback" });
227
247
  });
@@ -234,7 +254,7 @@ export function apply(ctx, config = {}) {
234
254
  ctx.systemPrompt.section({
235
255
  name: "tool:market",
236
256
  order: 120,
237
- text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), and market_installed lists a profile's plugins. A successful market_install only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both).",
257
+ text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both).",
238
258
  });
239
259
 
240
260
  ctx.tools.register(defineTool({
@@ -363,6 +383,56 @@ export function apply(ctx, config = {}) {
363
383
  }),
364
384
  }));
365
385
 
386
+ ctx.tools.register(defineTool({
387
+ name: "market_uninstall",
388
+ description: "Remove a plugin from a local dsh profile by running `pnpm remove` in that profile's directory, dropping its entry from the profile's bundle layer list, and deleting its client loader row from cordis.patch.yml if one was registered. Same flow as `dsh plugin --profile <name> remove <package>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. A successful removal only takes effect after the dsh process restarts.",
389
+ parameters: {
390
+ package: {
391
+ type: "string",
392
+ required: true,
393
+ description: "Installed package name to remove, e.g. \"@1e0zj/dsh-plugin-mall\" or \"dsh-at-file\".",
394
+ },
395
+ profile: {
396
+ type: "string",
397
+ description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
398
+ },
399
+ },
400
+ output: {
401
+ schema: {
402
+ type: "object",
403
+ additionalProperties: false,
404
+ properties: BACKGROUND_OUTPUT_PROPERTIES,
405
+ },
406
+ render: (args, value) => [{
407
+ type: "text",
408
+ text: `started background job ${value.jobId} (${args.package} ← profile "${args.profile ?? defaultProfile}"); poll with job_output, cancel with job_kill. Restart dsh after a successful uninstall.`,
409
+ }],
410
+ },
411
+ async execute(args, exec) {
412
+ const profile = String(args.profile ?? defaultProfile).trim();
413
+ const packageName = String(args.package ?? "").trim();
414
+ if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
415
+ try {
416
+ resolveProfileDir(profile);
417
+ } catch (error) {
418
+ throw new Error(`market_uninstall: invalid profile: ${error.message}`);
419
+ }
420
+ const jobId = ctx.jobs.start({
421
+ kind: "dsh-plugin-uninstall",
422
+ label: `dsh plugin --profile ${profile} remove ${packageName}`,
423
+ ...exec.agent ? { owner: exec.agent } : {},
424
+ run: () => runRemove({ profile, packageName }),
425
+ });
426
+ return { kind: "background", jobId };
427
+ },
428
+ presentCall: (args) => ({
429
+ card: "generic",
430
+ title: `dsh plugin --profile ${args.profile ?? defaultProfile} remove ${args.package}`,
431
+ kind: "execute",
432
+ content: [{ type: "text", text: "Remove a plugin from a dsh profile (background job)" }],
433
+ }),
434
+ }));
435
+
366
436
  ctx.tools.register(defineTool({
367
437
  name: "market_installed",
368
438
  description: "List the plugins installed in a local dsh profile: every dependency with its installed version and whether it declares a dsh bundle (i.e. is an active plugin layer).",
package/src/installer.js CHANGED
@@ -187,6 +187,34 @@ export function ensureClientRow(profileDir, packageName) {
187
187
  return { added: true, rowId };
188
188
  }
189
189
 
190
+ /**
191
+ * Remove the cordis.patch.yml loader row `ensureClientRow` registered for a
192
+ * package. Textual and idempotent: only the exact block the register emitted
193
+ * is spliced out, so user-authored rows survive byte-for-byte. An emptied
194
+ * file falls back to the stock `[]` template.
195
+ * @returns {{ removed: boolean, rowId?: string }}
196
+ */
197
+ export function removeClientRow(profileDir, packageName) {
198
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
199
+ if (!existsSync(patchPath)) return { removed: false };
200
+ const content = readFileSync(patchPath, "utf8");
201
+ const rowId = clientRowId(packageName);
202
+ const lines = content.split("\n");
203
+ let removed = false;
204
+ for (let index = 0; index < lines.length - 2; index++) {
205
+ if (lines[index] !== "- insert:") continue;
206
+ if (lines[index + 1] !== ` - id: ${rowId}`) continue;
207
+ if (lines[index + 2] !== ` name: '${packageName}'`) continue;
208
+ lines.splice(index, 3);
209
+ removed = true;
210
+ break;
211
+ }
212
+ if (!removed) return { removed: false };
213
+ const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
214
+ writeFileSync(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
215
+ return { removed: true, rowId };
216
+ }
217
+
190
218
  // ── build-script allow-listing ──────────────────────────────────────────────
191
219
 
192
220
  /** Extract package names from pnpm's "Ignored build scripts: ..." output. */
@@ -250,8 +278,12 @@ function ensureAllowBuilds(profileDir, names) {
250
278
 
251
279
  let trackerCounter = 0;
252
280
 
253
- /** Create a tracker for browser-started installs (see runInstall). */
254
- export function createInstallTracker() {
281
+ /**
282
+ * Create a tracker for browser-started install/uninstall jobs (see runInstall
283
+ * and runRemove). Same producer shape as the jobs registry ({cancel, done,
284
+ * readOutput}), just an independent registry.
285
+ */
286
+ export function createJobTracker() {
255
287
  const records = new Map();
256
288
  const prune = () => {
257
289
  const now = Date.now();
@@ -268,12 +300,14 @@ export function createInstallTracker() {
268
300
  }
269
301
  };
270
302
  return {
271
- start({ profile, spec }) {
303
+ start({ profile, spec, verb = "add" }) {
272
304
  const id = `market-${++trackerCounter}`;
273
- const producer = runInstall({ profile, spec });
305
+ const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
306
+ const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec });
274
307
  const record = {
275
308
  id,
276
- label: `dsh plugin --profile ${profile} add ${spec}`,
309
+ kind,
310
+ label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
277
311
  profile,
278
312
  spec,
279
313
  status: "running",
@@ -297,7 +331,7 @@ export function createInstallTracker() {
297
331
  return {
298
332
  snapshot: {
299
333
  id: record.id,
300
- kind: "dsh-plugin-install",
334
+ kind: record.kind,
301
335
  label: record.label,
302
336
  status: record.status,
303
337
  detail: record.detail,
@@ -427,3 +461,86 @@ export function runInstall({ profile, spec }) {
427
461
  },
428
462
  };
429
463
  }
464
+
465
+ // ── the background uninstall job ────────────────────────────────────────────
466
+
467
+ /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
468
+ function failedNow(detail) {
469
+ return {
470
+ cancel: () => {},
471
+ done: Promise.resolve({ status: "failed", detail }),
472
+ readOutput: () => "",
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Run `pnpm remove <package>` in the profile directory as a job producer with
478
+ * the same shape as `runInstall`. On success it reconciles
479
+ * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
480
+ * deletes the client loader row `ensureClientRow` had registered for it.
481
+ */
482
+ export function runRemove({ profile, packageName }) {
483
+ let profileDir;
484
+ try {
485
+ profileDir = resolveProfileDir(profile);
486
+ } catch (error) {
487
+ return failedNow(`invalid profile: ${error.message}`);
488
+ }
489
+ const manifestPath = join(profileDir, "package.json");
490
+ if (!existsSync(manifestPath)) {
491
+ return failedNow(`profile "${profile}" has no package.json — nothing installed to remove`);
492
+ }
493
+ const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
494
+ if (!beforeDeps.has(packageName)) {
495
+ return failedNow(`"${packageName}" is not a dependency of profile "${profile}" (installed: ${[...beforeDeps].join(", ") || "none"})`);
496
+ }
497
+ const deltaQueue = [];
498
+ const push = (text) => {
499
+ deltaQueue.push(text);
500
+ };
501
+ let current = undefined;
502
+
503
+ const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
504
+ cwd: profileDir,
505
+ env: process.env,
506
+ shell: process.platform === "win32",
507
+ stdio: ["ignore", "pipe", "pipe"],
508
+ windowsHide: true,
509
+ });
510
+ current = proc;
511
+ const done = new Promise((resolve) => {
512
+ proc.on("error", (error) => resolve({ spawnError: error }));
513
+ proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
514
+ }).then((outcome) => {
515
+ if (outcome.spawnError !== undefined) {
516
+ const hint = outcome.spawnError.code === "ENOENT"
517
+ ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
518
+ : `could not start pnpm: ${outcome.spawnError.message}`;
519
+ return { status: "failed", detail: hint };
520
+ }
521
+ if (outcome.exitCode === null) {
522
+ return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
523
+ }
524
+ if (outcome.exitCode !== 0) {
525
+ return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
526
+ }
527
+ const bundles = reconcileBundles(profileDir, beforeDeps);
528
+ const clientRow = removeClientRow(profileDir, packageName);
529
+ const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
530
+ if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
531
+ return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
532
+ });
533
+ proc.stdout?.on("data", (data) => push(data.toString()));
534
+ proc.stderr?.on("data", (data) => push(data.toString()));
535
+
536
+ return {
537
+ cancel: () => {
538
+ current?.kill();
539
+ },
540
+ done,
541
+ readOutput: () => {
542
+ if (deltaQueue.length === 0) return "";
543
+ return deltaQueue.splice(0).join("");
544
+ },
545
+ };
546
+ }