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.
@@ -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;
@@ -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.2.3",
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>(null)
74
+ const [data, setData] = useState<Registry | null>(cachedRegistry)
67
75
  const [loadError, setLoadError] = useState(false)
68
- const [installed, setInstalled] = useState<InstalledMap>({})
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
- : <div className={css.grid}>{plugins.map(pluginCard)}</div>}
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>
@@ -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
- const INSTALL_TIMEOUT_MS = 5 * 60 * 1000
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
- killChild(child)
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
- resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}` })
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
- resolvePromise({ exitCode: code, timedOut, stdout, stderr })
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 = { profile: config?.profile ?? argvProfile() ?? 'web' }
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
- /** Community dependencies of the profile (official in-box scope filtered out). */
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 (!name.startsWith('@deepseek-ai/')) installed[name] = spec
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
+ }