@1e0zj/dsh-plugin-mall 0.1.13 → 0.1.16
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 +26 -4
- package/package.json +1 -1
- package/src/client.js +6 -1
- package/src/github.js +91 -9
- package/src/installer.js +279 -37
package/README.md
CHANGED
|
@@ -140,9 +140,25 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
|
140
140
|
(文本级精准移除,用户手写的行不受影响)。
|
|
141
141
|
- 安装源解析:`github:owner/repo` 优先改写为同名 npm 包(仅当 registry
|
|
142
142
|
条目的 repository 指回该仓库,防止抢注),否则用 GitHub 全仓库 spec。
|
|
143
|
-
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
- 「更新至 x.y.z」按钮传的是 **`包名@版本`** 而不是裸包名。pnpm 11 有
|
|
144
|
+
`minimumReleaseAge` 供应链防护,默认拒绝发布不足 24 小时的版本:传裸包名
|
|
145
|
+
等于让 pnpm 自己挑「最新**可安装**版本」,于是按钮写着「更新至 0.12.3」、
|
|
146
|
+
pnpm 却回落到 0.12.2 并报 `Already up to date`,点多少次都不动。带上版本号
|
|
147
|
+
是明确指定,pnpm 照装并自行往 `minimumReleaseAgeExclude` 记一条豁免
|
|
148
|
+
(这行会出现在任务日志里)。更新检查读的是 registry 的 `/latest` 端点,
|
|
149
|
+
不经过该策略,所以两边看到的「最新版本」本就可能不同。
|
|
150
|
+
首次安装(卡片按钮)不带版本,沿用 pnpm 的策略默认值即可。
|
|
151
|
+
- GitHub 源的插件、以及带原生模块的包安装时要跑构建脚本,pnpm 默认拦截;
|
|
152
|
+
本插件检测到拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的
|
|
153
|
+
`allowBuilds` 并自动重试一次。合并时跟随文件里已有的形状(pnpm 两种都认:
|
|
154
|
+
序列 `- name` 和映射 `name: true`,但**混在一起就是无效 YAML**),没有已有
|
|
155
|
+
形状时用 pnpm 自己写的映射格式;pnpm 留下的未决占位符
|
|
156
|
+
(`name: set this to true or false`)会被改写成 `true` 而不是当作已放行。
|
|
157
|
+
- **对 profile 配置的每一次写入都是先写后校验、解析不过就回滚**
|
|
158
|
+
(`writeChecked`):装别人的插件失败,绝不能留下 dsh 或 pnpm 加载不了的
|
|
159
|
+
profile。覆盖 `pnpm-workspace.yaml`、`package.json`、`cordis.patch.yml`
|
|
160
|
+
三处。`allowBuilds` 是持久化的安全配置,所以安装最终失败时这次放宽会被
|
|
161
|
+
**撤销** —— 否则一个没装成的插件会让那个包名从此静默获得构建脚本执行权。
|
|
146
162
|
- 配置(`cordis.patch.yml` 中可改):`defaultProfile`(默认装进哪个 profile,
|
|
147
163
|
默认 `web`)、`apiBase`(GitHub API 地址)、`npmRegistry`(npm 查询源,留空
|
|
148
164
|
则跟随 pnpm 实际安装源)、`rawSources`(验证用的 package.json 源模板列表,
|
|
@@ -169,4 +185,10 @@ npm publish --access public
|
|
|
169
185
|
- node 半边导出具名成员 `{ name, inject, Config, apply }`,**不要** `export default`
|
|
170
186
|
(cordis loader 会做 `exports.default ?? exports` 解包,default 会吞掉 inject/Config);
|
|
171
187
|
client 半边是 `window.__ModuleLoader__.load({id, factory})`,导出 `{ apply, inject }`。
|
|
172
|
-
- 单测 `src/github.js`(无 harness 依赖):`node src/github.js --self-test
|
|
188
|
+
- 单测 `src/github.js`(无 harness 依赖):`node src/github.js --self-test`,
|
|
189
|
+
加 `--offline` 只跑不联网的 fixture(宿主依赖检测的判据固化在那里 ——
|
|
190
|
+
它当初的实测对象 dsh-TUI 已被上报修复,网络上不再有可复现的回归用例,
|
|
191
|
+
所以改 `HOST_PACKAGES` 前请先跑这组)。
|
|
192
|
+
- `src/installer.js` 也有一组 fixture,固化 `allowBuilds` 合并的全部形状
|
|
193
|
+
(改 `mergeAllowBuilds` 前必跑)。它有宿主依赖,所以要从**已安装副本**运行:
|
|
194
|
+
`node ~/.dsh/profiles/web/node_modules/@1e0zj/dsh-plugin-mall/src/installer.js --self-test`
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -244,9 +244,14 @@ window.__ModuleLoader__.load({
|
|
|
244
244
|
h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
|
|
245
245
|
h("span", { className: "mkt_depActions" },
|
|
246
246
|
h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
|
|
247
|
+
// 必须带上版本号。传裸包名等于让 pnpm 自己挑"最新可安装
|
|
248
|
+
// 版本",而 pnpm 11 的 minimumReleaseAge 会拒绝发布不足
|
|
249
|
+
// 24 小时的版本并回落到旧版 —— 按钮写着"更新至 0.12.3",
|
|
250
|
+
// pnpm 却报 "Already up to date",点多少次都不动。
|
|
251
|
+
// 带上版本是明确指定,pnpm 照装并自行记一条豁免。
|
|
247
252
|
upd && upd.hasUpdate ? h("button", {
|
|
248
253
|
className: "mkt_btn mkt_btnSm",
|
|
249
|
-
onClick: function () { props.onInstallSpec(dep.name); },
|
|
254
|
+
onClick: function () { props.onInstallSpec(dep.name + "@" + upd.latest); },
|
|
250
255
|
}, "更新至 " + upd.latest) : null,
|
|
251
256
|
h("button", {
|
|
252
257
|
className: "mkt_btn mkt_btnDanger mkt_btnSm",
|
package/src/github.js
CHANGED
|
@@ -289,7 +289,17 @@ export function compareVersions(a, b) {
|
|
|
289
289
|
|
|
290
290
|
/** Cap on concurrent outbound requests — shared by verification and update checks. */
|
|
291
291
|
export const NETWORK_CONCURRENCY = 8;
|
|
292
|
+
// repo -> {kind, name, version, hostDeps, ts}
|
|
292
293
|
const verifyCache = new Map();
|
|
294
|
+
// Verdicts expire. Caching a success for the process lifetime freezes the badge
|
|
295
|
+
// on whatever the repo looked like the first time it was seen: when dsh-TUI
|
|
296
|
+
// moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
|
|
297
|
+
// badge stayed up until dsh restarted, still accusing a repo that had been
|
|
298
|
+
// fixed. Ten minutes keeps a browsing session on cache while letting a fix
|
|
299
|
+
// surface on its own.
|
|
300
|
+
const VERIFY_TTL = 600000;
|
|
301
|
+
// Network failures retry sooner — "unknown" carries no information worth keeping.
|
|
302
|
+
const VERIFY_TTL_UNKNOWN = 60000;
|
|
293
303
|
|
|
294
304
|
/**
|
|
295
305
|
* Run `task` over `items` with at most `limit` workers in flight. The one
|
|
@@ -354,10 +364,21 @@ async function fetchRawPackageJson(repo, signal, sources) {
|
|
|
354
364
|
* peerDependencies) installs real copies into the profile — the loader then
|
|
355
365
|
* holds two module instances, symbol identities split, and every tool call
|
|
356
366
|
* crashes with an undiagnosable "reading 'prepare'". See installer docs.
|
|
367
|
+
*
|
|
368
|
+
* ONLY the @deepseek-ai scope belongs here. This used to also match the bare
|
|
369
|
+
* upstream `cosmokit` and `schemastery` on the theory that dsh forked the
|
|
370
|
+
* cordis trio and therefore shipped those too. It does not: the host tree has
|
|
371
|
+
* no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
|
|
372
|
+
* on one — the fork is complete and cut its ties to the upstream names. A
|
|
373
|
+
* plugin depending on bare `schemastery` shadows nothing, so flagging it was a
|
|
374
|
+
* pure false positive, and an expensive one: it hard-blocked 7 real plugins in
|
|
375
|
+
* the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
|
|
376
|
+
* every one of which declares its @deepseek-ai/* deps as peers correctly.
|
|
377
|
+
* Fixtures at the bottom of this file pin the distinction.
|
|
357
378
|
*/
|
|
358
|
-
const HOST_PACKAGES =
|
|
379
|
+
const HOST_PACKAGES = /^@deepseek-ai\//;
|
|
359
380
|
|
|
360
|
-
/** The @deepseek-ai/*
|
|
381
|
+
/** The @deepseek-ai/* entries inside `dependencies`. */
|
|
361
382
|
function hostShadowDependencies(pkg) {
|
|
362
383
|
if (pkg === undefined || typeof pkg !== "object") return undefined;
|
|
363
384
|
const deps = Object.keys(pkg.dependencies ?? {});
|
|
@@ -376,11 +397,12 @@ export async function verifyPlugins({ repos, signal, sources }) {
|
|
|
376
397
|
// which caller passes them in.
|
|
377
398
|
const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
|
|
378
399
|
.filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
|
|
379
|
-
//
|
|
400
|
+
// 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
|
|
380
401
|
const pending = wanted.filter((repo) => {
|
|
381
402
|
const cached = verifyCache.get(repo);
|
|
382
403
|
if (cached === undefined) return true;
|
|
383
|
-
|
|
404
|
+
const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
|
|
405
|
+
return Date.now() - (cached.ts ?? 0) > ttl;
|
|
384
406
|
});
|
|
385
407
|
await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
|
|
386
408
|
try {
|
|
@@ -389,18 +411,17 @@ export async function verifyPlugins({ repos, signal, sources }) {
|
|
|
389
411
|
: typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
|
|
390
412
|
: pkg.dsh?.client !== undefined ? "client"
|
|
391
413
|
: "plain";
|
|
392
|
-
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
|
|
414
|
+
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
|
|
393
415
|
} catch (error) {
|
|
394
416
|
if (error?.name === "AbortError") throw error;
|
|
395
417
|
verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
|
|
396
418
|
}
|
|
397
419
|
});
|
|
420
|
+
// 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
|
|
398
421
|
const results = {};
|
|
399
422
|
for (const repo of wanted) {
|
|
400
423
|
const cached = verifyCache.get(repo);
|
|
401
|
-
results[repo] = cached
|
|
402
|
-
? { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps }
|
|
403
|
-
: cached;
|
|
424
|
+
results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
|
|
404
425
|
}
|
|
405
426
|
return { results };
|
|
406
427
|
}
|
|
@@ -445,8 +466,69 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
|
|
|
445
466
|
};
|
|
446
467
|
}
|
|
447
468
|
|
|
448
|
-
//
|
|
469
|
+
// ── offline fixtures ────────────────────────────────────────────────────────
|
|
470
|
+
//
|
|
471
|
+
// The host-shadow check has no live regression case any more: dsh-TUI, the
|
|
472
|
+
// plugin it was built against, moved its @deepseek-ai/* deps to
|
|
473
|
+
// peerDependencies after being reported, and nothing else on the network pins
|
|
474
|
+
// the bare-vs-scoped distinction. These manifests do. Shapes are taken from
|
|
475
|
+
// real repositories in the dsh-plugin topic, trimmed to the relevant fields.
|
|
476
|
+
const HOST_SHADOW_FIXTURES = [
|
|
477
|
+
{
|
|
478
|
+
label: "omdsh-dev/DSH-better-sidebar — 上游裸包,宿主不提供,放行",
|
|
479
|
+
pkg: {
|
|
480
|
+
dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
|
|
481
|
+
peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
|
|
482
|
+
},
|
|
483
|
+
expect: undefined,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
|
|
487
|
+
pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
|
|
488
|
+
expect: undefined,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
label: "作用域包写进 dependencies — 真·宿主重复,拦截",
|
|
492
|
+
pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
|
|
493
|
+
expect: ["@deepseek-ai/schemastery"],
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
label: "合法 peer + 非法 dep 混合 — 仍须拦截",
|
|
497
|
+
pkg: {
|
|
498
|
+
dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
|
|
499
|
+
peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
|
|
500
|
+
},
|
|
501
|
+
expect: ["@deepseek-ai/dsh-settings"],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
label: "只在 peerDependencies 声明 — 正确写法,放行",
|
|
505
|
+
pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
|
|
506
|
+
expect: undefined,
|
|
507
|
+
},
|
|
508
|
+
{ label: "无 dependencies 字段", pkg: {}, expect: undefined },
|
|
509
|
+
{ label: "非对象清单", pkg: undefined, expect: undefined },
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
/** Run the offline fixtures; returns the failure count. */
|
|
513
|
+
function runHostShadowFixtures() {
|
|
514
|
+
let failed = 0;
|
|
515
|
+
for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
|
|
516
|
+
const actual = hostShadowDependencies(pkg);
|
|
517
|
+
const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
|
|
518
|
+
if (!ok) failed++;
|
|
519
|
+
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
|
|
520
|
+
}
|
|
521
|
+
return failed;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Self-test entry: node src/github.js --self-test
|
|
525
|
+
// Offline fixtures run first and gate the network smoke test below.
|
|
449
526
|
if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
|
|
527
|
+
console.log("宿主依赖检测 fixtures:");
|
|
528
|
+
const failed = runHostShadowFixtures();
|
|
529
|
+
console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
|
|
530
|
+
if (failed > 0) process.exit(1);
|
|
531
|
+
if (process.argv.includes("--offline")) process.exit(0);
|
|
450
532
|
const apiBase = "https://api.github.com";
|
|
451
533
|
const result = await searchPlugins({ query: "", perPage: 3, apiBase });
|
|
452
534
|
console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
|
package/src/installer.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
|
|
8
8
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { load } from "js-yaml";
|
|
@@ -31,6 +31,69 @@ export function normalizeSpec(raw) {
|
|
|
31
31
|
return spec; // bare npm package name
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// ── guarded config writes ───────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// Installing someone else's plugin must never be able to leave a profile that
|
|
37
|
+
// dsh or pnpm refuses to load. Every write to a shared profile config goes
|
|
38
|
+
// through here: the new bytes are parsed back, and anything that does not
|
|
39
|
+
// parse is rolled back to the previous bytes before the error propagates. A
|
|
40
|
+
// bug in our own editing then costs a failed install, not a profile the user
|
|
41
|
+
// has to repair by hand.
|
|
42
|
+
//
|
|
43
|
+
// This is not hypothetical. Editing `allowBuilds` as a YAML sequence while
|
|
44
|
+
// pnpm had already written its own mapping stub produced a file no parser
|
|
45
|
+
// accepted, and every later pnpm operation in that profile — install,
|
|
46
|
+
// uninstall, update, any plugin at all — failed until the file was fixed
|
|
47
|
+
// manually.
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write a config file only if the result still parses.
|
|
51
|
+
* @param filePath - the file to write.
|
|
52
|
+
* @param nextContent - the full new contents.
|
|
53
|
+
* @param parse - throws when the content is not valid.
|
|
54
|
+
* @param label - file name used in error messages.
|
|
55
|
+
* @returns a rollback function restoring the pre-write bytes.
|
|
56
|
+
*/
|
|
57
|
+
function writeChecked(filePath, nextContent, parse, label) {
|
|
58
|
+
const previous = existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
|
|
59
|
+
const rollback = () => {
|
|
60
|
+
if (previous === undefined) rmSync(filePath, { force: true });
|
|
61
|
+
else writeFileSync(filePath, previous);
|
|
62
|
+
};
|
|
63
|
+
writeFileSync(filePath, nextContent);
|
|
64
|
+
try {
|
|
65
|
+
parse(nextContent);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
rollback();
|
|
68
|
+
throw new Error(`${label} would not parse after the edit and was restored unchanged (${error.message}) — this is a bug in dsh-plugin-mall, please report it`);
|
|
69
|
+
}
|
|
70
|
+
return rollback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** writeChecked for YAML profile configs. */
|
|
74
|
+
function writeYamlChecked(filePath, nextContent, label) {
|
|
75
|
+
return writeChecked(filePath, nextContent, (text) => load(text), label);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** writeChecked for JSON profile configs. */
|
|
79
|
+
function writeJsonChecked(filePath, nextContent, label) {
|
|
80
|
+
return writeChecked(filePath, nextContent, (text) => JSON.parse(text), label);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* writeChecked for cordis.patch.yml — the loader patch layer. Beyond parsing,
|
|
85
|
+
* the contract is a top-level array; anything else and dsh fails to boot, so
|
|
86
|
+
* that is checked here too rather than discovered at the next start.
|
|
87
|
+
*/
|
|
88
|
+
function writePatchChecked(filePath, nextContent) {
|
|
89
|
+
return writeChecked(filePath, nextContent, (text) => {
|
|
90
|
+
const doc = load(text);
|
|
91
|
+
if (doc !== null && doc !== undefined && !Array.isArray(doc)) {
|
|
92
|
+
throw new Error("expected a top-level array of patch entries");
|
|
93
|
+
}
|
|
94
|
+
}, "cordis.patch.yml");
|
|
95
|
+
}
|
|
96
|
+
|
|
34
97
|
// ── profile management ──────────────────────────────────────────────────────
|
|
35
98
|
|
|
36
99
|
/** Resolve and initialize (on first use) the target profile directory. */
|
|
@@ -115,7 +178,7 @@ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
|
|
|
115
178
|
bundles: result,
|
|
116
179
|
},
|
|
117
180
|
};
|
|
118
|
-
|
|
181
|
+
writeJsonChecked(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n", "package.json");
|
|
119
182
|
}
|
|
120
183
|
return result;
|
|
121
184
|
}
|
|
@@ -263,12 +326,12 @@ export function ensureClientRow(profileDir, packageName) {
|
|
|
263
326
|
if (alreadyByName) return { added: false };
|
|
264
327
|
const rowId = clientRowId(packageName);
|
|
265
328
|
const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
|
|
266
|
-
|
|
329
|
+
const next = (Array.isArray(parsed) && parsed.length === 0)
|
|
267
330
|
// The stock template is a comment plus `[]`; replace it wholesale.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
331
|
+
? block
|
|
332
|
+
: content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
|
|
333
|
+
// 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
|
|
334
|
+
writePatchChecked(patchPath, next);
|
|
272
335
|
return { added: true, rowId };
|
|
273
336
|
}
|
|
274
337
|
|
|
@@ -296,7 +359,7 @@ export function removeClientRow(profileDir, packageName) {
|
|
|
296
359
|
}
|
|
297
360
|
if (!removed) return { removed: false };
|
|
298
361
|
const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
299
|
-
|
|
362
|
+
writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
|
|
300
363
|
return { removed: true, rowId };
|
|
301
364
|
}
|
|
302
365
|
|
|
@@ -328,40 +391,95 @@ function parseIgnoredBuilds(output) {
|
|
|
328
391
|
return [...names];
|
|
329
392
|
}
|
|
330
393
|
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Merge names into the profile's `pnpm-workspace.yaml` `allowBuilds`.
|
|
396
|
+
*
|
|
397
|
+
* pnpm accepts both shapes — a sequence (`- name`) and a mapping
|
|
398
|
+
* (`name: true`) — but never both under one key, and pnpm writes a mapping
|
|
399
|
+
* stub of its own (`name: set this to true or false`) when it blocks a build.
|
|
400
|
+
* Appending a sequence item to that stub is what produced an unparseable file.
|
|
401
|
+
* So: read the current shape through a real YAML parse, match it, and default
|
|
402
|
+
* to pnpm's own mapping shape when there is nothing to match, which keeps our
|
|
403
|
+
* edits and pnpm's from ever colliding again.
|
|
404
|
+
*
|
|
405
|
+
* A name already present as a mapping entry whose value is NOT `true` (pnpm's
|
|
406
|
+
* undecided stub) is rewritten rather than skipped — treating the stub as
|
|
407
|
+
* "already allowed" would leave the build still blocked on retry.
|
|
408
|
+
*
|
|
409
|
+
* Pure: takes the current file contents, returns the new contents (or
|
|
410
|
+
* undefined when nothing needs changing). The fs half is ensureAllowBuilds.
|
|
411
|
+
* Split out because this text surgery is the part that broke a profile, so it
|
|
412
|
+
* is the part the fixtures at the bottom of this file have to pin.
|
|
413
|
+
*
|
|
414
|
+
* @param content - current pnpm-workspace.yaml contents.
|
|
415
|
+
* @param names - package names to allow.
|
|
416
|
+
* @returns the new contents, or undefined when already satisfied.
|
|
417
|
+
* @throws when `content` does not parse as YAML.
|
|
418
|
+
*/
|
|
419
|
+
export function mergeAllowBuilds(content, names) {
|
|
420
|
+
const valid = (Array.isArray(names) ? names : []).filter((name) => NPM_NAME_RE.test(name));
|
|
421
|
+
if (valid.length === 0) return undefined;
|
|
422
|
+
// A file that is already broken is not ours to edit — we could only make it
|
|
423
|
+
// worse, and the user needs to see the real reason.
|
|
424
|
+
let parsed;
|
|
425
|
+
try {
|
|
426
|
+
parsed = load(content);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
throw new Error(`pnpm-workspace.yaml does not parse, refusing to edit it: ${error.message}`);
|
|
429
|
+
}
|
|
430
|
+
const current = parsed?.allowBuilds;
|
|
431
|
+
const asSequence = Array.isArray(current);
|
|
432
|
+
const asMapping = !asSequence && current !== null && typeof current === "object";
|
|
433
|
+
const allowed = new Set(asSequence
|
|
434
|
+
? current.map((entry) => String(entry))
|
|
435
|
+
: asMapping ? Object.entries(current).filter(([, value]) => value === true).map(([key]) => key) : []);
|
|
436
|
+
// pnpm 的未决占位符(值不是 true)要改写,不能当成已放行跳过。
|
|
437
|
+
const stubs = asMapping ? valid.filter((name) => name in current && current[name] !== true) : [];
|
|
438
|
+
const additions = valid.filter((name) => !allowed.has(name) && !stubs.includes(name));
|
|
439
|
+
if (additions.length === 0 && stubs.length === 0) return undefined;
|
|
440
|
+
|
|
441
|
+
// Quote sequence entries: a bare `@scope/name` opens with YAML's reserved
|
|
442
|
+
// `@` indicator. Mapping keys do not need it.
|
|
443
|
+
const render = (name) => (asSequence ? ` - '${name}'` : ` ${name}: true`);
|
|
339
444
|
const lines = content.split("\n");
|
|
445
|
+
for (const name of stubs) {
|
|
446
|
+
const pattern = new RegExp(`^(\\s*)(['"]?)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\2\\s*:.*$`);
|
|
447
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
448
|
+
if (index !== -1) lines[index] = ` ${name}: true`;
|
|
449
|
+
}
|
|
450
|
+
let next;
|
|
340
451
|
const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
|
|
341
452
|
if (keyIndex === -1) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// `@` indicator and fails to parse.
|
|
345
|
-
content += `\nallowBuilds:\n${valid.map((name) => ` - '${name}'`).join("\n")}\n`;
|
|
453
|
+
const base = lines.join("\n");
|
|
454
|
+
next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
|
|
346
455
|
} else {
|
|
347
|
-
|
|
456
|
+
// 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
|
|
348
457
|
let insertIndex = keyIndex + 1;
|
|
349
458
|
for (let index = keyIndex + 1; index < lines.length; index++) {
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
insertIndex = index + 1;
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (/^\S/.test(lines[index])) break;
|
|
357
|
-
}
|
|
358
|
-
const additions = valid.filter((name) => !existing.has(name));
|
|
359
|
-
if (additions.length > 0) {
|
|
360
|
-
lines.splice(insertIndex, 0, ...additions.map((name) => ` - '${name}'`));
|
|
361
|
-
content = lines.join("\n");
|
|
459
|
+
if (lines[index].trim().length === 0) continue;
|
|
460
|
+
if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
|
|
461
|
+
break;
|
|
362
462
|
}
|
|
463
|
+
if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
|
|
464
|
+
next = lines.join("\n");
|
|
363
465
|
}
|
|
364
|
-
|
|
466
|
+
return next;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Default pnpm-workspace.yaml for a profile that has none yet. */
|
|
470
|
+
const DEFAULT_WORKSPACE_YAML = "packages:\n - .\n\nnodeLinker: hoisted\n";
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Apply mergeAllowBuilds to the profile's pnpm-workspace.yaml through the
|
|
474
|
+
* guarded writer.
|
|
475
|
+
* @returns a rollback function, or undefined when nothing needed changing.
|
|
476
|
+
*/
|
|
477
|
+
function ensureAllowBuilds(profileDir, names) {
|
|
478
|
+
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
479
|
+
const content = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
|
|
480
|
+
const next = mergeAllowBuilds(content, names);
|
|
481
|
+
if (next === undefined) return undefined;
|
|
482
|
+
return writeYamlChecked(workspacePath, next, "pnpm-workspace.yaml");
|
|
365
483
|
}
|
|
366
484
|
|
|
367
485
|
// ── in-process install tracker (browser RPC surface) ────────────────────────
|
|
@@ -591,18 +709,39 @@ export function runInstall({ profile, spec }) {
|
|
|
591
709
|
if (ignored.length === 0) {
|
|
592
710
|
return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
593
711
|
}
|
|
594
|
-
|
|
595
|
-
|
|
712
|
+
// 放行构建脚本 = 允许这些包在安装期执行自己的任意代码,正是 pnpm 默认
|
|
713
|
+
// 拦下来的东西。说明白,别让它淹在 pnpm 的刷屏输出里。
|
|
714
|
+
push(`\n[dsh-plugin-mall] pnpm blocked install scripts for: ${ignored.join(", ")}\n`);
|
|
715
|
+
push(`[dsh-plugin-mall] allowing them in the profile's pnpm-workspace.yaml and retrying once — these packages will run their own install-time code.\n`);
|
|
716
|
+
let rollbackAllowBuilds;
|
|
717
|
+
try {
|
|
718
|
+
rollbackAllowBuilds = ensureAllowBuilds(profileDir, ignored);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched — approve them yourself with \`pnpm approve-builds\` in ${profileDir}, then retry.` };
|
|
721
|
+
}
|
|
722
|
+
// allowBuilds 是持久化的安全配置。为一次没装成的插件单向放宽它,等于以后
|
|
723
|
+
// 这个包名再出现(哪怕是别人的传递依赖)就静默放行——失败必须收回。
|
|
724
|
+
const revert = () => {
|
|
725
|
+
if (rollbackAllowBuilds === undefined) return;
|
|
726
|
+
try {
|
|
727
|
+
rollbackAllowBuilds();
|
|
728
|
+
push("[dsh-plugin-mall] install failed — reverted the allowBuilds change, the profile is as it was\n");
|
|
729
|
+
} catch {
|
|
730
|
+
/* 还原失败不该盖掉真正的失败原因 */
|
|
731
|
+
}
|
|
732
|
+
};
|
|
596
733
|
const retry = spawnAdd();
|
|
597
734
|
current = retry.proc;
|
|
598
735
|
const retryOutcome = await retry.done;
|
|
599
736
|
if (retryOutcome.spawnError !== undefined) {
|
|
737
|
+
revert();
|
|
600
738
|
return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
|
|
601
739
|
}
|
|
602
740
|
if (retryOutcome.exitCode === 0) {
|
|
603
741
|
return finalizeSuccess();
|
|
604
742
|
}
|
|
605
|
-
|
|
743
|
+
revert();
|
|
744
|
+
return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output. The allowBuilds change was reverted; pnpm may still have left the dependency in the profile's package.json — market_uninstall removes it.` };
|
|
606
745
|
};
|
|
607
746
|
|
|
608
747
|
const first = spawnAdd();
|
|
@@ -710,3 +849,106 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
|
|
|
710
849
|
},
|
|
711
850
|
};
|
|
712
851
|
}
|
|
852
|
+
|
|
853
|
+
// ── offline fixtures ────────────────────────────────────────────────────────
|
|
854
|
+
//
|
|
855
|
+
// mergeAllowBuilds is the text surgery that bricked a profile: it appended a
|
|
856
|
+
// YAML sequence item under a key pnpm had already written as a mapping, and
|
|
857
|
+
// the resulting file parsed nowhere, so every later pnpm operation in that
|
|
858
|
+
// profile failed until it was repaired by hand. These cases pin every shape it
|
|
859
|
+
// can meet. Run them from an INSTALLED copy (the bare imports at the top of
|
|
860
|
+
// this file resolve through the host, not through a bare checkout):
|
|
861
|
+
// node ~/.dsh/profiles/web/node_modules/@1e0zj/dsh-plugin-mall/src/installer.js --self-test
|
|
862
|
+
const BASE_WS = "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n";
|
|
863
|
+
|
|
864
|
+
const ALLOW_BUILDS_FIXTURES = [
|
|
865
|
+
{
|
|
866
|
+
label: "没有 allowBuilds —— 新建,用 pnpm 自己的映射格式",
|
|
867
|
+
content: BASE_WS,
|
|
868
|
+
names: ["node-pty"],
|
|
869
|
+
check: (out) => load(out).allowBuilds?.["node-pty"] === true,
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
label: "pnpm 的未决占位符 —— 改写成 true,而不是当作已放行跳过",
|
|
873
|
+
content: `${BASE_WS}allowBuilds:\n node-pty: set this to true or false\n`,
|
|
874
|
+
names: ["node-pty"],
|
|
875
|
+
check: (out) => load(out).allowBuilds?.["node-pty"] === true,
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
label: "已有映射条目 —— 追加同为映射,不混格式",
|
|
879
|
+
content: `${BASE_WS}allowBuilds:\n esbuild: true\n`,
|
|
880
|
+
names: ["node-pty"],
|
|
881
|
+
check: (out) => { const a = load(out).allowBuilds; return a?.esbuild === true && a?.["node-pty"] === true; },
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
label: "已有序列条目 —— 追加同为序列,不混格式(正是砖化那次的形状)",
|
|
885
|
+
content: `${BASE_WS}allowBuilds:\n - 'esbuild'\n`,
|
|
886
|
+
names: ["node-pty"],
|
|
887
|
+
check: (out) => { const a = load(out).allowBuilds; return Array.isArray(a) && a.includes("esbuild") && a.includes("node-pty"); },
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
label: "序列里已存在 —— 无需改动",
|
|
891
|
+
content: `${BASE_WS}allowBuilds:\n - 'node-pty'\n`,
|
|
892
|
+
names: ["node-pty"],
|
|
893
|
+
expectNoChange: true,
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
label: "映射里已是 true —— 无需改动",
|
|
897
|
+
content: `${BASE_WS}allowBuilds:\n node-pty: true\n`,
|
|
898
|
+
names: ["node-pty"],
|
|
899
|
+
expectNoChange: true,
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
label: "scoped 包名在序列里要加引号(@ 是 YAML 保留指示符)",
|
|
903
|
+
content: `${BASE_WS}allowBuilds:\n - 'esbuild'\n`,
|
|
904
|
+
names: ["@scope/native-thing"],
|
|
905
|
+
check: (out) => load(out).allowBuilds?.includes("@scope/native-thing"),
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
label: "allowBuilds 后面还有别的 key —— 不插到别人块里",
|
|
909
|
+
content: `packages:\n - .\n\nallowBuilds:\n esbuild: true\n\nnodeLinker: hoisted\n`,
|
|
910
|
+
names: ["node-pty"],
|
|
911
|
+
check: (out) => { const d = load(out); return d.allowBuilds?.["node-pty"] === true && d.nodeLinker === "hoisted"; },
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
label: "非法包名被过滤,无合法名时不动文件",
|
|
915
|
+
content: BASE_WS,
|
|
916
|
+
names: ["9 | - pkg", "Run \"pnpm approve-builds\""],
|
|
917
|
+
expectNoChange: true,
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
label: "文件本来就坏 —— 拒绝编辑,不让它更坏",
|
|
921
|
+
content: "allowBuilds:\n - 'a'\n a: b\n",
|
|
922
|
+
names: ["node-pty"],
|
|
923
|
+
expectThrow: true,
|
|
924
|
+
},
|
|
925
|
+
];
|
|
926
|
+
|
|
927
|
+
function runAllowBuildsFixtures() {
|
|
928
|
+
let failed = 0;
|
|
929
|
+
for (const fx of ALLOW_BUILDS_FIXTURES) {
|
|
930
|
+
let out, error;
|
|
931
|
+
try { out = mergeAllowBuilds(fx.content, fx.names); } catch (e) { error = e; }
|
|
932
|
+
let ok;
|
|
933
|
+
if (fx.expectThrow) ok = error !== undefined;
|
|
934
|
+
else if (error !== undefined) ok = false;
|
|
935
|
+
else if (fx.expectNoChange) ok = out === undefined;
|
|
936
|
+
else ok = out !== undefined && (() => { try { return fx.check(out) === true; } catch { return false; } })();
|
|
937
|
+
// 任何产出都必须是可解析的 YAML —— 这是这组用例存在的全部理由。
|
|
938
|
+
if (ok && out !== undefined) {
|
|
939
|
+
try { load(out); } catch { ok = false; }
|
|
940
|
+
}
|
|
941
|
+
if (!ok) failed++;
|
|
942
|
+
console.log(` ${ok ? "PASS" : "FAIL"} ${fx.label}`);
|
|
943
|
+
if (!ok && out !== undefined) console.log(` 产出:\n${out.split("\n").map((l) => ` | ${l}`).join("\n")}`);
|
|
944
|
+
if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
|
|
945
|
+
}
|
|
946
|
+
return failed;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
|
|
950
|
+
console.log("allowBuilds 合并 fixtures:");
|
|
951
|
+
const failed = runAllowBuildsFixtures();
|
|
952
|
+
console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
|
|
953
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
954
|
+
}
|