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.
@@ -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,
@@ -37,6 +38,14 @@ function OwnerAvatar({ name, owner }: { name: string; owner: string }) {
37
38
  )
38
39
  }
39
40
 
41
+ /**
42
+ * Module-scope caches so re-entering the section renders instantly instead
43
+ * of refetching and rebuilding from a spinner (#30 by @StarsTom). Module
44
+ * state survives section switches; a background refetch keeps it current.
45
+ */
46
+ let cachedRegistry: Registry | null = null
47
+ let cachedInstalled: InstalledMap | null = null
48
+
40
49
  export interface MarketSectionProps {
41
50
  t: Translate
42
51
  locale: {
@@ -62,9 +71,10 @@ export function MarketSection(props: MarketSectionProps) {
62
71
  props.themeStore.subscribe,
63
72
  props.themeStore.getSnapshot,
64
73
  )
65
- const [data, setData] = useState<Registry | null>(null)
74
+ const [data, setData] = useState<Registry | null>(cachedRegistry)
66
75
  const [loadError, setLoadError] = useState(false)
67
- const [installed, setInstalled] = useState<InstalledMap>({})
76
+ const [installed, setInstalledState] = useState<InstalledMap>(cachedInstalled ?? {})
77
+ const setInstalled = useCallback((value: InstalledMap) => { cachedInstalled = value; setInstalledState(value) }, [])
68
78
  const [skins, setSkins] = useState<string[]>([])
69
79
  const [tab, setTab] = useState(() => {
70
80
  const saved = sessionStorage.getItem('dshm-tab')
@@ -79,6 +89,28 @@ export function MarketSection(props: MarketSectionProps) {
79
89
  const [installError, setInstallError] = useState<string | null>(null)
80
90
  const [updates, setUpdates] = useState<Record<string, UpdateStatus>>({})
81
91
  const [updatingName, setUpdatingName] = useState<string | null>(null)
92
+ // Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button.
93
+ const [staleName, setStaleName] = useState<string | null>(null)
94
+ /**
95
+ * Cards rendered so far — the discover grid renders incrementally (60,
96
+ * then +120 as the sentinel scrolls into view) instead of all 400+ at
97
+ * once, which built a ~70k px DOM and made section switches lag (#30).
98
+ */
99
+ const [visibleCount, setVisibleCount] = useState(60)
100
+ const sentinelRef = useCallback((node: HTMLDivElement | null) => {
101
+ if (node === null) return
102
+ const observer = new IntersectionObserver((entries) => {
103
+ if (entries.some(entry => entry.isIntersecting)) setVisibleCount(count => count + 120)
104
+ }, { rootMargin: '600px' })
105
+ observer.observe(node)
106
+ // Cleanup runs when React detaches the node and calls back with null;
107
+ // the observer dies with the node, so a one-shot observe suffices.
108
+ }, [])
109
+
110
+ /** Determinate percent parsed from pnpm's Progress line, when available. */
111
+ const [progressPct, setProgressPct] = useState<number | null>(null)
112
+ /** Blocked build scripts from the last install: enables approve-and-retry (#6). */
113
+ const [buildsSkipped, setBuildsSkipped] = useState<{ plugin: RegistryPlugin; names: string[] } | null>(null)
82
114
  const [updatingAll, setUpdatingAll] = useState(false)
83
115
  const [updatedNames, setUpdatedNames] = useState<string[]>([])
84
116
  const [hotUrls, setHotUrls] = useState<string[]>([])
@@ -118,7 +150,7 @@ export function MarketSection(props: MarketSectionProps) {
118
150
  useEffect(() => {
119
151
  fetch('/dsh-market/registry', { cache: 'no-store' })
120
152
  .then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json() })
121
- .then(body => setData(body.registry))
153
+ .then(body => { cachedRegistry = body.registry; setData(body.registry) })
122
154
  .catch(() => setLoadError(true))
123
155
  fetch('/dsh-market/status', { cache: 'no-store' })
124
156
  .then(res => res.json())
@@ -189,8 +221,14 @@ export function MarketSection(props: MarketSectionProps) {
189
221
  .then(status => {
190
222
  if (status.active) {
191
223
  setProgressLine((status.lastLine || '…') + ' (' + status.seconds + 's)')
224
+ const m = /resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)/.exec(status.lastLine || '')
225
+ if (m !== null && Number(m[1]) > 0) {
226
+ const done = Number(m[2]) + Number(m[3]) + Number(m[4])
227
+ setProgressPct(Math.max(4, Math.min(96, Math.round(done / Number(m[1]) * 100))))
228
+ }
192
229
  } else {
193
230
  setProgressLine(null)
231
+ setProgressPct(null)
194
232
  setInstalled(status.installed || {})
195
233
  const pending = readSession('dshm-pending')
196
234
  if (pending !== null && busyUrl !== null) {
@@ -209,27 +247,14 @@ export function MarketSection(props: MarketSectionProps) {
209
247
  return () => clearInterval(timer)
210
248
  }, [busyUrl, updatingName, data])
211
249
 
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])
250
+ const plugins = useMemo(
251
+ () => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, sort })),
252
+ [data, q, cat, lang, sort])
253
+
254
+ useEffect(() => { setVisibleCount(60) }, [q, cat, sort])
231
255
 
232
256
  const doInstall = useCallback((plugin: RegistryPlugin) => {
257
+ setBuildsSkipped(null)
233
258
  setConfirming(null)
234
259
  setInstallError(null)
235
260
  setBusyUrl(plugin.url)
@@ -250,6 +275,11 @@ export function MarketSection(props: MarketSectionProps) {
250
275
  location.reload()
251
276
  return
252
277
  }
278
+ if (body.cancelled === true) {
279
+ // User-cancelled: quiet reset, nothing to report.
280
+ refreshInstalled()
281
+ return
282
+ }
253
283
  if (status === 200 && body.ok) {
254
284
  sessionStorage.setItem('dshm-tab', 'installed')
255
285
  if (body.hot) {
@@ -260,6 +290,13 @@ export function MarketSection(props: MarketSectionProps) {
260
290
  }
261
291
  refreshInstalled()
262
292
  } else {
293
+ if (status === 409) {
294
+ setInstallError(t('busyWait'))
295
+ return
296
+ }
297
+ if (Array.isArray(body.ignoredBuilds) && body.ignoredBuilds.length > 0) {
298
+ setBuildsSkipped({ plugin, names: body.ignoredBuilds.map(String) })
299
+ }
263
300
  const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
264
301
  const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
265
302
  setInstallError(t('installFail') + ': ' + plugin.name + ' — ' + detail.trim().slice(-600))
@@ -272,20 +309,33 @@ export function MarketSection(props: MarketSectionProps) {
272
309
  .finally(() => setBusyUrl(null))
273
310
  }, [refreshInstalled, t])
274
311
 
275
- const doUpdate = useCallback((name: string) => {
312
+ /** Cancel the running plugin command (#6 by @qichuang321). */
313
+ const doCancel = useCallback(() => {
314
+ fetch('/dsh-market/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
315
+ .catch(() => {})
316
+ }, [])
317
+
318
+ const doUpdate = useCallback((name: string, force = false) => {
276
319
  setInstallError(null)
320
+ setStaleName(null)
277
321
  setUpdatingName(name)
278
322
  return fetch('/dsh-market/update', {
279
323
  method: 'POST',
280
324
  headers: { 'content-type': 'application/json' },
281
- body: JSON.stringify({ name }),
325
+ body: JSON.stringify(force ? { name, force: true } : { name }),
282
326
  })
283
327
  .then(res => res.json().then(body => ({ status: res.status, body })))
284
328
  .then(({ status, body }) => {
329
+ if (body.cancelled === true) {
330
+ refreshInstalled()
331
+ return
332
+ }
285
333
  if (status === 200 && body.ok) {
286
334
  setUpdatedNames(names => names.concat(name))
287
335
  refreshInstalled()
288
336
  } else {
337
+ if (status === 409) { setInstallError(t('busyWait')); return }
338
+ if (body.stale === true) setStaleName(name)
289
339
  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
340
  const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
291
341
  setInstallError(t('updateFail') + ': ' + name + ' — ' + detail.trim().slice(-600))
@@ -365,9 +415,7 @@ export function MarketSection(props: MarketSectionProps) {
365
415
  name => !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable,
366
416
  )
367
417
 
368
- const themePlugins = data === null ? [] : data.plugins
369
- .filter(p => p.category === 'theme')
370
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
418
+ const themePlugins = data === null ? [] : themePluginsOf(data.plugins)
371
419
 
372
420
  const pluginCard = (p: RegistryPlugin) => {
373
421
  const desc = (p.description && (p.description[lang] || p.description.en)) || ''
@@ -413,6 +461,8 @@ export function MarketSection(props: MarketSectionProps) {
413
461
  <div className={css.progress}>
414
462
  <span className={css.spin} />
415
463
  <code className={css.grow}>{progressLine || t('progressHint')}</code>
464
+ {progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
465
+ <button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
416
466
  </div>
417
467
  )}
418
468
  </div>
@@ -591,7 +641,40 @@ export function MarketSection(props: MarketSectionProps) {
591
641
  </div>
592
642
  )}
593
643
  </div>
594
- {installError !== null && <div className={css.err}>{installError}</div>}
644
+ {buildsSkipped !== null && (
645
+ <div className={css.restart}>
646
+ <span className={css.grow}>{t('buildsSkipped')} {buildsSkipped.names.join(', ')}</span>
647
+ <Button
648
+ size="sm"
649
+ disabled={busyUrl !== null}
650
+ onClick={() => {
651
+ const { plugin, names } = buildsSkipped
652
+ setBuildsSkipped(null)
653
+ fetch('/dsh-market/approve-builds', {
654
+ method: 'POST',
655
+ headers: { 'content-type': 'application/json' },
656
+ body: JSON.stringify({ packages: names }),
657
+ })
658
+ .then(res => res.json())
659
+ .then((body) => {
660
+ if (body.ok) doInstall(plugin)
661
+ else setInstallError(String(body.error || 'approve failed'))
662
+ })
663
+ .catch(error => setInstallError(String(error)))
664
+ }}
665
+ >{t('approveBuilds')}</Button>
666
+ </div>
667
+ )}
668
+ {installError !== null && (
669
+ <div className={css.err}>
670
+ {installError}
671
+ {staleName !== null && (
672
+ <div className={css.staleAction}>
673
+ <Button size="sm" onClick={() => doUpdate(staleName, true)}>{t('updateNow')}</Button>
674
+ </div>
675
+ )}
676
+ </div>
677
+ )}
595
678
  <div
596
679
  className={css.body}
597
680
  ref={bodyRef}
@@ -609,7 +692,7 @@ export function MarketSection(props: MarketSectionProps) {
609
692
  <div ref={catsWrapRef} className={catsOpen || visibleCats === null ? `${css.catsWrap} ${css.catsCollapsed}` : css.catsWrap}>
610
693
  {(() => {
611
694
  // 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)]
695
+ const ordered = orderedCategories(categories, cat, catsOpen)
613
696
  const shown = catsOpen || visibleCats === null ? ordered : ordered.slice(0, Math.max(0, visibleCats - 1))
614
697
  return (
615
698
  <>
@@ -647,7 +730,12 @@ export function MarketSection(props: MarketSectionProps) {
647
730
  </div>
648
731
  {plugins.length === 0
649
732
  ? <div className={css.empty}>{t('empty')}</div>
650
- : <div className={css.grid}>{plugins.map(pluginCard)}</div>}
733
+ : (
734
+ <>
735
+ <div className={css.grid}>{plugins.slice(0, visibleCount).map(pluginCard)}</div>
736
+ {plugins.length > visibleCount && <div ref={sentinelRef} className={css.sentinel} />}
737
+ </>
738
+ )}
651
739
  </>
652
740
  )
653
741
  : tab === 'themes' && themeSnap !== null
@@ -696,6 +784,8 @@ export function MarketSection(props: MarketSectionProps) {
696
784
  <div className={css.progress}>
697
785
  <span className={css.spin} />
698
786
  <code className={css.grow}>{progressLine || t('progressHint')}</code>
787
+ {progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
788
+ <button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
699
789
  </div>
700
790
  )}
701
791
  </div>
@@ -29,6 +29,12 @@ export const zh = {
29
29
  update: '更新',
30
30
  updating: '更新中…',
31
31
  updated: '✓ 已更新,重启后生效',
32
+ cancelOp: '取消',
33
+ cancelled: '已取消',
34
+ busyWait: '已有操作正在进行,请等它结束(同一时间只执行一个安装/更新/卸载)',
35
+ approveBuilds: '放行构建脚本并重试',
36
+ buildsSkipped: '该插件需要运行构建脚本才能工作,出于安全默认被拦下。点击下方按钮为它放行并重装:',
37
+ updateNow: '立即更新',
32
38
  updateFail: '更新失败',
33
39
  upToDate: '已是最新',
34
40
  linkedDev: '本地开发链接',
@@ -91,6 +97,12 @@ export const en: Record<MarketKey, string> = {
91
97
  update: 'Update',
92
98
  updating: 'Updating…',
93
99
  updated: '✓ Updated — restart to apply',
100
+ cancelOp: 'Cancel',
101
+ cancelled: 'Cancelled',
102
+ busyWait: 'Another operation is already running — please wait for it to finish (one install/update/uninstall at a time)',
103
+ approveBuilds: 'Allow build scripts and retry',
104
+ buildsSkipped: 'This plugin needs its build scripts to run; they are blocked by default for safety. Click below to allow them and reinstall:',
105
+ updateNow: 'Update now',
94
106
  updateFail: 'Update failed',
95
107
  upToDate: 'Up to date',
96
108
  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,248 @@
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
+ // 15 min default (slow networks + git installs), overridable for CI/tests.
18
+ // (#6 by @qichuang321.)
19
+ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000
20
+
21
+ /**
22
+ * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
23
+ * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
24
+ */
25
+ export const winCmdShim = process.platform === 'win32'
26
+
27
+ /**
28
+ * Argv re-invoking the CLI that launched this host process, so installs work
29
+ * whether dsh runs from a global bin, a local install, or repo source
30
+ * (`node --import tsx/esm .../bin.ts`). Falls back to a PATH `dsh`.
31
+ */
32
+ export function dshArgv(): { file: string; args: string[]; cwd: string | undefined; viaShell: boolean } {
33
+ const entry = process.argv[1]
34
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
35
+ // Absolute paths are required: source launches (`pnpm dsh`) pass a
36
+ // relative entry, which the child resolves against its OWN cwd and dies
37
+ // with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
38
+ // (tsx/esm) resolvable on source launches.
39
+ const abs = resolve(entry)
40
+ return { file: process.execPath, args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
41
+ }
42
+ // Bare `dsh` is a .cmd shim on Windows that only a shell can start (#13).
43
+ return { file: 'dsh', args: [], cwd: undefined, viaShell: winCmdShim }
44
+ }
45
+
46
+ /** Outcome of one spawned plugin command. */
47
+ export interface InstallResult {
48
+ exitCode: number | null
49
+ timedOut: boolean
50
+ stdout: string
51
+ stderr: string
52
+ /** True when the run ended because the user cancelled it. */
53
+ cancelled: boolean
54
+ }
55
+
56
+ /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
57
+ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>
58
+
59
+ /**
60
+ * Kill a spawned child and, on Windows, its whole process tree — `kill()`
61
+ * there only terminates the wrapper, leaving pnpm children running.
62
+ * (Contributed in #7 by @mraing.)
63
+ */
64
+ export function killChild(child: ChildProcess): void {
65
+ if (process.platform === 'win32' && child.pid !== undefined) {
66
+ try {
67
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
68
+ return
69
+ } catch { /* fall through */ }
70
+ }
71
+ child.kill('SIGKILL')
72
+ }
73
+
74
+ /** The child of the operation currently running, for /dsh-market/cancel. */
75
+ let activeChild: ChildProcess | null = null
76
+ let cancelRequested = false
77
+
78
+ /**
79
+ * Kill a child and its whole tree, gracefully where the platform allows:
80
+ * taskkill /T /F on Windows (plain kill() leaves pnpm children running),
81
+ * SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
82
+ * (Cancel flow contributed in #6 by @qichuang321.)
83
+ */
84
+ function killTree(child: ChildProcess): void {
85
+ if (process.platform === 'win32' && child.pid !== undefined) {
86
+ try {
87
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
88
+ return
89
+ } catch { /* fall through */ }
90
+ }
91
+ // POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
92
+ // plain child.kill() leaves running — it keeps our stdio pipes open, so
93
+ // the close event never fires and the market looks stuck "installing".
94
+ // The child is spawned detached as its own process GROUP; kill the group.
95
+ const signalTree = (signal: NodeJS.Signals): void => {
96
+ if (child.pid === undefined) return
97
+ try { process.kill(-child.pid, signal) } catch {
98
+ try { child.kill(signal) } catch { /* already gone */ }
99
+ }
100
+ }
101
+ signalTree('SIGTERM')
102
+ const escalate = setTimeout(() => signalTree('SIGKILL'), 5000)
103
+ escalate.unref?.()
104
+ }
105
+
106
+ /**
107
+ * Cancel the plugin command currently running.
108
+ * @returns true when there was one to cancel.
109
+ */
110
+ export function cancelActive(): boolean {
111
+ if (activeChild === null) return false
112
+ cancelRequested = true
113
+ killTree(activeChild)
114
+ return true
115
+ }
116
+
117
+ /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
118
+ let pnpmReady = false
119
+
120
+ /** Probe `pnpm --version` on PATH. */
121
+ export function probePnpm(): Promise<boolean> {
122
+ if (pnpmReady) return Promise.resolve(true)
123
+ return new Promise((resolvePromise) => {
124
+ const child = spawn('pnpm', ['--version'], { stdio: 'ignore', shell: winCmdShim })
125
+ child.on('error', () => resolvePromise(false))
126
+ child.on('close', (code) => {
127
+ pnpmReady = code === 0
128
+ resolvePromise(pnpmReady)
129
+ })
130
+ })
131
+ }
132
+
133
+ function runQuiet(file: string, args: string[], timeoutMs: number): Promise<{ code: number | null; output: string }> {
134
+ return new Promise((resolvePromise) => {
135
+ const child = spawn(file, args, {
136
+ env: { ...process.env, CI: 'true' },
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ shell: winCmdShim,
139
+ })
140
+ let output = ''
141
+ const timer = setTimeout(() => killChild(child), timeoutMs)
142
+ const collect = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-8 * 1024) }
143
+ child.stdout.on('data', collect)
144
+ child.stderr.on('data', collect)
145
+ child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }) })
146
+ child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }) })
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Provision pnpm without user involvement: corepack (ships with Node) first,
152
+ * a global npm install as fallback.
153
+ * @returns true when `pnpm --version` succeeds afterwards.
154
+ */
155
+ export async function provisionPnpm(): Promise<boolean> {
156
+ const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000)
157
+ logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`)
158
+ if (await probePnpm()) return true
159
+ const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000)
160
+ logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`)
161
+ return probePnpm()
162
+ }
163
+
164
+ /** Live progress of the running plugin command, for the status route. */
165
+ export interface InstallProgress {
166
+ active: boolean
167
+ target: string
168
+ startedAt: number
169
+ lastLine: string
170
+ }
171
+
172
+ /** Singleton progress state; the status route reads it, runDshPlugin writes it. */
173
+ export const progress: InstallProgress = { active: false, target: '', startedAt: 0, lastLine: '' }
174
+
175
+ /** Identifies this host process; the client scopes its pending-restart flags to it. */
176
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`
177
+
178
+ function trackProgress(chunk: string): void {
179
+ const lines = chunk.split('\n').map(l => l.trim()).filter(l => l !== '')
180
+ if (lines.length > 0) progress.lastLine = lines[lines.length - 1].slice(0, 200)
181
+ }
182
+
183
+ /**
184
+ * Central allowlist for every spawn target, regardless of which route built
185
+ * it (defense in depth on top of per-route validation — the win32 bare-dsh
186
+ * fallback runs through a shell). Suggested in #16 by @anupamme.
187
+ */
188
+ const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/
189
+
190
+ /** Run one `dsh plugin --profile <p> …` command with timeout and progress tracking. */
191
+ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<InstallResult> {
192
+ const { file, args, cwd, viaShell } = dshArgv()
193
+ pluginArgs = pluginArgsFor(profileDir(profile), pluginArgs)
194
+ const target = pluginArgs[pluginArgs.length - 1] ?? ''
195
+ if (!TARGET_RE.test(target)) {
196
+ logEvent('error', 'install', `unsafe plugin target rejected: ${JSON.stringify(target)}`)
197
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}`, cancelled: false })
198
+ }
199
+ progress.active = true
200
+ progress.target = target
201
+ progress.startedAt = Date.now()
202
+ progress.lastLine = ''
203
+ return new Promise((resolvePromise) => {
204
+ const child = spawn(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
205
+ cwd,
206
+ // pnpm v10 blocks forever on a silent interactive prompt without a TTY
207
+ // (observed on re-add over a pinned git spec); CI mode forces it to act
208
+ // or fail instead of asking.
209
+ env: { ...process.env, CI: 'true' },
210
+ stdio: ['ignore', 'pipe', 'pipe'],
211
+ shell: viaShell,
212
+ // Own process group on POSIX so cancel/timeout can kill the whole
213
+ // tree (dsh wrapper + pnpm grandchild) with one group signal.
214
+ detached: process.platform !== 'win32',
215
+ })
216
+ activeChild = child
217
+ cancelRequested = false
218
+ let stdout = ''
219
+ let stderr = ''
220
+ let timedOut = false
221
+ const timer = setTimeout(() => {
222
+ timedOut = true
223
+ killTree(child)
224
+ }, INSTALL_TIMEOUT_MS)
225
+ child.stdout.on('data', (chunk: Buffer) => {
226
+ const text = chunk.toString()
227
+ stdout = (stdout + text).slice(-256 * 1024)
228
+ trackProgress(text)
229
+ })
230
+ child.stderr.on('data', (chunk: Buffer) => {
231
+ const text = chunk.toString()
232
+ stderr = (stderr + text).slice(-64 * 1024)
233
+ trackProgress(text)
234
+ })
235
+ child.on('error', (error) => {
236
+ clearTimeout(timer)
237
+ progress.active = false
238
+ if (activeChild === child) activeChild = null
239
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false })
240
+ })
241
+ child.on('close', (code) => {
242
+ clearTimeout(timer)
243
+ progress.active = false
244
+ if (activeChild === child) activeChild = null
245
+ resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested })
246
+ })
247
+ })
248
+ }
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
+ }