@1e0zj/dsh-plugin-mall 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
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 } 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
+ // ── profile management ──────────────────────────────────────────────────────
35
+
36
+ /** Resolve and initialize (on first use) the target profile directory. */
37
+ export function ensureProfile(profile) {
38
+ const dir = resolveProfileDir(profile); // throws on invalid names
39
+ if (!existsSync(join(dir, "package.json"))) {
40
+ initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES);
41
+ }
42
+ return dir;
43
+ }
44
+
45
+ /** Read the profile's package.json manifest. */
46
+ function readManifest(profileDir) {
47
+ return JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
48
+ }
49
+
50
+ /** Locate an installed dependency's package.json under the profile tree. */
51
+ function packageJsonPathOf(packageName, profileDir) {
52
+ const direct = join(profileDir, "node_modules", ...packageName.split("/"), "package.json");
53
+ if (existsSync(direct)) return direct;
54
+ try {
55
+ return createRequire(join(profileDir, "noop.js")).resolve(`${packageName}/package.json`);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /** Whether an installed dependency declares `dsh.bundle.patch` (is a plugin bundle). */
62
+ function isBundlePackage(packageName, profileDir) {
63
+ return classifyPackage(packageName, profileDir) === "bundle";
64
+ }
65
+
66
+ /**
67
+ * Classify an installed package by its `dsh` declaration:
68
+ * `bundle` (a profile patch layer), `client` (a browser-side UI plugin),
69
+ * `plain` (a plain dependency), or `missing` (not resolvable).
70
+ */
71
+ export function classifyPackage(packageName, profileDir) {
72
+ const path = packageJsonPathOf(packageName, profileDir);
73
+ if (path === undefined) return "missing";
74
+ try {
75
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
76
+ if (typeof manifest.dsh?.bundle?.patch === "string") return "bundle";
77
+ if (manifest.dsh?.client !== undefined) return "client";
78
+ return "plain";
79
+ } catch {
80
+ return "missing";
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reconcile `dsh.profile.bundles` against installed dependencies: a
86
+ * dependency that declares a dsh bundle joins the layer stack (in dependency
87
+ * order); an entry that was a dependency but no longer is one — removed, or
88
+ * the installed version dropped its bundle declaration — leaves it. In-box
89
+ * template bundles are not dependencies and are NEVER touched (mirroring the
90
+ * official `dsh plugin` reconcile).
91
+ * @param profileDir - the profile directory.
92
+ * @param beforeDeps - dependency keys as they were before `pnpm add` ran.
93
+ */
94
+ export function reconcileBundles(profileDir, beforeDeps = new Set()) {
95
+ const manifestPath = join(profileDir, "package.json");
96
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
97
+ const dependencies = new Set(Object.keys(manifest.dependencies ?? {}));
98
+ const bundles = [...(manifest.dsh?.profile?.bundles ?? [])];
99
+ const result = [];
100
+ for (const bundleName of bundles) {
101
+ const wasDependency = beforeDeps.has(bundleName) || dependencies.has(bundleName);
102
+ const stillBundle = dependencies.has(bundleName) && isBundlePackage(bundleName, profileDir);
103
+ // Keep template bundles and dependency-bundles; drop only entries that
104
+ // were dependencies and stopped being bundles.
105
+ if (!wasDependency || stillBundle) result.push(bundleName);
106
+ }
107
+ for (const dependencyName of dependencies) {
108
+ if (!result.includes(dependencyName) && isBundlePackage(dependencyName, profileDir)) result.push(dependencyName);
109
+ }
110
+ if (JSON.stringify(result) !== JSON.stringify(bundles)) {
111
+ manifest.dsh = {
112
+ ...manifest.dsh,
113
+ profile: {
114
+ ...manifest.dsh?.profile,
115
+ bundles: result,
116
+ },
117
+ };
118
+ writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n");
119
+ }
120
+ return result;
121
+ }
122
+
123
+ /** List a profile's installed plugins (dependencies with classification + version). */
124
+ export function listInstalled(profile) {
125
+ const dir = resolveProfileDir(profile);
126
+ const manifestPath = join(dir, "package.json");
127
+ if (!existsSync(manifestPath)) return { dir, deps: [] };
128
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
129
+ const deps = Object.keys(manifest.dependencies ?? {}).map((name) => {
130
+ const path = packageJsonPathOf(name, dir);
131
+ let version = "?";
132
+ let kind = "missing";
133
+ if (path !== undefined) {
134
+ try {
135
+ const installed = JSON.parse(readFileSync(path, "utf8"));
136
+ version = installed.version ?? "?";
137
+ kind = classifyPackage(name, dir);
138
+ } catch {
139
+ /* keep defaults */
140
+ }
141
+ }
142
+ return { name, version, kind };
143
+ });
144
+ return { dir, deps };
145
+ }
146
+
147
+ // ── client-plugin row registration ──────────────────────────────────────────
148
+
149
+ /** Profile patch file name (the user's own layer, applied after bundle layers). */
150
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
151
+
152
+ /** Derive a friendly row id from a package name: "@s/dsh-client-ui-aqua" -> "ui-aqua". */
153
+ function clientRowId(packageName) {
154
+ const last = packageName.split("/").pop() ?? packageName;
155
+ const trimmed = last.replace(/^dsh-/, "").replace(/^client-ui-/, "").replace(/^client-/, "");
156
+ return trimmed.length > 0 ? trimmed : last;
157
+ }
158
+
159
+ /**
160
+ * Idempotently register a `dsh.client` package as a loader row in the
161
+ * profile's cordis.patch.yml (client packages are discovered from the loader
162
+ * entry tree, so the dependency alone does not activate them).
163
+ * A row whose `name` already exists (any id) is left untouched.
164
+ * @returns {{ added: boolean, rowId?: string }}
165
+ */
166
+ export function ensureClientRow(profileDir, packageName) {
167
+ const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
168
+ const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
169
+ let parsed = null;
170
+ try {
171
+ parsed = load(content);
172
+ } catch {
173
+ /* a text-level name check still runs below */
174
+ }
175
+ const alreadyByName = (Array.isArray(parsed) && parsed.some((entry) =>
176
+ Array.isArray(entry?.insert) && entry.insert.some((row) => row?.name === packageName)
177
+ )) || new RegExp(`name:\\s*["']${packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content);
178
+ if (alreadyByName) return { added: false };
179
+ const rowId = clientRowId(packageName);
180
+ const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
181
+ if (Array.isArray(parsed) && parsed.length === 0) {
182
+ // The stock template is a comment plus `[]`; replace it wholesale.
183
+ writeFileSync(patchPath, block);
184
+ } else {
185
+ writeFileSync(patchPath, content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`);
186
+ }
187
+ return { added: true, rowId };
188
+ }
189
+
190
+ // ── build-script allow-listing ──────────────────────────────────────────────
191
+
192
+ /** Extract package names from pnpm's "Ignored build scripts: ..." output. */
193
+ function parseIgnoredBuilds(output) {
194
+ const names = new Set();
195
+ const pattern = /(?:Ignored build scripts|allowBuilds|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
196
+ let match;
197
+ while ((match = pattern.exec(output)) !== null) {
198
+ for (const raw of match[1].split(",")) {
199
+ const trimmed = raw.trim();
200
+ if (trimmed.length === 0) continue;
201
+ // Strip a trailing @version so `foo@1.2.3` -> `foo`, `@s/n@1.0.0` -> `@s/n`.
202
+ const name = trimmed.includes("@") && !trimmed.startsWith("@")
203
+ ? trimmed.split("@").slice(0, -1).join("@")
204
+ : trimmed.replace(/@[^@/]+$/, "");
205
+ if (name.length > 0) names.add(name);
206
+ }
207
+ }
208
+ return [...names];
209
+ }
210
+
211
+ /** Merge new names into the profile's pnpm-workspace.yaml `allowBuilds` list. */
212
+ function ensureAllowBuilds(profileDir, names) {
213
+ const workspacePath = join(profileDir, "pnpm-workspace.yaml");
214
+ let content = existsSync(workspacePath)
215
+ ? readFileSync(workspacePath, "utf8")
216
+ : "packages:\n - .\n\nnodeLinker: hoisted\n";
217
+ const lines = content.split("\n");
218
+ const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
219
+ if (keyIndex === -1) {
220
+ if (!content.endsWith("\n")) content += "\n";
221
+ content += `\nallowBuilds:\n${names.map((name) => ` - ${name}`).join("\n")}\n`;
222
+ } else {
223
+ const existing = new Set();
224
+ let insertIndex = keyIndex + 1;
225
+ for (let index = keyIndex + 1; index < lines.length; index++) {
226
+ const item = /^\s*-\s+(.+?)\s*$/.exec(lines[index]);
227
+ if (item) {
228
+ existing.add(item[1]);
229
+ insertIndex = index + 1;
230
+ continue;
231
+ }
232
+ if (/^\S/.test(lines[index])) break;
233
+ }
234
+ const additions = names.filter((name) => !existing.has(name));
235
+ if (additions.length > 0) {
236
+ lines.splice(insertIndex, 0, ...additions.map((name) => ` - ${name}`));
237
+ content = lines.join("\n");
238
+ }
239
+ }
240
+ writeFileSync(workspacePath, content);
241
+ }
242
+
243
+ // ── in-process install tracker (browser RPC surface) ────────────────────────
244
+ //
245
+ // The web host plane has no job controller (dsh-tool-jobs mounts per agent
246
+ // session), so ctx.jobs refuses background installs started outside an agent
247
+ // turn. The /market RPC channel therefore tracks its own installs in-process:
248
+ // same producer shape as the jobs registry ({cancel, done, readOutput}), just
249
+ // an independent registry.
250
+
251
+ let trackerCounter = 0;
252
+
253
+ /** Create a tracker for browser-started installs (see runInstall). */
254
+ export function createInstallTracker() {
255
+ const records = new Map();
256
+ const prune = () => {
257
+ const now = Date.now();
258
+ for (const [id, record] of records) {
259
+ const terminal = record.status !== "running";
260
+ if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
261
+ }
262
+ if (records.size > 20) {
263
+ const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
264
+ for (const [id, record] of ordered) {
265
+ if (records.size <= 20) break;
266
+ if (record.status !== "running") records.delete(id);
267
+ }
268
+ }
269
+ };
270
+ return {
271
+ start({ profile, spec }) {
272
+ const id = `market-${++trackerCounter}`;
273
+ const producer = runInstall({ profile, spec });
274
+ const record = {
275
+ id,
276
+ label: `dsh plugin --profile ${profile} add ${spec}`,
277
+ profile,
278
+ spec,
279
+ status: "running",
280
+ detail: undefined,
281
+ startedAt: Date.now(),
282
+ finishedAt: undefined,
283
+ producer,
284
+ };
285
+ producer.done.then((outcome) => {
286
+ record.status = outcome.status ?? "failed";
287
+ record.detail = outcome.detail;
288
+ record.finishedAt = Date.now();
289
+ });
290
+ records.set(id, record);
291
+ prune();
292
+ return id;
293
+ },
294
+ get(jobId) {
295
+ const record = records.get(String(jobId));
296
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
297
+ return {
298
+ snapshot: {
299
+ id: record.id,
300
+ kind: "dsh-plugin-install",
301
+ label: record.label,
302
+ status: record.status,
303
+ detail: record.detail,
304
+ startedAt: record.startedAt,
305
+ finishedAt: record.finishedAt,
306
+ },
307
+ output: record.producer.readOutput(),
308
+ };
309
+ },
310
+ cancel(jobId) {
311
+ const record = records.get(String(jobId));
312
+ if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
313
+ record.producer.cancel();
314
+ return "requested";
315
+ },
316
+ };
317
+ }
318
+
319
+ // ── the background install job ──────────────────────────────────────────────
320
+
321
+ /**
322
+ * Run `pnpm add <spec>` in the profile directory as a job producer with the
323
+ * shape `ctx.jobs.start` expects: `{ cancel, done: Promise<outcome>, readOutput: () => string }`.
324
+ * A failure whose output lists ignored build scripts gets one automatic
325
+ * retry after merging those names into `allowBuilds`.
326
+ */
327
+ export function runInstall({ profile, spec }) {
328
+ const profileDir = ensureProfile(profile);
329
+ // Dependency keys BEFORE pnpm add, so reconcile only manages entries that
330
+ // were (or became) dependencies and never touches template bundles.
331
+ const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
332
+ const collected = [];
333
+ const deltaQueue = [];
334
+ const push = (text) => {
335
+ collected.push(text);
336
+ deltaQueue.push(text);
337
+ };
338
+ let current = undefined;
339
+
340
+ const spawnAdd = () => {
341
+ const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
342
+ cwd: profileDir,
343
+ env: process.env,
344
+ shell: process.platform === "win32",
345
+ stdio: ["ignore", "pipe", "pipe"],
346
+ windowsHide: true,
347
+ });
348
+ const done = new Promise((resolve) => {
349
+ proc.on("error", (error) => resolve({ spawnError: error }));
350
+ proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
351
+ });
352
+ proc.stdout?.on("data", (data) => push(data.toString()));
353
+ proc.stderr?.on("data", (data) => push(data.toString()));
354
+ return { proc, done };
355
+ };
356
+
357
+ /** Post-success accounting: reconcile bundles, register client rows, summarize. */
358
+ const finalizeSuccess = () => {
359
+ const bundles = reconcileBundles(profileDir, beforeDeps);
360
+ const manifest = readManifest(profileDir);
361
+ const currentDeps = new Set(Object.keys(manifest.dependencies ?? {}));
362
+ const clientRows = [];
363
+ const plainDeps = [];
364
+ for (const depName of currentDeps) {
365
+ if (beforeDeps.has(depName)) continue;
366
+ const kind = classifyPackage(depName, profileDir);
367
+ if (kind === "client") {
368
+ const row = ensureClientRow(profileDir, depName);
369
+ if (row.added) clientRows.push(row.rowId);
370
+ } else if (kind !== "bundle") {
371
+ plainDeps.push(depName);
372
+ }
373
+ }
374
+ const notes = [];
375
+ const activeBundles = bundles.filter((name) => currentDeps.has(name));
376
+ if (activeBundles.length > 0) notes.push(`bundle layer(s): ${activeBundles.join(", ")}`);
377
+ if (clientRows.length > 0) notes.push(`registered client row(s) in cordis.patch.yml: ${clientRows.join(", ")}`);
378
+ if (plainDeps.length > 0) notes.push(`plain dependency (no dsh.bundle/dsh.client): ${plainDeps.join(", ")}`);
379
+ const noteText = notes.length > 0 ? ` — ${notes.join("; ")}` : " — installed as a plain dependency (declares no dsh.bundle)";
380
+ return { status: "completed", detail: `installed ${spec} into profile "${profile}"${noteText}. Restart dsh for plugin code to load.` };
381
+ };
382
+
383
+ const settle = async (outcome) => {
384
+ if (outcome.spawnError !== undefined) {
385
+ const hint = outcome.spawnError.code === "ENOENT"
386
+ ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
387
+ : `could not start pnpm: ${outcome.spawnError.message}`;
388
+ return { status: "failed", detail: hint };
389
+ }
390
+ if (outcome.exitCode === null) {
391
+ return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
392
+ }
393
+ if (outcome.exitCode === 0) {
394
+ return finalizeSuccess();
395
+ }
396
+ const log = collected.join("");
397
+ const ignored = parseIgnoredBuilds(log);
398
+ if (ignored.length === 0) {
399
+ return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
400
+ }
401
+ push(`\n[dsh-plugin-mall] pnpm blocked build scripts: ${ignored.join(", ")} — merging into allowBuilds and retrying once\n`);
402
+ ensureAllowBuilds(profileDir, ignored);
403
+ const retry = spawnAdd();
404
+ current = retry.proc;
405
+ const retryOutcome = await retry.done;
406
+ if (retryOutcome.spawnError !== undefined) {
407
+ return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
408
+ }
409
+ if (retryOutcome.exitCode === 0) {
410
+ return finalizeSuccess();
411
+ }
412
+ return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output.` };
413
+ };
414
+
415
+ const first = spawnAdd();
416
+ current = first.proc;
417
+ const done = first.done.then((outcome) => settle(outcome));
418
+
419
+ return {
420
+ cancel: () => {
421
+ current?.kill();
422
+ },
423
+ done,
424
+ readOutput: () => {
425
+ if (deltaQueue.length === 0) return "";
426
+ return deltaQueue.splice(0).join("");
427
+ },
428
+ };
429
+ }