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/README.md +8 -0
- package/README.zh.md +8 -0
- package/client/client.js +264 -87
- package/client/client.js.map +1 -1
- package/lib/dsh-cli.js +224 -0
- package/lib/http.js +39 -0
- package/lib/install.js +132 -0
- package/lib/pnpm-compat.js +72 -0
- package/lib/profile.js +166 -0
- package/lib/routes.js +152 -558
- package/lib/sources.js +85 -0
- package/lib/themes.js +102 -0
- package/lib/types/dsh-cli.d.ts +68 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/install.d.ts +55 -0
- package/lib/types/pnpm-compat.d.ts +42 -0
- package/lib/types/profile.d.ts +32 -0
- package/lib/types/routes.d.ts +7 -21
- package/lib/types/sources.d.ts +42 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/updates.d.ts +16 -0
- package/lib/updates.js +61 -0
- package/package.json +5 -2
- package/src/client/Market.module.css +8 -1
- package/src/client/MarketSection.tsx +121 -31
- package/src/client/locales.ts +12 -0
- package/src/client/market-data.ts +60 -4
- package/src/dsh-cli.ts +248 -0
- package/src/http.ts +41 -0
- package/src/install.ts +141 -0
- package/src/pnpm-compat.ts +83 -0
- package/src/profile.ts +162 -0
- package/src/routes.ts +161 -592
- package/src/sources.ts +84 -0
- package/src/themes.ts +120 -0
- package/src/updates.ts +70 -0
|
@@ -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,
|
|
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>(
|
|
74
|
+
const [data, setData] = useState<Registry | null>(cachedRegistry)
|
|
66
75
|
const [loadError, setLoadError] = useState(false)
|
|
67
|
-
const [installed,
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 =
|
|
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
|
-
:
|
|
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>
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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_.-]+)
|
|
109
|
-
if (match !== null)
|
|
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
|
+
}
|