dsh-plugin 0.1.0 → 1.0.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.
Files changed (64) hide show
  1. package/README.en.md +52 -27
  2. package/README.md +52 -27
  3. package/client/client.js +2148 -229
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +396 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +51 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/pending-restart.js +34 -0
  12. package/lib/services/preflight.js +94 -0
  13. package/lib/services/profile.js +88 -0
  14. package/lib/services/progress.js +50 -0
  15. package/lib/services/task-queue.js +417 -0
  16. package/lib/types/http/routes.d.ts +23 -0
  17. package/lib/types/index.d.ts +27 -0
  18. package/lib/types/services/install-types.d.ts +63 -0
  19. package/lib/types/services/install.d.ts +18 -0
  20. package/lib/types/services/installed-versions.d.ts +14 -0
  21. package/lib/types/services/loader.d.ts +43 -0
  22. package/lib/types/services/pending-restart.d.ts +13 -0
  23. package/lib/types/services/preflight.d.ts +6 -0
  24. package/lib/types/services/profile.d.ts +24 -0
  25. package/lib/types/services/progress.d.ts +17 -0
  26. package/lib/types/services/task-queue.d.ts +76 -0
  27. package/package.json +27 -6
  28. package/src/client/components/CatalogControls.tsx +104 -0
  29. package/src/client/components/CatalogHeader.tsx +78 -0
  30. package/src/client/components/CatalogList.tsx +75 -0
  31. package/src/client/components/CategoryTabs.tsx +35 -0
  32. package/src/client/components/Dropdown.tsx +80 -0
  33. package/src/client/components/NotificationsModal.tsx +230 -0
  34. package/src/client/components/PluginCard.tsx +104 -0
  35. package/src/client/components/PluginHubSection.tsx +339 -0
  36. package/src/client/components/ProgressView.tsx +23 -0
  37. package/src/client/components/icons.tsx +125 -0
  38. package/src/client/components/modals.tsx +365 -0
  39. package/src/client/hooks/useCatalog.ts +192 -0
  40. package/src/client/hooks/useTaskQueue.ts +405 -0
  41. package/src/client/index.tsx +8 -422
  42. package/src/client/lib/catalog.ts +204 -0
  43. package/src/client/lib/env.ts +16 -0
  44. package/src/client/lib/failures.ts +153 -0
  45. package/src/client/lib/format.ts +22 -0
  46. package/src/client/locales.ts +186 -18
  47. package/src/client/styles/Dropdown.module.css +123 -0
  48. package/src/client/styles/Header.module.css +432 -0
  49. package/src/client/styles/List.module.css +324 -0
  50. package/src/client/styles/Modal.module.css +965 -0
  51. package/src/client/styles/tokens.module.css +86 -0
  52. package/src/client/types.ts +80 -0
  53. package/src/server/http/routes.ts +397 -0
  54. package/src/server/index.ts +35 -0
  55. package/src/server/services/install-types.ts +63 -0
  56. package/src/server/services/install.ts +18 -0
  57. package/src/server/services/installed-versions.ts +65 -0
  58. package/src/server/services/loader.ts +110 -0
  59. package/src/server/services/pending-restart.ts +36 -0
  60. package/src/server/services/preflight.ts +97 -0
  61. package/src/server/services/profile.ts +89 -0
  62. package/src/server/services/progress.ts +48 -0
  63. package/src/server/services/task-queue.ts +450 -0
  64. package/src/client/Section.module.css +0 -569
@@ -0,0 +1,86 @@
1
+ /* Plugin Hub design tokens.
2
+ *
3
+ * Semantic aliases over the host's --dsw-alias-* theme tokens, each with the
4
+ * original light-mode literal as fallback for hosts that do not define the
5
+ * underlying token. Declared on :root and on the dark body so every theme
6
+ * re-resolves the aliases against its own token set; the custom property is
7
+ * then inherited by all section elements.
8
+ *
9
+ * Keep raw colors out of the per-area stylesheets: reference --hub-* here so
10
+ * new styles pick up the theme automatically. */
11
+ :root,
12
+ body[data-ds-dark-theme] {
13
+ /* text */
14
+ --hub-text-primary: var(--dsw-alias-label-primary, #1f2328);
15
+ --hub-text-secondary: var(--dsw-alias-label-secondary, #6b7280);
16
+ --hub-text-tertiary: var(--dsw-alias-label-tertiary, #8b93a1);
17
+ --hub-text-disabled: var(--dsw-alias-label-disabled, #aab1bd);
18
+ --hub-text-on-fill: var(--dsw-alias-label-primary-foreground, #ffffff);
19
+
20
+ /* surfaces */
21
+ --hub-bg-1: var(--dsw-alias-bg-layer-1, #ffffff);
22
+ --hub-bg-2: var(--dsw-alias-bg-layer-2, rgba(128, 128, 128, 0.08));
23
+ --hub-bg-3: rgba(128, 128, 128, 0.1);
24
+ /* 计数胶囊底色(已安装/未安装按钮内的数量胶囊) */
25
+ --hub-bg-btn: rgba(128, 128, 128, 0.12);
26
+ --hub-bg-hover: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.16));
27
+ --hub-bg-on-fill: rgba(128, 128, 128, 0.22);
28
+
29
+ /* borders */
30
+ --hub-border-1: var(--dsw-alias-border-l1, #eceef1);
31
+ --hub-border-2: var(--dsw-alias-border-l2, #e5e7eb);
32
+ --hub-border-ghost: var(--dsw-alias-button-ghost-active-border, rgba(79, 110, 247, 0.25));
33
+ --hub-border-input: var(--dsw-alias-input-border, rgba(107, 114, 128, 0.25));
34
+
35
+ /* brand (fixed in both themes) */
36
+ --hub-brand: var(--dsw-alias-state-business-primary, #4f6ef7);
37
+ --hub-brand-hover: #3b5bdb;
38
+ --hub-brand-tint: rgba(79, 110, 247, 0.06);
39
+ --hub-brand-tint-strong: rgba(79, 110, 247, 0.12);
40
+ --hub-brand-border: rgba(79, 110, 247, 0.35);
41
+ --hub-brand-border-soft: rgba(79, 110, 247, 0.22);
42
+
43
+ /* purple brand accents (fixed in both themes) */
44
+ --hub-purple-1: #4f46e5;
45
+ --hub-purple-2: #7c3aed;
46
+ --hub-purple-border: rgba(139, 92, 246, 0.55);
47
+ --hub-purple-tint: rgba(79, 70, 229, 0.08);
48
+ --hub-purple-shadow: rgba(79, 70, 229, 0.28);
49
+ --hub-purple-shadow-strong: rgba(124, 58, 237, 0.42);
50
+
51
+ /* primary action button */
52
+ --hub-btn-fill: var(--dsw-alias-button-primary-fill, #1f2328);
53
+ --hub-btn-hover: var(--dsw-alias-button-primary-hover, #43454a);
54
+
55
+ /* success */
56
+ --hub-success: var(--dsw-alias-state-success-primary, #1a7f37);
57
+ --hub-success-tint: rgba(34, 197, 94, 0.14);
58
+ --hub-success-border: var(--dsw-alias-state-success-secondary, rgba(26, 127, 55, 0.3));
59
+
60
+ /* warning (amber) */
61
+ --hub-warn: var(--dsw-alias-state-warn-primary, #b8860b);
62
+ --hub-warn-tint: rgba(184, 134, 11, 0.08);
63
+ --hub-warn-border: rgba(184, 134, 11, 0.35);
64
+ --hub-warn-strong: rgba(184, 134, 11, 0.85);
65
+
66
+ /* warning (deep amber) */
67
+ --hub-warning: var(--dsw-alias-state-warning-primary, #b45309);
68
+ --hub-warning-tint: rgba(180, 83, 9, 0.08);
69
+ --hub-warning-border: rgba(180, 83, 9, 0.3);
70
+
71
+ /* danger */
72
+ --hub-danger: var(--dsw-alias-state-danger-primary, #d1242f);
73
+ --hub-danger-hover: var(--dsw-alias-state-danger-hover, #b91c1c);
74
+ --hub-danger-strong: #b0202a;
75
+ --hub-danger-text: #e5484d;
76
+ --hub-danger-tint: rgba(229, 72, 77, 0.08);
77
+ --hub-danger-tint-weak: rgba(229, 72, 77, 0.05);
78
+ --hub-danger-border: rgba(229, 72, 77, 0.35);
79
+ --hub-danger-border-soft: rgba(229, 72, 77, 0.28);
80
+ --hub-danger-soft: rgba(209, 36, 47, 0.08);
81
+ }
82
+
83
+ /* 暗色下的计数胶囊底色:白半透明,深色底上更可辨 */
84
+ body[data-ds-dark-theme] {
85
+ --hub-bg-btn: rgba(255, 255, 255, 0.12);
86
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared types for the dsh-plugin browser client. Kept dependency-free so
3
+ * any client module can import them without pulling in components or styles.
4
+ */
5
+
6
+ export type LocaleId = 'zh' | 'en'
7
+
8
+ /** Translation function signature shared by the host binder and the in-component dictionary. */
9
+ export type Translate = (key: string, params?: Record<string, string | number>) => string
10
+
11
+ /** The subset of the locale service this plugin touches. */
12
+ export interface LocaleService {
13
+ register(namespace: string, dicts: { zh: Record<string, string>; en: Record<string, string> }): unknown
14
+ bind(namespace: string): (key: string, params?: Record<string, string | number>) => string
15
+ getSnapshot(): { active: LocaleId }
16
+ subscribe(fn: () => void): () => void
17
+ }
18
+
19
+ /** The subset of the slots service this plugin touches. */
20
+ export interface SlotsService {
21
+ inject(slot: string, register: () => unknown): void
22
+ register(meta: Record<string, unknown>, component: () => unknown): unknown
23
+ }
24
+
25
+ /** The client cordis context shape this plugin relies on (structural). */
26
+ export interface HubClientContext {
27
+ effect(callback: () => unknown, label?: string): void
28
+ slots: SlotsService
29
+ locale: LocaleService
30
+ }
31
+
32
+ export interface SectionProps {
33
+ t: Translate
34
+ locale: LocaleService
35
+ }
36
+
37
+ /** Slim projection of the site's DirectoryPlugin entries. */
38
+ export interface HubPlugin {
39
+ /** 作者小写 key,与 slug 共同构成唯一身份(两级路径 /plugins/{ownerSlug}/{slug});旧数据可能缺省 */
40
+ ownerSlug?: string
41
+ slug: string
42
+ displayName?: string
43
+ /** 仓库已发布版本(GitHub release tag,如 v1.2.3);无 release 的仓库无此字段 */
44
+ version?: string
45
+ category?: string
46
+ topics?: string[]
47
+ features?: string[]
48
+ description?: string
49
+ source?: { repo?: string }
50
+ compatibility?: { status?: string }
51
+ dates?: { repoUpdatedAt?: string; addedAt?: string }
52
+ stats?: { stargazers_count?: number; forks_count?: number }
53
+ }
54
+
55
+ /** 后台安装/卸载任务快照:进度 0-100 + 实时输出行(服务端估算,客户端轮询)。 */
56
+ export interface TaskState {
57
+ id: number
58
+ /** pending 排队中 / running 执行中 / cancelling 取消中(客户端过渡态)/ done 完成 / failed 失败 / cancelled 已取消 */
59
+ status: 'pending' | 'running' | 'cancelling' | 'done' | 'failed' | 'cancelled'
60
+ progress: number
61
+ lines: string[]
62
+ }
63
+
64
+ export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail'
65
+
66
+ export interface ToastState {
67
+ id: number
68
+ kind: ToastKind
69
+ }
70
+
71
+ /** 宿主机器环境快照(后端 /dsh-plugin-hub/env):提交 bug 时拼进 issue 正文,便于作者复现。 */
72
+ export interface EnvInfo {
73
+ dshVersion: string | null
74
+ nodeVersion: string
75
+ platform: string
76
+ arch: string
77
+ release: string
78
+ profile: string
79
+ dshHome: string
80
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Local HTTP routes exposing real installs to the in-app Plugin Hub UI.
3
+ * The client fetches the same-origin `/dsh-plugin-hub/*` endpoints; the
4
+ * install handler validates the target, then spawns the official dsh CLI
5
+ * (see services/install.ts) and reports the captured result back.
6
+ */
7
+ import { readFileSync } from 'node:fs'
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { homedir, release } from 'node:os'
10
+ import { dirname, join, resolve } from 'node:path'
11
+ import { spawn } from 'node:child_process'
12
+ import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install.ts'
13
+ import { recordInstalledVersion, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.ts'
14
+ import { preflightTarget } from '../services/preflight.ts'
15
+
16
+ export interface WebRoute {
17
+ kind: 'exact'
18
+ path: string
19
+ handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
20
+ }
21
+
22
+ /** The subset of the host web-server service this plugin touches. */
23
+ export interface WebServerService {
24
+ register(route: WebRoute): () => void
25
+ }
26
+
27
+ const PROFILE_RE = /^[A-Za-z0-9_-]+$/
28
+ const BODY_LIMIT_BYTES = 4 * 1024
29
+ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000
30
+
31
+ function profileDirectory(profile: string): string {
32
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
33
+ }
34
+
35
+ /** Read non-official dependencies installed into one profile. */
36
+ export function readInstalled(profile: string): Record<string, string> {
37
+ try {
38
+ const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8')) as {
39
+ dependencies?: Record<string, string>
40
+ }
41
+ return Object.fromEntries(
42
+ Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')),
43
+ )
44
+ } catch {
45
+ return {}
46
+ }
47
+ }
48
+
49
+ /** 宿主 dsh CLI 版本:从入口(process.argv[1])向上找最近的 dsh/harness 相关 package.json;找不到返回 null。 */
50
+ function hostDshVersion(): string | null {
51
+ const entry = process.argv[1]
52
+ if (entry === undefined) return null
53
+ let dir = dirname(resolve(entry))
54
+ for (let depth = 0; depth < 4; depth++) {
55
+ try {
56
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string }
57
+ const name = manifest.name ?? ''
58
+ if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
59
+ return manifest.version
60
+ }
61
+ } catch {
62
+ /* not a package root — keep walking up */
63
+ }
64
+ const parent = dirname(dir)
65
+ if (parent === dir) break
66
+ dir = parent
67
+ }
68
+ return null
69
+ }
70
+
71
+ /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
72
+ function hostEnv(profile: string): Record<string, string | null> {
73
+ return {
74
+ dshVersion: hostDshVersion(),
75
+ nodeVersion: process.version,
76
+ platform: process.platform,
77
+ arch: process.arch,
78
+ release: release(),
79
+ profile,
80
+ dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
81
+ }
82
+ }
83
+
84
+ /** Match an install target (`github:<owner>/<repo>`) against the installed table; returns the package name when already installed. */
85
+ function installedByRepo(installed: Record<string, string>, target: string): string | null {
86
+ const needle = target.toLowerCase()
87
+ for (const [name, spec] of Object.entries(installed)) {
88
+ if (spec.toLowerCase().includes(needle)) return name
89
+ }
90
+ return null
91
+ }
92
+
93
+ function sendJson(response: ServerResponse, status: number, value: unknown): void {
94
+ response.writeHead(status, {
95
+ 'cache-control': 'no-store',
96
+ 'content-type': 'application/json; charset=utf-8',
97
+ })
98
+ response.end(JSON.stringify(value))
99
+ }
100
+
101
+ /** POST mutations are only accepted from the local web server origin. */
102
+ function isSameOrigin(request: IncomingMessage): boolean {
103
+ const origin = request.headers.origin
104
+ const host = request.headers.host
105
+ if (origin === undefined || host === undefined) return false
106
+ try {
107
+ const url = new URL(origin)
108
+ const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]'])
109
+ return url.host === host && localHostnames.has(url.hostname)
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ function requireMethod(request: IncomingMessage, response: ServerResponse, method: 'GET' | 'POST'): boolean {
116
+ if (request.method === method) return true
117
+ response.writeHead(405, { allow: method })
118
+ response.end()
119
+ return false
120
+ }
121
+
122
+ function requireTrustedPost(request: IncomingMessage, response: ServerResponse): boolean {
123
+ if (!requireMethod(request, response, 'POST')) return false
124
+ if (isSameOrigin(request)) return true
125
+ sendJson(response, 403, { error: 'untrusted origin' })
126
+ return false
127
+ }
128
+
129
+ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
130
+ const chunks: Buffer[] = []
131
+ let size = 0
132
+ for await (const chunk of request) {
133
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
134
+ size += buffer.length
135
+ if (size > BODY_LIMIT_BYTES) throw new Error('request body too large')
136
+ chunks.push(buffer)
137
+ }
138
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
139
+ }
140
+
141
+ /**
142
+ * Register the Plugin Hub API on the host web server and return a disposer.
143
+ * @param webServer - DSH web server service.
144
+ * @param profile - profile that owns plugin mutations.
145
+ * @param loader - running loader (optional): lets uninstall remove the entry
146
+ * immediately so the page survives a refresh without a host restart.
147
+ */
148
+ export function mountPluginHubRoutes(webServer: WebServerService, profile: string, loader?: LoaderHandle): () => void {
149
+ if (!PROFILE_RE.test(profile)) throw new Error(`invalid profile name: ${profile}`)
150
+ console.log(`[hub] routes mounted (profile=${profile}, loader=${loader === undefined ? 'undefined' : 'provided'})`)
151
+ const disposers = [
152
+ webServer.register({
153
+ kind: 'exact',
154
+ path: '/dsh-plugin-hub/debug/loader-entries',
155
+ handler: (request, response) => {
156
+ if (!requireMethod(request, response, 'GET')) return
157
+ sendJson(response, 200, { entries: dumpLoaderEntries(loader) })
158
+ },
159
+ }),
160
+ webServer.register({
161
+ kind: 'exact',
162
+ path: '/dsh-plugin-hub/install',
163
+ handler: async (request, response) => {
164
+ if (!requireTrustedPost(request, response)) return
165
+ // 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
166
+ try {
167
+ const body = await readJsonBody(request)
168
+ // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
169
+ const mode = body !== null && typeof body === 'object' && (body as { mode?: unknown }).mode === 'update'
170
+ ? 'update'
171
+ : 'install'
172
+ const rawRepo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
173
+ ? (body as { repo: string }).repo.trim()
174
+ : ''
175
+ // 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
176
+ const repoTarget = githubTarget(rawRepo)
177
+ const target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null)
178
+ if (target === null) {
179
+ sendJson(response, 400, { error: 'unsupported install target' })
180
+ return
181
+ }
182
+ // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
183
+ // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
184
+ // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装
185
+ const already = installedByRepo(readInstalled(profile), target)
186
+ if (already !== null && mode !== 'update') {
187
+ sendJson(response, 409, { error: `already installed: ${already}` })
188
+ return
189
+ }
190
+ // 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
191
+ if (hasQueuedTarget(target)) {
192
+ sendJson(response, 409, { error: `already queued: ${target}` })
193
+ return
194
+ }
195
+ // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
196
+ // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
197
+ // 只支持官方默认安装方式(dsh plugin add github:owner/repo),不做任何 npm 兜底降级;
198
+ // [packaging] 前缀供客户端归类为「插件未适配官方默认安装方式」并引导去仓库提 Issue
199
+ if (already === null) {
200
+ const preflight = await preflightTarget(target)
201
+ if (!preflight.ok) {
202
+ sendJson(response, 400, {
203
+ error: `[packaging] ${target}: does not support the official default install method — the git distribution lacks build output (the entry file ${preflight.missing ?? '?'} declared in package.json is not in the repository). Report to the author.`,
204
+ })
205
+ return
206
+ }
207
+ }
208
+ // 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
209
+ // 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
210
+ // JS 单线程保证同一目标至多一个任务,杜绝双击/并发产生双任务
211
+ if (hasQueuedTarget(target)) {
212
+ sendJson(response, 409, { error: `already queued: ${target}` })
213
+ return
214
+ }
215
+ // Kick off the CLI in the background; the client polls /status for progress
216
+ const task = startPluginMutation({
217
+ action: 'add',
218
+ profile,
219
+ target,
220
+ timeoutMs: COMMAND_TIMEOUT_MS,
221
+ env: { ...process.env, CI: 'true' },
222
+ })
223
+ sendJson(response, 200, { ok: true, task: task.id })
224
+ } catch (error) {
225
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
226
+ }
227
+ },
228
+ }),
229
+ webServer.register({
230
+ kind: 'exact',
231
+ path: '/dsh-plugin-hub/uninstall',
232
+ handler: async (request, response) => {
233
+ if (!requireTrustedPost(request, response)) return
234
+ // 与安装一致:卸载也进入同一队列,FIFO 串行执行
235
+ try {
236
+ const body = await readJsonBody(request)
237
+ const name = typeof body === 'object' && body !== null && typeof (body as { name?: unknown }).name === 'string'
238
+ ? (body as { name: string }).name
239
+ : ''
240
+ // 展示用的 owner/repo:卸载命令只需要 npm 包名,但待重启行要与安装行一致地显示仓库名
241
+ const repo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
242
+ ? (body as { repo: string }).repo
243
+ : ''
244
+ if (!validPackageName(name) || name === 'dsh-plugin') {
245
+ sendJson(response, 400, { error: 'plugin cannot be uninstalled here' })
246
+ return
247
+ }
248
+ if (readInstalled(profile)[name] === undefined) {
249
+ sendJson(response, 400, { error: 'plugin is not installed' })
250
+ return
251
+ }
252
+ // 队列级去重:同一包已在排队/执行中卸载时拒绝
253
+ if (hasQueuedTarget(name)) {
254
+ sendJson(response, 409, { error: `already queued: ${name}` })
255
+ return
256
+ }
257
+ const task = startPluginMutation({
258
+ action: 'remove',
259
+ profile,
260
+ target: name,
261
+ // 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
262
+ displayTarget: githubTarget(repo) ?? undefined,
263
+ // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
264
+ uninstallLoader: loader,
265
+ timeoutMs: COMMAND_TIMEOUT_MS,
266
+ env: { ...process.env, CI: 'true' },
267
+ })
268
+ sendJson(response, 200, { ok: true, task: task.id })
269
+ } catch (error) {
270
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
271
+ }
272
+ },
273
+ }),
274
+ webServer.register({
275
+ kind: 'exact',
276
+ path: '/dsh-plugin-hub/status',
277
+ handler: (request, response) => {
278
+ if (!requireMethod(request, response, 'GET')) return
279
+ const url = new URL(request.url ?? '/', 'http://localhost')
280
+ const id = Number(url.searchParams.get('task'))
281
+ const task = Number.isInteger(id) ? getTask(id) : undefined
282
+ if (!task) {
283
+ sendJson(response, 404, { error: 'task not found' })
284
+ return
285
+ }
286
+ sendJson(response, 200, { task })
287
+ },
288
+ }),
289
+ webServer.register({
290
+ kind: 'exact',
291
+ path: '/dsh-plugin-hub/active',
292
+ handler: (request, response) => {
293
+ if (!requireMethod(request, response, 'GET')) return
294
+ // 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
295
+ // 进行中/排队任务与「装完没重启」的持久提醒
296
+ sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() })
297
+ },
298
+ }),
299
+ webServer.register({
300
+ kind: 'exact',
301
+ path: '/dsh-plugin-hub/env',
302
+ handler: (request, response) => {
303
+ if (!requireMethod(request, response, 'GET')) return
304
+ // 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
305
+ sendJson(response, 200, hostEnv(profile))
306
+ },
307
+ }),
308
+ webServer.register({
309
+ kind: 'exact',
310
+ path: '/dsh-plugin-hub/cancel',
311
+ handler: async (request, response) => {
312
+ if (!requireTrustedPost(request, response)) return
313
+ try {
314
+ const body = await readJsonBody(request)
315
+ const id = typeof body === 'object' && body !== null && typeof (body as { id?: unknown }).id === 'number'
316
+ ? (body as { id: number }).id
317
+ : NaN
318
+ const ok = Number.isInteger(id) && cancelTask(id)
319
+ sendJson(response, 200, { ok })
320
+ } catch (error) {
321
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
322
+ }
323
+ },
324
+ }),
325
+ webServer.register({
326
+ kind: 'exact',
327
+ path: '/dsh-plugin-hub/restart',
328
+ handler: (request, response) => {
329
+ if (!requireTrustedPost(request, response)) return
330
+ // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
331
+ const host = request.headers.host ?? ''
332
+ const portMatch = host.match(/:(\d+)$/)
333
+ const port = portMatch ? Number(portMatch[1]) : 7923
334
+ // detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
335
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
336
+ const script = [
337
+ `pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
338
+ '[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
339
+ `i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
340
+ 'mkdir -p "$HOME/.dsh/logs"',
341
+ `nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
342
+ ].join('\n')
343
+ spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref()
344
+ sendJson(response, 200, { ok: true, port })
345
+ },
346
+ }),
347
+ webServer.register({
348
+ kind: 'exact',
349
+ path: '/dsh-plugin-hub/installed-version',
350
+ handler: async (request, response) => {
351
+ if (!requireTrustedPost(request, response)) return
352
+ try {
353
+ const body = await readJsonBody(request)
354
+ const repo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
355
+ ? (body as { repo: string }).repo
356
+ : ''
357
+ if (githubTarget(repo) === null) {
358
+ sendJson(response, 400, { error: 'invalid install target' })
359
+ return
360
+ }
361
+ // version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
362
+ // 供「有更新」比对:有版本比版本,无版本比更新时间
363
+ const version = body !== null && typeof (body as { version?: unknown }).version === 'string'
364
+ ? (body as { version: string }).version
365
+ : null
366
+ if (version === null) {
367
+ removeInstalledVersion(profile, repo)
368
+ } else {
369
+ const updatedAt = body !== null && typeof (body as { updatedAt?: unknown }).updatedAt === 'string'
370
+ ? (body as { updatedAt: string }).updatedAt
371
+ : ''
372
+ recordInstalledVersion(profile, repo, version, updatedAt)
373
+ }
374
+ sendJson(response, 200, { ok: true })
375
+ } catch (error) {
376
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
377
+ }
378
+ },
379
+ }),
380
+ webServer.register({
381
+ kind: 'exact',
382
+ path: '/dsh-plugin-hub/installed',
383
+ handler: (request, response) => {
384
+ if (!requireMethod(request, response, 'GET')) return
385
+ // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本),
386
+ // 客户端合并两者判断「是否有更新」
387
+ sendJson(response, 200, { profile, installed: readInstalled(profile), versions: readInstalledVersions(profile) })
388
+ },
389
+ }),
390
+ ]
391
+ return () => {
392
+ for (const dispose of disposers) dispose()
393
+ }
394
+ }
395
+
396
+ /** Profile resolution shared with the client route docs. */
397
+ export { readProfileArg }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * dsh-plugin host entry: mounts the Plugin Hub HTTP routes once the host
3
+ * web server service is available. The browser bundle (src/client) talks to
4
+ * these same-origin routes to perform real installs from inside the app.
5
+ */
6
+ import { readProfileArg, type LoaderHandle } from './services/install.ts'
7
+ import { mountPluginHubRoutes, type WebServerService } from './http/routes.ts'
8
+
9
+ export const name = 'dsh-plugin'
10
+
11
+ export interface Config {
12
+ /** DSH profile that owns plugin mutations. Defaults to the booted profile. */
13
+ profile?: string
14
+ }
15
+
16
+ /** The subset of the cordis host context this plugin touches. */
17
+ interface HostContext {
18
+ webServer: WebServerService
19
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(宿主未提供时卸载仍需重启) */
20
+ loader?: LoaderHandle
21
+ inject(services: string[], callback: (host: HostContext) => void): void
22
+ effect(callback: () => void, label?: string): void
23
+ }
24
+
25
+ /**
26
+ * Mount the Plugin Hub routes after the web server service becomes available.
27
+ * @param ctx - cordis host context.
28
+ * @param config - optional profile override.
29
+ */
30
+ export function apply(ctx: HostContext, config: Config = {}): void {
31
+ const profile = config.profile ?? readProfileArg('web')
32
+ ctx.inject(['webServer'], (host) => {
33
+ host.effect(() => mountPluginHubRoutes(host.webServer, profile, host.loader), 'dsh-plugin: http routes')
34
+ })
35
+ }
@@ -0,0 +1,63 @@
1
+ /** 安装/卸载运行时的公共类型与常量(无依赖,供各服务模块复用)。 */
2
+
3
+ /** 一个 pnpm 安装子进程的结果快照。 */
4
+ export interface InstallResult {
5
+ exitCode: number | null
6
+ timedOut: boolean
7
+ error: string | null
8
+ stdout: string
9
+ stderr: string
10
+ }
11
+
12
+ export interface InstallTask {
13
+ id: number
14
+ /** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
15
+ target: string
16
+ action: 'add' | 'remove'
17
+ /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
18
+ status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
19
+ timedOut: boolean
20
+ exitCode: number | null
21
+ /** 0-100 估算进度:解析 pnpm 的 `Progress: resolved…` 输出,阶段行兜底 */
22
+ progress: number
23
+ /** Newest output lines first (consumer shows the tail). */
24
+ lines: string[]
25
+ }
26
+
27
+ /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
28
+ export interface Invocation {
29
+ file: string
30
+ prefixArgs: string[]
31
+ cwd: string
32
+ useShell: boolean
33
+ }
34
+
35
+ /** 排队中的变更任务及其启动参数。 */
36
+ export interface QueueItem {
37
+ task: InstallTask
38
+ options: { action: 'add' | 'remove'; profile: string; target: string; timeoutMs?: number; env?: NodeJS.ProcessEnv }
39
+ }
40
+
41
+ /**
42
+ * 安装成功但宿主尚未重启的插件(待重启后生效)。纯内存态:宿主进程一重启
43
+ * 它自然清空,正好等于「重启后提醒消失」的语义,无需任何重启探测。
44
+ */
45
+ export interface PendingRestart {
46
+ /** `github:<owner>/<repo>` 安装目标(与任务一致;卸载时为展示用的 owner/repo) */
47
+ target: string
48
+ /** 待重启语义:install 装完等挂载生效 / uninstall 卸完等清理 loader 残留 */
49
+ kind: 'install' | 'uninstall'
50
+ /** 任务完成时刻(epoch ms) */
51
+ at: number
52
+ }
53
+
54
+ /** Grammar of an accepted `github:<owner>/<repo>` install target. */
55
+ export const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
56
+ /** Grammar of an npm package name (used for uninstall targets). */
57
+ export const PACKAGE_RE = /^(?:@[a-z0-9._-]+\/)?[A-Za-z0-9._-]+$/
58
+ /** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
59
+ export const CAPTURE_LIMIT_BYTES = 64 * 1024
60
+ /** Max output lines kept per task (newest wins). */
61
+ export const MAX_TASK_LINES = 200
62
+ /** Max tracked tasks; oldest finished tasks are dropped first. */
63
+ export const MAX_TASKS = 50
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Install runner 的统一出口:执行真实插件变更(spawn 官方 dsh CLI 子进程)。
3
+ * 实现按职责拆分在同目录子模块,本文件只做公共 API 的 re-export,保持外部
4
+ * import 面稳定(routes.ts / index.ts 仍从 `services/install.ts` 引入)。
5
+ *
6
+ * 模块划分:
7
+ * - install-types.ts 公共类型与常量
8
+ * - pending-restart.ts 待重启列表内存态管理
9
+ * - loader.ts 运行中 loader 的读取与卸载即时停用
10
+ * - profile.ts 宿主 profile / 目标校验 / allowBuilds 写入
11
+ * - task-queue.ts 任务注册表 + FIFO 队列 worker + 子进程生命周期
12
+ */
13
+ export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem } from './install-types.ts'
14
+ export type { LoaderHandle } from './loader.ts'
15
+ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.ts'
16
+ export { dumpLoaderEntries, removeLoadedEntry } from './loader.ts'
17
+ export { addAllowBuildsKey, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.ts'
18
+ export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts'