dshmarket 1.2.3 → 1.3.0
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 +4 -0
- package/README.zh.md +4 -0
- package/client/client.js +270 -58
- package/client/client.js.map +1 -1
- package/data/registry-snapshot.json +4512 -975
- package/lib/dsh-cli.js +65 -5
- package/lib/index.js +4 -1
- package/lib/install.js +24 -2
- package/lib/profile.js +48 -3
- package/lib/restart.js +86 -0
- package/lib/routes.js +141 -15
- package/lib/types/dsh-cli.d.ts +7 -0
- package/lib/types/install.d.ts +6 -0
- package/lib/types/profile.d.ts +9 -1
- package/lib/types/restart.d.ts +37 -0
- package/lib/types/routes.d.ts +2 -0
- package/package.json +2 -2
- package/src/client/Market.module.css +4 -0
- package/src/client/MarketSection.tsx +157 -4
- package/src/client/locales.ts +18 -0
- package/src/dsh-cli.ts +59 -5
- package/src/index.ts +4 -1
- package/src/install.ts +22 -2
- package/src/profile.ts +45 -3
- package/src/restart.ts +96 -0
- package/src/routes.ts +147 -18
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-restart: relaunch the exact DSH invocation that booted this host so
|
|
3
|
+
* pending (non-hot) plugin changes take effect without the user leaving the
|
|
4
|
+
* UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
|
|
5
|
+
*
|
|
6
|
+
* Safety model: the endpoint accepts only direct same-origin loopback
|
|
7
|
+
* requests (no forwarding headers), refuses while a plugin operation runs,
|
|
8
|
+
* and deployments under a supervisor (systemd/launchd/pm2) can disable the
|
|
9
|
+
* whole feature with `allowRestart: false` — the supervisor owns restarts.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage } from 'node:http';
|
|
12
|
+
/** Self-restart is enabled by default and disabled only by an explicit false. */
|
|
13
|
+
export declare function restartAllowed(config: {
|
|
14
|
+
allowRestart?: boolean;
|
|
15
|
+
}): boolean;
|
|
16
|
+
/** Whether a process-control request came from this Web host on loopback. */
|
|
17
|
+
export declare function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
18
|
+
/** The exact boot invocation the detached restart helper replays. */
|
|
19
|
+
export declare function restartLaunch(): {
|
|
20
|
+
file: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
cwd: string;
|
|
23
|
+
viaShell: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** What scheduleRestart reports back to the caller for logging/response. */
|
|
26
|
+
export interface RestartResult {
|
|
27
|
+
pid: number;
|
|
28
|
+
helperPid: number | undefined;
|
|
29
|
+
logOut: string;
|
|
30
|
+
logErr: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Relaunch this exact DSH entry after a short detached handoff, then stop
|
|
34
|
+
* this process. The helper outlives us (detached + unref), waits for our
|
|
35
|
+
* port to free up, and logs the replacement's output under tmpdir.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scheduleRestart(): RestartResult;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface MarketHost {
|
|
|
42
42
|
export interface MarketConfig {
|
|
43
43
|
/** Profile the market installs into; matches the profile serving this UI. */
|
|
44
44
|
profile: string;
|
|
45
|
+
/** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
|
|
46
|
+
allowRestart?: boolean;
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
49
|
* Register the market's HTTP routes.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
25
25
|
"build": "tsc -p tsconfig.json && npm run build:client",
|
|
26
26
|
"build:client": "tsdown && node scripts/normalize-client-banner.mjs",
|
|
27
|
-
"check": "npm run typecheck && npm run build",
|
|
27
|
+
"check": "npm run typecheck && npm run build && node scripts/restart-smoke.mjs",
|
|
28
28
|
"test": "vitest run",
|
|
29
29
|
"test:compat": "vitest run --config vitest.compat.config.ts",
|
|
30
30
|
"prepack": "npm run build && node scripts/preflight.mjs",
|
|
@@ -72,3 +72,7 @@
|
|
|
72
72
|
.dangerBtn.dangerBtn,.warnBtn.warnBtn,.dangerArmed.dangerArmed{white-space:nowrap;flex-shrink:0}
|
|
73
73
|
.spec{font-size:11px;color:var(--dsw-alias-label-secondary,#9ca3af);font-family:ui-monospace,Menlo,monospace}
|
|
74
74
|
.staleAction{margin-top:8px}
|
|
75
|
+
.pct{font-weight:600;font-size:11px;flex-shrink:0;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
76
|
+
.cancelBtn{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:6px;padding:2px 10px;font:inherit;font-size:11px;line-height:16px;cursor:pointer;flex-shrink:0;white-space:nowrap}
|
|
77
|
+
.cancelBtn:hover{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626)}
|
|
78
|
+
.sentinel{height:1px}
|
|
@@ -38,6 +38,14 @@ function OwnerAvatar({ name, owner }: { name: string; owner: string }) {
|
|
|
38
38
|
)
|
|
39
39
|
}
|
|
40
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
|
+
|
|
41
49
|
export interface MarketSectionProps {
|
|
42
50
|
t: Translate
|
|
43
51
|
locale: {
|
|
@@ -63,9 +71,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
63
71
|
props.themeStore.subscribe,
|
|
64
72
|
props.themeStore.getSnapshot,
|
|
65
73
|
)
|
|
66
|
-
const [data, setData] = useState<Registry | null>(
|
|
74
|
+
const [data, setData] = useState<Registry | null>(cachedRegistry)
|
|
67
75
|
const [loadError, setLoadError] = useState(false)
|
|
68
|
-
const [installed,
|
|
76
|
+
const [installed, setInstalledState] = useState<InstalledMap>(cachedInstalled ?? {})
|
|
77
|
+
const setInstalled = useCallback((value: InstalledMap) => { cachedInstalled = value; setInstalledState(value) }, [])
|
|
69
78
|
const [skins, setSkins] = useState<string[]>([])
|
|
70
79
|
const [tab, setTab] = useState(() => {
|
|
71
80
|
const saved = sessionStorage.getItem('dshm-tab')
|
|
@@ -82,6 +91,26 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
82
91
|
const [updatingName, setUpdatingName] = useState<string | null>(null)
|
|
83
92
|
// Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button.
|
|
84
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)
|
|
85
114
|
const [updatingAll, setUpdatingAll] = useState(false)
|
|
86
115
|
const [updatedNames, setUpdatedNames] = useState<string[]>([])
|
|
87
116
|
const [hotUrls, setHotUrls] = useState<string[]>([])
|
|
@@ -94,6 +123,9 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
94
123
|
const [envFixing, setEnvFixing] = useState(false)
|
|
95
124
|
const [envFailed, setEnvFailed] = useState(false)
|
|
96
125
|
const [bootId, setBootId] = useState<string | null>(null)
|
|
126
|
+
/** One-click restart (#14 by @ysyyhhh): server capability + in-flight state. */
|
|
127
|
+
const [restartEnabled, setRestartEnabled] = useState(false)
|
|
128
|
+
const [restarting, setRestarting] = useState(false)
|
|
97
129
|
const [showTop, setShowTop] = useState(false)
|
|
98
130
|
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
99
131
|
const [sort, setSort] = useState('hot')
|
|
@@ -121,13 +153,14 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
121
153
|
useEffect(() => {
|
|
122
154
|
fetch('/dsh-market/registry', { cache: 'no-store' })
|
|
123
155
|
.then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json() })
|
|
124
|
-
.then(body => setData(body.registry))
|
|
156
|
+
.then(body => { cachedRegistry = body.registry; setData(body.registry) })
|
|
125
157
|
.catch(() => setLoadError(true))
|
|
126
158
|
fetch('/dsh-market/status', { cache: 'no-store' })
|
|
127
159
|
.then(res => res.json())
|
|
128
160
|
.then(status => {
|
|
129
161
|
setEnvReady(status.pnpm !== false)
|
|
130
162
|
if (typeof status.boot === 'string') setBootId(status.boot)
|
|
163
|
+
setRestartEnabled(status.restart === true)
|
|
131
164
|
})
|
|
132
165
|
.catch(() => {})
|
|
133
166
|
refreshInstalled()
|
|
@@ -192,8 +225,14 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
192
225
|
.then(status => {
|
|
193
226
|
if (status.active) {
|
|
194
227
|
setProgressLine((status.lastLine || '…') + ' (' + status.seconds + 's)')
|
|
228
|
+
const m = /resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)/.exec(status.lastLine || '')
|
|
229
|
+
if (m !== null && Number(m[1]) > 0) {
|
|
230
|
+
const done = Number(m[2]) + Number(m[3]) + Number(m[4])
|
|
231
|
+
setProgressPct(Math.max(4, Math.min(96, Math.round(done / Number(m[1]) * 100))))
|
|
232
|
+
}
|
|
195
233
|
} else {
|
|
196
234
|
setProgressLine(null)
|
|
235
|
+
setProgressPct(null)
|
|
197
236
|
setInstalled(status.installed || {})
|
|
198
237
|
const pending = readSession('dshm-pending')
|
|
199
238
|
if (pending !== null && busyUrl !== null) {
|
|
@@ -216,7 +255,10 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
216
255
|
() => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, sort })),
|
|
217
256
|
[data, q, cat, lang, sort])
|
|
218
257
|
|
|
258
|
+
useEffect(() => { setVisibleCount(60) }, [q, cat, sort])
|
|
259
|
+
|
|
219
260
|
const doInstall = useCallback((plugin: RegistryPlugin) => {
|
|
261
|
+
setBuildsSkipped(null)
|
|
220
262
|
setConfirming(null)
|
|
221
263
|
setInstallError(null)
|
|
222
264
|
setBusyUrl(plugin.url)
|
|
@@ -237,6 +279,11 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
237
279
|
location.reload()
|
|
238
280
|
return
|
|
239
281
|
}
|
|
282
|
+
if (body.cancelled === true) {
|
|
283
|
+
// User-cancelled: quiet reset, nothing to report.
|
|
284
|
+
refreshInstalled()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
240
287
|
if (status === 200 && body.ok) {
|
|
241
288
|
sessionStorage.setItem('dshm-tab', 'installed')
|
|
242
289
|
if (body.hot) {
|
|
@@ -247,6 +294,13 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
247
294
|
}
|
|
248
295
|
refreshInstalled()
|
|
249
296
|
} else {
|
|
297
|
+
if (status === 409) {
|
|
298
|
+
setInstallError(t('busyWait'))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(body.ignoredBuilds) && body.ignoredBuilds.length > 0) {
|
|
302
|
+
setBuildsSkipped({ plugin, names: body.ignoredBuilds.map(String) })
|
|
303
|
+
}
|
|
250
304
|
const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
|
|
251
305
|
const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
|
|
252
306
|
setInstallError(t('installFail') + ': ' + plugin.name + ' — ' + detail.trim().slice(-600))
|
|
@@ -259,6 +313,59 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
259
313
|
.finally(() => setBusyUrl(null))
|
|
260
314
|
}, [refreshInstalled, t])
|
|
261
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Restart the host and reload once the boot id changes (#14 by @ysyyhhh).
|
|
318
|
+
* The 202 races the process's SIGTERM, so network errors on the initial
|
|
319
|
+
* request are expected and treated as "restart under way".
|
|
320
|
+
*/
|
|
321
|
+
const doRestart = useCallback(() => {
|
|
322
|
+
if (bootId === null || restarting) return
|
|
323
|
+
const previousBoot = bootId
|
|
324
|
+
setRestarting(true)
|
|
325
|
+
setInstallError(null)
|
|
326
|
+
const awaitNewBoot = () => {
|
|
327
|
+
const deadline = Date.now() + 60000
|
|
328
|
+
const poll = () => {
|
|
329
|
+
fetch('/dsh-market/status', { cache: 'no-store' })
|
|
330
|
+
.then(res => res.json())
|
|
331
|
+
.then((next) => {
|
|
332
|
+
if (typeof next.boot === 'string' && next.boot !== previousBoot) {
|
|
333
|
+
location.reload()
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
retry()
|
|
337
|
+
})
|
|
338
|
+
.catch(retry)
|
|
339
|
+
}
|
|
340
|
+
const retry = () => {
|
|
341
|
+
if (Date.now() > deadline) {
|
|
342
|
+
setRestarting(false)
|
|
343
|
+
setInstallError(t('restartTimeout'))
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
setTimeout(poll, 1500)
|
|
347
|
+
}
|
|
348
|
+
poll()
|
|
349
|
+
}
|
|
350
|
+
fetch('/dsh-market/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
351
|
+
.then(res => res.json().then(body => ({ status: res.status, body })))
|
|
352
|
+
.then(({ status, body }) => {
|
|
353
|
+
if (status !== 202 || body.ok !== true) {
|
|
354
|
+
setRestarting(false)
|
|
355
|
+
setInstallError(t('restartFail') + ': ' + String(body.error || ('HTTP ' + String(status))))
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
awaitNewBoot()
|
|
359
|
+
})
|
|
360
|
+
.catch(awaitNewBoot) // the host may die mid-response; keep polling
|
|
361
|
+
}, [bootId, restarting, t])
|
|
362
|
+
|
|
363
|
+
/** Cancel the running plugin command (#6 by @qichuang321). */
|
|
364
|
+
const doCancel = useCallback(() => {
|
|
365
|
+
fetch('/dsh-market/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
366
|
+
.catch(() => {})
|
|
367
|
+
}, [])
|
|
368
|
+
|
|
262
369
|
const doUpdate = useCallback((name: string, force = false) => {
|
|
263
370
|
setInstallError(null)
|
|
264
371
|
setStaleName(null)
|
|
@@ -270,10 +377,15 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
270
377
|
})
|
|
271
378
|
.then(res => res.json().then(body => ({ status: res.status, body })))
|
|
272
379
|
.then(({ status, body }) => {
|
|
380
|
+
if (body.cancelled === true) {
|
|
381
|
+
refreshInstalled()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
273
384
|
if (status === 200 && body.ok) {
|
|
274
385
|
setUpdatedNames(names => names.concat(name))
|
|
275
386
|
refreshInstalled()
|
|
276
387
|
} else {
|
|
388
|
+
if (status === 409) { setInstallError(t('busyWait')); return }
|
|
277
389
|
if (body.stale === true) setStaleName(name)
|
|
278
390
|
const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
|
|
279
391
|
const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
|
|
@@ -400,6 +512,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
400
512
|
<div className={css.progress}>
|
|
401
513
|
<span className={css.spin} />
|
|
402
514
|
<code className={css.grow}>{progressLine || t('progressHint')}</code>
|
|
515
|
+
{progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
|
|
516
|
+
<button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
|
|
403
517
|
</div>
|
|
404
518
|
)}
|
|
405
519
|
</div>
|
|
@@ -575,9 +689,41 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
575
689
|
<span>🔄</span>
|
|
576
690
|
<span className={css.grow}><b>{pendingRestart}</b> {t('restartBanner')}</span>
|
|
577
691
|
<span title={t('restartHint')}>ℹ️</span>
|
|
692
|
+
{restartEnabled && (
|
|
693
|
+
<Button
|
|
694
|
+
variant="primary"
|
|
695
|
+
size="sm"
|
|
696
|
+
disabled={restarting || busyUrl !== null || updatingName !== null || removingName !== null}
|
|
697
|
+
onClick={doRestart}
|
|
698
|
+
>{restarting ? t('restarting') : t('restartNow')}</Button>
|
|
699
|
+
)}
|
|
578
700
|
</div>
|
|
579
701
|
)}
|
|
580
702
|
</div>
|
|
703
|
+
{buildsSkipped !== null && (
|
|
704
|
+
<div className={css.restart}>
|
|
705
|
+
<span className={css.grow}>{t('buildsSkipped')} {buildsSkipped.names.join(', ')}</span>
|
|
706
|
+
<Button
|
|
707
|
+
size="sm"
|
|
708
|
+
disabled={busyUrl !== null}
|
|
709
|
+
onClick={() => {
|
|
710
|
+
const { plugin, names } = buildsSkipped
|
|
711
|
+
setBuildsSkipped(null)
|
|
712
|
+
fetch('/dsh-market/approve-builds', {
|
|
713
|
+
method: 'POST',
|
|
714
|
+
headers: { 'content-type': 'application/json' },
|
|
715
|
+
body: JSON.stringify({ packages: names }),
|
|
716
|
+
})
|
|
717
|
+
.then(res => res.json())
|
|
718
|
+
.then((body) => {
|
|
719
|
+
if (body.ok) doInstall(plugin)
|
|
720
|
+
else setInstallError(String(body.error || 'approve failed'))
|
|
721
|
+
})
|
|
722
|
+
.catch(error => setInstallError(String(error)))
|
|
723
|
+
}}
|
|
724
|
+
>{t('approveBuilds')}</Button>
|
|
725
|
+
</div>
|
|
726
|
+
)}
|
|
581
727
|
{installError !== null && (
|
|
582
728
|
<div className={css.err}>
|
|
583
729
|
{installError}
|
|
@@ -643,7 +789,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
643
789
|
</div>
|
|
644
790
|
{plugins.length === 0
|
|
645
791
|
? <div className={css.empty}>{t('empty')}</div>
|
|
646
|
-
:
|
|
792
|
+
: (
|
|
793
|
+
<>
|
|
794
|
+
<div className={css.grid}>{plugins.slice(0, visibleCount).map(pluginCard)}</div>
|
|
795
|
+
{plugins.length > visibleCount && <div ref={sentinelRef} className={css.sentinel} />}
|
|
796
|
+
</>
|
|
797
|
+
)}
|
|
647
798
|
</>
|
|
648
799
|
)
|
|
649
800
|
: tab === 'themes' && themeSnap !== null
|
|
@@ -692,6 +843,8 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
692
843
|
<div className={css.progress}>
|
|
693
844
|
<span className={css.spin} />
|
|
694
845
|
<code className={css.grow}>{progressLine || t('progressHint')}</code>
|
|
846
|
+
{progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
|
|
847
|
+
<button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
|
|
695
848
|
</div>
|
|
696
849
|
)}
|
|
697
850
|
</div>
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,15 @@ export const zh = {
|
|
|
29
29
|
update: '更新',
|
|
30
30
|
updating: '更新中…',
|
|
31
31
|
updated: '✓ 已更新,重启后生效',
|
|
32
|
+
cancelOp: '取消',
|
|
33
|
+
cancelled: '已取消',
|
|
34
|
+
busyWait: '已有操作正在进行,请等它结束(同一时间只执行一个安装/更新/卸载)',
|
|
35
|
+
approveBuilds: '放行构建脚本并重试',
|
|
36
|
+
buildsSkipped: '该插件需要运行构建脚本才能工作,出于安全默认被拦下。点击下方按钮为它放行并重装:',
|
|
37
|
+
restartNow: '立即重启',
|
|
38
|
+
restarting: '正在重启…',
|
|
39
|
+
restartFail: '重启失败',
|
|
40
|
+
restartTimeout: '等待 DeepSeek Harness 启动超时',
|
|
32
41
|
updateNow: '立即更新',
|
|
33
42
|
updateFail: '更新失败',
|
|
34
43
|
upToDate: '已是最新',
|
|
@@ -92,6 +101,15 @@ export const en: Record<MarketKey, string> = {
|
|
|
92
101
|
update: 'Update',
|
|
93
102
|
updating: 'Updating…',
|
|
94
103
|
updated: '✓ Updated — restart to apply',
|
|
104
|
+
cancelOp: 'Cancel',
|
|
105
|
+
cancelled: 'Cancelled',
|
|
106
|
+
busyWait: 'Another operation is already running — please wait for it to finish (one install/update/uninstall at a time)',
|
|
107
|
+
approveBuilds: 'Allow build scripts and retry',
|
|
108
|
+
buildsSkipped: 'This plugin needs its build scripts to run; they are blocked by default for safety. Click below to allow them and reinstall:',
|
|
109
|
+
restartNow: 'Restart now',
|
|
110
|
+
restarting: 'Restarting…',
|
|
111
|
+
restartFail: 'Restart failed',
|
|
112
|
+
restartTimeout: 'Timed out waiting for DeepSeek Harness to start',
|
|
95
113
|
updateNow: 'Update now',
|
|
96
114
|
updateFail: 'Update failed',
|
|
97
115
|
upToDate: 'Up to date',
|
package/src/dsh-cli.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { logEvent } from './log.ts'
|
|
|
14
14
|
import { pluginArgsFor } from './pnpm-compat.ts'
|
|
15
15
|
import { profileDir } from './profile.ts'
|
|
16
16
|
|
|
17
|
-
|
|
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
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
|
|
@@ -47,6 +49,8 @@ export interface InstallResult {
|
|
|
47
49
|
timedOut: boolean
|
|
48
50
|
stdout: string
|
|
49
51
|
stderr: string
|
|
52
|
+
/** True when the run ended because the user cancelled it. */
|
|
53
|
+
cancelled: boolean
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/** The shape every orchestration function takes to run plugin commands (injectable in tests). */
|
|
@@ -67,6 +71,49 @@ export function killChild(child: ChildProcess): void {
|
|
|
67
71
|
child.kill('SIGKILL')
|
|
68
72
|
}
|
|
69
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
|
+
|
|
70
117
|
/** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
|
|
71
118
|
let pnpmReady = false
|
|
72
119
|
|
|
@@ -147,7 +194,7 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
|
|
|
147
194
|
const target = pluginArgs[pluginArgs.length - 1] ?? ''
|
|
148
195
|
if (!TARGET_RE.test(target)) {
|
|
149
196
|
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)}
|
|
197
|
+
return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: `unsafe plugin target rejected: ${JSON.stringify(target)}`, cancelled: false })
|
|
151
198
|
}
|
|
152
199
|
progress.active = true
|
|
153
200
|
progress.target = target
|
|
@@ -162,13 +209,18 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
|
|
|
162
209
|
env: { ...process.env, CI: 'true' },
|
|
163
210
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
164
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',
|
|
165
215
|
})
|
|
216
|
+
activeChild = child
|
|
217
|
+
cancelRequested = false
|
|
166
218
|
let stdout = ''
|
|
167
219
|
let stderr = ''
|
|
168
220
|
let timedOut = false
|
|
169
221
|
const timer = setTimeout(() => {
|
|
170
222
|
timedOut = true
|
|
171
|
-
|
|
223
|
+
killTree(child)
|
|
172
224
|
}, INSTALL_TIMEOUT_MS)
|
|
173
225
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
174
226
|
const text = chunk.toString()
|
|
@@ -183,12 +235,14 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
|
|
|
183
235
|
child.on('error', (error) => {
|
|
184
236
|
clearTimeout(timer)
|
|
185
237
|
progress.active = false
|
|
186
|
-
|
|
238
|
+
if (activeChild === child) activeChild = null
|
|
239
|
+
resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false })
|
|
187
240
|
})
|
|
188
241
|
child.on('close', (code) => {
|
|
189
242
|
clearTimeout(timer)
|
|
190
243
|
progress.active = false
|
|
191
|
-
|
|
244
|
+
if (activeChild === child) activeChild = null
|
|
245
|
+
resolvePromise({ exitCode: code, timedOut, stdout, stderr, cancelled: cancelRequested })
|
|
192
246
|
})
|
|
193
247
|
})
|
|
194
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,10 @@ function argvProfile(): string | undefined {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export function apply(ctx: Context, config?: Config): void {
|
|
32
|
-
const resolved: MarketConfig = {
|
|
32
|
+
const resolved: MarketConfig = {
|
|
33
|
+
profile: config?.profile ?? argvProfile() ?? 'web',
|
|
34
|
+
allowRestart: config?.allowRestart ?? true,
|
|
35
|
+
}
|
|
33
36
|
ctx.inject(['webServer', 'loader'], (hostCtx: Context) => {
|
|
34
37
|
const host = hostCtx as unknown as MarketHost & {
|
|
35
38
|
effect(callback: () => () => void, label: string): void
|
package/src/install.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { logEvent } from './log.ts'
|
|
|
22
22
|
*/
|
|
23
23
|
export async function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[]): Promise<InstallResult> {
|
|
24
24
|
let result = await run(profile, pluginArgs)
|
|
25
|
-
const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut
|
|
25
|
+
const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
|
|
26
26
|
if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
|
|
27
27
|
logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
|
|
28
28
|
// --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
|
|
@@ -30,7 +30,7 @@ export async function withHoistRecovery(run: PluginRunner, profile: string, plug
|
|
|
30
30
|
const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
|
|
31
31
|
if (ok(rebuild)) result = await run(profile, pluginArgs)
|
|
32
32
|
}
|
|
33
|
-
if (!ok(result)) {
|
|
33
|
+
if (!ok(result) && !result.cancelled) {
|
|
34
34
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)
|
|
35
35
|
if (failure !== null) result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` }
|
|
36
36
|
}
|
|
@@ -119,3 +119,23 @@ export function isStaleUpdate(check: {
|
|
|
119
119
|
? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
|
|
120
120
|
: check.beforeVersion !== null && check.afterVersion === check.beforeVersion
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Package names pnpm reported as having their build scripts ignored
|
|
125
|
+
* ("Ignored build scripts: esbuild, koffi."). Empty when none.
|
|
126
|
+
* (#6 by @qichuang321.)
|
|
127
|
+
*/
|
|
128
|
+
export function parseIgnoredBuilds(stdout: string, stderr: string): string[] {
|
|
129
|
+
const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`)
|
|
130
|
+
if (m === null) return []
|
|
131
|
+
const found: string[] = []
|
|
132
|
+
for (const chunk of m[1].split(',')) {
|
|
133
|
+
// Entries may carry a version suffix and the sentence's final period.
|
|
134
|
+
const trimmed = chunk.trim().replace(/\.$/, '')
|
|
135
|
+
if (trimmed === '') continue
|
|
136
|
+
const at = trimmed.lastIndexOf('@')
|
|
137
|
+
const name = at > 0 ? trimmed.slice(0, at) : trimmed
|
|
138
|
+
if (name !== '' && !found.includes(name)) found.push(name)
|
|
139
|
+
}
|
|
140
|
+
return found
|
|
141
|
+
}
|
package/src/profile.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* functions of the directory contents; no processes, no network.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
@@ -14,7 +14,20 @@ export function profileDir(profile: string): string {
|
|
|
14
14
|
return join(home, 'profiles', profile)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The in-box bundles dsh's profile templates install themselves — the ONLY
|
|
19
|
+
* names the market hides from the installed list. Community plugins may
|
|
20
|
+
* legitimately publish under the official scope (#28), so a whole-scope
|
|
21
|
+
* filter would make them invisible and fail install validation.
|
|
22
|
+
* (Diagnosis and fix proposed in #28 by @Lograthmic.)
|
|
23
|
+
*/
|
|
24
|
+
const INBOX_BUNDLES = new Set([
|
|
25
|
+
'@deepseek-ai/dsh-base',
|
|
26
|
+
'@deepseek-ai/dsh-web-app',
|
|
27
|
+
'@deepseek-ai/dsh-headless',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
/** Community dependencies of the profile (in-box bundles filtered out). */
|
|
18
31
|
export function readInstalled(profile: string): Record<string, string> {
|
|
19
32
|
try {
|
|
20
33
|
const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8')) as {
|
|
@@ -22,7 +35,7 @@ export function readInstalled(profile: string): Record<string, string> {
|
|
|
22
35
|
}
|
|
23
36
|
const installed: Record<string, string> = {}
|
|
24
37
|
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
25
|
-
if (!
|
|
38
|
+
if (!INBOX_BUNDLES.has(name)) installed[name] = spec
|
|
26
39
|
}
|
|
27
40
|
return installed
|
|
28
41
|
} catch {
|
|
@@ -118,3 +131,32 @@ export function pluginSubdirs(root: string): string[] {
|
|
|
118
131
|
}
|
|
119
132
|
return found.slice(0, 8)
|
|
120
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Allow the given packages' build scripts in the profile's
|
|
137
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
138
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
139
|
+
* (#6 by @qichuang321.)
|
|
140
|
+
* @returns every package now allowed.
|
|
141
|
+
*/
|
|
142
|
+
export function setAllowBuilds(profile: string, packages: string[]): string[] {
|
|
143
|
+
const file = join(profileDir(profile), 'pnpm-workspace.yaml')
|
|
144
|
+
let yaml = ''
|
|
145
|
+
try { yaml = readFileSync(file, 'utf8') } catch { /* created below */ }
|
|
146
|
+
const blockRe = /allowBuilds:\n((?:[ \t]+[^\n]*\n?)*)/
|
|
147
|
+
const map: Record<string, string> = {}
|
|
148
|
+
const blockMatch = blockRe.exec(yaml)
|
|
149
|
+
if (blockMatch !== null) {
|
|
150
|
+
for (const line of blockMatch[1].split('\n')) {
|
|
151
|
+
const m = /^[ \t]+([^:\s]+(?:\/[^:\s]+)?)\s*:\s*(\S.*)?$/.exec(line)
|
|
152
|
+
if (m !== null) map[m[1]] = m[2] ?? 'true'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const pkg of packages) {
|
|
156
|
+
if (/^[A-Za-z0-9@/_.-]+$/.test(pkg)) map[pkg] = 'true'
|
|
157
|
+
}
|
|
158
|
+
const block = Object.entries(map).map(([k, v]) => ` ${k}: ${v}`).join('\n')
|
|
159
|
+
const blockText = `allowBuilds:\n${block}\n`
|
|
160
|
+
writeFileSync(file, blockMatch !== null ? yaml.replace(blockRe, blockText) : `${yaml.replace(/\n?$/, '\n')}${blockText}`)
|
|
161
|
+
return Object.keys(map)
|
|
162
|
+
}
|