dshmarket 1.2.2 → 1.2.4

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/lib/routes.js CHANGED
@@ -1,426 +1,25 @@
1
1
  /**
2
- * HTTP routes bridging the browser market UI to the host: registry fallback,
3
- * installed-plugin listing, and the install executor.
2
+ * HTTP routes bridging the browser market UI to the host. This layer only
3
+ * parses requests, calls the service modules, and serializes responses —
4
+ * process spawning lives in dsh-cli.ts, filesystem reads in profile.ts,
5
+ * orchestration in install.ts / themes.ts / updates.ts.
4
6
  *
5
7
  * Security: the install route executes a shell command, so it accepts only
6
8
  * same-origin POSTs and only sources present in the curated registry.
7
9
  */
8
- import { spawn } from 'node:child_process';
9
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
- import { homedir } from 'node:os';
11
- import { dirname, join, resolve } from 'node:path';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
12
  import { loadRegistry } from './registry.js';
13
- import { cleanHotDir, hotMount, hotUnmount, listHotMounts, mountClientOnlyDeps, readDisabledThemes, writeDisabledThemes, } from './hot.js';
13
+ import { cleanHotDir, hotMount, hotUnmount, listHotMounts, mountClientOnlyDeps, readDisabledThemes, } from './hot.js';
14
14
  import { exportLogs, logEvent } from './log.js';
15
+ import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
16
+ import { profileDir, readInstalled, readInstalledVersion, readLockCommits, setAllowBuilds } from './profile.js';
17
+ import { findInstalledAlias, installTargetFor } from './sources.js';
18
+ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
19
+ import { checkUpdates, invalidateUpdates } from './updates.js';
20
+ import { createThemeManager } from './themes.js';
21
+ import { readJsonBody, sameOrigin, sendJson } from './http.js';
15
22
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
16
- const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
17
- const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
18
- /**
19
- * Argv re-invoking the CLI that launched this host process, so installs work
20
- * whether dsh runs from a global bin, a local install, or repo source
21
- * (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
22
- *
23
- * Installs run through node:child_process, not ctx.shell: the shell service is
24
- * the agent's sandboxed executor and denies writes to the profile directory.
25
- */
26
- function dshArgv() {
27
- const entry = process.argv[1];
28
- if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
29
- // Absolute paths are required: source launches (`pnpm dsh`) pass a
30
- // relative entry, which the child resolves against its OWN cwd and dies
31
- // with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
32
- // (tsx/esm) resolvable on source launches.
33
- const abs = resolve(entry);
34
- return { file: process.execPath, args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false };
35
- }
36
- // Bare `dsh` is a .cmd shim on Windows that only a shell can start (#13).
37
- return { file: 'dsh', args: [], cwd: undefined, viaShell: winCmdShim };
38
- }
39
- /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
40
- let pnpmReady = false;
41
- /**
42
- * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
43
- * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
44
- */
45
- const winCmdShim = process.platform === 'win32';
46
- /**
47
- * Kill a spawned child and, on Windows, its whole process tree — `kill()`
48
- * there only terminates the wrapper, leaving pnpm children running.
49
- * (Contributed in #7 by @mraing.)
50
- */
51
- function killChild(child) {
52
- if (process.platform === 'win32' && child.pid !== undefined) {
53
- try {
54
- spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
55
- return;
56
- }
57
- catch { /* fall through */ }
58
- }
59
- child.kill('SIGKILL');
60
- }
61
- function probePnpm() {
62
- if (pnpmReady)
63
- return Promise.resolve(true);
64
- return new Promise((resolvePromise) => {
65
- const child = spawn('pnpm', ['--version'], { stdio: 'ignore', shell: winCmdShim });
66
- child.on('error', () => resolvePromise(false));
67
- child.on('close', (code) => {
68
- pnpmReady = code === 0;
69
- resolvePromise(pnpmReady);
70
- });
71
- });
72
- }
73
- function runQuiet(file, args, timeoutMs) {
74
- return new Promise((resolvePromise) => {
75
- const child = spawn(file, args, {
76
- env: { ...process.env, CI: 'true' },
77
- stdio: ['ignore', 'pipe', 'pipe'],
78
- shell: winCmdShim,
79
- });
80
- let output = '';
81
- const timer = setTimeout(() => killChild(child), timeoutMs);
82
- const collect = (chunk) => { output = (output + chunk.toString()).slice(-8 * 1024); };
83
- child.stdout.on('data', collect);
84
- child.stderr.on('data', collect);
85
- child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }); });
86
- child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }); });
87
- });
88
- }
89
- /**
90
- * Provision pnpm without user involvement: corepack (ships with Node) first,
91
- * a global npm install as fallback.
92
- * @returns true when `pnpm --version` succeeds afterwards.
93
- */
94
- async function provisionPnpm() {
95
- const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000);
96
- logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`);
97
- if (await probePnpm())
98
- return true;
99
- const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000);
100
- logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`);
101
- return probePnpm();
102
- }
103
- const progress = { active: false, target: '', startedAt: 0, lastLine: '' };
104
- /** Identifies this host process; the client scopes its pending-restart flags to it. */
105
- const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
106
- function trackProgress(chunk) {
107
- const lines = chunk.split('\n').map(l => l.trim()).filter(l => l !== '');
108
- if (lines.length > 0)
109
- progress.lastLine = lines[lines.length - 1].slice(0, 200);
110
- }
111
- /**
112
- * Central allowlist for every spawn target, regardless of which route built
113
- * it (defense in depth on top of per-route validation — the win32 bare-dsh
114
- * fallback runs through a shell). Suggested in #16 by @anupamme.
115
- */
116
- const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
117
- function runDshPlugin(profile, pluginArgs) {
118
- const { file, args, cwd, viaShell } = dshArgv();
119
- // pnpm 9 refuses to add at a workspace root without -w (#17); pnpm 10/11
120
- // accept the flag as a no-op there, so it is safe to pass always.
121
- if (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove') {
122
- pluginArgs = [pluginArgs[0], '-w', ...pluginArgs.slice(1)];
123
- }
124
- const target = pluginArgs[pluginArgs.length - 1] ?? '';
125
- if (!TARGET_RE.test(target)) {
126
- logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`);
127
- return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}` });
128
- }
129
- progress.active = true;
130
- progress.target = target;
131
- progress.startedAt = Date.now();
132
- progress.lastLine = '';
133
- return new Promise((resolvePromise) => {
134
- const child = spawn(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
135
- cwd,
136
- // pnpm v10 blocks forever on a silent interactive prompt without a TTY
137
- // (observed on re-add over a pinned git spec); CI mode forces it to act
138
- // or fail instead of asking.
139
- env: { ...process.env, CI: 'true' },
140
- stdio: ['ignore', 'pipe', 'pipe'],
141
- shell: viaShell,
142
- });
143
- let stdout = '';
144
- let stderr = '';
145
- let timedOut = false;
146
- const timer = setTimeout(() => {
147
- timedOut = true;
148
- killChild(child);
149
- }, INSTALL_TIMEOUT_MS);
150
- child.stdout.on('data', (chunk) => {
151
- const text = chunk.toString();
152
- stdout = (stdout + text).slice(-256 * 1024);
153
- trackProgress(text);
154
- });
155
- child.stderr.on('data', (chunk) => {
156
- const text = chunk.toString();
157
- stderr = (stderr + text).slice(-64 * 1024);
158
- trackProgress(text);
159
- });
160
- child.on('error', (error) => {
161
- clearTimeout(timer);
162
- progress.active = false;
163
- resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` });
164
- });
165
- child.on('close', (code) => {
166
- clearTimeout(timer);
167
- progress.active = false;
168
- resolvePromise({ exitCode: code, timedOut, stdout, stderr });
169
- });
170
- });
171
- }
172
- function sendJson(response, status, payload) {
173
- response.writeHead(status, {
174
- 'cache-control': 'no-store',
175
- 'content-type': 'application/json; charset=utf-8',
176
- });
177
- response.end(JSON.stringify(payload));
178
- }
179
- function sameOrigin(request) {
180
- const origin = request.headers.origin;
181
- const host = request.headers.host;
182
- if (origin === undefined || host === undefined)
183
- return false;
184
- try {
185
- return new URL(origin).host === host;
186
- }
187
- catch {
188
- return false;
189
- }
190
- }
191
- async function readJsonBody(request) {
192
- const chunks = [];
193
- let size = 0;
194
- for await (const chunk of request) {
195
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
196
- size += buffer.length;
197
- if (size > 4096)
198
- throw new Error('request body too large');
199
- chunks.push(buffer);
200
- }
201
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
202
- }
203
- function profileDir(profile) {
204
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
205
- return join(home, 'profiles', profile);
206
- }
207
- /** Community dependencies of the profile (official in-box scope filtered out). */
208
- function readInstalled(profile) {
209
- try {
210
- const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8'));
211
- const installed = {};
212
- for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
213
- if (!name.startsWith('@deepseek-ai/'))
214
- installed[name] = spec;
215
- }
216
- return installed;
217
- }
218
- catch {
219
- return {};
220
- }
221
- }
222
- /** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
223
- /**
224
- * Parse a registry source url: a github repo, optionally with a
225
- * `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
226
- * subpackages, e.g. dsh-plugins#theme-gallery).
227
- */
228
- function parseSourceUrl(url) {
229
- const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url);
230
- if (m === null || !REPO_RE.test(m[1]))
231
- return null;
232
- const subpath = m[2] ?? null;
233
- if (subpath !== null && !/^[A-Za-z0-9_./-]+$/.test(subpath))
234
- return null;
235
- return { repo: m[1], subpath };
236
- }
237
- function repoOf(url) {
238
- return parseSourceUrl(url)?.repo ?? null;
239
- }
240
- /** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
241
- function readLockCommits(profile) {
242
- const commits = new Map();
243
- try {
244
- const lock = readFileSync(join(profileDir(profile), 'pnpm-lock.yaml'), 'utf8');
245
- for (const m of lock.matchAll(/codeload\.github\.com\/([^/\s]+\/[^/\s]+)\/tar\.gz\/([0-9a-f]{40})/g)) {
246
- commits.set(m[1].toLowerCase(), m[2]);
247
- }
248
- }
249
- catch { /* no lockfile — no git installs to report */ }
250
- return commits;
251
- }
252
- /**
253
- * Some registry entries point at collection repos whose actual plugin lives
254
- * in a subdirectory — the root has no package.json, and pnpm installs the
255
- * bare fileset with exit 0. Detect that junk install, drop it, and re-add
256
- * each plugin subdirectory through pnpm's `#path:` selector.
257
- * @returns overall success (true when nothing needed retargeting).
258
- */
259
- /**
260
- * True when the package's declared entry artifact actually exists — github
261
- * source checkouts of build-required plugins ship no lib/, and promoting one
262
- * into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
263
- * whole profile, #18).
264
- */
265
- function entryArtifactExists(dir) {
266
- try {
267
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
268
- const candidates = [];
269
- if (typeof manifest.main === 'string')
270
- candidates.push(manifest.main);
271
- const rootExport = typeof manifest.exports === 'string'
272
- ? manifest.exports
273
- : manifest.exports?.['.'];
274
- if (typeof rootExport === 'string')
275
- candidates.push(rootExport);
276
- else if (rootExport !== null && typeof rootExport === 'object') {
277
- for (const value of Object.values(rootExport))
278
- if (typeof value === 'string')
279
- candidates.push(value);
280
- }
281
- if (candidates.length === 0)
282
- candidates.push('index.js');
283
- return candidates.some(rel => existsSync(join(dir, rel)));
284
- }
285
- catch {
286
- return false;
287
- }
288
- }
289
- /** True when the installed package's manifest declares a dsh plugin surface. */
290
- function hasDshManifest(dir) {
291
- try {
292
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
293
- return manifest.dsh !== undefined;
294
- }
295
- catch {
296
- return false;
297
- }
298
- }
299
- /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
300
- function pluginSubdirs(root) {
301
- const found = [];
302
- let level1 = [];
303
- try {
304
- level1 = readdirSync(root, { withFileTypes: true })
305
- .filter(dirent => dirent.isDirectory() && /^[A-Za-z0-9_.-]+$/.test(dirent.name) && dirent.name !== 'node_modules')
306
- .map(dirent => dirent.name);
307
- }
308
- catch {
309
- return found;
310
- }
311
- for (const sub of level1) {
312
- if (hasDshManifest(join(root, sub))) {
313
- found.push(sub);
314
- continue;
315
- }
316
- try {
317
- for (const inner of readdirSync(join(root, sub), { withFileTypes: true })) {
318
- if (!inner.isDirectory() || !/^[A-Za-z0-9_.-]+$/.test(inner.name) || inner.name === 'node_modules')
319
- continue;
320
- if (hasDshManifest(join(root, sub, inner.name)))
321
- found.push(`${sub}/${inner.name}`);
322
- }
323
- }
324
- catch { /* unreadable level — skip */ }
325
- if (found.length >= 8)
326
- break;
327
- }
328
- return found.slice(0, 8);
329
- }
330
- async function retargetCollections(profile, before, target) {
331
- if (!target.startsWith('github:'))
332
- return true;
333
- // A collection checkout: no root package.json at all, or a root manifest
334
- // that declares no dsh surface (workspace roots are usually also private
335
- // with no entry point — #18).
336
- const junk = Object.keys(readInstalled(profile)).filter((name) => {
337
- if (before.has(name))
338
- return false;
339
- const root = join(profileDir(profile), 'node_modules', name);
340
- if (!existsSync(join(root, 'package.json')))
341
- return true;
342
- return !hasDshManifest(root);
343
- });
344
- let allOk = true;
345
- for (const name of junk) {
346
- const root = join(profileDir(profile), 'node_modules', name);
347
- const candidates = pluginSubdirs(root);
348
- logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`);
349
- await runDshPlugin(profile, ['remove', name]);
350
- if (candidates.length === 0) {
351
- allOk = false;
352
- continue;
353
- }
354
- for (const sub of candidates) {
355
- const result = await runDshPlugin(profile, ['add', `${target}#path:/${sub}`]);
356
- if (result.exitCode !== 0 || result.timedOut) {
357
- allOk = false;
358
- logEvent('error', 'install', `${target}#path:/${sub}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`);
359
- }
360
- }
361
- }
362
- return allOk;
363
- }
364
- function readInstalledVersion(profile, name) {
365
- try {
366
- const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'node_modules', name, 'package.json'), 'utf8'));
367
- return manifest.version ?? null;
368
- }
369
- catch {
370
- return null;
371
- }
372
- }
373
- const UPDATES_TTL_MS = 30 * 60 * 1000;
374
- let updatesCache = null;
375
- async function fetchJson(url) {
376
- const res = await fetch(url, {
377
- headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
378
- signal: AbortSignal.timeout(4000),
379
- });
380
- if (!res.ok)
381
- throw new Error(`HTTP ${res.status}`);
382
- return res.json();
383
- }
384
- /** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
385
- async function checkUpdates(profile, force = false) {
386
- if (!force && updatesCache && Date.now() - updatesCache.at < UPDATES_TTL_MS)
387
- return updatesCache.data;
388
- const installed = readInstalled(profile);
389
- const lockCommits = readLockCommits(profile);
390
- const result = {};
391
- await Promise.all(Object.entries(installed).map(async ([name, spec]) => {
392
- const version = readInstalledVersion(profile, name);
393
- if (spec.startsWith('link:') || spec.startsWith('file:')) {
394
- result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false };
395
- return;
396
- }
397
- const gh = /^(?:github:)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:#.*)?$/.exec(spec);
398
- try {
399
- if (spec.startsWith('github:') && gh !== null) {
400
- const current = lockCommits.get(gh[1].toLowerCase()) ?? null;
401
- const head = (await fetchJson(`https://api.github.com/repos/${gh[1]}/commits/HEAD`));
402
- const latest = typeof head.sha === 'string' ? head.sha : null;
403
- result[name] = {
404
- kind: 'github', version, current, latest,
405
- updateAvailable: current !== null && latest !== null && current !== latest,
406
- };
407
- }
408
- else {
409
- const meta = (await fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`));
410
- const latest = typeof meta.version === 'string' ? meta.version : null;
411
- result[name] = {
412
- kind: 'npm', version, current: version, latest,
413
- updateAvailable: version !== null && latest !== null && version !== latest,
414
- };
415
- }
416
- }
417
- catch {
418
- result[name] = { kind: spec.startsWith('github:') ? 'github' : 'npm', version, current: null, latest: null, updateAvailable: false };
419
- }
420
- }));
421
- updatesCache = { at: Date.now(), data: result };
422
- return result;
423
- }
424
23
  /**
425
24
  * Register the market's HTTP routes.
426
25
  * @param host - Acquired webServer + shell services.
@@ -434,11 +33,12 @@ export function mountMarketRoutes(host, config) {
434
33
  // Boot-time wipe: stale hot-mount inputs from a previous session must never
435
34
  // survive into a composition where the bundle layer already covers them.
436
35
  cleanHotDir(profileDir(config.profile));
36
+ // The user's persisted theme choice; activateTheme mutates and writes it.
37
+ const disabledThemes = readDisabledThemes(profileDir(config.profile));
38
+ const themes = createThemeManager(host, config.profile, disabledThemes);
437
39
  // Client-only packages (dsh.client without dsh.bundle) are invisible to the
438
40
  // bundle layer in every boot; the market shim-mounts them so their client
439
41
  // bundles are actually served.
440
- // The user's persisted theme choice; activateTheme mutates and writes it.
441
- const disabledThemes = readDisabledThemes(profileDir(config.profile));
442
42
  void mountClientOnlyDeps(host, profileDir(config.profile)).then(async (mounted) => {
443
43
  if (mounted.length > 0)
444
44
  logEvent('info', 'boot', `client-only shims mounted: ${mounted.join(', ')}`);
@@ -446,7 +46,7 @@ export function mountMarketRoutes(host, config) {
446
46
  // from get live-disabled again (bundle trees are in-memory, so the
447
47
  // disable never persists on its own).
448
48
  for (const name of disabledThemes) {
449
- if (await setEntryDisabled(name, true))
49
+ if (await themes.setEntryDisabled(name, true))
450
50
  logEvent('info', 'boot', `theme kept off: ${name}`);
451
51
  }
452
52
  });
@@ -456,92 +56,25 @@ export function mountMarketRoutes(host, config) {
456
56
  host.on?.('internal/plugin', (fiber) => {
457
57
  const name = fiber.entry?.options?.name;
458
58
  if (name !== undefined && disabledThemes.has(name))
459
- void setEntryDisabled(name, true);
59
+ void themes.setEntryDisabled(name, true);
460
60
  });
461
61
  let installing = false;
462
- /** Installed package names classified as themes by the registry's theme category. */
463
- async function installedThemeNames(profile) {
464
- const names = new Set();
465
- try {
466
- const { registry } = await loadRegistry();
467
- const themeEntries = registry.plugins.filter(p => p.category === 'theme');
468
- const themeNames = new Set(themeEntries.map(p => p.name));
469
- const themeRepos = new Set(themeEntries.map(p => repoOf(p.url)).filter((r) => r !== null).map(r => r.toLowerCase()));
470
- for (const [name, spec] of Object.entries(readInstalled(profile))) {
471
- if (themeNames.has(name)) {
472
- names.add(name);
473
- continue;
474
- }
475
- const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase());
476
- if (match !== null && themeRepos.has(match[1]))
477
- names.add(name);
478
- }
479
- }
480
- catch { /* registry unavailable — nothing classifies as a theme */ }
481
- return names;
482
- }
483
- /**
484
- * Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
485
- * are in-memory (write is a no-op), so this never touches any file — the
486
- * market persists the choice itself and replays it at boot.
487
- * @returns true when a matching live entry was found and updated.
488
- */
489
- async function setEntryDisabled(name, disabledFlag) {
490
- let found = false;
491
- for (const entry of host.loader.entries()) {
492
- if (entry.options.name !== name)
493
- continue;
494
- // A disable can land while the entry's init is still in flight: the
495
- // options flip but the finishing init brings the fiber up anyway, and a
496
- // plain re-update no-ops on the empty diff. Force the update and verify
497
- // the live state, retrying until reality matches the flag.
498
- for (let attempt = 0; attempt < 3; attempt++) {
499
- try {
500
- await entry.update({ disabled: disabledFlag ? true : null }, false, true);
501
- found = true;
502
- }
503
- catch (error) {
504
- logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`);
505
- break;
506
- }
507
- const live = entry.fiber !== undefined;
508
- if (live !== disabledFlag)
509
- break;
510
- await new Promise(resolvePromise => setTimeout(resolvePromise, 200));
511
- }
512
- logEvent('info', 'toggle', `${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`);
513
- }
514
- if (!found)
515
- logEvent('info', 'toggle', `${name}: no loader entry matched`);
516
- return found;
517
- }
518
62
  /**
519
- * Make `name` the one active theme: deactivate every other installed theme
520
- * (market hot mounts unmount; bundle-layer entries live-disable) and bring
521
- * it up. The choice persists in state.json and is replayed at boot.
63
+ * Drop live hot mounts whose package was removed outside the market
64
+ * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
65
+ * serving a client bundle that 404s after refresh, wedging the page
66
+ * until a restart (#29 by @SunYanbox).
522
67
  */
523
- async function activateTheme(name) {
524
- const dir = profileDir(config.profile);
525
- const themes = await installedThemeNames(config.profile);
526
- for (const other of themes) {
527
- if (other === name)
68
+ async function dropStaleHotMounts() {
69
+ for (const name of listHotMounts()) {
70
+ if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json')))
528
71
  continue;
529
- if (listHotMounts().includes(other)) {
530
- await hotUnmount(other);
531
- disabledThemes.add(other);
532
- }
533
- else if (await setEntryDisabled(other, true)) {
534
- disabledThemes.add(other);
535
- }
72
+ await hotUnmount(name);
73
+ logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
536
74
  }
537
- disabledThemes.delete(name);
538
- writeDisabledThemes(dir, disabledThemes);
539
- if (listHotMounts().includes(name))
540
- return true;
541
- if (await setEntryDisabled(name, false))
542
- return true;
543
- return hotMount(host, dir, name);
544
75
  }
76
+ /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
77
+ const runPlugin = (profile, args) => withHoistRecovery(runDshPlugin, profile, args);
545
78
  const disposers = [
546
79
  host.webServer.register({
547
80
  kind: 'exact',
@@ -564,12 +97,13 @@ export function mountMarketRoutes(host, config) {
564
97
  host.webServer.register({
565
98
  kind: 'exact',
566
99
  path: '/dsh-market/installed',
567
- handler: (request, response) => {
100
+ handler: async (request, response) => {
568
101
  if (request.method !== 'GET') {
569
102
  response.writeHead(405, { allow: 'GET' });
570
103
  response.end();
571
104
  return;
572
105
  }
106
+ await dropStaleHotMounts();
573
107
  sendJson(response, 200, {
574
108
  profile: config.profile,
575
109
  installed: readInstalled(config.profile),
@@ -594,12 +128,12 @@ export function mountMarketRoutes(host, config) {
594
128
  const body = (await readJsonBody(request));
595
129
  const name = typeof body.name === 'string' ? body.name : '';
596
130
  const installed = readInstalled(config.profile);
597
- const themes = await installedThemeNames(config.profile);
598
- if (installed[name] === undefined || !themes.has(name)) {
131
+ const themeNames = await themes.installedThemeNames();
132
+ if (installed[name] === undefined || !themeNames.has(name)) {
599
133
  sendJson(response, 400, { error: 'not an installed theme' });
600
134
  return;
601
135
  }
602
- const activated = await activateTheme(name);
136
+ const activated = await themes.activateTheme(name);
603
137
  logEvent(activated ? 'info' : 'error', 'use-skin', `${name}: ${activated ? 'active' : 'failed'}`);
604
138
  sendJson(response, activated ? 200 : 502, { ok: activated, live: listHotMounts() });
605
139
  }
@@ -619,6 +153,7 @@ export function mountMarketRoutes(host, config) {
619
153
  response.end();
620
154
  return;
621
155
  }
156
+ await dropStaleHotMounts();
622
157
  sendJson(response, 200, {
623
158
  active: progress.active,
624
159
  target: progress.target,
@@ -695,6 +230,7 @@ export function mountMarketRoutes(host, config) {
695
230
  try {
696
231
  const body = (await readJsonBody(request));
697
232
  const name = typeof body.name === 'string' ? body.name : '';
233
+ const force = body.force === true;
698
234
  const spec = readInstalled(config.profile)[name];
699
235
  if (spec === undefined) {
700
236
  sendJson(response, 400, { error: 'plugin is not installed' });
@@ -713,29 +249,35 @@ export function mountMarketRoutes(host, config) {
713
249
  const beforeCommit = repoKey !== null ? readLockCommits(config.profile).get(repoKey) ?? null : null;
714
250
  installing = true;
715
251
  try {
716
- const result = await runDshPlugin(config.profile, ['add', target]);
717
- let ok = result.exitCode === 0 && !result.timedOut;
252
+ // force: the user chose to install a fresh release without the
253
+ // default one-day safety wait; scoped to this single command.
254
+ const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target];
255
+ const result = await runPlugin(config.profile, addArgs);
256
+ const cancelled = result.cancelled;
257
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
718
258
  let stale = false;
719
259
  if (ok) {
720
- // pnpm's minimumReleaseAge silently keeps the old version and
721
- // exits 0 when the new release is "too young" (#13) — a clean
722
- // exit alone does not mean the update happened.
723
- const afterVersion = readInstalledVersion(config.profile, name);
724
- const afterCommit = repoKey !== null ? readLockCommits(config.profile).get(repoKey) ?? null : null;
725
- stale = isGit
726
- ? beforeCommit !== null && afterCommit === beforeCommit
727
- : beforeVersion !== null && afterVersion === beforeVersion;
260
+ stale = isStaleUpdate({
261
+ isGit,
262
+ beforeVersion,
263
+ afterVersion: readInstalledVersion(config.profile, name),
264
+ beforeCommit,
265
+ afterCommit: repoKey !== null ? readLockCommits(config.profile).get(repoKey) ?? null : null,
266
+ });
728
267
  if (stale)
729
268
  ok = false;
730
269
  }
731
270
  if (ok)
732
- updatesCache = null;
271
+ invalidateUpdates();
733
272
  const staleError = stale
734
- ? `still v${beforeVersion ?? beforeCommit?.slice(0, 7) ?? '?'} after the update — pnpm 的 minimumReleaseAge 安全策略会暂时拦下发布不久的新版本(静默保留旧版且返回成功)。请稍后再试;着急可调整 profile pnpm-workspace.yaml 的 minimumReleaseAge / pnpm's minimumReleaseAge holds back very fresh releases; retry later or tune it in the profile's pnpm-workspace.yaml`
273
+ ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
735
274
  : null;
736
- logEvent(ok ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
737
- sendJson(response, ok ? 200 : 502, {
275
+ logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok || cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
276
+ // A user-cancelled run is a quiet outcome, not an error.
277
+ sendJson(response, ok || cancelled ? 200 : 502, {
738
278
  ok,
279
+ cancelled: cancelled || undefined,
280
+ stale: stale || undefined,
739
281
  error: staleError ?? undefined,
740
282
  exitCode: result.exitCode,
741
283
  timedOut: result.timedOut,
@@ -777,6 +319,64 @@ export function mountMarketRoutes(host, config) {
777
319
  }
778
320
  },
779
321
  }),
322
+ host.webServer.register({
323
+ kind: 'exact',
324
+ path: '/dsh-market/approve-builds',
325
+ handler: async (request, response) => {
326
+ if (request.method !== 'POST') {
327
+ response.writeHead(405, { allow: 'POST' });
328
+ response.end();
329
+ return;
330
+ }
331
+ if (!sameOrigin(request)) {
332
+ sendJson(response, 403, { error: 'untrusted origin' });
333
+ return;
334
+ }
335
+ try {
336
+ // One-click build-script approval (#6 by @qichuang321): only
337
+ // packages ALREADY installed in the profile can be allowed — the
338
+ // list is not free input.
339
+ const body = (await readJsonBody(request));
340
+ const installed = readInstalled(config.profile);
341
+ const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
342
+ .filter(name => installed[name] !== undefined);
343
+ if (packages.length === 0) {
344
+ sendJson(response, 400, { error: 'no installed packages given' });
345
+ return;
346
+ }
347
+ const approved = setAllowBuilds(config.profile, packages);
348
+ logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
349
+ sendJson(response, 200, { ok: true, approved });
350
+ }
351
+ catch (error) {
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ logEvent('error', 'approve-builds', `route error: ${message}`);
354
+ sendJson(response, 500, { error: message });
355
+ }
356
+ },
357
+ }),
358
+ host.webServer.register({
359
+ kind: 'exact',
360
+ path: '/dsh-market/cancel',
361
+ handler: async (request, response) => {
362
+ if (request.method !== 'POST') {
363
+ response.writeHead(405, { allow: 'POST' });
364
+ response.end();
365
+ return;
366
+ }
367
+ if (!sameOrigin(request)) {
368
+ sendJson(response, 403, { error: 'untrusted origin' });
369
+ return;
370
+ }
371
+ // Cancel flow contributed in #6 by @qichuang321.
372
+ if (!cancelActive()) {
373
+ sendJson(response, 400, { error: 'no operation is running' });
374
+ return;
375
+ }
376
+ logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
377
+ sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
378
+ },
379
+ }),
780
380
  host.webServer.register({
781
381
  kind: 'exact',
782
382
  path: '/dsh-market/uninstall',
@@ -807,16 +407,18 @@ export function mountMarketRoutes(host, config) {
807
407
  }
808
408
  installing = true;
809
409
  try {
810
- const result = await runDshPlugin(config.profile, ['remove', name]);
811
- const ok = result.exitCode === 0 && !result.timedOut;
410
+ const result = await runPlugin(config.profile, ['remove', name]);
411
+ const cancelled = result.cancelled;
412
+ const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
812
413
  let hot = false;
813
414
  if (ok) {
814
- updatesCache = null;
415
+ invalidateUpdates();
815
416
  hot = await hotUnmount(name);
816
417
  }
817
- logEvent(ok ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${ok ? ` live-removed=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
818
- sendJson(response, ok ? 200 : 502, {
418
+ logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
419
+ sendJson(response, ok || cancelled ? 200 : 502, {
819
420
  ok,
421
+ cancelled: cancelled || undefined,
820
422
  hot,
821
423
  exitCode: result.exitCode,
822
424
  stdout: result.stdout,
@@ -863,59 +465,49 @@ export function mountMarketRoutes(host, config) {
863
465
  sendJson(response, 400, { error: 'plugin is not in the curated registry' });
864
466
  return;
865
467
  }
866
- const source = parseSourceUrl(entry.url);
867
- if (source === null) {
468
+ const target = installTargetFor(entry);
469
+ if (target === null) {
868
470
  sendJson(response, 400, { error: 'unsupported source url' });
869
471
  return;
870
472
  }
871
- const repo = source.repo;
872
- // Registry tarballs beat full-repo GitHub downloads: smaller,
873
- // prebuilt, and CDN/mirror served. The npm name comes from our
874
- // curated registry, which only maps repo-verified packages.
875
- const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
876
- const target = typeof entry.npm === 'string' && NPM_NAME_RE.test(entry.npm)
877
- ? entry.npm
878
- : source.subpath !== null
879
- ? `github:${repo}#path:/${source.subpath}`
880
- : `github:${repo}`;
473
+ // Duplicate guard (#27): the same plugin listed under another name
474
+ // (an alias entry pointing at the same repo) must never install
475
+ // twice — two loader entries with one id brick the next boot.
476
+ // Monorepo subpath entries (distinct plugins in one repo) pass:
477
+ // their entry urls differ by subpath and identity is name-based.
478
+ const aliasOf = findInstalledAlias(entry, readInstalled(config.profile));
479
+ if (aliasOf !== null) {
480
+ logEvent('warn', 'install-rejected', `${entry.name}: same plugin already installed as ${aliasOf}`);
481
+ sendJson(response, 400, { error: `已以「${aliasOf}」安装过同一个插件,无需重复安装 / this plugin is already installed as "${aliasOf}"` });
482
+ return;
483
+ }
881
484
  installing = true;
882
485
  try {
883
486
  const before = new Set(Object.keys(readInstalled(config.profile)));
884
- const result = await runDshPlugin(config.profile, ['add', target]);
885
- let ok = result.exitCode === 0 && !result.timedOut;
487
+ const result = await runPlugin(config.profile, ['add', target]);
488
+ const cancelled = result.cancelled;
489
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
886
490
  if (ok)
887
- updatesCache = null;
491
+ invalidateUpdates();
888
492
  if (ok) {
889
493
  // Collection repos (e.g. skin monorepos) install as a junk
890
494
  // fileset with no root package.json; retarget to the real
891
495
  // plugin subdirectories via pnpm's #path: selector.
892
- ok = await retargetCollections(config.profile, before, target);
496
+ ok = await retargetCollections(runPlugin, config.profile, before, target);
893
497
  }
894
498
  // Fake-success guard (#18): a clean exit that added nothing
895
- // installable must not read as success — and a plugin whose entry
896
- // artifact is missing (source-only checkout, build blocked) would
897
- // brick the next boot, so it is removed on the spot.
499
+ // installable must not read as success. Runs even when
500
+ // retargeting partially failed — a broken piece that slipped in
501
+ // must never survive to brick the next boot.
898
502
  let notAPlugin = false;
899
503
  let removedBroken = [];
900
- // Runs even when retargeting partially failed — a broken piece
901
- // that slipped in must never survive to brick the next boot.
902
- if (result.exitCode === 0 && !result.timedOut) {
903
- const addedNow = Object.keys(readInstalled(config.profile)).filter(n => !before.has(n));
904
- const keep = [];
905
- for (const n of addedNow) {
906
- const dir = join(profileDir(config.profile), 'node_modules', n);
907
- if (hasDshManifest(dir) && entryArtifactExists(dir)) {
908
- keep.push(n);
909
- }
910
- else {
911
- removedBroken.push(n);
912
- await runDshPlugin(config.profile, ['remove', n]);
913
- }
914
- }
504
+ if (result.exitCode === 0 && !result.timedOut && !cancelled) {
505
+ const validated = await validateAddedPlugins(runPlugin, config.profile, before);
506
+ removedBroken = validated.removedBroken;
915
507
  if (removedBroken.length > 0) {
916
508
  logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`);
917
509
  }
918
- if (keep.length === 0) {
510
+ if (validated.keep.length === 0) {
919
511
  ok = false;
920
512
  notAPlugin = true;
921
513
  logEvent('error', 'install', `${target}: nothing installable survived validation`);
@@ -935,17 +527,19 @@ export function mountMarketRoutes(host, config) {
935
527
  hot = true;
936
528
  for (const name of added) {
937
529
  const live = entry.category === 'theme'
938
- ? await activateTheme(name)
530
+ ? await themes.activateTheme(name)
939
531
  : await hotMount(host, profileDir(config.profile), name);
940
532
  if (!live)
941
533
  hot = false;
942
534
  }
943
535
  }
944
536
  }
945
- logEvent(ok ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${ok ? ` hot=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
946
- sendJson(response, ok ? 200 : 502, {
537
+ logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
538
+ sendJson(response, ok || cancelled ? 200 : 502, {
947
539
  ok,
540
+ cancelled: cancelled || undefined,
948
541
  hot,
542
+ ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined; })(),
949
543
  error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
950
544
  exitCode: result.exitCode,
951
545
  timedOut: result.timedOut,