@1e0zj/dsh-plugin-mall 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/package.json +1 -1
- package/src/client.js +173 -37
- package/src/index.js +259 -10
- package/src/installer.js +196 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Curated lists only show what has been reviewed and merged. This marketplace is o
|
|
|
15
15
|
- **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
|
|
16
16
|
- **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
|
|
17
17
|
- **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
|
|
18
|
-
- **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure
|
|
18
|
+
- **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
|
|
19
19
|
- **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable).
|
|
20
20
|
|
|
21
21
|
## Install
|
|
@@ -71,7 +71,15 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard add <spec> -
|
|
|
71
71
|
node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --profile web -- dsh web
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
**
|
|
74
|
+
**A plain `dsh web` already resolves pending installs.** The marketplace plugin runs
|
|
75
|
+
recovery when it loads: reaching that point proves dsh booted far enough to compose
|
|
76
|
+
the profile, so the pending marker is committed (or rolled back when the profile
|
|
77
|
+
fails validation). Without this a single install would wedge the profile — every
|
|
78
|
+
later install and uninstall refuses while a marker is outstanding.
|
|
79
|
+
|
|
80
|
+
**`guard launch` is still strictly better**, because it also covers what a plain
|
|
81
|
+
start cannot: a plugin that boots fine and then crashes seconds later. It checks the
|
|
82
|
+
profile's pending-install marker before starting the command after `--`:
|
|
75
83
|
|
|
76
84
|
- **No pending install** — the command runs as-is, inheriting the terminal, and its exit code is preserved.
|
|
77
85
|
- **Clearly broken on disk** — the profile is rolled back to its pre-install snapshot *before* launch, then the command starts on the restored state.
|
|
@@ -105,7 +113,7 @@ Limitations: the grace window is the probation period — a failure that only su
|
|
|
105
113
|
- **防抢注**:仅当 npm registry 条目的 `repository` 指回同一 GitHub 仓库时才用 npm 安装,否则回退 `github:` 源
|
|
106
114
|
- **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
|
|
107
115
|
- **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
|
|
108
|
-
- **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile
|
|
116
|
+
- **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
|
|
109
117
|
- **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)
|
|
110
118
|
|
|
111
119
|
## 安装
|
|
@@ -182,7 +190,13 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard add <spec> -
|
|
|
182
190
|
node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --profile web -- dsh web
|
|
183
191
|
```
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
**普通的 `dsh web` 就会了结 pending 安装。** 本插件加载时即执行恢复——能加载
|
|
194
|
+
本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending 标记
|
|
195
|
+
(profile 校验不过则回滚)。没有这一步的话,装完一个插件就会把 profile 卡住:
|
|
196
|
+
只要标记还在,之后所有安装和卸载都会被拒绝。
|
|
197
|
+
|
|
198
|
+
**`guard launch` 仍然更强**,因为它覆盖普通启动覆盖不了的情况:插件启动正常、
|
|
199
|
+
几秒后才崩。它在启动 `--` 之后的命令前检查该 profile 的 pending 安装标记:
|
|
186
200
|
|
|
187
201
|
- **无 pending 安装** —— 命令原样运行(继承终端),透传退出码;
|
|
188
202
|
- **静态校验明显过不了** —— 启动*之前*先把 profile 回滚到安装前快照,再在恢复后的状态上启动;
|
|
@@ -220,6 +234,22 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
|
|
|
220
234
|
(这行会出现在任务日志里)。更新检查读的是 registry 的 `/latest` 端点,
|
|
221
235
|
不经过该策略,所以两边看到的「最新版本」本就可能不同。
|
|
222
236
|
首次安装(卡片按钮)不带版本,沿用 pnpm 的策略默认值即可。
|
|
237
|
+
- **启用 / 停用,不必卸载**:已装面板每行一个开关,关掉即刻卸载该插件的
|
|
238
|
+
fiber,重新打开时它、以及因依赖它而挂起的插件都会回来。三层落地:内存用
|
|
239
|
+
`entry.update({disabled})`;持久化改写 profile 的 `cordis.patch.yml`(保留
|
|
240
|
+
注释),由 dsh 自己的 `watchUserPatches` 事务性重放,所以重启后状态保持;
|
|
241
|
+
写入前自动备份到 `<profile>/backups/`(留最近 20 份)。
|
|
242
|
+
市场插件自身不给开关——停用了就没有界面再打开它。用户手写的
|
|
243
|
+
`disabled: !!js …` 条件表达式会被**拒绝接管**并提示手改:那是条件逻辑,
|
|
244
|
+
两态开关覆盖它等于把条件永久压成固定值。
|
|
245
|
+
(界面类插件的变化需要刷新页面才反映:浏览器那半边靠页面加载时注入的
|
|
246
|
+
启动清单,后端的开关立即生效,已加载的模块不会自行卸载。)
|
|
247
|
+
- **一次点击一个任务,日志从第一毫秒开始流**:预检本身就是一个任务,点安装
|
|
248
|
+
的瞬间就出现在面板里,隔离探针的 pnpm 输出实时写入——而不是让按钮干等几秒
|
|
249
|
+
再冒出结果。预检通过后由安装任务接管同一条日志、撤掉预检条目,所以面板上
|
|
250
|
+
始终只有一条记录。运行中只露最后 8 行,落定后折叠成「查看日志(N 行)」。
|
|
251
|
+
- **预检通过直接装**:verdict 为 safe 时不弹任何确认;只有有风险或被阻止才
|
|
252
|
+
出面板内联卡片(原因列表 + 取消 / 继续),不用模态弹窗打断。
|
|
223
253
|
- **安装期代码要用户点头**:pnpm 默认拦掉依赖的构建脚本,放行等于让那些命令
|
|
224
254
|
以用户的权限在其机器上运行(早于任何插件代码加载)——这个决定属于用户。
|
|
225
255
|
所以被拦时安装**停下**,如实列出要批准的到底是什么:包名@版本、确切的
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -64,7 +64,14 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
".mkt_approvePkg{display:flex;flex-direction:column;gap:4px}",
|
|
65
65
|
".mkt_approveName{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-right:8px;overflow-wrap:anywhere}",
|
|
66
66
|
".mkt_approveCmd{max-height:none;margin:0}",
|
|
67
|
-
".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
67
|
+
".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}",
|
|
68
|
+
".mkt_logBlock{display:flex;flex-direction:column;gap:4px;align-items:flex-start}",
|
|
69
|
+
".mkt_depOff{opacity:.5;text-decoration:line-through}",
|
|
70
|
+
".mkt_switch{position:relative;width:34px;height:18px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-secondary,#e5e7eb);cursor:pointer;padding:0;transition:background .15s,border-color .15s}",
|
|
71
|
+
".mkt_switch:disabled{opacity:.55;cursor:default}",
|
|
72
|
+
".mkt_switchOn{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent}",
|
|
73
|
+
".mkt_switchKnob{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25)}",
|
|
74
|
+
".mkt_switchOn .mkt_switchKnob{transform:translateX(16px)}",
|
|
68
75
|
".mkt_issueList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}",
|
|
69
76
|
".mkt_issue{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-secondary,#fff)}",
|
|
70
77
|
".mkt_issueBlock{border-color:var(--dsw-alias-state-error-primary)}",
|
|
@@ -123,7 +130,9 @@ window.__ModuleLoader__.load({
|
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
// ── job polling ─────────────────────────────────────────────────────────
|
|
126
|
-
|
|
133
|
+
// onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
|
|
134
|
+
// 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
|
|
135
|
+
function useJobPolling(call, onSettled, onApprovalToken, onPreflightSettled) {
|
|
127
136
|
var jobsRef = useRef({});
|
|
128
137
|
var _jobs = useState({});
|
|
129
138
|
var jobs = _jobs[0];
|
|
@@ -145,8 +154,15 @@ window.__ModuleLoader__.load({
|
|
|
145
154
|
if (snapshot.needsApproval && snapshot.approvalToken && onApprovalToken) {
|
|
146
155
|
try { onApprovalToken(snapshot.spec, snapshot.approvalToken); } catch (e) { /* best effort */ }
|
|
147
156
|
}
|
|
148
|
-
if (!wasTerminal && nowTerminal
|
|
149
|
-
|
|
157
|
+
if (!wasTerminal && nowTerminal) {
|
|
158
|
+
if (onSettled) {
|
|
159
|
+
try { onSettled(id, snapshot); } catch (e) { /* best effort */ }
|
|
160
|
+
}
|
|
161
|
+
// 预检 job 落定 → 把结论交给上层后继(只触发一次)
|
|
162
|
+
if (snapshot.kind === "dsh-plugin-preflight" && !old.preflightHandled && onPreflightSettled) {
|
|
163
|
+
jobsRef.current[id] = Object.assign({}, old, { preflightHandled: true });
|
|
164
|
+
try { onPreflightSettled(snapshot.spec, snapshot.extras, id); } catch (e) { /* best effort */ }
|
|
165
|
+
}
|
|
150
166
|
}
|
|
151
167
|
var output = (old.output || "") + (value.output || "");
|
|
152
168
|
jobsRef.current = Object.assign({}, jobsRef.current, { [id]: Object.assign({}, old, {
|
|
@@ -154,6 +170,7 @@ window.__ModuleLoader__.load({
|
|
|
154
170
|
detail: snapshot.detail,
|
|
155
171
|
needsApproval: snapshot.needsApproval,
|
|
156
172
|
approvalToken: snapshot.approvalToken,
|
|
173
|
+
kind: snapshot.kind,
|
|
157
174
|
output: output,
|
|
158
175
|
}) });
|
|
159
176
|
setJobs(Object.assign({}, jobsRef.current));
|
|
@@ -162,9 +179,19 @@ window.__ModuleLoader__.load({
|
|
|
162
179
|
}, 1200);
|
|
163
180
|
return function () { clearInterval(timer); };
|
|
164
181
|
}, [call, onSettled, onApprovalToken]);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
// carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
|
|
183
|
+
// 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
|
|
184
|
+
// market-2 安装两条并排,让人以为自己点了两次。
|
|
185
|
+
var track = useCallback(function (id, spec, carryFromId) {
|
|
186
|
+
var next = Object.assign({}, jobsRef.current);
|
|
187
|
+
var carried = "";
|
|
188
|
+
if (carryFromId && next[carryFromId]) {
|
|
189
|
+
carried = next[carryFromId].output || "";
|
|
190
|
+
delete next[carryFromId];
|
|
191
|
+
}
|
|
192
|
+
next[id] = { status: "running", spec: spec, output: carried };
|
|
193
|
+
jobsRef.current = next;
|
|
194
|
+
setJobs(Object.assign({}, next));
|
|
168
195
|
}, []);
|
|
169
196
|
var clear = useCallback(function () {
|
|
170
197
|
jobsRef.current = {};
|
|
@@ -317,6 +344,54 @@ window.__ModuleLoader__.load({
|
|
|
317
344
|
);
|
|
318
345
|
}
|
|
319
346
|
|
|
347
|
+
var MARKET_PACKAGE = "@1e0zj/dsh-plugin-mall";
|
|
348
|
+
|
|
349
|
+
function jobKindLabel(kind) {
|
|
350
|
+
if (kind === "dsh-plugin-preflight") return "预检 ";
|
|
351
|
+
if (kind === "dsh-plugin-uninstall") return "卸载 ";
|
|
352
|
+
return "安装 ";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function jobStatusLabel(status) {
|
|
356
|
+
if (status === "completed") return "完成";
|
|
357
|
+
if (status === "failed") return "失败";
|
|
358
|
+
if (status === "killed") return "已取消";
|
|
359
|
+
return "进行中";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function jobStatusClass(status) {
|
|
363
|
+
if (status === "completed") return "mkt_ok";
|
|
364
|
+
if (status === "failed" || status === "killed") return "mkt_error";
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 任务日志:跑的时候只露尾部几行(够看进度,不霸屏),落定后默认折叠
|
|
369
|
+
// ——成功的日志没人回头读,失败的才需要,点开即可。
|
|
370
|
+
var LOG_TAIL_LINES = 8;
|
|
371
|
+
function JobLog(props) {
|
|
372
|
+
var _open = useState(false);
|
|
373
|
+
var open = _open[0];
|
|
374
|
+
var setOpen = _open[1];
|
|
375
|
+
var text = String(props.output || "").trimEnd();
|
|
376
|
+
if (text.length === 0) return null;
|
|
377
|
+
var lines = text.split("\n");
|
|
378
|
+
var collapsed = props.done && !open;
|
|
379
|
+
var shown = (props.done || lines.length <= LOG_TAIL_LINES) ? lines : lines.slice(-LOG_TAIL_LINES);
|
|
380
|
+
// 展开/收起始终在日志块上方:放在下方的话,点开后按钮会被推到长长的
|
|
381
|
+
// 日志末尾,想收起还得先滚回去。
|
|
382
|
+
return h("div", { className: "mkt_logBlock" },
|
|
383
|
+
props.done
|
|
384
|
+
? h("span", {
|
|
385
|
+
className: "mkt_link",
|
|
386
|
+
onClick: function () { setOpen(!open); },
|
|
387
|
+
}, open ? "收起日志" : "查看日志(" + lines.length + " 行)")
|
|
388
|
+
: lines.length > LOG_TAIL_LINES
|
|
389
|
+
? h("div", { className: "mkt_meta" }, "只显示最后 " + LOG_TAIL_LINES + " 行")
|
|
390
|
+
: null,
|
|
391
|
+
collapsed ? null : h("pre", { className: "mkt_pre" }, shown.join("\n").slice(-4000))
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
320
395
|
// ── jobs panel ──────────────────────────────────────────────────────────
|
|
321
396
|
function JobsPanel(props) {
|
|
322
397
|
var ids = Object.keys(props.jobs);
|
|
@@ -330,8 +405,13 @@ window.__ModuleLoader__.load({
|
|
|
330
405
|
var job = props.jobs[id];
|
|
331
406
|
var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
|
|
332
407
|
return h("div", { key: id, style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
408
|
+
// 不再显示 market-N 这种内部 id——对用户没有意义,反而让人以为
|
|
409
|
+
// 自己点了两次。只说清「在做什么 · 到哪一步了」。
|
|
410
|
+
// 状态词单独上色(完成绿、失败/取消红),前半段保持灰——整行
|
|
411
|
+
// 都染色会喧宾夺主,只有结论需要一眼看见。
|
|
333
412
|
h("div", { className: "mkt_meta" },
|
|
334
|
-
|
|
413
|
+
jobKindLabel(job.kind) + clip(job.spec || "", 44) + " · ",
|
|
414
|
+
h("span", { className: jobStatusClass(job.status) }, jobStatusLabel(job.status))),
|
|
335
415
|
job.needsApproval && job.needsApproval.length > 0
|
|
336
416
|
? h(ApprovalRequest, {
|
|
337
417
|
needsApproval: job.needsApproval,
|
|
@@ -345,9 +425,10 @@ window.__ModuleLoader__.load({
|
|
|
345
425
|
onDismiss: function () { props.onDismiss(id); },
|
|
346
426
|
})
|
|
347
427
|
: job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
|
|
348
|
-
|
|
428
|
+
// 预检完成不提示重启——它没有改动任何东西,接下来才是安装。
|
|
429
|
+
// 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
|
|
430
|
+
done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
|
|
349
431
|
? h("div", { className: "mkt_jobDone" },
|
|
350
|
-
h("span", { className: "mkt_ok" }, "完成"),
|
|
351
432
|
h("button", {
|
|
352
433
|
className: "mkt_btn mkt_btnPrimary mkt_btnSm",
|
|
353
434
|
disabled: props.restarting === true,
|
|
@@ -356,7 +437,7 @@ window.__ModuleLoader__.load({
|
|
|
356
437
|
: job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
|
|
357
438
|
? h("div", { className: "mkt_error" }, "失败,见下方输出")
|
|
358
439
|
: null,
|
|
359
|
-
|
|
440
|
+
h(JobLog, { output: job.output, done: done })
|
|
360
441
|
);
|
|
361
442
|
})
|
|
362
443
|
);
|
|
@@ -389,10 +470,23 @@ window.__ModuleLoader__.load({
|
|
|
389
470
|
: h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
|
|
390
471
|
var busy = (props.removing || {})[dep.name] === true;
|
|
391
472
|
var upd = (props.updates || {})[dep.name];
|
|
473
|
+
var entry = (props.entries || {})[dep.name];
|
|
474
|
+
// 没在装配树里的依赖(普通依赖、或声明了 client 但没挂载的)
|
|
475
|
+
// 没有可切换的东西,不给开关。
|
|
476
|
+
var togglable = entry !== undefined && dep.name !== MARKET_PACKAGE;
|
|
477
|
+
var enabled = entry === undefined ? true : entry.enabled !== false;
|
|
478
|
+
var toggling = (props.toggling || {})[dep.name] === true;
|
|
392
479
|
return h("div", { key: dep.name, className: "mkt_depRow" },
|
|
393
|
-
h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
|
|
480
|
+
h("span", { className: "mkt_desc" + (enabled ? "" : " mkt_depOff") }, dep.name + "@" + dep.version),
|
|
394
481
|
h("span", { className: "mkt_depActions" },
|
|
395
|
-
h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
|
|
482
|
+
h("span", { className: "mkt_badge" }, enabled ? kindLabel(dep.kind) : "已停用"),
|
|
483
|
+
togglable ? h("button", {
|
|
484
|
+
className: "mkt_switch" + (enabled ? " mkt_switchOn" : ""),
|
|
485
|
+
disabled: toggling,
|
|
486
|
+
title: enabled ? "停用(立即生效,不卸载)" : "启用(立即生效)",
|
|
487
|
+
"aria-pressed": enabled ? "true" : "false",
|
|
488
|
+
onClick: function () { props.onToggle(dep.name, !enabled); },
|
|
489
|
+
}, h("span", { className: "mkt_switchKnob" })) : null,
|
|
396
490
|
upd && upd.hasUpdate ? h("button", {
|
|
397
491
|
className: "mkt_btn mkt_btnSm",
|
|
398
492
|
onClick: function () { props.onInstallSpec(dep.name + "@" + upd.latest); },
|
|
@@ -433,6 +527,9 @@ window.__ModuleLoader__.load({
|
|
|
433
527
|
var _removing = useState({});
|
|
434
528
|
var removing = _removing[0];
|
|
435
529
|
var setRemoving = _removing[1];
|
|
530
|
+
var _toggling = useState({});
|
|
531
|
+
var toggling = _toggling[0];
|
|
532
|
+
var setToggling = _toggling[1];
|
|
436
533
|
var _page = useState(1);
|
|
437
534
|
var page = _page[0];
|
|
438
535
|
var setPage = _page[1];
|
|
@@ -558,6 +655,26 @@ window.__ModuleLoader__.load({
|
|
|
558
655
|
return function () { observer.disconnect(); };
|
|
559
656
|
}, [loadMore]);
|
|
560
657
|
|
|
658
|
+
// 启用/停用:热生效,不重启也不重装——所以成功后只刷新已装列表,
|
|
659
|
+
// 不提示重启,也不动任务面板(它不是一个需要看日志的长任务)。
|
|
660
|
+
var doToggle = useCallback(function (packageName, enabled) {
|
|
661
|
+
setToggling(function (prev) { return Object.assign({}, prev, { [packageName]: true }); });
|
|
662
|
+
setError(null);
|
|
663
|
+
call("togglePlugin", { package: packageName, enabled: enabled }).then(function (value) {
|
|
664
|
+
setInstalled(function (prev) {
|
|
665
|
+
return prev && !prev.error ? Object.assign({}, prev, { entries: value.entries }) : prev;
|
|
666
|
+
});
|
|
667
|
+
}).catch(function (e) {
|
|
668
|
+
setError(errorText(e));
|
|
669
|
+
}).finally(function () {
|
|
670
|
+
setToggling(function (prev) {
|
|
671
|
+
var next = Object.assign({}, prev);
|
|
672
|
+
delete next[packageName];
|
|
673
|
+
return next;
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
}, [call]);
|
|
677
|
+
|
|
561
678
|
var refreshInstalled = useCallback(function () {
|
|
562
679
|
call("installed", {}).then(function (value) {
|
|
563
680
|
setInstalled(value);
|
|
@@ -584,7 +701,14 @@ window.__ModuleLoader__.load({
|
|
|
584
701
|
}
|
|
585
702
|
}, []);
|
|
586
703
|
|
|
587
|
-
|
|
704
|
+
// 预检落定的后继动作要用到下面才定义的 doRawInstall,用 ref 转接,
|
|
705
|
+
// 免得为了闭包顺序把整块逻辑往上搬。
|
|
706
|
+
var preflightHandlerRef = useRef(null);
|
|
707
|
+
var onPreflightSettled = useCallback(function (spec, report, jobId) {
|
|
708
|
+
if (preflightHandlerRef.current) preflightHandlerRef.current(spec, report, jobId);
|
|
709
|
+
}, []);
|
|
710
|
+
|
|
711
|
+
var polling = useJobPolling(call, onJobSettled, onApprovalToken, onPreflightSettled);
|
|
588
712
|
var track = polling.track;
|
|
589
713
|
var jobs = polling.jobs;
|
|
590
714
|
var clearJobs = polling.clear;
|
|
@@ -595,7 +719,7 @@ window.__ModuleLoader__.load({
|
|
|
595
719
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
596
720
|
}, []);
|
|
597
721
|
|
|
598
|
-
var doRawInstall = useCallback(function (spec, extra) {
|
|
722
|
+
var doRawInstall = useCallback(function (spec, extra, carryFromId) {
|
|
599
723
|
setInstalling(function (prev) {
|
|
600
724
|
var next = Object.assign({}, prev, { [spec]: true });
|
|
601
725
|
return next;
|
|
@@ -603,7 +727,7 @@ window.__ModuleLoader__.load({
|
|
|
603
727
|
setError(null);
|
|
604
728
|
var payload = Object.assign({ spec: spec }, extra || {});
|
|
605
729
|
call("install", payload).then(function (value) {
|
|
606
|
-
track(value.jobId, spec);
|
|
730
|
+
track(value.jobId, spec, carryFromId);
|
|
607
731
|
}).catch(function (e) {
|
|
608
732
|
delete approvalTokensRef.current[spec];
|
|
609
733
|
setError(errorText(e));
|
|
@@ -626,31 +750,33 @@ window.__ModuleLoader__.load({
|
|
|
626
750
|
doRawInstall(spec, extra);
|
|
627
751
|
}, [doRawInstall]);
|
|
628
752
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
call("preflight", { spec: spec }).then(function (report) {
|
|
637
|
-
// 检查通过直接装,不打扰;有风险才出内联卡片等人表态;被阻止
|
|
638
|
-
// 只告知原因。任何路径都不再弹模态框。
|
|
753
|
+
// 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
|
|
754
|
+
useEffect(function () {
|
|
755
|
+
preflightHandlerRef.current = function (spec, report, jobId) {
|
|
756
|
+
if (!report || !report.verdict) {
|
|
757
|
+
setError("预检没有返回结论,请重试");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
639
760
|
if (report.verdict === "safe") {
|
|
640
|
-
|
|
761
|
+
// 安全:接过预检的日志、撤掉预检条目,面板上只剩这一个任务。
|
|
762
|
+
doRawInstall(spec, {}, jobId);
|
|
641
763
|
} else {
|
|
642
|
-
setPreflight({ spec: spec, report: report });
|
|
764
|
+
setPreflight({ spec: spec, report: report, jobId: jobId });
|
|
643
765
|
}
|
|
766
|
+
};
|
|
767
|
+
}, [doRawInstall]);
|
|
768
|
+
|
|
769
|
+
// 点安装 = 立刻起一个预检 job,面板马上有东西看、日志实时流。
|
|
770
|
+
// 结论由轮询经 onPreflightSettled 交回上面的 handler,这里不等待。
|
|
771
|
+
var preflightAndInstall = useCallback(function (spec) {
|
|
772
|
+
delete approvalTokensRef.current[spec];
|
|
773
|
+
setError(null);
|
|
774
|
+
call("preflight", { spec: spec }).then(function (value) {
|
|
775
|
+
track(value.jobId, value.spec || spec);
|
|
644
776
|
}).catch(function (e) {
|
|
645
777
|
setError(errorText(e));
|
|
646
|
-
}).finally(function () {
|
|
647
|
-
setInstalling(function (prev) {
|
|
648
|
-
var next = Object.assign({}, prev);
|
|
649
|
-
delete next[spec];
|
|
650
|
-
return next;
|
|
651
|
-
});
|
|
652
778
|
});
|
|
653
|
-
}, [call,
|
|
779
|
+
}, [call, track]);
|
|
654
780
|
|
|
655
781
|
var doInstall = useCallback(function (repo) {
|
|
656
782
|
preflightAndInstall("github:" + repo);
|
|
@@ -746,7 +872,16 @@ window.__ModuleLoader__.load({
|
|
|
746
872
|
"只看已验证插件")
|
|
747
873
|
),
|
|
748
874
|
error ? h("div", { className: "mkt_error" }, error) : null,
|
|
749
|
-
h(InstalledPanel, {
|
|
875
|
+
h(InstalledPanel, {
|
|
876
|
+
installed: installed,
|
|
877
|
+
removing: removing,
|
|
878
|
+
updates: updates,
|
|
879
|
+
entries: installed && !installed.error ? installed.entries : undefined,
|
|
880
|
+
toggling: toggling,
|
|
881
|
+
onToggle: doToggle,
|
|
882
|
+
onUninstall: doUninstall,
|
|
883
|
+
onInstallSpec: preflightAndInstall,
|
|
884
|
+
}),
|
|
750
885
|
h(JobsPanel, {
|
|
751
886
|
jobs: jobs,
|
|
752
887
|
onClear: function () {
|
|
@@ -777,8 +912,9 @@ window.__ModuleLoader__.load({
|
|
|
777
912
|
busy: installing[preflight.spec] === true,
|
|
778
913
|
onConfirm: function () {
|
|
779
914
|
var spec = preflight.spec;
|
|
915
|
+
var carry = preflight.jobId;
|
|
780
916
|
setPreflight(null);
|
|
781
|
-
doRawInstall(spec, { acceptWarnings: true });
|
|
917
|
+
doRawInstall(spec, { acceptWarnings: true }, carry);
|
|
782
918
|
},
|
|
783
919
|
onClose: function () {
|
|
784
920
|
if (preflight && preflight.spec) delete approvalTokensRef.current[preflight.spec];
|
package/src/index.js
CHANGED
|
@@ -24,11 +24,16 @@ import { createRequire } from "node:module";
|
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
26
26
|
import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
27
|
-
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof } from "./installer.js";
|
|
28
|
-
import { preflightInstall, inspectRemoteCandidate } from "./guard.js";
|
|
27
|
+
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof, persistPluginDisabled } from "./installer.js";
|
|
28
|
+
import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
|
|
29
29
|
|
|
30
30
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
31
|
-
|
|
31
|
+
// `loader` 用来读装配树、并对单个 entry 做热开关(entry.update)。读法照抄
|
|
32
|
+
// 官方的 @deepseek-ai/dsh-host-plugin-inventory —— 它是只读投影
|
|
33
|
+
// ("Read-only Remote projection of current Cordis Loader plugin state"),
|
|
34
|
+
// 写入侧留白,正是这里补的位置。持久化不走 loader(见 togglePlugin 的说明)。
|
|
35
|
+
// loader 必然存在——没有它我们根本加载不了。
|
|
36
|
+
export const inject = ["tools", "jobs", "systemPrompt", "loader"];
|
|
32
37
|
|
|
33
38
|
export const Config = z.object({
|
|
34
39
|
defaultProfile: z.string().default("web"),
|
|
@@ -194,7 +199,7 @@ function pinPreflight(profileDir, spec) {
|
|
|
194
199
|
* Run the isolated preflight for a resolved install spec, reusing a fresh
|
|
195
200
|
* cache entry ONLY if the profile fingerprint matches.
|
|
196
201
|
*/
|
|
197
|
-
async function runPreflight({ profile, spec, force = false }) {
|
|
202
|
+
async function runPreflight({ profile, spec, force = false, onOutput }) {
|
|
198
203
|
let profileDir;
|
|
199
204
|
try {
|
|
200
205
|
profileDir = resolveProfileDir(profile);
|
|
@@ -220,7 +225,7 @@ async function runPreflight({ profile, spec, force = false }) {
|
|
|
220
225
|
return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
|
|
221
226
|
}
|
|
222
227
|
|
|
223
|
-
const report = await preflightInstall({ profileDir, spec });
|
|
228
|
+
const report = await preflightInstall({ profileDir, spec, onOutput });
|
|
224
229
|
preflightCache.set(key, {
|
|
225
230
|
report,
|
|
226
231
|
fingerprint: currentFingerprint,
|
|
@@ -858,6 +863,57 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
858
863
|
return id;
|
|
859
864
|
},
|
|
860
865
|
|
|
866
|
+
/**
|
|
867
|
+
* A visible, log-streaming job for a phase that is not install/remove.
|
|
868
|
+
* Preflight above all: it used to run inside the RPC call before any job
|
|
869
|
+
* existed, so the panel sat empty for the seconds the probe took. Now the
|
|
870
|
+
* job appears the instant the user clicks and the probe's pnpm output
|
|
871
|
+
* streams into it. The verdict rides `extras` on the snapshot so the
|
|
872
|
+
* polling client can continue into the install (or raise a risk card).
|
|
873
|
+
* Cancel is advisory — the probe has no kill handle — so it just marks the
|
|
874
|
+
* record killed and discards the outcome; the throwaway probe directory
|
|
875
|
+
* lives in the system tmpdir and is reclaimed by the OS.
|
|
876
|
+
*/
|
|
877
|
+
startCustom({ kind, label, profile, spec, surface = "browser", session, run }) {
|
|
878
|
+
const id = `market-${++trackerCounter}`;
|
|
879
|
+
const queue = [];
|
|
880
|
+
const push = (text) => { queue.push(String(text ?? "")); };
|
|
881
|
+
const record = {
|
|
882
|
+
id,
|
|
883
|
+
kind,
|
|
884
|
+
label,
|
|
885
|
+
profile,
|
|
886
|
+
spec,
|
|
887
|
+
surface,
|
|
888
|
+
session: session ?? "",
|
|
889
|
+
status: "running",
|
|
890
|
+
detail: undefined,
|
|
891
|
+
extras: undefined,
|
|
892
|
+
startedAt: Date.now(),
|
|
893
|
+
finishedAt: undefined,
|
|
894
|
+
cancelled: false,
|
|
895
|
+
readOutput: () => (queue.length === 0 ? "" : queue.splice(0).join("")),
|
|
896
|
+
};
|
|
897
|
+
(async () => {
|
|
898
|
+
try {
|
|
899
|
+
const outcome = await run(push);
|
|
900
|
+
if (record.cancelled) return; // 用户已放弃,结果作废
|
|
901
|
+
record.status = outcome?.status ?? "failed";
|
|
902
|
+
record.detail = outcome?.detail;
|
|
903
|
+
record.extras = outcome?.extras;
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (record.cancelled) return;
|
|
906
|
+
record.status = "failed";
|
|
907
|
+
record.detail = error?.message ?? String(error);
|
|
908
|
+
} finally {
|
|
909
|
+
if (!record.cancelled) record.finishedAt = Date.now();
|
|
910
|
+
}
|
|
911
|
+
})();
|
|
912
|
+
records.set(id, record);
|
|
913
|
+
prune();
|
|
914
|
+
return id;
|
|
915
|
+
},
|
|
916
|
+
|
|
861
917
|
get(jobId, session) {
|
|
862
918
|
const record = records.get(String(jobId));
|
|
863
919
|
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
@@ -871,11 +927,15 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
871
927
|
detail: record.detail,
|
|
872
928
|
needsApproval: record.needsApproval,
|
|
873
929
|
approvalToken: isSameSession ? record.approvalToken : undefined,
|
|
930
|
+
// extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
|
|
931
|
+
extras: isSameSession ? record.extras : undefined,
|
|
874
932
|
spec: record.spec,
|
|
875
933
|
startedAt: record.startedAt,
|
|
876
934
|
finishedAt: record.finishedAt,
|
|
877
935
|
},
|
|
878
|
-
output: typeof record.
|
|
936
|
+
output: typeof record.readOutput === "function"
|
|
937
|
+
? record.readOutput()
|
|
938
|
+
: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
|
|
879
939
|
};
|
|
880
940
|
},
|
|
881
941
|
|
|
@@ -885,6 +945,13 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
885
945
|
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
886
946
|
throw new Error("unauthorized to cancel job from another session");
|
|
887
947
|
}
|
|
948
|
+
if (record.cancelled !== undefined) { // startCustom:无句柄可杀,立即标 killed 并作废结果
|
|
949
|
+
record.cancelled = true;
|
|
950
|
+
if (record.status === "running") {
|
|
951
|
+
record.status = "killed";
|
|
952
|
+
record.finishedAt = Date.now();
|
|
953
|
+
}
|
|
954
|
+
}
|
|
888
955
|
if (typeof record.producer?.cancel === "function") {
|
|
889
956
|
record.producer.cancel();
|
|
890
957
|
}
|
|
@@ -911,6 +978,53 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
911
978
|
};
|
|
912
979
|
}
|
|
913
980
|
|
|
981
|
+
/**
|
|
982
|
+
* Group the loader's mounted entries by the package that provides them, so a
|
|
983
|
+
* profile dependency can be shown (and toggled) as one row.
|
|
984
|
+
*
|
|
985
|
+
* One package can insert several rows, so `enabled` means EVERY row of that
|
|
986
|
+
* package is live — a half-disabled package is reported as disabled, and
|
|
987
|
+
* toggling acts on the whole set. Group rows are skipped, mirroring the
|
|
988
|
+
* official read-only projection in @deepseek-ai/dsh-host-plugin-inventory.
|
|
989
|
+
*/
|
|
990
|
+
export function loaderEntriesByPackage(ctx) {
|
|
991
|
+
const byPackage = {};
|
|
992
|
+
try {
|
|
993
|
+
for (const entry of ctx.loader.entries()) {
|
|
994
|
+
if (entry.options?.group) continue;
|
|
995
|
+
const moduleName = entry.options?.name;
|
|
996
|
+
if (typeof moduleName !== "string" || moduleName.length === 0) continue;
|
|
997
|
+
const bucket = byPackage[moduleName] ??= { entryIds: [], entries: [], enabled: true };
|
|
998
|
+
// 两个 id 必须分清:
|
|
999
|
+
// entry.id 运行时全路径,父链拼出来的(`include:dsh-at-file`)
|
|
1000
|
+
// entry.options.id 配置文件里写的那个(`dsh-at-file`)
|
|
1001
|
+
// patch 层的 id 定向覆盖按后者匹配(applyEntryPatches 从组装数据建
|
|
1002
|
+
// entryMap,键是各 patch 声明的 id)。拿前者去写 patch,那条覆盖行
|
|
1003
|
+
// 永远匹配不到目标,dsh 只会 warn 一句然后忽略——停用看着成功了,
|
|
1004
|
+
// 重启后插件照常回来。
|
|
1005
|
+
const configId = entry.options?.id;
|
|
1006
|
+
bucket.entryIds.push(entry.id);
|
|
1007
|
+
bucket.entries.push({ id: entry.id, configId, entry });
|
|
1008
|
+
if (entry.disabled) bucket.enabled = false;
|
|
1009
|
+
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
/* loader 读不到就不给开关,安装/卸载照常可用 */
|
|
1012
|
+
}
|
|
1013
|
+
return byPackage;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* The serializable half of loaderEntriesByPackage — live `entry` objects must
|
|
1018
|
+
* never reach the RPC envelope (they carry the whole fiber graph).
|
|
1019
|
+
*/
|
|
1020
|
+
function serializableEntries(byPackage) {
|
|
1021
|
+
const out = {};
|
|
1022
|
+
for (const [moduleName, bucket] of Object.entries(byPackage)) {
|
|
1023
|
+
out[moduleName] = { entryIds: bucket.entryIds, enabled: bucket.enabled };
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
914
1028
|
/** Render one preflight issue as a compact line for model/error output. */
|
|
915
1029
|
function renderPreflightIssue(entry) {
|
|
916
1030
|
const badge = entry.severity === "block" ? "BLOCK" : "WARN";
|
|
@@ -1131,10 +1245,74 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1131
1245
|
} catch (error) {
|
|
1132
1246
|
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
1133
1247
|
}
|
|
1134
|
-
|
|
1248
|
+
// 带上每个依赖在装配树里的启用状态,浏览器据此渲染开关。
|
|
1249
|
+
return rpcOk({ ...listInstalled(profile), entries: serializableEntries(loaderEntriesByPackage(ctx)) });
|
|
1250
|
+
}
|
|
1251
|
+
case "togglePlugin": {
|
|
1252
|
+
// 启用/停用,三层(同 cynch18/plugin-switch 的做法):
|
|
1253
|
+
// 1. 内存 —— entry.update({disabled}) 立即 dispose/start 对应 fiber
|
|
1254
|
+
// 2. 持久化 —— 文本改写用户的 cordis.patch.yml,由 dsh 自己的
|
|
1255
|
+
// watchUserPatches 事务性重放(启动时若无 HMR 会当场创建一个,
|
|
1256
|
+
// 见 @deepseek-ai/dsh 的 profile-boot:ctx.loader.create(hmr) →
|
|
1257
|
+
// watchUserPatches(profile patch) + watchUserPatches(home patch))
|
|
1258
|
+
// 3. 保险 —— 写前备份到 <profile>/backups/,留最近 20 份
|
|
1259
|
+
// 刻意不用 ctx.loader.update():它的 tree.write() 写的是 cordis.yml,
|
|
1260
|
+
// 那是组装产物;用户的选择该留在自己的 patch 层。
|
|
1261
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1262
|
+
const packageName = String(payload?.package ?? "").trim();
|
|
1263
|
+
const enabled = payload?.enabled === true;
|
|
1264
|
+
if (packageName.length === 0) return rpcFail(new Error("togglePlugin: package name is required"));
|
|
1265
|
+
if (packageName === name) {
|
|
1266
|
+
// 停用市场自己 = 关掉正在操作的这个界面,之后只能手改配置文件才能回来。
|
|
1267
|
+
return rpcFail(new Error("refusing to disable the marketplace itself — you would lose the UI needed to re-enable it"));
|
|
1268
|
+
}
|
|
1269
|
+
let profileDir;
|
|
1270
|
+
try {
|
|
1271
|
+
profileDir = resolveProfileDir(profile);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
1274
|
+
}
|
|
1275
|
+
const targets = loaderEntriesByPackage(ctx)[packageName]?.entries ?? [];
|
|
1276
|
+
if (targets.length === 0) {
|
|
1277
|
+
return rpcFail(new Error(`no loader entry found for ${packageName} — it may not be mounted in this profile`));
|
|
1278
|
+
}
|
|
1279
|
+
// 先持久化:patch 层写不了(!!js 表达式等)就整个放弃,不留下
|
|
1280
|
+
// 「内存里关了、重启又回来」的错位状态。
|
|
1281
|
+
const backups = [];
|
|
1282
|
+
try {
|
|
1283
|
+
for (const target of targets) {
|
|
1284
|
+
// 用 options.id(配置文件里的 id),不是 entry.id(运行时全路径)。
|
|
1285
|
+
if (typeof target.configId !== "string" || target.configId.length === 0) {
|
|
1286
|
+
throw new Error(`${packageName} has a loader entry without a configured id — it cannot be targeted from the patch layer`);
|
|
1287
|
+
}
|
|
1288
|
+
const result = persistPluginDisabled(profileDir, target.configId, !enabled, packageName);
|
|
1289
|
+
if (result.backup !== undefined) backups.push(result.backup);
|
|
1290
|
+
}
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
return rpcFail(error);
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
for (const target of targets) {
|
|
1296
|
+
await target.entry.update({ disabled: enabled ? undefined : true });
|
|
1297
|
+
}
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
return rpcFail(new Error(`${packageName} was written to the patch layer but the live toggle failed: ${error.message} — restart dsh to apply it`));
|
|
1300
|
+
}
|
|
1301
|
+
return rpcOk({ package: packageName, enabled, backups, entries: serializableEntries(loaderEntriesByPackage(ctx)) });
|
|
1135
1302
|
}
|
|
1136
1303
|
case "preflight": {
|
|
1304
|
+
// 预检本身做成 job:点击安装的瞬间任务就出现在面板里,探针的 pnpm
|
|
1305
|
+
// 输出实时流入——此前预检阻塞在 RPC 里,面板数秒空白只有按钮干等。
|
|
1306
|
+
// 结论经 snapshot.extras 返回,轮询端据此后接安装或出风险卡片;
|
|
1307
|
+
// 缓存同时被填热,随后的 install 调用不再重复探装。
|
|
1137
1308
|
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
1309
|
+
let session;
|
|
1310
|
+
try {
|
|
1311
|
+
// 与 install 同规格:job 归属浏览器 session,extras 才只对本人可见。
|
|
1312
|
+
session = requireBrowserSession(payload?.session);
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
return rpcFail(error);
|
|
1315
|
+
}
|
|
1138
1316
|
let spec;
|
|
1139
1317
|
try {
|
|
1140
1318
|
spec = normalizeSpec(payload?.spec);
|
|
@@ -1144,9 +1322,22 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1144
1322
|
}
|
|
1145
1323
|
try {
|
|
1146
1324
|
const registry = await registryFor(profile, npmRegistry);
|
|
1147
|
-
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1325
|
+
const resolved = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
1326
|
+
const jobId = tracker.startCustom({
|
|
1327
|
+
kind: "dsh-plugin-preflight",
|
|
1328
|
+
label: `preflight ${resolved}`,
|
|
1329
|
+
profile,
|
|
1330
|
+
spec: resolved,
|
|
1331
|
+
surface: "browser",
|
|
1332
|
+
session,
|
|
1333
|
+
run: async (push) => {
|
|
1334
|
+
push(`[dsh-plugin-mall] 预检 ${resolved}:隔离目录探装(脚本禁用)\n`);
|
|
1335
|
+
const { report } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text) });
|
|
1336
|
+
push(`[dsh-plugin-mall] 预检结论:${report.verdict}\n`);
|
|
1337
|
+
return { status: "completed", detail: `预检完成:${report.verdict}`, extras: report };
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
return rpcOk({ jobId, profile, spec: resolved });
|
|
1150
1341
|
} catch (error) {
|
|
1151
1342
|
return rpcFail(error);
|
|
1152
1343
|
}
|
|
@@ -1363,6 +1554,31 @@ export function apply(ctx, config = {}) {
|
|
|
1363
1554
|
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
1364
1555
|
const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
|
|
1365
1556
|
|
|
1557
|
+
// Startup recovery. A pending install marker blocks every later install and
|
|
1558
|
+
// uninstall in that profile until something resolves it — and until now the
|
|
1559
|
+
// only thing that did was `guard launch`, a wrapper nobody uses: people type
|
|
1560
|
+
// `dsh web`. One install then wedged the profile permanently, with an error
|
|
1561
|
+
// telling users to run a CLI they have never heard of.
|
|
1562
|
+
//
|
|
1563
|
+
// Reaching `apply` IS the proof the pending install did not break the host:
|
|
1564
|
+
// this code only runs because dsh booted far enough to compose the profile
|
|
1565
|
+
// and load this plugin. So resolve the marker right here — recoverProfile
|
|
1566
|
+
// commits when the profile validates and rolls back when it does not. The
|
|
1567
|
+
// grace-window probation of `guard launch` stays strictly better (it also
|
|
1568
|
+
// catches a crash seconds later); this is the floor for a plain start.
|
|
1569
|
+
try {
|
|
1570
|
+
const result = recoverProfile(resolveProfileDir(defaultProfile));
|
|
1571
|
+
if (result.action === "committed") {
|
|
1572
|
+
console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
|
|
1573
|
+
} else if (result.action === "rolled-back") {
|
|
1574
|
+
console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
|
|
1575
|
+
}
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
// 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
|
|
1578
|
+
// `dsh-plugin-guard guard recover`),而不是连界面都进不去。
|
|
1579
|
+
console.error("[dsh-plugin-mall] startup recovery failed:", error);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1366
1582
|
ctx.systemPrompt.section({
|
|
1367
1583
|
name: "tool:market",
|
|
1368
1584
|
order: 120,
|
|
@@ -2124,6 +2340,39 @@ export async function runSelfTests() {
|
|
|
2124
2340
|
"tracker rejection fixture 使用注入 producer,不触碰真实 profile",
|
|
2125
2341
|
producerCalls === 1 && trackerSnapshot.status === "failed" && settledOutcome?.status === "failed",
|
|
2126
2342
|
);
|
|
2343
|
+
|
|
2344
|
+
// ── 8. 启用/停用:装配树条目按包分组 ─────────────────────────────────
|
|
2345
|
+
// 一个包可以插入多行,所以分组、以及「有一行停用就算整体停用」是这块最
|
|
2346
|
+
// 容易写错的地方;group 行必须跳过(照 dsh-host-plugin-inventory 的读法)。
|
|
2347
|
+
const fakeLoaderCtx = (entries) => ({ loader: { entries: () => entries } });
|
|
2348
|
+
const grouped = loaderEntriesByPackage(fakeLoaderCtx([
|
|
2349
|
+
{ id: "e1", options: { name: "dsh-at-file" }, disabled: false },
|
|
2350
|
+
{ id: "e2", options: { name: "multi-row" }, disabled: false },
|
|
2351
|
+
{ id: "e3", options: { name: "multi-row" }, disabled: true },
|
|
2352
|
+
{ id: "g1", options: { name: "some-group", group: true }, disabled: false },
|
|
2353
|
+
{ id: "e4", options: {}, disabled: false },
|
|
2354
|
+
]));
|
|
2355
|
+
check("单行包:分组并标记启用", grouped["dsh-at-file"]?.entryIds.length === 1 && grouped["dsh-at-file"].enabled === true);
|
|
2356
|
+
check("多行包:合并为一项", grouped["multi-row"]?.entryIds.length === 2);
|
|
2357
|
+
check("多行包有一行停用 → 整体判为停用", grouped["multi-row"]?.enabled === false);
|
|
2358
|
+
check("group 行被跳过", grouped["some-group"] === undefined);
|
|
2359
|
+
check("无名条目被跳过", Object.keys(grouped).length === 2);
|
|
2360
|
+
check("loader 抛错时降级为空表,不拖垮已装列表", Object.keys(loaderEntriesByPackage({
|
|
2361
|
+
loader: { entries: () => { throw new Error("loader unavailable"); } },
|
|
2362
|
+
})).length === 0);
|
|
2363
|
+
|
|
2364
|
+
// entry.id 是运行时全路径(父链拼接,`include:dsh-at-file`),
|
|
2365
|
+
// entry.options.id 才是配置文件里的 id(`dsh-at-file`)。patch 层的
|
|
2366
|
+
// id 定向覆盖按后者匹配——用错了那条覆盖行永远命中不了目标,
|
|
2367
|
+
// 停用看着成功、重启后插件照常回来(真实环境踩过)。
|
|
2368
|
+
const prefixed = loaderEntriesByPackage(fakeLoaderCtx([
|
|
2369
|
+
{ id: "include:dsh-at-file", options: { id: "dsh-at-file", name: "dsh-at-file" }, disabled: false },
|
|
2370
|
+
]));
|
|
2371
|
+
check("运行时 id 与配置 id 分别保留", prefixed["dsh-at-file"]?.entries[0].id === "include:dsh-at-file"
|
|
2372
|
+
&& prefixed["dsh-at-file"]?.entries[0].configId === "dsh-at-file");
|
|
2373
|
+
check("configId 缺失时可被识别(调用方据此拒绝写 patch)", loaderEntriesByPackage(fakeLoaderCtx([
|
|
2374
|
+
{ id: "anon-1", options: { name: "no-id-pkg" }, disabled: false },
|
|
2375
|
+
]))["no-id-pkg"]?.entries[0].configId === undefined);
|
|
2127
2376
|
} finally {
|
|
2128
2377
|
rmSync(root, { recursive: true, force: true });
|
|
2129
2378
|
}
|
package/src/installer.js
CHANGED
|
@@ -368,6 +368,134 @@ export function removeClientRow(profileDir, packageName) {
|
|
|
368
368
|
return { removed: true, rowId };
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
// ── enable / disable persistence ────────────────────────────────────────────
|
|
372
|
+
//
|
|
373
|
+
// Toggling a plugin is three layers, and only the middle one lives here:
|
|
374
|
+
// 1. memory — `entry.update({disabled})` disposes/starts the fiber (index.js)
|
|
375
|
+
// 2. persistence — rewrite the profile's cordis.patch.yml, replayed
|
|
376
|
+
// transactionally by dsh's own `watchUserPatches` (this file)
|
|
377
|
+
// 3. safety — back the file up before every edit so a bad write is undoable
|
|
378
|
+
//
|
|
379
|
+
// Persistence deliberately does NOT go through `loader.update()` even though
|
|
380
|
+
// that would write for us: its `tree.write()` targets `cordis.yml`, the
|
|
381
|
+
// composed artifact. A user's choice belongs in the patch layer they own, not
|
|
382
|
+
// baked into the thing composition regenerates. Same conclusion as
|
|
383
|
+
// cynch18/plugin-switch, which spells it out in its header comment.
|
|
384
|
+
|
|
385
|
+
/** A patch row's `disabled:` line, when it is a plain literal we may rewrite. */
|
|
386
|
+
const DISABLED_LINE_RE = /^(\s*)disabled\s*:\s*(.*?)\s*$/;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Text-level edit of one entry's `disabled` in a patch file, preserving every
|
|
390
|
+
* other byte (comments included — users hand-write this file).
|
|
391
|
+
*
|
|
392
|
+
* A profile's patch layer normally starts EMPTY (`[]`): plugins are mounted by
|
|
393
|
+
* the bundle layers, not by the user's file. So "no row for this id" is the
|
|
394
|
+
* common case, not an error — we append an id-targeted override row, which is
|
|
395
|
+
* exactly what the patch layer is for (dsh-app-boot's applyEntryPatches treats
|
|
396
|
+
* a non-insert row as "override these keys on the entry with this id", and
|
|
397
|
+
* warns on a `name` mismatch, so we pass `name` as a guard).
|
|
398
|
+
*
|
|
399
|
+
* @param content - current cordis.patch.yml text.
|
|
400
|
+
* @param entryId - the loader entry id whose row to edit.
|
|
401
|
+
* @param disabled - desired state.
|
|
402
|
+
* @param moduleName - the entry's module name, written alongside a NEW row so
|
|
403
|
+
* dsh can detect a stale patch if the id is ever reused.
|
|
404
|
+
* @returns the new text, or undefined when it already reads that way.
|
|
405
|
+
* @throws when the row's `disabled` is a `!!js` expression.
|
|
406
|
+
*/
|
|
407
|
+
export function setPatchRowDisabled(content, entryId, disabled, moduleName) {
|
|
408
|
+
const lines = String(content ?? "").split("\n");
|
|
409
|
+
// 行尾允许跟注释:`- id: at-file # 我的备注` 是用户会写的形状。
|
|
410
|
+
const idPattern = new RegExp(`^(\\s*)-?\\s*id\\s*:\\s*['"]?${entryId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?\\s*(?:#.*)?$`);
|
|
411
|
+
const rowIndex = lines.findIndex((line) => idPattern.test(line));
|
|
412
|
+
if (rowIndex === -1) {
|
|
413
|
+
// patch 层里还没有这一条——这是常态(profile 的 patch 层默认是空的 `[]`,
|
|
414
|
+
// 插件由 bundle 层挂载)。追加一条 id 定向覆盖行。
|
|
415
|
+
if (!disabled) return undefined; // 没有覆盖行 = 本来就是启用状态
|
|
416
|
+
const block = moduleName === undefined
|
|
417
|
+
? `- id: ${entryId}\n disabled: true\n`
|
|
418
|
+
: `- id: ${entryId}\n name: '${moduleName}'\n disabled: true\n`;
|
|
419
|
+
const trimmed = String(content ?? "").trim();
|
|
420
|
+
// 模板是注释 + `[]`,整体替换掉那个空数组;否则在末尾追加。
|
|
421
|
+
if (trimmed.endsWith("[]")) {
|
|
422
|
+
return `${trimmed.slice(0, trimmed.lastIndexOf("[]")).trimEnd()}\n${block}`.replace(/^\n/, "");
|
|
423
|
+
}
|
|
424
|
+
return `${trimmed.length === 0 ? "" : `${trimmed}\n`}${block}`;
|
|
425
|
+
}
|
|
426
|
+
const indent = (idPattern.exec(lines[rowIndex])[1] ?? "").length;
|
|
427
|
+
// 同一条目的后续行:缩进更深,或与 `- id:` 的内容对齐。遇到下一个条目/顶格即止。
|
|
428
|
+
let existing = -1;
|
|
429
|
+
for (let index = rowIndex + 1; index < lines.length; index++) {
|
|
430
|
+
const line = lines[index];
|
|
431
|
+
if (line.trim().length === 0) continue;
|
|
432
|
+
const lead = line.length - line.trimStart().length;
|
|
433
|
+
if (lead <= indent && /^\s*-\s/.test(line)) break; // 下一个条目
|
|
434
|
+
if (lead < indent) break; // 退出该块
|
|
435
|
+
const match = DISABLED_LINE_RE.exec(line);
|
|
436
|
+
if (match !== null) { existing = index; break; }
|
|
437
|
+
}
|
|
438
|
+
if (existing !== -1) {
|
|
439
|
+
const value = DISABLED_LINE_RE.exec(lines[existing])[2];
|
|
440
|
+
// 用户写的是条件逻辑(如「只在 Windows 上停用」)。我们的开关只有两态,
|
|
441
|
+
// 覆盖它等于把条件永久压成固定值,而且用户不会察觉——拒绝接管,让人手改。
|
|
442
|
+
if (value.startsWith("!!js")) {
|
|
443
|
+
throw new Error(`cannot toggle ${entryId}: its "disabled" is a !!js expression — edit cordis.patch.yml by hand`);
|
|
444
|
+
}
|
|
445
|
+
if ((value === "true") === disabled) return undefined; // 已是目标状态
|
|
446
|
+
lines[existing] = lines[existing].replace(DISABLED_LINE_RE, `$1disabled: ${disabled}`);
|
|
447
|
+
return lines.join("\n");
|
|
448
|
+
}
|
|
449
|
+
if (!disabled) return undefined; // 没有 disabled 行本就是启用状态
|
|
450
|
+
// 插在 id 行之后,缩进与 id 的内容列对齐。
|
|
451
|
+
lines.splice(rowIndex + 1, 0, `${" ".repeat(indent + 2)}disabled: true`);
|
|
452
|
+
return lines.join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Keep the most recent N backups of a profile file, oldest pruned first. */
|
|
456
|
+
const PATCH_BACKUP_KEEP = 20;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Snapshot cordis.patch.yml before editing it. The file is hand-editable and
|
|
460
|
+
* carries the user's own rows; a bad automated write must be undoable without
|
|
461
|
+
* reaching for git.
|
|
462
|
+
* @returns the backup path, or undefined when there was nothing to back up.
|
|
463
|
+
*/
|
|
464
|
+
export function backupProfilePatch(profileDir) {
|
|
465
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
466
|
+
if (!existsSync(patchPath)) return undefined;
|
|
467
|
+
const dir = join(profileDir, "backups");
|
|
468
|
+
mkdirSync(dir, { recursive: true });
|
|
469
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
470
|
+
const target = join(dir, `cordis.patch.${stamp}.yml`);
|
|
471
|
+
writeFileSync(target, readFileSync(patchPath, "utf8"));
|
|
472
|
+
try {
|
|
473
|
+
const kept = readdirSync(dir).filter((entry) => /^cordis\.patch\..*\.yml$/.test(entry)).sort();
|
|
474
|
+
for (const stale of kept.slice(0, Math.max(0, kept.length - PATCH_BACKUP_KEEP))) {
|
|
475
|
+
rmSync(join(dir, stale), { force: true });
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
/* 清理失败不该让切换失败 */
|
|
479
|
+
}
|
|
480
|
+
return target;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Persist a toggle into the profile's patch layer: back up, edit, write through
|
|
485
|
+
* the checked writer. dsh's own `watchUserPatches` replays the file
|
|
486
|
+
* transactionally, so this is also what makes the change survive a restart.
|
|
487
|
+
* @returns `{changed, backup?}`; `changed:false` means it already read that way.
|
|
488
|
+
*/
|
|
489
|
+
export function persistPluginDisabled(profileDir, entryId, disabled, moduleName) {
|
|
490
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
491
|
+
const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
|
|
492
|
+
const next = setPatchRowDisabled(content, entryId, disabled, moduleName);
|
|
493
|
+
if (next === undefined) return { changed: false };
|
|
494
|
+
const backup = backupProfilePatch(profileDir);
|
|
495
|
+
writePatchChecked(patchPath, next);
|
|
496
|
+
return { changed: true, backup };
|
|
497
|
+
}
|
|
498
|
+
|
|
371
499
|
// ── build-script allow-listing ──────────────────────────────────────────────
|
|
372
500
|
|
|
373
501
|
/** Extract package names from pnpm's "Ignored build scripts: ..." output. */
|
|
@@ -2492,9 +2620,76 @@ async function runTransactionFixtures() {
|
|
|
2492
2620
|
return failed;
|
|
2493
2621
|
}
|
|
2494
2622
|
|
|
2623
|
+
/**
|
|
2624
|
+
* The patch-layer edit behind enable/disable. Text surgery on a file users
|
|
2625
|
+
* hand-write, so every shape it can meet is pinned here.
|
|
2626
|
+
*/
|
|
2627
|
+
function runToggleFixtures() {
|
|
2628
|
+
let failed = 0;
|
|
2629
|
+
const check = (label, ok, extra = "") => {
|
|
2630
|
+
if (!ok) failed++;
|
|
2631
|
+
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` ${extra}`}`);
|
|
2632
|
+
};
|
|
2633
|
+
// 真实环境里 patch 层默认就是这个样子——注释 + 空数组。插件由 bundle 层
|
|
2634
|
+
// 挂载,用户文件里一条都没有。第一版只测了「行已存在」的情形,于是停用
|
|
2635
|
+
// 被静默跳过、重启后插件又回来了。这组用例先钉死这个场景。
|
|
2636
|
+
const stockTemplate = "# Your patch layer for this dsh profile\n[]\n";
|
|
2637
|
+
const fresh = setPatchRowDisabled(stockTemplate, "at-file", true, "dsh-at-file");
|
|
2638
|
+
check("空 patch 层(模板 [])→ 追加 id 定向覆盖行", /- id: at-file/.test(fresh ?? "") && /disabled: true/.test(fresh ?? ""), JSON.stringify(fresh));
|
|
2639
|
+
check("空 patch 层:替换掉 [] 而不是留着", !/\[\]/.test(fresh ?? ""), JSON.stringify(fresh));
|
|
2640
|
+
check("空 patch 层:保留原有注释", (fresh ?? "").includes("# Your patch layer"));
|
|
2641
|
+
check("新建行带 name 便于 dsh 校验陈旧 patch", /name: 'dsh-at-file'/.test(fresh ?? ""));
|
|
2642
|
+
check("空 patch 层 + 要启用 → 不改动", setPatchRowDisabled(stockTemplate, "at-file", false, "dsh-at-file") === undefined);
|
|
2643
|
+
check("新建的行能被 YAML 解析且是数组", (() => {
|
|
2644
|
+
try { return Array.isArray(load(fresh)); } catch { return false; }
|
|
2645
|
+
})());
|
|
2646
|
+
check("新建行的语义正确(id + disabled)", (() => {
|
|
2647
|
+
try { const doc = load(fresh); return doc[0].id === "at-file" && doc[0].disabled === true; } catch { return false; }
|
|
2648
|
+
})());
|
|
2649
|
+
|
|
2650
|
+
const base = "- id: at-file\n name: dsh-at-file\n";
|
|
2651
|
+
|
|
2652
|
+
const off = setPatchRowDisabled(base, "at-file", true);
|
|
2653
|
+
check("无 disabled 行 → 插入 disabled: true", /^\s{2}disabled: true$/m.test(off ?? ""), JSON.stringify(off));
|
|
2654
|
+
check("插入后其余字节不变", (off ?? "").includes("name: dsh-at-file"));
|
|
2655
|
+
|
|
2656
|
+
const on = setPatchRowDisabled(`- id: at-file\n name: dsh-at-file\n disabled: true\n`, "at-file", false);
|
|
2657
|
+
check("已停用 → 改回 false", /disabled: false/.test(on ?? ""), JSON.stringify(on));
|
|
2658
|
+
|
|
2659
|
+
check("已是目标状态 → 不改动", setPatchRowDisabled(`- id: at-file\n disabled: true\n`, "at-file", true) === undefined);
|
|
2660
|
+
check("本就启用且要启用 → 不改动", setPatchRowDisabled(base, "at-file", false) === undefined);
|
|
2661
|
+
check("条目不在 patch 层 → 追加新行,原有条目不动", (() => {
|
|
2662
|
+
const out = setPatchRowDisabled(base, "other-id", true, "pkg-other");
|
|
2663
|
+
return /- id: other-id/.test(out ?? "") && (out ?? "").includes("- id: at-file");
|
|
2664
|
+
})());
|
|
2665
|
+
|
|
2666
|
+
// 用户写的条件逻辑不能被两态开关压平——必须拒绝并让人手改。
|
|
2667
|
+
let threw = false;
|
|
2668
|
+
try { setPatchRowDisabled(`- id: at-file\n disabled: !!js process.platform === 'win32'\n`, "at-file", false); }
|
|
2669
|
+
catch (error) { threw = /!!js expression/.test(error.message); }
|
|
2670
|
+
check("disabled 是 !!js 表达式 → 拒绝接管", threw);
|
|
2671
|
+
|
|
2672
|
+
// 注释是用户手写的,一个字节都不能动。
|
|
2673
|
+
const commented = "# 我的覆盖\n- id: at-file # 保留这个注释\n name: dsh-at-file\n";
|
|
2674
|
+
const kept = setPatchRowDisabled(commented, "at-file", true);
|
|
2675
|
+
check("注释原样保留", (kept ?? "").includes("# 我的覆盖") && (kept ?? "").includes("# 保留这个注释"));
|
|
2676
|
+
|
|
2677
|
+
// 多条目:只动目标那条,相邻条目不受影响。
|
|
2678
|
+
const multi = "- id: a\n name: pkg-a\n- id: at-file\n name: dsh-at-file\n- id: z\n name: pkg-z\n disabled: true\n";
|
|
2679
|
+
const one = setPatchRowDisabled(multi, "at-file", true);
|
|
2680
|
+
check("多条目:只动目标条目", (one ?? "").split("disabled: true").length - 1 === 2 && (one ?? "").includes("- id: z"));
|
|
2681
|
+
check("多条目:不误伤相邻条目的 disabled", setPatchRowDisabled(multi, "a", true)?.includes("- id: z\n name: pkg-z\n disabled: true") === true);
|
|
2682
|
+
|
|
2683
|
+
check("带引号的 id 也能匹配", setPatchRowDisabled(`- id: '@scope/pkg'\n name: x\n`, "@scope/pkg", true) !== undefined);
|
|
2684
|
+
return failed;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2495
2687
|
if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
|
|
2688
|
+
console.log("启用/停用 patch 层 fixtures:");
|
|
2689
|
+
const toggleFailed = runToggleFixtures();
|
|
2690
|
+
console.log();
|
|
2496
2691
|
console.log("allowBuilds 合并 fixtures:");
|
|
2497
|
-
const failed = runAllowBuildsFixtures();
|
|
2692
|
+
const failed = runAllowBuildsFixtures() + toggleFailed;
|
|
2498
2693
|
console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
|
|
2499
2694
|
// 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
|
|
2500
2695
|
// marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须
|