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