dshmarket 1.2.2 → 1.2.3

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.
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, use
7
7
  import { Button, IconChevronDownOutline14, IconChevronUpOutline14, IconSearchOutline16, Input, Modal, Pill, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
8
8
  import css from './Market.module.css'
9
9
  import {
10
- avatarColor, entryForDep, isInstalled, LOGO_URI, looksTerminal, matchInstalledName, readSession, repoOf, themeSwatch,
10
+ avatarColor, entryForDep, isInstalled, LOGO_URI, looksTerminal, matchInstalledName, orderedCategories,
11
+ readSession, repoOf, themePlugins as themePluginsOf, themeSwatch, visiblePlugins,
11
12
  } from './market-data.ts'
12
13
  import type {
13
14
  InstalledMap, Registry, RegistryPlugin, ThemeSnapshot, Translate, UpdateStatus,
@@ -79,6 +80,8 @@ export function MarketSection(props: MarketSectionProps) {
79
80
  const [installError, setInstallError] = useState<string | null>(null)
80
81
  const [updates, setUpdates] = useState<Record<string, UpdateStatus>>({})
81
82
  const [updatingName, setUpdatingName] = useState<string | null>(null)
83
+ // Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button.
84
+ const [staleName, setStaleName] = useState<string | null>(null)
82
85
  const [updatingAll, setUpdatingAll] = useState(false)
83
86
  const [updatedNames, setUpdatedNames] = useState<string[]>([])
84
87
  const [hotUrls, setHotUrls] = useState<string[]>([])
@@ -209,25 +212,9 @@ export function MarketSection(props: MarketSectionProps) {
209
212
  return () => clearInterval(timer)
210
213
  }, [busyUrl, updatingName, data])
211
214
 
212
- const plugins = useMemo(() => {
213
- if (data === null) return []
214
- const query = q.trim().toLowerCase()
215
- const list = data.plugins.filter(p => {
216
- if (cat !== 'all' && p.category !== cat) return false
217
- if (query === '') return true
218
- const desc = (p.description && (p.description[lang] || p.description.en)) || ''
219
- return p.name.toLowerCase().includes(query)
220
- || p.owner.toLowerCase().includes(query)
221
- || desc.toLowerCase().includes(query)
222
- })
223
- if (sort === 'hot') {
224
- return [...list].sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1))
225
- }
226
- if (sort === 'new') {
227
- return [...list].sort((a, b) => String(b.added).localeCompare(String(a.added)))
228
- }
229
- return list
230
- }, [data, q, cat, lang, sort])
215
+ const plugins = useMemo(
216
+ () => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, sort })),
217
+ [data, q, cat, lang, sort])
231
218
 
232
219
  const doInstall = useCallback((plugin: RegistryPlugin) => {
233
220
  setConfirming(null)
@@ -272,13 +259,14 @@ export function MarketSection(props: MarketSectionProps) {
272
259
  .finally(() => setBusyUrl(null))
273
260
  }, [refreshInstalled, t])
274
261
 
275
- const doUpdate = useCallback((name: string) => {
262
+ const doUpdate = useCallback((name: string, force = false) => {
276
263
  setInstallError(null)
264
+ setStaleName(null)
277
265
  setUpdatingName(name)
278
266
  return fetch('/dsh-market/update', {
279
267
  method: 'POST',
280
268
  headers: { 'content-type': 'application/json' },
281
- body: JSON.stringify({ name }),
269
+ body: JSON.stringify(force ? { name, force: true } : { name }),
282
270
  })
283
271
  .then(res => res.json().then(body => ({ status: res.status, body })))
284
272
  .then(({ status, body }) => {
@@ -286,6 +274,7 @@ export function MarketSection(props: MarketSectionProps) {
286
274
  setUpdatedNames(names => names.concat(name))
287
275
  refreshInstalled()
288
276
  } else {
277
+ if (body.stale === true) setStaleName(name)
289
278
  const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
290
279
  const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
291
280
  setInstallError(t('updateFail') + ': ' + name + ' — ' + detail.trim().slice(-600))
@@ -365,9 +354,7 @@ export function MarketSection(props: MarketSectionProps) {
365
354
  name => !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable,
366
355
  )
367
356
 
368
- const themePlugins = data === null ? [] : data.plugins
369
- .filter(p => p.category === 'theme')
370
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
357
+ const themePlugins = data === null ? [] : themePluginsOf(data.plugins)
371
358
 
372
359
  const pluginCard = (p: RegistryPlugin) => {
373
360
  const desc = (p.description && (p.description[lang] || p.description.en)) || ''
@@ -591,7 +578,16 @@ export function MarketSection(props: MarketSectionProps) {
591
578
  </div>
592
579
  )}
593
580
  </div>
594
- {installError !== null && <div className={css.err}>{installError}</div>}
581
+ {installError !== null && (
582
+ <div className={css.err}>
583
+ {installError}
584
+ {staleName !== null && (
585
+ <div className={css.staleAction}>
586
+ <Button size="sm" onClick={() => doUpdate(staleName, true)}>{t('updateNow')}</Button>
587
+ </div>
588
+ )}
589
+ </div>
590
+ )}
595
591
  <div
596
592
  className={css.body}
597
593
  ref={bodyRef}
@@ -609,7 +605,7 @@ export function MarketSection(props: MarketSectionProps) {
609
605
  <div ref={catsWrapRef} className={catsOpen || visibleCats === null ? `${css.catsWrap} ${css.catsCollapsed}` : css.catsWrap}>
610
606
  {(() => {
611
607
  // Collapsed, the selected category is pulled to the front so it never hides.
612
- const ordered = catsOpen || cat === 'all' ? categories : [cat, ...categories.filter(id => id !== cat)]
608
+ const ordered = orderedCategories(categories, cat, catsOpen)
613
609
  const shown = catsOpen || visibleCats === null ? ordered : ordered.slice(0, Math.max(0, visibleCats - 1))
614
610
  return (
615
611
  <>
@@ -29,6 +29,7 @@ export const zh = {
29
29
  update: '更新',
30
30
  updating: '更新中…',
31
31
  updated: '✓ 已更新,重启后生效',
32
+ updateNow: '立即更新',
32
33
  updateFail: '更新失败',
33
34
  upToDate: '已是最新',
34
35
  linkedDev: '本地开发链接',
@@ -91,6 +92,7 @@ export const en: Record<MarketKey, string> = {
91
92
  update: 'Update',
92
93
  updating: 'Updating…',
93
94
  updated: '✓ Updated — restart to apply',
95
+ updateNow: 'Update now',
94
96
  updateFail: 'Update failed',
95
97
  upToDate: 'Up to date',
96
98
  linkedDev: 'linked (dev)',
@@ -84,6 +84,55 @@ export function looksTerminal(plugin: RegistryPlugin, lang: string): boolean {
84
84
  return /\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name + ' ' + desc)
85
85
  }
86
86
 
87
+ /** Filters and sort order driving the discover list. */
88
+ export interface ListQuery {
89
+ /** Active category id, or 'all'. */
90
+ category: string
91
+ /** Raw search input (trimmed and lowercased internally). */
92
+ query: string
93
+ /** UI language for description matching ('zh' / 'en'). */
94
+ lang: string
95
+ /** 'hot' (stars desc), 'new' (added desc), anything else keeps registry order. */
96
+ sort: string
97
+ }
98
+
99
+ /**
100
+ * The discover list: category filter, then search across name / owner /
101
+ * localized description, then the selected sort. Pure — the section renders
102
+ * exactly this.
103
+ */
104
+ export function visiblePlugins(plugins: RegistryPlugin[], options: ListQuery): RegistryPlugin[] {
105
+ const query = options.query.trim().toLowerCase()
106
+ const list = plugins.filter((p) => {
107
+ if (options.category !== 'all' && p.category !== options.category) return false
108
+ if (query === '') return true
109
+ const desc = (p.description && (p.description[options.lang] || p.description.en)) || ''
110
+ return p.name.toLowerCase().includes(query)
111
+ || p.owner.toLowerCase().includes(query)
112
+ || desc.toLowerCase().includes(query)
113
+ })
114
+ if (options.sort === 'hot') {
115
+ return [...list].sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1))
116
+ }
117
+ if (options.sort === 'new') {
118
+ return [...list].sort((a, b) => String(b.added).localeCompare(String(a.added)))
119
+ }
120
+ return list
121
+ }
122
+
123
+ /** The themes tab listing: theme category only, most-starred first. */
124
+ export function themePlugins(plugins: RegistryPlugin[]): RegistryPlugin[] {
125
+ return plugins.filter(p => p.category === 'theme').sort((a, b) => (b.stars || 0) - (a.stars || 0))
126
+ }
127
+
128
+ /**
129
+ * Category chip order: collapsed with an active non-'all' chip, the active
130
+ * one moves to the front so it stays visible inside the two-row clip.
131
+ */
132
+ export function orderedCategories(categories: string[], active: string, open: boolean): string[] {
133
+ return open || active === 'all' ? categories : [active, ...categories.filter(id => id !== active)]
134
+ }
135
+
87
136
  /**
88
137
  * Unified installed-state matching (#15): both sides collapse to lowercase
89
138
  * identity sets — the registry entry contributes its bare name, npm name and
@@ -94,8 +143,12 @@ export function looksTerminal(plugin: RegistryPlugin, lang: string): boolean {
94
143
  function entryIdentities(plugin: RegistryPlugin): Set<string> {
95
144
  const ids = new Set<string>([plugin.name.toLowerCase()])
96
145
  if (plugin.npm) ids.add(plugin.npm.toLowerCase())
97
- const repo = repoOf(plugin.url)
98
- if (repo !== null) ids.add(repo.toLowerCase())
146
+ // Subpath-aware: a /tree/ entry identifies as repo#path:/sub, never the
147
+ // bare repo — two subpackages of one monorepo must not cross-match.
148
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(plugin.url)
149
+ if (m !== null) {
150
+ ids.add(m[2] !== undefined ? `${m[1]!.toLowerCase()}#path:/${m[2].toLowerCase()}` : m[1]!.toLowerCase())
151
+ }
99
152
  return ids
100
153
  }
101
154
 
@@ -105,8 +158,11 @@ function depIdentities(name: string, spec: string): Set<string> {
105
158
  // npm-installed plugin still matches an entry whose npm field is unset.
106
159
  const scoped = /^@([^/]+)\/(.+)$/.exec(name)
107
160
  if (scoped !== null) ids.add(`${scoped[1]!.toLowerCase()}/${scoped[2]!.toLowerCase()}`)
108
- const match = /github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)/i.exec(spec)
109
- if (match !== null) ids.add(match[1]!.toLowerCase())
161
+ const match = /github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:#path:\/([A-Za-z0-9_./-]+))?/i.exec(spec)
162
+ if (match !== null) {
163
+ ids.add(match[1]!.toLowerCase())
164
+ if (match[2] !== undefined) ids.add(`${match[1]!.toLowerCase()}#path:/${match[2].toLowerCase()}`)
165
+ }
110
166
  return ids
111
167
  }
112
168
 
package/src/dsh-cli.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Process layer: re-invoking the dsh CLI that launched this host, spawning
3
+ * `dsh plugin` commands with timeouts and live progress, and provisioning
4
+ * pnpm. This is the only module that starts child processes.
5
+ *
6
+ * Installs run through node:child_process, not ctx.shell: the shell service is
7
+ * the agent's sandboxed executor and denies writes to the profile directory.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import type { ChildProcess } from 'node:child_process'
12
+ import { dirname, resolve } from 'node:path'
13
+ import { logEvent } from './log.ts'
14
+ import { pluginArgsFor } from './pnpm-compat.ts'
15
+ import { profileDir } from './profile.ts'
16
+
17
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1000
18
+
19
+ /**
20
+ * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
21
+ * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
22
+ */
23
+ export const winCmdShim = process.platform === 'win32'
24
+
25
+ /**
26
+ * Argv re-invoking the CLI that launched this host process, so installs work
27
+ * whether dsh runs from a global bin, a local install, or repo source
28
+ * (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
29
+ */
30
+ export function dshArgv(): { file: string; args: string[]; cwd: string | undefined; viaShell: boolean } {
31
+ const entry = process.argv[1]
32
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
33
+ // Absolute paths are required: source launches (`pnpm dsh`) pass a
34
+ // relative entry, which the child resolves against its OWN cwd and dies
35
+ // with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
36
+ // (tsx/esm) resolvable on source launches.
37
+ const abs = resolve(entry)
38
+ return { file: process.execPath, args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
39
+ }
40
+ // Bare `dsh` is a .cmd shim on Windows that only a shell can start (#13).
41
+ return { file: 'dsh', args: [], cwd: undefined, viaShell: winCmdShim }
42
+ }
43
+
44
+ /** Outcome of one spawned plugin command. */
45
+ export interface InstallResult {
46
+ exitCode: number | null
47
+ timedOut: boolean
48
+ stdout: string
49
+ stderr: string
50
+ }
51
+
52
+ /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
53
+ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>
54
+
55
+ /**
56
+ * Kill a spawned child and, on Windows, its whole process tree — `kill()`
57
+ * there only terminates the wrapper, leaving pnpm children running.
58
+ * (Contributed in #7 by @mraing.)
59
+ */
60
+ export function killChild(child: ChildProcess): void {
61
+ if (process.platform === 'win32' && child.pid !== undefined) {
62
+ try {
63
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
64
+ return
65
+ } catch { /* fall through */ }
66
+ }
67
+ child.kill('SIGKILL')
68
+ }
69
+
70
+ /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
71
+ let pnpmReady = false
72
+
73
+ /** Probe `pnpm --version` on PATH. */
74
+ export function probePnpm(): Promise<boolean> {
75
+ if (pnpmReady) return Promise.resolve(true)
76
+ return new Promise((resolvePromise) => {
77
+ const child = spawn('pnpm', ['--version'], { stdio: 'ignore', shell: winCmdShim })
78
+ child.on('error', () => resolvePromise(false))
79
+ child.on('close', (code) => {
80
+ pnpmReady = code === 0
81
+ resolvePromise(pnpmReady)
82
+ })
83
+ })
84
+ }
85
+
86
+ function runQuiet(file: string, args: string[], timeoutMs: number): Promise<{ code: number | null; output: string }> {
87
+ return new Promise((resolvePromise) => {
88
+ const child = spawn(file, args, {
89
+ env: { ...process.env, CI: 'true' },
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ shell: winCmdShim,
92
+ })
93
+ let output = ''
94
+ const timer = setTimeout(() => killChild(child), timeoutMs)
95
+ const collect = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-8 * 1024) }
96
+ child.stdout.on('data', collect)
97
+ child.stderr.on('data', collect)
98
+ child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }) })
99
+ child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }) })
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Provision pnpm without user involvement: corepack (ships with Node) first,
105
+ * a global npm install as fallback.
106
+ * @returns true when `pnpm --version` succeeds afterwards.
107
+ */
108
+ export async function provisionPnpm(): Promise<boolean> {
109
+ const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000)
110
+ logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`)
111
+ if (await probePnpm()) return true
112
+ const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000)
113
+ logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`)
114
+ return probePnpm()
115
+ }
116
+
117
+ /** Live progress of the running plugin command, for the status route. */
118
+ export interface InstallProgress {
119
+ active: boolean
120
+ target: string
121
+ startedAt: number
122
+ lastLine: string
123
+ }
124
+
125
+ /** Singleton progress state; the status route reads it, runDshPlugin writes it. */
126
+ export const progress: InstallProgress = { active: false, target: '', startedAt: 0, lastLine: '' }
127
+
128
+ /** Identifies this host process; the client scopes its pending-restart flags to it. */
129
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`
130
+
131
+ function trackProgress(chunk: string): void {
132
+ const lines = chunk.split('\n').map(l => l.trim()).filter(l => l !== '')
133
+ if (lines.length > 0) progress.lastLine = lines[lines.length - 1].slice(0, 200)
134
+ }
135
+
136
+ /**
137
+ * Central allowlist for every spawn target, regardless of which route built
138
+ * it (defense in depth on top of per-route validation — the win32 bare-dsh
139
+ * fallback runs through a shell). Suggested in #16 by @anupamme.
140
+ */
141
+ const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/
142
+
143
+ /** Run one `dsh plugin --profile <p> …` command with timeout and progress tracking. */
144
+ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<InstallResult> {
145
+ const { file, args, cwd, viaShell } = dshArgv()
146
+ pluginArgs = pluginArgsFor(profileDir(profile), pluginArgs)
147
+ const target = pluginArgs[pluginArgs.length - 1] ?? ''
148
+ if (!TARGET_RE.test(target)) {
149
+ logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`)
150
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}` })
151
+ }
152
+ progress.active = true
153
+ progress.target = target
154
+ progress.startedAt = Date.now()
155
+ progress.lastLine = ''
156
+ return new Promise((resolvePromise) => {
157
+ const child = spawn(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
158
+ cwd,
159
+ // pnpm v10 blocks forever on a silent interactive prompt without a TTY
160
+ // (observed on re-add over a pinned git spec); CI mode forces it to act
161
+ // or fail instead of asking.
162
+ env: { ...process.env, CI: 'true' },
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ shell: viaShell,
165
+ })
166
+ let stdout = ''
167
+ let stderr = ''
168
+ let timedOut = false
169
+ const timer = setTimeout(() => {
170
+ timedOut = true
171
+ killChild(child)
172
+ }, INSTALL_TIMEOUT_MS)
173
+ child.stdout.on('data', (chunk: Buffer) => {
174
+ const text = chunk.toString()
175
+ stdout = (stdout + text).slice(-256 * 1024)
176
+ trackProgress(text)
177
+ })
178
+ child.stderr.on('data', (chunk: Buffer) => {
179
+ const text = chunk.toString()
180
+ stderr = (stderr + text).slice(-64 * 1024)
181
+ trackProgress(text)
182
+ })
183
+ child.on('error', (error) => {
184
+ clearTimeout(timer)
185
+ progress.active = false
186
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` })
187
+ })
188
+ child.on('close', (code) => {
189
+ clearTimeout(timer)
190
+ progress.active = false
191
+ resolvePromise({ exitCode: code, timedOut, stdout, stderr })
192
+ })
193
+ })
194
+ }
package/src/http.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Minimal HTTP helpers shared by every market route: JSON serialization,
3
+ * same-origin enforcement for mutating endpoints, and a size-capped JSON
4
+ * body reader.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+
9
+ /** Write a JSON payload with no-store caching. */
10
+ export function sendJson(response: ServerResponse, status: number, payload: unknown): void {
11
+ response.writeHead(status, {
12
+ 'cache-control': 'no-store',
13
+ 'content-type': 'application/json; charset=utf-8',
14
+ })
15
+ response.end(JSON.stringify(payload))
16
+ }
17
+
18
+ /** True when the request's Origin matches its Host — required on every POST route. */
19
+ export function sameOrigin(request: IncomingMessage): boolean {
20
+ const origin = request.headers.origin
21
+ const host = request.headers.host
22
+ if (origin === undefined || host === undefined) return false
23
+ try {
24
+ return new URL(origin).host === host
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /** Read and parse a JSON request body, rejecting anything over 4 KiB. */
31
+ export async function readJsonBody(request: IncomingMessage): Promise<unknown> {
32
+ const chunks: Buffer[] = []
33
+ let size = 0
34
+ for await (const chunk of request) {
35
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
36
+ size += buffer.length
37
+ if (size > 4096) throw new Error('request body too large')
38
+ chunks.push(buffer)
39
+ }
40
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
41
+ }
package/src/install.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Install orchestration: collection-repo retargeting, post-install
3
+ * validation that keeps broken pieces from bricking the next boot, and
4
+ * update staleness detection. Every function takes the plugin runner as a
5
+ * parameter so tests can substitute a recording fake.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import type { InstallResult, PluginRunner } from './dsh-cli.ts'
11
+ import { classifyPnpmFailure } from './pnpm-compat.ts'
12
+ import { entryArtifactExists, hasDshManifest, pluginSubdirs, profileDir, readInstalled } from './profile.ts'
13
+ import { logEvent } from './log.ts'
14
+
15
+ /**
16
+ * Run one plugin command with automatic recovery from the pnpm-major drift
17
+ * failure (#20 bug 2): when the modules directory was built by a different
18
+ * pnpm major, pnpm's documented remedy is one `install` to recreate it —
19
+ * do that silently and retry the original command once. Any recognized
20
+ * failure that survives gets its bilingual explanation appended to stderr
21
+ * so the UI shows an actionable message instead of a wall of text (#20 bug 3).
22
+ */
23
+ export async function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[]): Promise<InstallResult> {
24
+ let result = await run(profile, pluginArgs)
25
+ const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut
26
+ if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
27
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
28
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
29
+ // where a lockfile written by the old major would otherwise be refused.
30
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
31
+ if (ok(rebuild)) result = await run(profile, pluginArgs)
32
+ }
33
+ if (!ok(result)) {
34
+ const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)
35
+ if (failure !== null) result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` }
36
+ }
37
+ return result
38
+ }
39
+
40
+ /**
41
+ * Some registry entries point at collection repos whose actual plugin lives
42
+ * in a subdirectory — the root has no package.json (or a workspace root with
43
+ * no dsh surface), and pnpm installs the bare fileset with exit 0. Detect
44
+ * that junk install, drop it, and re-add each plugin subdirectory through
45
+ * pnpm's `#path:` selector (#18).
46
+ * @returns overall success (true when nothing needed retargeting).
47
+ */
48
+ export async function retargetCollections(
49
+ run: PluginRunner, profile: string, before: Set<string>, target: string,
50
+ ): Promise<boolean> {
51
+ if (!target.startsWith('github:')) return true
52
+ const junk = Object.keys(readInstalled(profile)).filter((name) => {
53
+ if (before.has(name)) return false
54
+ const root = join(profileDir(profile), 'node_modules', name)
55
+ if (!existsSync(join(root, 'package.json'))) return true
56
+ return !hasDshManifest(root)
57
+ })
58
+ let allOk = true
59
+ for (const name of junk) {
60
+ const root = join(profileDir(profile), 'node_modules', name)
61
+ const candidates = pluginSubdirs(root)
62
+ logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`)
63
+ await run(profile, ['remove', name])
64
+ if (candidates.length === 0) {
65
+ allOk = false
66
+ continue
67
+ }
68
+ for (const sub of candidates) {
69
+ const result = await run(profile, ['add', `${target}#path:/${sub}`])
70
+ if (result.exitCode !== 0 || result.timedOut) {
71
+ allOk = false
72
+ logEvent('error', 'install',
73
+ `${target}#path:/${sub}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`)
74
+ }
75
+ }
76
+ }
77
+ return allOk
78
+ }
79
+
80
+ /**
81
+ * Fake-success guard (#18): validate every package the install added. A
82
+ * piece without a dsh manifest or without its declared entry artifact
83
+ * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
84
+ * next boot, so it is removed on the spot.
85
+ * @returns names kept and names removed as broken.
86
+ */
87
+ export async function validateAddedPlugins(
88
+ run: PluginRunner, profile: string, before: Set<string>,
89
+ ): Promise<{ keep: string[]; removedBroken: string[] }> {
90
+ const addedNow = Object.keys(readInstalled(profile)).filter(n => !before.has(n))
91
+ const keep: string[] = []
92
+ const removedBroken: string[] = []
93
+ for (const n of addedNow) {
94
+ const dir = join(profileDir(profile), 'node_modules', n)
95
+ if (hasDshManifest(dir) && entryArtifactExists(dir)) {
96
+ keep.push(n)
97
+ } else {
98
+ removedBroken.push(n)
99
+ await run(profile, ['remove', n])
100
+ }
101
+ }
102
+ return { keep, removedBroken }
103
+ }
104
+
105
+ /**
106
+ * Whether a clean-exit update actually changed nothing — pnpm's
107
+ * minimumReleaseAge silently keeps the old version and exits 0 when the new
108
+ * release is "too young" (#13, #22), so a clean exit alone does not mean the
109
+ * update happened.
110
+ */
111
+ export function isStaleUpdate(check: {
112
+ isGit: boolean
113
+ beforeVersion: string | null
114
+ afterVersion: string | null
115
+ beforeCommit: string | null
116
+ afterCommit: string | null
117
+ }): boolean {
118
+ return check.isGit
119
+ ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
120
+ : check.beforeVersion !== null && check.afterVersion === check.beforeVersion
121
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * pnpm compatibility layer — everything the market needs to know about how
3
+ * different pnpm majors behave inside a dsh profile directory, kept pure and
4
+ * separately testable (test/unit + test/integration exercise this module
5
+ * against real pnpm 9/10/11).
6
+ *
7
+ * Verified behavior matrix (2026-08, pnpm 9.15.9 / 10.28.2 / 11.21.0):
8
+ * - workspace root, `add` without -w: pnpm 9 fails ERR_PNPM_ADDING_TO_ROOT;
9
+ * pnpm 10/11 succeed.
10
+ * - `add -w` where NO pnpm-workspace.yaml exists: ALL majors fail with
11
+ * "--workspace-root may only be used inside a workspace".
12
+ * - modules dir built by pnpm 9, then pnpm 10/11 mutate it:
13
+ * ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF (defaults drifted between majors).
14
+ */
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+
20
+ /**
21
+ * Decide the argv for a `dsh plugin <add|remove> …` call in the given profile.
22
+ *
23
+ * pnpm 9 refuses to add at a workspace root without -w (#17, #20); every
24
+ * pnpm major refuses -w when the directory is NOT a workspace. So the flag
25
+ * is injected exactly when the profile has a pnpm-workspace.yaml.
26
+ * @param profileDir - resolved profile directory (owns pnpm-workspace.yaml, or not).
27
+ * @param pluginArgs - the raw args, e.g. ['add', 'dshmarket@latest'].
28
+ * @returns args with -w injected when — and only when — the profile is a workspace root.
29
+ */
30
+ export function pluginArgsFor(profileDir: string, pluginArgs: string[]): string[] {
31
+ if (pluginArgs[0] !== 'add' && pluginArgs[0] !== 'remove') return pluginArgs
32
+ if (!existsSync(join(profileDir, 'pnpm-workspace.yaml'))) return pluginArgs
33
+ return [pluginArgs[0], '-w', ...pluginArgs.slice(1)]
34
+ }
35
+
36
+ /** One recognized pnpm failure, with a bilingual explanation for the UI. */
37
+ export interface PnpmFailure {
38
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing'
39
+ /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
40
+ message: string
41
+ /** True when re-running `pnpm install` in the profile is the documented recovery. */
42
+ recoverable: boolean
43
+ }
44
+
45
+ /**
46
+ * Map a failed pnpm run's combined output to a known failure mode.
47
+ *
48
+ * dsh's own wrapper line ("dsh: pnpm failed in profile directory …") names no
49
+ * cause, so the market must recognize pnpm's real diagnostics itself (#20).
50
+ * @param output - stdout+stderr of the failed run.
51
+ * @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
52
+ */
53
+ export function classifyPnpmFailure(output: string): PnpmFailure | null {
54
+ if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
55
+ return {
56
+ code: 'hoist-pattern-diff',
57
+ recoverable: true,
58
+ message: 'profile 的 node_modules 是旧版 pnpm 创建的,与当前 pnpm 的默认配置不兼容,需要重建后重试 / this profile\'s node_modules was created by a different pnpm major; it must be rebuilt (pnpm install) before changes can be applied',
59
+ }
60
+ }
61
+ if (output.includes('ERR_PNPM_ADDING_TO_ROOT')) {
62
+ return {
63
+ code: 'adding-to-root',
64
+ recoverable: false,
65
+ message: 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)。这是市场的 bug,请升级 dshmarket 到最新版 / pnpm refused to add at a workspace root (missing -w); this is a market bug — please update dshmarket',
66
+ }
67
+ }
68
+ if (/--workspace-root may only be used inside a workspace/i.test(output)) {
69
+ return {
70
+ code: 'not-a-workspace',
71
+ recoverable: false,
72
+ message: 'profile 目录不是 pnpm workspace,却传入了 -w。这是市场的 bug,请升级 dshmarket 到最新版 / -w was passed but the profile is not a pnpm workspace; this is a market bug — please update dshmarket',
73
+ }
74
+ }
75
+ if (output.includes('pnpm not found on PATH')) {
76
+ return {
77
+ code: 'pnpm-missing',
78
+ recoverable: false,
79
+ message: '找不到 pnpm,请先在市场页顶部一键安装组件 / pnpm is not on PATH — use the one-click setup at the top of the market page',
80
+ }
81
+ }
82
+ return null
83
+ }