@1e0zj/dsh-plugin-mall 0.1.16 → 0.1.18

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/src/installer.js CHANGED
@@ -1,854 +1,915 @@
1
- // Installation backend: run `pnpm add <spec>` inside a profile directory,
2
- // reconcile the profile's `dsh.profile.bundles` layer list, and auto-allow
3
- // blocked build scripts (git-hosted plugins) exactly once.
4
- //
5
- // This mirrors what the official `dsh plugin --profile <name> add <spec>`
6
- // command does (see @deepseek-ai/dsh/lib/plugin-*.js), reusing the public
7
- // @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
8
-
9
- import { spawn } from "node:child_process";
10
- import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { createRequire } from "node:module";
13
- import { load } from "js-yaml";
14
- import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
15
-
16
- // ── spec normalization ──────────────────────────────────────────────────────
17
-
18
- /**
19
- * Normalize a user-supplied install spec into a pnpm-ready argument.
20
- * "owner/repo" and GitHub URLs become `github:owner/repo`; scoped npm names,
21
- * schemes, and absolute file/link paths pass through untouched.
22
- */
23
- export function normalizeSpec(raw) {
24
- const spec = String(raw ?? "").trim();
25
- if (spec.length === 0) throw new Error("empty install spec: pass \"owner/repo\", a GitHub URL, an npm package name, or a tarball URL");
26
- if (spec.startsWith("@")) return spec; // scoped npm package like @scope/name[@ver]
27
- const githubUrl = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?(?:\/.*)?$/i.exec(spec);
28
- if (githubUrl) return `github:${githubUrl[1]}`;
29
- if (/^(?:github:|git\+|git:|ssh:|npm:|file:|link:|https?:\/\/|\.{1,2}(?:[/\\]|$))/i.test(spec)) return spec;
30
- if (/^[^@/\s]+\/[^@/\s]+$/.test(spec)) return `github:${spec}`;
31
- return spec; // bare npm package name
32
- }
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
-
97
- // ── profile management ──────────────────────────────────────────────────────
98
-
99
- /** Resolve and initialize (on first use) the target profile directory. */
100
- export function ensureProfile(profile) {
101
- const dir = resolveProfileDir(profile); // throws on invalid names
102
- if (!existsSync(join(dir, "package.json"))) {
103
- initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES);
104
- }
105
- return dir;
106
- }
107
-
108
- /** Read the profile's package.json manifest. */
109
- function readManifest(profileDir) {
110
- return JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
111
- }
112
-
113
- /** Locate an installed dependency's package.json under the profile tree. */
114
- function packageJsonPathOf(packageName, profileDir) {
115
- const direct = join(profileDir, "node_modules", ...packageName.split("/"), "package.json");
116
- if (existsSync(direct)) return direct;
117
- try {
118
- return createRequire(join(profileDir, "noop.js")).resolve(`${packageName}/package.json`);
119
- } catch {
120
- return undefined;
121
- }
122
- }
123
-
124
- /** Whether an installed dependency declares `dsh.bundle.patch` (is a plugin bundle). */
125
- function isBundlePackage(packageName, profileDir) {
126
- return classifyPackage(packageName, profileDir) === "bundle";
127
- }
128
-
129
- /**
130
- * Classify an installed package by its `dsh` declaration:
131
- * `bundle` (a profile patch layer), `client` (a browser-side UI plugin),
132
- * `plain` (a plain dependency), or `missing` (not resolvable).
133
- */
134
- export function classifyPackage(packageName, profileDir) {
135
- const path = packageJsonPathOf(packageName, profileDir);
136
- if (path === undefined) return "missing";
137
- try {
138
- const manifest = JSON.parse(readFileSync(path, "utf8"));
139
- if (typeof manifest.dsh?.bundle?.patch === "string") return "bundle";
140
- if (manifest.dsh?.client !== undefined) return "client";
141
- return "plain";
142
- } catch {
143
- return "missing";
144
- }
145
- }
146
-
147
- /**
148
- * Reconcile `dsh.profile.bundles` against installed dependencies: a
149
- * dependency that declares a dsh bundle joins the layer stack (in dependency
150
- * order); an entry that was a dependency but no longer is one — removed, or
151
- * the installed version dropped its bundle declaration — leaves it. In-box
152
- * template bundles are not dependencies and are NEVER touched (mirroring the
153
- * official `dsh plugin` reconcile).
154
- * @param profileDir - the profile directory.
155
- * @param beforeDeps - dependency keys as they were before `pnpm add` ran.
156
- */
157
- export function reconcileBundles(profileDir, beforeDeps = new Set()) {
158
- const manifestPath = join(profileDir, "package.json");
159
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
160
- const dependencies = new Set(Object.keys(manifest.dependencies ?? {}));
161
- const bundles = [...(manifest.dsh?.profile?.bundles ?? [])];
162
- const result = [];
163
- for (const bundleName of bundles) {
164
- const wasDependency = beforeDeps.has(bundleName) || dependencies.has(bundleName);
165
- const stillBundle = dependencies.has(bundleName) && isBundlePackage(bundleName, profileDir);
166
- // Keep template bundles and dependency-bundles; drop only entries that
167
- // were dependencies and stopped being bundles.
168
- if (!wasDependency || stillBundle) result.push(bundleName);
169
- }
170
- for (const dependencyName of dependencies) {
171
- if (!result.includes(dependencyName) && isBundlePackage(dependencyName, profileDir)) result.push(dependencyName);
172
- }
173
- if (JSON.stringify(result) !== JSON.stringify(bundles)) {
174
- manifest.dsh = {
175
- ...manifest.dsh,
176
- profile: {
177
- ...manifest.dsh?.profile,
178
- bundles: result,
179
- },
180
- };
181
- writeJsonChecked(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n", "package.json");
182
- }
183
- return result;
184
- }
185
-
186
- /** List a profile's installed plugins (dependencies with classification + version). */
187
- export function listInstalled(profile) {
188
- const dir = resolveProfileDir(profile);
189
- const manifestPath = join(dir, "package.json");
190
- if (!existsSync(manifestPath)) return { dir, deps: [] };
191
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
192
- const deps = Object.keys(manifest.dependencies ?? {}).map((name) => {
193
- const path = packageJsonPathOf(name, dir);
194
- let version = "?";
195
- let kind = "missing";
196
- if (path !== undefined) {
197
- try {
198
- const installed = JSON.parse(readFileSync(path, "utf8"));
199
- version = installed.version ?? "?";
200
- kind = classifyPackage(name, dir);
201
- } catch {
202
- /* keep defaults */
203
- }
204
- }
205
- return { name, version, kind };
206
- });
207
- return { dir, deps };
208
- }
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
-
295
- // ── client-plugin row registration ──────────────────────────────────────────
296
-
297
- /** Profile patch file name (the user's own layer, applied after bundle layers). */
298
- const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
299
-
300
- /** Derive a friendly row id from a package name: "@s/dsh-client-ui-aqua" -> "ui-aqua". */
301
- function clientRowId(packageName) {
302
- const last = packageName.split("/").pop() ?? packageName;
303
- const trimmed = last.replace(/^dsh-/, "").replace(/^client-ui-/, "").replace(/^client-/, "");
304
- return trimmed.length > 0 ? trimmed : last;
305
- }
306
-
307
- /**
308
- * Idempotently register a `dsh.client` package as a loader row in the
309
- * profile's cordis.patch.yml (client packages are discovered from the loader
310
- * entry tree, so the dependency alone does not activate them).
311
- * A row whose `name` already exists (any id) is left untouched.
312
- * @returns {{ added: boolean, rowId?: string }}
313
- */
314
- export function ensureClientRow(profileDir, packageName) {
315
- const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
316
- const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
317
- let parsed = null;
318
- try {
319
- parsed = load(content);
320
- } catch {
321
- /* a text-level name check still runs below */
322
- }
323
- const alreadyByName = (Array.isArray(parsed) && parsed.some((entry) =>
324
- Array.isArray(entry?.insert) && entry.insert.some((row) => row?.name === packageName)
325
- )) || new RegExp(`name:\\s*["']${packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content);
326
- if (alreadyByName) return { added: false };
327
- const rowId = clientRowId(packageName);
328
- const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
329
- const next = (Array.isArray(parsed) && parsed.length === 0)
330
- // The stock template is a comment plus `[]`; replace it wholesale.
331
- ? block
332
- : content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
333
- // 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
334
- writePatchChecked(patchPath, next);
335
- return { added: true, rowId };
336
- }
337
-
338
- /**
339
- * Remove the cordis.patch.yml loader row `ensureClientRow` registered for a
340
- * package. Textual and idempotent: only the exact block the register emitted
341
- * is spliced out, so user-authored rows survive byte-for-byte. An emptied
342
- * file falls back to the stock `[]` template.
343
- * @returns {{ removed: boolean, rowId?: string }}
344
- */
345
- export function removeClientRow(profileDir, packageName) {
346
- const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
347
- if (!existsSync(patchPath)) return { removed: false };
348
- const content = readFileSync(patchPath, "utf8");
349
- const rowId = clientRowId(packageName);
350
- const lines = content.split("\n");
351
- let removed = false;
352
- for (let index = 0; index < lines.length - 2; index++) {
353
- if (lines[index] !== "- insert:") continue;
354
- if (lines[index + 1] !== ` - id: ${rowId}`) continue;
355
- if (lines[index + 2] !== ` name: '${packageName}'`) continue;
356
- lines.splice(index, 3);
357
- removed = true;
358
- break;
359
- }
360
- if (!removed) return { removed: false };
361
- const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
362
- writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
363
- return { removed: true, rowId };
364
- }
365
-
366
- // ── build-script allow-listing ──────────────────────────────────────────────
367
-
368
- /** Extract package names from pnpm's "Ignored build scripts: ..." output. */
369
- /** Valid npm package name (scoped or bare) — anything else is not a name. */
370
- const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
371
-
372
- function parseIgnoredBuilds(output) {
373
- const names = new Set();
374
- // Only pnpm's own notice line is a parsing source. "allowBuilds" also
375
- // appears in pnpm's advice/error text (never followed by a name list), and
376
- // matching it fed error echoes into the allow-list, corrupting the YAML.
377
- const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
378
- let match;
379
- while ((match = pattern.exec(output)) !== null) {
380
- for (const raw of match[1].split(",")) {
381
- const candidate = raw.trim();
382
- if (candidate.length === 0) continue;
383
- // Strip a trailing @version or @tarball-url so `foo@1.2.3` -> `foo`,
384
- // `@s/n@1.0.0` -> `@s/n`, `@s/n@https://…` -> `@s/n`. Whatever remains
385
- // must be a valid npm name; pnpm error echoes ("9 | - pkg", advice
386
- // sentences) are dropped instead of being written to the YAML.
387
- const name = candidate.replace(/@(?:[\w.+-]+|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/, "");
388
- if (NPM_NAME_RE.test(name)) names.add(name);
389
- }
390
- }
391
- return [...names];
392
- }
393
-
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`);
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;
451
- const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
452
- if (keyIndex === -1) {
453
- const base = lines.join("\n");
454
- next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
455
- } else {
456
- // 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
457
- let insertIndex = keyIndex + 1;
458
- for (let index = keyIndex + 1; index < lines.length; index++) {
459
- if (lines[index].trim().length === 0) continue;
460
- if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
461
- break;
462
- }
463
- if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
464
- next = lines.join("\n");
465
- }
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");
483
- }
484
-
485
- // ── in-process install tracker (browser RPC surface) ────────────────────────
486
- //
487
- // The web host plane has no job controller (dsh-tool-jobs mounts per agent
488
- // session), so ctx.jobs refuses background installs started outside an agent
489
- // turn. The /market RPC channel therefore tracks its own installs in-process:
490
- // same producer shape as the jobs registry ({cancel, done, readOutput}), just
491
- // an independent registry.
492
-
493
- let trackerCounter = 0;
494
-
495
- /**
496
- * Create a tracker for browser-started install/uninstall jobs (see runInstall
497
- * and runRemove). Same producer shape as the jobs registry ({cancel, done,
498
- * readOutput}), just an independent registry.
499
- */
500
- export function createJobTracker() {
501
- const records = new Map();
502
- const prune = () => {
503
- const now = Date.now();
504
- for (const [id, record] of records) {
505
- const terminal = record.status !== "running";
506
- if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
507
- }
508
- if (records.size > 20) {
509
- const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
510
- for (const [id, record] of ordered) {
511
- if (records.size <= 20) break;
512
- if (record.status !== "running") records.delete(id);
513
- }
514
- }
515
- };
516
- return {
517
- start({ profile, spec, verb = "add" }) {
518
- const id = `market-${++trackerCounter}`;
519
- const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
520
- const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec });
521
- const record = {
522
- id,
523
- kind,
524
- label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
525
- profile,
526
- spec,
527
- status: "running",
528
- detail: undefined,
529
- startedAt: Date.now(),
530
- finishedAt: undefined,
531
- producer,
532
- };
533
- producer.done.then((outcome) => {
534
- record.status = outcome.status ?? "failed";
535
- record.detail = outcome.detail;
536
- record.finishedAt = Date.now();
537
- });
538
- records.set(id, record);
539
- prune();
540
- return id;
541
- },
542
- get(jobId) {
543
- const record = records.get(String(jobId));
544
- if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
545
- return {
546
- snapshot: {
547
- id: record.id,
548
- kind: record.kind,
549
- label: record.label,
550
- status: record.status,
551
- detail: record.detail,
552
- startedAt: record.startedAt,
553
- finishedAt: record.finishedAt,
554
- },
555
- output: record.producer.readOutput(),
556
- };
557
- },
558
- cancel(jobId) {
559
- const record = records.get(String(jobId));
560
- if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
561
- record.producer.cancel();
562
- return "requested";
563
- },
564
- };
565
- }
566
-
567
- // ── spec shape guard ────────────────────────────────────────────────────────
568
-
569
- // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
570
- // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
571
- // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
572
- const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
573
-
574
- /** Reject install/remove specs carrying shell metacharacters. */
575
- export function assertSafeSpec(spec) {
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)}`);
586
- }
587
- }
588
-
589
- // ── pnpm self-heal (corepack) ───────────────────────────────────────────────
590
-
591
- /**
592
- * Try to provision pnpm once via `corepack enable pnpm` (corepack ships with
593
- * Node). Output lands in the caller's job log; returns whether a retry of the
594
- * pnpm spawn is worth attempting.
595
- */
596
- async function enablePnpmViaCorepack(push) {
597
- push("\n[dsh-plugin-mall] pnpm not found on PATH — trying `corepack enable pnpm` once\n");
598
- return await new Promise((resolve) => {
599
- let proc;
600
- try {
601
- proc = spawn("corepack", ["enable", "pnpm"], {
602
- env: process.env,
603
- shell: process.platform === "win32",
604
- stdio: ["ignore", "pipe", "pipe"],
605
- windowsHide: true,
606
- });
607
- } catch {
608
- resolve(false);
609
- return;
610
- }
611
- proc.on("error", () => resolve(false));
612
- proc.stdout?.on("data", (data) => push(data.toString()));
613
- proc.stderr?.on("data", (data) => push(data.toString()));
614
- proc.on("close", (code) => resolve(code === 0));
615
- });
616
- }
617
-
618
- // ── the background install job ──────────────────────────────────────────────
619
-
620
- /**
621
- * Run `pnpm add <spec>` in the profile directory as a job producer with the
622
- * shape `ctx.jobs.start` expects: `{ cancel, done: Promise<outcome>, readOutput: () => string }`.
623
- * A failure whose output lists ignored build scripts gets one automatic
624
- * retry after merging those names into `allowBuilds`.
625
- */
626
- export function runInstall({ profile, spec }) {
627
- const profileDir = ensureProfile(profile);
628
- // Dependency keys BEFORE pnpm add, so reconcile only manages entries that
629
- // were (or became) dependencies and never touches template bundles.
630
- const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
631
- const collected = [];
632
- const deltaQueue = [];
633
- const push = (text) => {
634
- collected.push(text);
635
- deltaQueue.push(text);
636
- };
637
- let current = undefined;
638
- let pnpmSelfHealed = false;
639
-
640
- const spawnAdd = () => {
641
- const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
642
- cwd: profileDir,
643
- env: process.env,
644
- shell: process.platform === "win32",
645
- stdio: ["ignore", "pipe", "pipe"],
646
- windowsHide: true,
647
- });
648
- const done = new Promise((resolve) => {
649
- proc.on("error", (error) => resolve({ spawnError: error }));
650
- proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
651
- });
652
- proc.stdout?.on("data", (data) => push(data.toString()));
653
- proc.stderr?.on("data", (data) => push(data.toString()));
654
- return { proc, done };
655
- };
656
-
657
- /** Post-success accounting: reconcile bundles, register client rows, summarize. */
658
- const finalizeSuccess = () => {
659
- const bundles = reconcileBundles(profileDir, beforeDeps);
660
- const manifest = readManifest(profileDir);
661
- const currentDeps = new Set(Object.keys(manifest.dependencies ?? {}));
662
- const clientRows = [];
663
- const plainDeps = [];
664
- for (const depName of currentDeps) {
665
- if (beforeDeps.has(depName)) continue;
666
- const kind = classifyPackage(depName, profileDir);
667
- if (kind === "client") {
668
- const row = ensureClientRow(profileDir, depName);
669
- if (row.added) clientRows.push(row.rowId);
670
- } else if (kind !== "bundle") {
671
- plainDeps.push(depName);
672
- }
673
- }
674
- const notes = [];
675
- const activeBundles = bundles.filter((name) => currentDeps.has(name));
676
- if (activeBundles.length > 0) notes.push(`bundle layer(s): ${activeBundles.join(", ")}`);
677
- if (clientRows.length > 0) notes.push(`registered client row(s) in cordis.patch.yml: ${clientRows.join(", ")}`);
678
- if (plainDeps.length > 0) notes.push(`plain dependency (no dsh.bundle/dsh.client): ${plainDeps.join(", ")}`);
679
- const noteText = notes.length > 0 ? ` — ${notes.join("; ")}` : " — installed as a plain dependency (declares no dsh.bundle)";
680
- return { status: "completed", detail: `installed ${spec} into profile "${profile}"${noteText}. Restart dsh for plugin code to load.` };
681
- };
682
-
683
- const settle = async (outcome) => {
684
- if (outcome.spawnError !== undefined) {
685
- // pnpm 缺失时先尝试 corepack 自愈一次,成功则重跑安装。
686
- if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
687
- pnpmSelfHealed = true;
688
- const healed = await enablePnpmViaCorepack(push);
689
- if (healed) {
690
- const retry = spawnAdd();
691
- current = retry.proc;
692
- return settle(await retry.done);
693
- }
694
- return { status: "failed", detail: "pnpm not found on PATH and `corepack enable pnpm` could not provision it — install pnpm (e.g. `npm i -g pnpm`) to manage profile plugins" };
695
- }
696
- const hint = outcome.spawnError.code === "ENOENT"
697
- ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
698
- : `could not start pnpm: ${outcome.spawnError.message}`;
699
- return { status: "failed", detail: hint };
700
- }
701
- if (outcome.exitCode === null) {
702
- return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
703
- }
704
- if (outcome.exitCode === 0) {
705
- return finalizeSuccess();
706
- }
707
- const log = collected.join("");
708
- const ignored = parseIgnoredBuilds(log);
709
- if (ignored.length === 0) {
710
- return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
711
- }
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
- };
733
- const retry = spawnAdd();
734
- current = retry.proc;
735
- const retryOutcome = await retry.done;
736
- if (retryOutcome.spawnError !== undefined) {
737
- revert();
738
- return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
739
- }
740
- if (retryOutcome.exitCode === 0) {
741
- return finalizeSuccess();
742
- }
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.` };
745
- };
746
-
747
- const first = spawnAdd();
748
- current = first.proc;
749
- const done = first.done.then((outcome) => settle(outcome));
750
-
751
- return {
752
- cancel: () => {
753
- current?.kill();
754
- },
755
- done,
756
- readOutput: () => {
757
- if (deltaQueue.length === 0) return "";
758
- return deltaQueue.splice(0).join("");
759
- },
760
- };
761
- }
762
-
763
- // ── the background uninstall job ────────────────────────────────────────────
764
-
765
- /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
766
- function failedNow(detail) {
767
- return {
768
- cancel: () => {},
769
- done: Promise.resolve({ status: "failed", detail }),
770
- readOutput: () => "",
771
- };
772
- }
773
-
774
- /**
775
- * Run `pnpm remove <package>` in the profile directory as a job producer with
776
- * the same shape as `runInstall`. On success it reconciles
777
- * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
778
- * deletes the client loader row `ensureClientRow` had registered for it.
779
- */
780
- export function runRemove({ profile, packageName }, selfHealed = false) {
781
- let profileDir;
782
- try {
783
- profileDir = resolveProfileDir(profile);
784
- } catch (error) {
785
- return failedNow(`invalid profile: ${error.message}`);
786
- }
787
- const manifestPath = join(profileDir, "package.json");
788
- if (!existsSync(manifestPath)) {
789
- return failedNow(`profile "${profile}" has no package.json — nothing installed to remove`);
790
- }
791
- const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
792
- if (!beforeDeps.has(packageName)) {
793
- return failedNow(`"${packageName}" is not a dependency of profile "${profile}" (installed: ${[...beforeDeps].join(", ") || "none"})`);
794
- }
795
- const deltaQueue = [];
796
- const push = (text) => {
797
- deltaQueue.push(text);
798
- };
799
- let current = undefined;
800
-
801
- const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
802
- cwd: profileDir,
803
- env: process.env,
804
- shell: process.platform === "win32",
805
- stdio: ["ignore", "pipe", "pipe"],
806
- windowsHide: true,
807
- });
808
- current = proc;
809
- const done = new Promise((resolve) => {
810
- proc.on("error", (error) => resolve({ spawnError: error }));
811
- proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
812
- }).then(async (outcome) => {
813
- if (outcome.spawnError !== undefined) {
814
- // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
815
- // 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
816
- // 把成功任务记成 failed)。
817
- if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
818
- const healed = await enablePnpmViaCorepack(push);
819
- if (healed) return await runRemove({ profile, packageName }, true).done;
820
- }
821
- const hint = outcome.spawnError.code === "ENOENT"
822
- ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
823
- : `could not start pnpm: ${outcome.spawnError.message}`;
824
- return { status: "failed", detail: hint };
825
- }
826
- if (outcome.exitCode === null) {
827
- return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
828
- }
829
- if (outcome.exitCode !== 0) {
830
- return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
831
- }
832
- const bundles = reconcileBundles(profileDir, beforeDeps);
833
- const clientRow = removeClientRow(profileDir, packageName);
834
- const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
835
- if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
836
- return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
837
- });
838
- proc.stdout?.on("data", (data) => push(data.toString()));
839
- proc.stderr?.on("data", (data) => push(data.toString()));
840
-
841
- return {
842
- cancel: () => {
843
- current?.kill();
844
- },
845
- done,
846
- readOutput: () => {
847
- if (deltaQueue.length === 0) return "";
848
- return deltaQueue.splice(0).join("");
849
- },
850
- };
851
- }
1
+ // Installation backend: run `pnpm add <spec>` inside a profile directory,
2
+ // reconcile the profile's `dsh.profile.bundles` layer list, and auto-allow
3
+ // blocked build scripts (git-hosted plugins) exactly once.
4
+ //
5
+ // This mirrors what the official `dsh plugin --profile <name> add <spec>`
6
+ // command does (see @deepseek-ai/dsh/lib/plugin-*.js), reusing the public
7
+ // @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { createRequire } from "node:module";
13
+ import { load } from "js-yaml";
14
+ import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
15
+ import { describeBuildScripts, npmNameOf } from "./github.js";
16
+
17
+ // ── spec normalization ──────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Normalize a user-supplied install spec into a pnpm-ready argument.
21
+ * "owner/repo" and GitHub URLs become `github:owner/repo`; scoped npm names,
22
+ * schemes, and absolute file/link paths pass through untouched.
23
+ */
24
+ export function normalizeSpec(raw) {
25
+ const spec = String(raw ?? "").trim();
26
+ if (spec.length === 0) throw new Error("empty install spec: pass \"owner/repo\", a GitHub URL, an npm package name, or a tarball URL");
27
+ if (spec.startsWith("@")) return spec; // scoped npm package like @scope/name[@ver]
28
+ const githubUrl = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?(?:\/.*)?$/i.exec(spec);
29
+ if (githubUrl) return `github:${githubUrl[1]}`;
30
+ if (/^(?:github:|git\+|git:|ssh:|npm:|file:|link:|https?:\/\/|\.{1,2}(?:[/\\]|$))/i.test(spec)) return spec;
31
+ if (/^[^@/\s]+\/[^@/\s]+$/.test(spec)) return `github:${spec}`;
32
+ return spec; // bare npm package name
33
+ }
34
+
35
+ // ── guarded config writes ───────────────────────────────────────────────────
36
+ //
37
+ // Installing someone else's plugin must never be able to leave a profile that
38
+ // dsh or pnpm refuses to load. Every write to a shared profile config goes
39
+ // through here: the new bytes are parsed back, and anything that does not
40
+ // parse is rolled back to the previous bytes before the error propagates. A
41
+ // bug in our own editing then costs a failed install, not a profile the user
42
+ // has to repair by hand.
43
+ //
44
+ // This is not hypothetical. Editing `allowBuilds` as a YAML sequence while
45
+ // pnpm had already written its own mapping stub produced a file no parser
46
+ // accepted, and every later pnpm operation in that profile — install,
47
+ // uninstall, update, any plugin at all — failed until the file was fixed
48
+ // manually.
49
+
50
+ /**
51
+ * Write a config file only if the result still parses.
52
+ * @param filePath - the file to write.
53
+ * @param nextContent - the full new contents.
54
+ * @param parse - throws when the content is not valid.
55
+ * @param label - file name used in error messages.
56
+ * @returns a rollback function restoring the pre-write bytes.
57
+ */
58
+ function writeChecked(filePath, nextContent, parse, label) {
59
+ const previous = existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
60
+ const rollback = () => {
61
+ if (previous === undefined) rmSync(filePath, { force: true });
62
+ else writeFileSync(filePath, previous);
63
+ };
64
+ writeFileSync(filePath, nextContent);
65
+ try {
66
+ parse(nextContent);
67
+ } catch (error) {
68
+ rollback();
69
+ 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`);
70
+ }
71
+ return rollback;
72
+ }
73
+
74
+ /** writeChecked for YAML profile configs. */
75
+ function writeYamlChecked(filePath, nextContent, label) {
76
+ return writeChecked(filePath, nextContent, (text) => load(text), label);
77
+ }
78
+
79
+ /** writeChecked for JSON profile configs. */
80
+ function writeJsonChecked(filePath, nextContent, label) {
81
+ return writeChecked(filePath, nextContent, (text) => JSON.parse(text), label);
82
+ }
83
+
84
+ /**
85
+ * writeChecked for cordis.patch.yml — the loader patch layer. Beyond parsing,
86
+ * the contract is a top-level array; anything else and dsh fails to boot, so
87
+ * that is checked here too rather than discovered at the next start.
88
+ */
89
+ function writePatchChecked(filePath, nextContent) {
90
+ return writeChecked(filePath, nextContent, (text) => {
91
+ const doc = load(text);
92
+ if (doc !== null && doc !== undefined && !Array.isArray(doc)) {
93
+ throw new Error("expected a top-level array of patch entries");
94
+ }
95
+ }, "cordis.patch.yml");
96
+ }
97
+
98
+ // ── profile management ──────────────────────────────────────────────────────
99
+
100
+ /** Resolve and initialize (on first use) the target profile directory. */
101
+ export function ensureProfile(profile) {
102
+ const dir = resolveProfileDir(profile); // throws on invalid names
103
+ if (!existsSync(join(dir, "package.json"))) {
104
+ initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES);
105
+ }
106
+ return dir;
107
+ }
108
+
109
+ /** Read the profile's package.json manifest. */
110
+ function readManifest(profileDir) {
111
+ return JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
112
+ }
113
+
114
+ /** Locate an installed dependency's package.json under the profile tree. */
115
+ function packageJsonPathOf(packageName, profileDir) {
116
+ const direct = join(profileDir, "node_modules", ...packageName.split("/"), "package.json");
117
+ if (existsSync(direct)) return direct;
118
+ try {
119
+ return createRequire(join(profileDir, "noop.js")).resolve(`${packageName}/package.json`);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /** Whether an installed dependency declares `dsh.bundle.patch` (is a plugin bundle). */
126
+ function isBundlePackage(packageName, profileDir) {
127
+ return classifyPackage(packageName, profileDir) === "bundle";
128
+ }
129
+
130
+ /**
131
+ * Classify an installed package by its `dsh` declaration:
132
+ * `bundle` (a profile patch layer), `client` (a browser-side UI plugin),
133
+ * `plain` (a plain dependency), or `missing` (not resolvable).
134
+ */
135
+ export function classifyPackage(packageName, profileDir) {
136
+ const path = packageJsonPathOf(packageName, profileDir);
137
+ if (path === undefined) return "missing";
138
+ try {
139
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
140
+ if (typeof manifest.dsh?.bundle?.patch === "string") return "bundle";
141
+ if (manifest.dsh?.client !== undefined) return "client";
142
+ return "plain";
143
+ } catch {
144
+ return "missing";
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Reconcile `dsh.profile.bundles` against installed dependencies: a
150
+ * dependency that declares a dsh bundle joins the layer stack (in dependency
151
+ * order); an entry that was a dependency but no longer is one — removed, or
152
+ * the installed version dropped its bundle declaration — leaves it. In-box
153
+ * template bundles are not dependencies and are NEVER touched (mirroring the
154
+ * official `dsh plugin` reconcile).
155
+ * @param profileDir - the profile directory.
156
+ * @param beforeDeps - dependency keys as they were before `pnpm add` ran.
157
+ */
158
+ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
159
+ const manifestPath = join(profileDir, "package.json");
160
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
161
+ const dependencies = new Set(Object.keys(manifest.dependencies ?? {}));
162
+ const bundles = [...(manifest.dsh?.profile?.bundles ?? [])];
163
+ const result = [];
164
+ for (const bundleName of bundles) {
165
+ const wasDependency = beforeDeps.has(bundleName) || dependencies.has(bundleName);
166
+ const stillBundle = dependencies.has(bundleName) && isBundlePackage(bundleName, profileDir);
167
+ // Keep template bundles and dependency-bundles; drop only entries that
168
+ // were dependencies and stopped being bundles.
169
+ if (!wasDependency || stillBundle) result.push(bundleName);
170
+ }
171
+ for (const dependencyName of dependencies) {
172
+ if (!result.includes(dependencyName) && isBundlePackage(dependencyName, profileDir)) result.push(dependencyName);
173
+ }
174
+ if (JSON.stringify(result) !== JSON.stringify(bundles)) {
175
+ manifest.dsh = {
176
+ ...manifest.dsh,
177
+ profile: {
178
+ ...manifest.dsh?.profile,
179
+ bundles: result,
180
+ },
181
+ };
182
+ writeJsonChecked(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n", "package.json");
183
+ }
184
+ return result;
185
+ }
186
+
187
+ /** List a profile's installed plugins (dependencies with classification + version). */
188
+ export function listInstalled(profile) {
189
+ const dir = resolveProfileDir(profile);
190
+ const manifestPath = join(dir, "package.json");
191
+ if (!existsSync(manifestPath)) return { dir, deps: [] };
192
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
193
+ const deps = Object.keys(manifest.dependencies ?? {}).map((name) => {
194
+ const path = packageJsonPathOf(name, dir);
195
+ let version = "?";
196
+ let kind = "missing";
197
+ if (path !== undefined) {
198
+ try {
199
+ const installed = JSON.parse(readFileSync(path, "utf8"));
200
+ version = installed.version ?? "?";
201
+ kind = classifyPackage(name, dir);
202
+ } catch {
203
+ /* keep defaults */
204
+ }
205
+ }
206
+ return { name, version, kind };
207
+ });
208
+ return { dir, deps };
209
+ }
210
+
211
+ // ── npm registry resolution ─────────────────────────────────────────────────
212
+ //
213
+ // Registry lookups (anti-squatting, update checks, the host-shadow guard) have
214
+ // to hit the same registry pnpm installs from. Hardcoding npmjs while the user
215
+ // is on a mirror breaks all three silently — see the header comment in
216
+ // github.js. Resolution order mirrors pnpm's own: the profile's .npmrc, then
217
+ // `pnpm config get registry` (which folds in the user and global .npmrc
218
+ // chain), then npmjs. Cached per profile for the process lifetime; changing a
219
+ // registry needs a dsh restart anyway, like every other profile setting.
220
+
221
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
222
+ const registryCache = new Map(); // profile -> Promise<string>
223
+
224
+ /** The `registry=` value from a profile-local .npmrc, if it sets one. */
225
+ function registryFromNpmrc(profileDir) {
226
+ const npmrcPath = join(profileDir, ".npmrc");
227
+ if (!existsSync(npmrcPath)) return undefined;
228
+ try {
229
+ for (const line of readFileSync(npmrcPath, "utf8").split("\n")) {
230
+ const match = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line);
231
+ if (match !== null) return match[1];
232
+ }
233
+ } catch {
234
+ /* unreadable .npmrc — fall through to pnpm */
235
+ }
236
+ return undefined;
237
+ }
238
+
239
+ /** `pnpm config get registry`, or undefined if pnpm is missing, slow, or unset. */
240
+ function registryFromPnpm() {
241
+ return new Promise((resolve) => {
242
+ let proc;
243
+ try {
244
+ proc = spawn("pnpm", ["config", "get", "registry"], {
245
+ env: process.env,
246
+ shell: process.platform === "win32",
247
+ stdio: ["ignore", "pipe", "pipe"],
248
+ windowsHide: true,
249
+ });
250
+ } catch {
251
+ resolve(undefined);
252
+ return;
253
+ }
254
+ let out = "";
255
+ // 一次安装不该被一个探测子进程拖住:5s 没结果就当没有,走兜底。
256
+ const timer = setTimeout(() => {
257
+ proc.kill();
258
+ resolve(undefined);
259
+ }, 5000);
260
+ proc.stdout?.on("data", (data) => { out += data.toString(); });
261
+ proc.on("error", () => { clearTimeout(timer); resolve(undefined); });
262
+ proc.on("close", (code) => {
263
+ clearTimeout(timer);
264
+ const value = out.trim();
265
+ // pnpm prints "undefined" for an unset key — only take a real URL.
266
+ resolve(code === 0 && /^https?:\/\//i.test(value) ? value : undefined);
267
+ });
268
+ });
269
+ }
270
+
271
+ /**
272
+ * The registry pnpm installs from for this profile.
273
+ * @param profile - profile name.
274
+ * @returns a promise for the registry base URL, without a trailing slash.
275
+ */
276
+ export function resolveRegistry(profile) {
277
+ const key = String(profile ?? "");
278
+ const cached = registryCache.get(key);
279
+ if (cached !== undefined) return cached;
280
+ const pending = (async () => {
281
+ let dir;
282
+ try {
283
+ dir = resolveProfileDir(key);
284
+ } catch {
285
+ dir = undefined; // invalid profile name — the caller reports it, we just fall back
286
+ }
287
+ const fromNpmrc = dir === undefined ? undefined : registryFromNpmrc(dir);
288
+ if (fromNpmrc !== undefined) return fromNpmrc.replace(/\/+$/, "");
289
+ const fromPnpm = await registryFromPnpm();
290
+ return (fromPnpm ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
291
+ })();
292
+ registryCache.set(key, pending);
293
+ return pending;
294
+ }
295
+
296
+ // ── client-plugin row registration ──────────────────────────────────────────
297
+
298
+ /** Profile patch file name (the user's own layer, applied after bundle layers). */
299
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
300
+
301
+ /** Derive a friendly row id from a package name: "@s/dsh-client-ui-aqua" -> "ui-aqua". */
302
+ function clientRowId(packageName) {
303
+ const last = packageName.split("/").pop() ?? packageName;
304
+ const trimmed = last.replace(/^dsh-/, "").replace(/^client-ui-/, "").replace(/^client-/, "");
305
+ return trimmed.length > 0 ? trimmed : last;
306
+ }
307
+
308
+ /**
309
+ * Idempotently register a `dsh.client` package as a loader row in the
310
+ * profile's cordis.patch.yml (client packages are discovered from the loader
311
+ * entry tree, so the dependency alone does not activate them).
312
+ * A row whose `name` already exists (any id) is left untouched.
313
+ * @returns {{ added: boolean, rowId?: string }}
314
+ */
315
+ export function ensureClientRow(profileDir, packageName) {
316
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
317
+ const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
318
+ let parsed = null;
319
+ try {
320
+ parsed = load(content);
321
+ } catch {
322
+ /* a text-level name check still runs below */
323
+ }
324
+ const alreadyByName = (Array.isArray(parsed) && parsed.some((entry) =>
325
+ Array.isArray(entry?.insert) && entry.insert.some((row) => row?.name === packageName)
326
+ )) || new RegExp(`name:\\s*["']${packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content);
327
+ if (alreadyByName) return { added: false };
328
+ const rowId = clientRowId(packageName);
329
+ const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
330
+ const next = (Array.isArray(parsed) && parsed.length === 0)
331
+ // The stock template is a comment plus `[]`; replace it wholesale.
332
+ ? block
333
+ : content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
334
+ // 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
335
+ writePatchChecked(patchPath, next);
336
+ return { added: true, rowId };
337
+ }
338
+
339
+ /**
340
+ * Remove the cordis.patch.yml loader row `ensureClientRow` registered for a
341
+ * package. Textual and idempotent: only the exact block the register emitted
342
+ * is spliced out, so user-authored rows survive byte-for-byte. An emptied
343
+ * file falls back to the stock `[]` template.
344
+ * @returns {{ removed: boolean, rowId?: string }}
345
+ */
346
+ export function removeClientRow(profileDir, packageName) {
347
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
348
+ if (!existsSync(patchPath)) return { removed: false };
349
+ const content = readFileSync(patchPath, "utf8");
350
+ const rowId = clientRowId(packageName);
351
+ const lines = content.split("\n");
352
+ let removed = false;
353
+ for (let index = 0; index < lines.length - 2; index++) {
354
+ if (lines[index] !== "- insert:") continue;
355
+ if (lines[index + 1] !== ` - id: ${rowId}`) continue;
356
+ if (lines[index + 2] !== ` name: '${packageName}'`) continue;
357
+ lines.splice(index, 3);
358
+ removed = true;
359
+ break;
360
+ }
361
+ if (!removed) return { removed: false };
362
+ const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
363
+ writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
364
+ return { removed: true, rowId };
365
+ }
366
+
367
+ // ── build-script allow-listing ──────────────────────────────────────────────
368
+
369
+ /** Extract package names from pnpm's "Ignored build scripts: ..." output. */
370
+ /** Valid npm package name (scoped or bare) — anything else is not a name. */
371
+ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
372
+
373
+ /**
374
+ * @returns `{name, version}` per blocked package. The version matters: the
375
+ * disclosure has to show the scripts of the version that would actually run,
376
+ * not whatever `latest` happens to be today.
377
+ */
378
+ function parseIgnoredBuilds(output) {
379
+ const found = new Map();
380
+ // Only pnpm's own notice line is a parsing source. "allowBuilds" also
381
+ // appears in pnpm's advice/error text (never followed by a name list), and
382
+ // matching it fed error echoes into the allow-list, corrupting the YAML.
383
+ const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
384
+ let match;
385
+ while ((match = pattern.exec(output)) !== null) {
386
+ for (const raw of match[1].split(",")) {
387
+ const candidate = raw.trim();
388
+ if (candidate.length === 0) continue;
389
+ // Split a trailing @version or @tarball-url so `foo@1.2.3` -> `foo` +
390
+ // `1.2.3`, `@s/n@1.0.0` -> `@s/n` + `1.0.0`. Whatever remains must be a
391
+ // valid npm name; pnpm error echoes ("9 | - pkg", advice sentences) are
392
+ // dropped instead of being written to the YAML.
393
+ const suffix = /@(?:([\w.+-]+)|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/.exec(candidate);
394
+ const name = suffix === null ? candidate : candidate.slice(0, suffix.index);
395
+ if (NPM_NAME_RE.test(name) && !found.has(name)) found.set(name, { name, version: suffix?.[1] });
396
+ }
397
+ }
398
+ return [...found.values()];
399
+ }
400
+
401
+ /**
402
+ * Merge names into the profile's `pnpm-workspace.yaml` `allowBuilds`.
403
+ *
404
+ * pnpm accepts both shapes — a sequence (`- name`) and a mapping
405
+ * (`name: true`) — but never both under one key, and pnpm writes a mapping
406
+ * stub of its own (`name: set this to true or false`) when it blocks a build.
407
+ * Appending a sequence item to that stub is what produced an unparseable file.
408
+ * So: read the current shape through a real YAML parse, match it, and default
409
+ * to pnpm's own mapping shape when there is nothing to match, which keeps our
410
+ * edits and pnpm's from ever colliding again.
411
+ *
412
+ * A name already present as a mapping entry whose value is NOT `true` (pnpm's
413
+ * undecided stub) is rewritten rather than skipped — treating the stub as
414
+ * "already allowed" would leave the build still blocked on retry.
415
+ *
416
+ * Pure: takes the current file contents, returns the new contents (or
417
+ * undefined when nothing needs changing). The fs half is ensureAllowBuilds.
418
+ * Split out because this text surgery is the part that broke a profile, so it
419
+ * is the part the fixtures at the bottom of this file have to pin.
420
+ *
421
+ * @param content - current pnpm-workspace.yaml contents.
422
+ * @param names - package names to allow.
423
+ * @returns the new contents, or undefined when already satisfied.
424
+ * @throws when `content` does not parse as YAML.
425
+ */
426
+ export function mergeAllowBuilds(content, names) {
427
+ const valid = (Array.isArray(names) ? names : []).filter((name) => NPM_NAME_RE.test(name));
428
+ if (valid.length === 0) return undefined;
429
+ // A file that is already broken is not ours to edit — we could only make it
430
+ // worse, and the user needs to see the real reason.
431
+ let parsed;
432
+ try {
433
+ parsed = load(content);
434
+ } catch (error) {
435
+ throw new Error(`pnpm-workspace.yaml does not parse, refusing to edit it: ${error.message}`);
436
+ }
437
+ const current = parsed?.allowBuilds;
438
+ const asSequence = Array.isArray(current);
439
+ const asMapping = !asSequence && current !== null && typeof current === "object";
440
+ const allowed = new Set(asSequence
441
+ ? current.map((entry) => String(entry))
442
+ : asMapping ? Object.entries(current).filter(([, value]) => value === true).map(([key]) => key) : []);
443
+ // pnpm 的未决占位符(值不是 true)要改写,不能当成已放行跳过。
444
+ const stubs = asMapping ? valid.filter((name) => name in current && current[name] !== true) : [];
445
+ const additions = valid.filter((name) => !allowed.has(name) && !stubs.includes(name));
446
+ if (additions.length === 0 && stubs.length === 0) return undefined;
447
+
448
+ // Quote sequence entries: a bare `@scope/name` opens with YAML's reserved
449
+ // `@` indicator. Mapping keys do not need it.
450
+ const render = (name) => (asSequence ? ` - '${name}'` : ` ${name}: true`);
451
+ const lines = content.split("\n");
452
+ for (const name of stubs) {
453
+ const pattern = new RegExp(`^(\\s*)(['"]?)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\2\\s*:.*$`);
454
+ const index = lines.findIndex((line) => pattern.test(line));
455
+ if (index !== -1) lines[index] = ` ${name}: true`;
456
+ }
457
+ let next;
458
+ const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
459
+ if (keyIndex === -1) {
460
+ const base = lines.join("\n");
461
+ next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
462
+ } else {
463
+ // 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
464
+ let insertIndex = keyIndex + 1;
465
+ for (let index = keyIndex + 1; index < lines.length; index++) {
466
+ if (lines[index].trim().length === 0) continue;
467
+ if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
468
+ break;
469
+ }
470
+ if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
471
+ next = lines.join("\n");
472
+ }
473
+ return next;
474
+ }
475
+
476
+ /** Default pnpm-workspace.yaml for a profile that has none yet. */
477
+ const DEFAULT_WORKSPACE_YAML = "packages:\n - .\n\nnodeLinker: hoisted\n";
478
+
479
+ /**
480
+ * Apply mergeAllowBuilds to the profile's pnpm-workspace.yaml through the
481
+ * guarded writer.
482
+ * @returns a rollback function, or undefined when nothing needed changing.
483
+ */
484
+ function ensureAllowBuilds(profileDir, names) {
485
+ const workspacePath = join(profileDir, "pnpm-workspace.yaml");
486
+ const content = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
487
+ const next = mergeAllowBuilds(content, names);
488
+ if (next === undefined) return undefined;
489
+ return writeYamlChecked(workspacePath, next, "pnpm-workspace.yaml");
490
+ }
491
+
492
+ // ── in-process install tracker (browser RPC surface) ────────────────────────
493
+ //
494
+ // The web host plane has no job controller (dsh-tool-jobs mounts per agent
495
+ // session), so ctx.jobs refuses background installs started outside an agent
496
+ // turn. The /market RPC channel therefore tracks its own installs in-process:
497
+ // same producer shape as the jobs registry ({cancel, done, readOutput}), just
498
+ // an independent registry.
499
+
500
+ let trackerCounter = 0;
501
+
502
+ /**
503
+ * Create a tracker for browser-started install/uninstall jobs (see runInstall
504
+ * and runRemove). Same producer shape as the jobs registry ({cancel, done,
505
+ * readOutput}), just an independent registry.
506
+ */
507
+ export function createJobTracker() {
508
+ const records = new Map();
509
+ const prune = () => {
510
+ const now = Date.now();
511
+ for (const [id, record] of records) {
512
+ const terminal = record.status !== "running";
513
+ if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
514
+ }
515
+ if (records.size > 20) {
516
+ const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
517
+ for (const [id, record] of ordered) {
518
+ if (records.size <= 20) break;
519
+ if (record.status !== "running") records.delete(id);
520
+ }
521
+ }
522
+ };
523
+ return {
524
+ start({ profile, spec, verb = "add", allowBuildScripts }) {
525
+ const id = `market-${++trackerCounter}`;
526
+ const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
527
+ const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec, allowBuildScripts });
528
+ const record = {
529
+ id,
530
+ kind,
531
+ label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
532
+ profile,
533
+ spec,
534
+ status: "running",
535
+ detail: undefined,
536
+ startedAt: Date.now(),
537
+ finishedAt: undefined,
538
+ producer,
539
+ };
540
+ producer.done.then((outcome) => {
541
+ record.status = outcome.status ?? "failed";
542
+ record.detail = outcome.detail;
543
+ // 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
544
+ // 一段文本,用户看不出要批准的到底是什么。
545
+ record.needsApproval = outcome.needsApproval;
546
+ record.finishedAt = Date.now();
547
+ });
548
+ records.set(id, record);
549
+ prune();
550
+ return id;
551
+ },
552
+ get(jobId) {
553
+ const record = records.get(String(jobId));
554
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
555
+ return {
556
+ snapshot: {
557
+ id: record.id,
558
+ kind: record.kind,
559
+ label: record.label,
560
+ status: record.status,
561
+ detail: record.detail,
562
+ needsApproval: record.needsApproval,
563
+ spec: record.spec,
564
+ startedAt: record.startedAt,
565
+ finishedAt: record.finishedAt,
566
+ },
567
+ output: record.producer.readOutput(),
568
+ };
569
+ },
570
+ cancel(jobId) {
571
+ const record = records.get(String(jobId));
572
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
573
+ record.producer.cancel();
574
+ return "requested";
575
+ },
576
+ };
577
+ }
578
+
579
+ // ── spec shape guard ────────────────────────────────────────────────────────
580
+
581
+ // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
582
+ // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
583
+ // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
584
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
585
+
586
+ /** Reject install/remove specs carrying shell metacharacters. */
587
+ export function assertSafeSpec(spec) {
588
+ const value = String(spec ?? "");
589
+ if (UNSAFE_SPEC_RE.test(value)) {
590
+ throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
591
+ }
592
+ // Windows 下 pnpm 走 shell,而 Node 只是把参数用空格 join 后交给 cmd,
593
+ // 不逐参加引号——带空格的本地路径会被拆成两个参数,pnpm 报一个和路径
594
+ // 毫无关系的错。用户也没法自己加引号绕过:`"` 就在上面的黑名单里。
595
+ // 与其让它以看不懂的方式失败,不如在这里说清楚。
596
+ if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
597
+ 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)}`);
598
+ }
599
+ }
600
+
601
+ // ── pnpm self-heal (corepack) ───────────────────────────────────────────────
602
+
603
+ /**
604
+ * Try to provision pnpm once via `corepack enable pnpm` (corepack ships with
605
+ * Node). Output lands in the caller's job log; returns whether a retry of the
606
+ * pnpm spawn is worth attempting.
607
+ */
608
+ async function enablePnpmViaCorepack(push) {
609
+ push("\n[dsh-plugin-mall] pnpm not found on PATH — trying `corepack enable pnpm` once\n");
610
+ return await new Promise((resolve) => {
611
+ let proc;
612
+ try {
613
+ proc = spawn("corepack", ["enable", "pnpm"], {
614
+ env: process.env,
615
+ shell: process.platform === "win32",
616
+ stdio: ["ignore", "pipe", "pipe"],
617
+ windowsHide: true,
618
+ });
619
+ } catch {
620
+ resolve(false);
621
+ return;
622
+ }
623
+ proc.on("error", () => resolve(false));
624
+ proc.stdout?.on("data", (data) => push(data.toString()));
625
+ proc.stderr?.on("data", (data) => push(data.toString()));
626
+ proc.on("close", (code) => resolve(code === 0));
627
+ });
628
+ }
629
+
630
+ // ── the background install job ──────────────────────────────────────────────
631
+
632
+ /**
633
+ * Run `pnpm add <spec>` in the profile directory as a job producer with the
634
+ * shape `ctx.jobs.start` expects: `{ cancel, done: Promise<outcome>, readOutput: () => string }`.
635
+ * A failure whose output lists ignored build scripts gets one automatic
636
+ * retry after merging those names into `allowBuilds`.
637
+ */
638
+ /**
639
+ * Render "here is exactly what you would be approving". Deliberately avoids
640
+ * any wording like "security check passed" — approving these scripts says
641
+ * nothing about what the plugin does once it is loaded.
642
+ */
643
+ function renderApprovalNeeded(spec, disclosure) {
644
+ const lines = [
645
+ `installing ${spec} requires running install-time code — approval needed.`,
646
+ "No install script ran and no plugin code loaded. pnpm did leave the downloaded",
647
+ "files and a dependency entry in the profile (unusable until the scripts run);",
648
+ "approving continues from there, and market_uninstall removes them if you cancel.",
649
+ "",
650
+ ];
651
+ for (const entry of disclosure) {
652
+ const origin = entry.direct
653
+ ? "the plugin itself"
654
+ : "a transitive dependency — NOT the package you asked for";
655
+ lines.push(` ${entry.name}${entry.version ? `@${entry.version}` : ""} (${origin})`);
656
+ for (const [key, command] of Object.entries(entry.scripts ?? {})) lines.push(` ${key}: ${command}`);
657
+ const facts = [];
658
+ if (typeof entry.weeklyDownloads === "number") facts.push(`${entry.weeklyDownloads.toLocaleString()} weekly downloads`);
659
+ facts.push(entry.provenance === true ? "has provenance" : "no provenance");
660
+ if (typeof entry.unpackedSize === "number") facts.push(`${Math.round(entry.unpackedSize / 104857.6) / 10} MB unpacked`);
661
+ lines.push(` ${facts.join(" · ")}`);
662
+ }
663
+ lines.push("");
664
+ lines.push("These commands run on your machine, with your privileges, before any plugin code loads.");
665
+ lines.push(`To proceed, install again with allowBuildScripts: [${disclosure.map((entry) => JSON.stringify(entry.name)).join(", ")}]`);
666
+ return lines.join("\n");
667
+ }
668
+
669
+ export function runInstall({ profile, spec, allowBuildScripts }) {
670
+ const profileDir = ensureProfile(profile);
671
+ // Dependency keys BEFORE pnpm add, so reconcile only manages entries that
672
+ // were (or became) dependencies and never touches template bundles.
673
+ const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
674
+ const collected = [];
675
+ const deltaQueue = [];
676
+ const push = (text) => {
677
+ collected.push(text);
678
+ deltaQueue.push(text);
679
+ };
680
+ let current = undefined;
681
+ let pnpmSelfHealed = false;
682
+
683
+ const spawnAdd = () => {
684
+ const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
685
+ cwd: profileDir,
686
+ env: process.env,
687
+ shell: process.platform === "win32",
688
+ stdio: ["ignore", "pipe", "pipe"],
689
+ windowsHide: true,
690
+ });
691
+ const done = new Promise((resolve) => {
692
+ proc.on("error", (error) => resolve({ spawnError: error }));
693
+ proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
694
+ });
695
+ proc.stdout?.on("data", (data) => push(data.toString()));
696
+ proc.stderr?.on("data", (data) => push(data.toString()));
697
+ return { proc, done };
698
+ };
699
+
700
+ /** Post-success accounting: reconcile bundles, register client rows, summarize. */
701
+ const finalizeSuccess = () => {
702
+ const bundles = reconcileBundles(profileDir, beforeDeps);
703
+ const manifest = readManifest(profileDir);
704
+ const currentDeps = new Set(Object.keys(manifest.dependencies ?? {}));
705
+ const clientRows = [];
706
+ const plainDeps = [];
707
+ for (const depName of currentDeps) {
708
+ if (beforeDeps.has(depName)) continue;
709
+ const kind = classifyPackage(depName, profileDir);
710
+ if (kind === "client") {
711
+ const row = ensureClientRow(profileDir, depName);
712
+ if (row.added) clientRows.push(row.rowId);
713
+ } else if (kind !== "bundle") {
714
+ plainDeps.push(depName);
715
+ }
716
+ }
717
+ const notes = [];
718
+ const activeBundles = bundles.filter((name) => currentDeps.has(name));
719
+ if (activeBundles.length > 0) notes.push(`bundle layer(s): ${activeBundles.join(", ")}`);
720
+ if (clientRows.length > 0) notes.push(`registered client row(s) in cordis.patch.yml: ${clientRows.join(", ")}`);
721
+ if (plainDeps.length > 0) notes.push(`plain dependency (no dsh.bundle/dsh.client): ${plainDeps.join(", ")}`);
722
+ const noteText = notes.length > 0 ? ` — ${notes.join("; ")}` : " — installed as a plain dependency (declares no dsh.bundle)";
723
+ return { status: "completed", detail: `installed ${spec} into profile "${profile}"${noteText}. Restart dsh for plugin code to load.` };
724
+ };
725
+
726
+ const settle = async (outcome) => {
727
+ if (outcome.spawnError !== undefined) {
728
+ // pnpm 缺失时先尝试 corepack 自愈一次,成功则重跑安装。
729
+ if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
730
+ pnpmSelfHealed = true;
731
+ const healed = await enablePnpmViaCorepack(push);
732
+ if (healed) {
733
+ const retry = spawnAdd();
734
+ current = retry.proc;
735
+ return settle(await retry.done);
736
+ }
737
+ return { status: "failed", detail: "pnpm not found on PATH and `corepack enable pnpm` could not provision it — install pnpm (e.g. `npm i -g pnpm`) to manage profile plugins" };
738
+ }
739
+ const hint = outcome.spawnError.code === "ENOENT"
740
+ ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
741
+ : `could not start pnpm: ${outcome.spawnError.message}`;
742
+ return { status: "failed", detail: hint };
743
+ }
744
+ if (outcome.exitCode === null) {
745
+ return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
746
+ }
747
+ if (outcome.exitCode === 0) {
748
+ return finalizeSuccess();
749
+ }
750
+ const log = collected.join("");
751
+ const ignored = parseIgnoredBuilds(log);
752
+ if (ignored.length === 0) {
753
+ return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
754
+ }
755
+ // 放行构建脚本 = 让这些包在用户机器上、以用户的权限、在任何插件代码加载
756
+ // 之前执行任意命令。这个决定属于用户,不属于我们。所以没有点名同意时就停
757
+ // 在这里——pnpm 拦截的位置恰好在「已下载」与「已执行」之间,此刻什么都还
758
+ // 没跑,profile 也一个字节没动。
759
+ const consented = new Set((Array.isArray(allowBuildScripts) ? allowBuildScripts : []).map((name) => String(name)));
760
+ const missing = ignored.filter((entry) => !consented.has(entry.name));
761
+ if (missing.length > 0) {
762
+ push(`\n[dsh-plugin-mall] pnpm blocked install scripts for: ${ignored.map((entry) => entry.name).join(", ")}\n`);
763
+ push("[dsh-plugin-mall] stopping for approval — no install script ran, nothing is loadable yet.\n");
764
+ let disclosure;
765
+ try {
766
+ disclosure = await describeBuildScripts(missing, {
767
+ registry: await resolveRegistry(profile),
768
+ installedName: npmNameOf(spec) ?? undefined,
769
+ });
770
+ } catch {
771
+ disclosure = missing.map((entry) => ({ ...entry, direct: false }));
772
+ }
773
+ return { status: "failed", detail: renderApprovalNeeded(spec, disclosure), needsApproval: disclosure };
774
+ }
775
+ push(`\n[dsh-plugin-mall] approved install scripts: ${ignored.map((entry) => entry.name).join(", ")}\n`);
776
+ push("[dsh-plugin-mall] allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
777
+ let rollbackAllowBuilds;
778
+ try {
779
+ rollbackAllowBuilds = ensureAllowBuilds(profileDir, ignored.map((entry) => entry.name));
780
+ } catch (error) {
781
+ 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.` };
782
+ }
783
+ // allowBuilds 是持久化的安全配置。为一次没装成的插件单向放宽它,等于以后
784
+ // 这个包名再出现(哪怕是别人的传递依赖)就静默放行——失败必须收回。
785
+ const revert = () => {
786
+ if (rollbackAllowBuilds === undefined) return;
787
+ try {
788
+ rollbackAllowBuilds();
789
+ push("[dsh-plugin-mall] install failed — reverted the allowBuilds change, the profile is as it was\n");
790
+ } catch {
791
+ /* 还原失败不该盖掉真正的失败原因 */
792
+ }
793
+ };
794
+ const retry = spawnAdd();
795
+ current = retry.proc;
796
+ const retryOutcome = await retry.done;
797
+ if (retryOutcome.spawnError !== undefined) {
798
+ revert();
799
+ return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
800
+ }
801
+ if (retryOutcome.exitCode === 0) {
802
+ return finalizeSuccess();
803
+ }
804
+ revert();
805
+ 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.` };
806
+ };
807
+
808
+ const first = spawnAdd();
809
+ current = first.proc;
810
+ const done = first.done.then((outcome) => settle(outcome));
811
+
812
+ return {
813
+ cancel: () => {
814
+ current?.kill();
815
+ },
816
+ done,
817
+ readOutput: () => {
818
+ if (deltaQueue.length === 0) return "";
819
+ return deltaQueue.splice(0).join("");
820
+ },
821
+ };
822
+ }
823
+
824
+ // ── the background uninstall job ────────────────────────────────────────────
825
+
826
+ /** A terminal producer for fast-fail cases (no pnpm spawn needed). */
827
+ function failedNow(detail) {
828
+ return {
829
+ cancel: () => {},
830
+ done: Promise.resolve({ status: "failed", detail }),
831
+ readOutput: () => "",
832
+ };
833
+ }
834
+
835
+ /**
836
+ * Run `pnpm remove <package>` in the profile directory as a job producer with
837
+ * the same shape as `runInstall`. On success it reconciles
838
+ * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
839
+ * deletes the client loader row `ensureClientRow` had registered for it.
840
+ */
841
+ export function runRemove({ profile, packageName }, selfHealed = false) {
842
+ let profileDir;
843
+ try {
844
+ profileDir = resolveProfileDir(profile);
845
+ } catch (error) {
846
+ return failedNow(`invalid profile: ${error.message}`);
847
+ }
848
+ const manifestPath = join(profileDir, "package.json");
849
+ if (!existsSync(manifestPath)) {
850
+ return failedNow(`profile "${profile}" has no package.json — nothing installed to remove`);
851
+ }
852
+ const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
853
+ if (!beforeDeps.has(packageName)) {
854
+ return failedNow(`"${packageName}" is not a dependency of profile "${profile}" (installed: ${[...beforeDeps].join(", ") || "none"})`);
855
+ }
856
+ const deltaQueue = [];
857
+ const push = (text) => {
858
+ deltaQueue.push(text);
859
+ };
860
+ let current = undefined;
861
+
862
+ const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
863
+ cwd: profileDir,
864
+ env: process.env,
865
+ shell: process.platform === "win32",
866
+ stdio: ["ignore", "pipe", "pipe"],
867
+ windowsHide: true,
868
+ });
869
+ current = proc;
870
+ const done = new Promise((resolve) => {
871
+ proc.on("error", (error) => resolve({ spawnError: error }));
872
+ proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
873
+ }).then(async (outcome) => {
874
+ if (outcome.spawnError !== undefined) {
875
+ // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
876
+ // 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
877
+ // 把成功任务记成 failed)。
878
+ if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
879
+ const healed = await enablePnpmViaCorepack(push);
880
+ if (healed) return await runRemove({ profile, packageName }, true).done;
881
+ }
882
+ const hint = outcome.spawnError.code === "ENOENT"
883
+ ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
884
+ : `could not start pnpm: ${outcome.spawnError.message}`;
885
+ return { status: "failed", detail: hint };
886
+ }
887
+ if (outcome.exitCode === null) {
888
+ return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
889
+ }
890
+ if (outcome.exitCode !== 0) {
891
+ return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
892
+ }
893
+ const bundles = reconcileBundles(profileDir, beforeDeps);
894
+ const clientRow = removeClientRow(profileDir, packageName);
895
+ const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
896
+ if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
897
+ return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
898
+ });
899
+ proc.stdout?.on("data", (data) => push(data.toString()));
900
+ proc.stderr?.on("data", (data) => push(data.toString()));
901
+
902
+ return {
903
+ cancel: () => {
904
+ current?.kill();
905
+ },
906
+ done,
907
+ readOutput: () => {
908
+ if (deltaQueue.length === 0) return "";
909
+ return deltaQueue.splice(0).join("");
910
+ },
911
+ };
912
+ }
852
913
 
853
914
  // ── offline fixtures ────────────────────────────────────────────────────────
854
915
  //