@1e0zj/dsh-plugin-mall 0.1.12 → 0.1.15
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 +186 -123
- package/cordis.patch.yml +12 -0
- package/package.json +1 -1
- package/src/github.js +189 -52
- package/src/index.js +31 -13
- package/src/installer.js +374 -39
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
|
}
|
|
@@ -144,6 +207,91 @@ export function listInstalled(profile) {
|
|
|
144
207
|
return { dir, deps };
|
|
145
208
|
}
|
|
146
209
|
|
|
210
|
+
// ── npm registry resolution ─────────────────────────────────────────────────
|
|
211
|
+
//
|
|
212
|
+
// Registry lookups (anti-squatting, update checks, the host-shadow guard) have
|
|
213
|
+
// to hit the same registry pnpm installs from. Hardcoding npmjs while the user
|
|
214
|
+
// is on a mirror breaks all three silently — see the header comment in
|
|
215
|
+
// github.js. Resolution order mirrors pnpm's own: the profile's .npmrc, then
|
|
216
|
+
// `pnpm config get registry` (which folds in the user and global .npmrc
|
|
217
|
+
// chain), then npmjs. Cached per profile for the process lifetime; changing a
|
|
218
|
+
// registry needs a dsh restart anyway, like every other profile setting.
|
|
219
|
+
|
|
220
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
221
|
+
const registryCache = new Map(); // profile -> Promise<string>
|
|
222
|
+
|
|
223
|
+
/** The `registry=` value from a profile-local .npmrc, if it sets one. */
|
|
224
|
+
function registryFromNpmrc(profileDir) {
|
|
225
|
+
const npmrcPath = join(profileDir, ".npmrc");
|
|
226
|
+
if (!existsSync(npmrcPath)) return undefined;
|
|
227
|
+
try {
|
|
228
|
+
for (const line of readFileSync(npmrcPath, "utf8").split("\n")) {
|
|
229
|
+
const match = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line);
|
|
230
|
+
if (match !== null) return match[1];
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
/* unreadable .npmrc — fall through to pnpm */
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `pnpm config get registry`, or undefined if pnpm is missing, slow, or unset. */
|
|
239
|
+
function registryFromPnpm() {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
let proc;
|
|
242
|
+
try {
|
|
243
|
+
proc = spawn("pnpm", ["config", "get", "registry"], {
|
|
244
|
+
env: process.env,
|
|
245
|
+
shell: process.platform === "win32",
|
|
246
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
247
|
+
windowsHide: true,
|
|
248
|
+
});
|
|
249
|
+
} catch {
|
|
250
|
+
resolve(undefined);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
let out = "";
|
|
254
|
+
// 一次安装不该被一个探测子进程拖住:5s 没结果就当没有,走兜底。
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
proc.kill();
|
|
257
|
+
resolve(undefined);
|
|
258
|
+
}, 5000);
|
|
259
|
+
proc.stdout?.on("data", (data) => { out += data.toString(); });
|
|
260
|
+
proc.on("error", () => { clearTimeout(timer); resolve(undefined); });
|
|
261
|
+
proc.on("close", (code) => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
const value = out.trim();
|
|
264
|
+
// pnpm prints "undefined" for an unset key — only take a real URL.
|
|
265
|
+
resolve(code === 0 && /^https?:\/\//i.test(value) ? value : undefined);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The registry pnpm installs from for this profile.
|
|
272
|
+
* @param profile - profile name.
|
|
273
|
+
* @returns a promise for the registry base URL, without a trailing slash.
|
|
274
|
+
*/
|
|
275
|
+
export function resolveRegistry(profile) {
|
|
276
|
+
const key = String(profile ?? "");
|
|
277
|
+
const cached = registryCache.get(key);
|
|
278
|
+
if (cached !== undefined) return cached;
|
|
279
|
+
const pending = (async () => {
|
|
280
|
+
let dir;
|
|
281
|
+
try {
|
|
282
|
+
dir = resolveProfileDir(key);
|
|
283
|
+
} catch {
|
|
284
|
+
dir = undefined; // invalid profile name — the caller reports it, we just fall back
|
|
285
|
+
}
|
|
286
|
+
const fromNpmrc = dir === undefined ? undefined : registryFromNpmrc(dir);
|
|
287
|
+
if (fromNpmrc !== undefined) return fromNpmrc.replace(/\/+$/, "");
|
|
288
|
+
const fromPnpm = await registryFromPnpm();
|
|
289
|
+
return (fromPnpm ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
290
|
+
})();
|
|
291
|
+
registryCache.set(key, pending);
|
|
292
|
+
return pending;
|
|
293
|
+
}
|
|
294
|
+
|
|
147
295
|
// ── client-plugin row registration ──────────────────────────────────────────
|
|
148
296
|
|
|
149
297
|
/** Profile patch file name (the user's own layer, applied after bundle layers). */
|
|
@@ -178,12 +326,12 @@ export function ensureClientRow(profileDir, packageName) {
|
|
|
178
326
|
if (alreadyByName) return { added: false };
|
|
179
327
|
const rowId = clientRowId(packageName);
|
|
180
328
|
const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
|
|
181
|
-
|
|
329
|
+
const next = (Array.isArray(parsed) && parsed.length === 0)
|
|
182
330
|
// The stock template is a comment plus `[]`; replace it wholesale.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
331
|
+
? block
|
|
332
|
+
: content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
|
|
333
|
+
// 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
|
|
334
|
+
writePatchChecked(patchPath, next);
|
|
187
335
|
return { added: true, rowId };
|
|
188
336
|
}
|
|
189
337
|
|
|
@@ -211,7 +359,7 @@ export function removeClientRow(profileDir, packageName) {
|
|
|
211
359
|
}
|
|
212
360
|
if (!removed) return { removed: false };
|
|
213
361
|
const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
214
|
-
|
|
362
|
+
writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
|
|
215
363
|
return { removed: true, rowId };
|
|
216
364
|
}
|
|
217
365
|
|
|
@@ -243,40 +391,95 @@ function parseIgnoredBuilds(output) {
|
|
|
243
391
|
return [...names];
|
|
244
392
|
}
|
|
245
393
|
|
|
246
|
-
/**
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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`);
|
|
254
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;
|
|
255
451
|
const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
|
|
256
452
|
if (keyIndex === -1) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// `@` indicator and fails to parse.
|
|
260
|
-
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`;
|
|
261
455
|
} else {
|
|
262
|
-
|
|
456
|
+
// 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
|
|
263
457
|
let insertIndex = keyIndex + 1;
|
|
264
458
|
for (let index = keyIndex + 1; index < lines.length; index++) {
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
insertIndex = index + 1;
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (/^\S/.test(lines[index])) break;
|
|
272
|
-
}
|
|
273
|
-
const additions = valid.filter((name) => !existing.has(name));
|
|
274
|
-
if (additions.length > 0) {
|
|
275
|
-
lines.splice(insertIndex, 0, ...additions.map((name) => ` - '${name}'`));
|
|
276
|
-
content = lines.join("\n");
|
|
459
|
+
if (lines[index].trim().length === 0) continue;
|
|
460
|
+
if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
|
|
461
|
+
break;
|
|
277
462
|
}
|
|
463
|
+
if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
|
|
464
|
+
next = lines.join("\n");
|
|
278
465
|
}
|
|
279
|
-
|
|
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");
|
|
280
483
|
}
|
|
281
484
|
|
|
282
485
|
// ── in-process install tracker (browser RPC surface) ────────────────────────
|
|
@@ -370,8 +573,16 @@ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
|
|
|
370
573
|
|
|
371
574
|
/** Reject install/remove specs carrying shell metacharacters. */
|
|
372
575
|
export function assertSafeSpec(spec) {
|
|
373
|
-
|
|
374
|
-
|
|
576
|
+
const value = String(spec ?? "");
|
|
577
|
+
if (UNSAFE_SPEC_RE.test(value)) {
|
|
578
|
+
throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
|
|
579
|
+
}
|
|
580
|
+
// Windows 下 pnpm 走 shell,而 Node 只是把参数用空格 join 后交给 cmd,
|
|
581
|
+
// 不逐参加引号——带空格的本地路径会被拆成两个参数,pnpm 报一个和路径
|
|
582
|
+
// 毫无关系的错。用户也没法自己加引号绕过:`"` 就在上面的黑名单里。
|
|
583
|
+
// 与其让它以看不懂的方式失败,不如在这里说清楚。
|
|
584
|
+
if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
|
|
585
|
+
throw new Error(`local path specs cannot contain spaces on Windows — pnpm is spawned through cmd, which would split the path into two arguments: ${JSON.stringify(value)}`);
|
|
375
586
|
}
|
|
376
587
|
}
|
|
377
588
|
|
|
@@ -498,18 +709,39 @@ export function runInstall({ profile, spec }) {
|
|
|
498
709
|
if (ignored.length === 0) {
|
|
499
710
|
return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
500
711
|
}
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
};
|
|
503
733
|
const retry = spawnAdd();
|
|
504
734
|
current = retry.proc;
|
|
505
735
|
const retryOutcome = await retry.done;
|
|
506
736
|
if (retryOutcome.spawnError !== undefined) {
|
|
737
|
+
revert();
|
|
507
738
|
return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
|
|
508
739
|
}
|
|
509
740
|
if (retryOutcome.exitCode === 0) {
|
|
510
741
|
return finalizeSuccess();
|
|
511
742
|
}
|
|
512
|
-
|
|
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.` };
|
|
513
745
|
};
|
|
514
746
|
|
|
515
747
|
const first = spawnAdd();
|
|
@@ -617,3 +849,106 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
|
|
|
617
849
|
},
|
|
618
850
|
};
|
|
619
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
|
+
}
|