dsh-plugin 0.1.1 → 1.1.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 (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -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 +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,91 @@
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: #16a34a;
57
+ --hub-success-tint: rgba(22, 163, 74, 0.12);
58
+ --hub-success-border: rgba(22, 163, 74, 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
+
87
+ /* 暗色下的成功绿:提亮一档,对勾在深底上清晰且不刺眼 */
88
+ --hub-success: #22c55e;
89
+ --hub-success-tint: rgba(34, 197, 94, 0.16);
90
+ --hub-success-border: rgba(34, 197, 94, 0.35);
91
+ }
@@ -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; npmPackage?: 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,432 @@
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, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.ts'
14
+ import { resolveNpmPackage } from '../services/npm-resolve.ts'
15
+ import { preflightTarget } from '../services/preflight.ts'
16
+
17
+ export interface WebRoute {
18
+ kind: 'exact'
19
+ path: string
20
+ handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
21
+ }
22
+
23
+ /** The subset of the host web-server service this plugin touches. */
24
+ export interface WebServerService {
25
+ register(route: WebRoute): () => void
26
+ }
27
+
28
+ const PROFILE_RE = /^[A-Za-z0-9_-]+$/
29
+ const BODY_LIMIT_BYTES = 4 * 1024
30
+ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000
31
+
32
+ function profileDirectory(profile: string): string {
33
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
34
+ }
35
+
36
+ /** Read non-official dependencies installed into one profile. */
37
+ export function readInstalled(profile: string): Record<string, string> {
38
+ try {
39
+ const manifest = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8')) as {
40
+ dependencies?: Record<string, string>
41
+ }
42
+ return Object.fromEntries(
43
+ Object.entries(manifest.dependencies ?? {}).filter(([name]) => !name.startsWith('@deepseek-ai/')),
44
+ )
45
+ } catch {
46
+ return {}
47
+ }
48
+ }
49
+
50
+ /** 宿主 dsh CLI 版本:从入口(process.argv[1])向上找最近的 dsh/harness 相关 package.json;找不到返回 null。 */
51
+ function hostDshVersion(): string | null {
52
+ const entry = process.argv[1]
53
+ if (entry === undefined) return null
54
+ let dir = dirname(resolve(entry))
55
+ for (let depth = 0; depth < 4; depth++) {
56
+ try {
57
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string }
58
+ const name = manifest.name ?? ''
59
+ if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
60
+ return manifest.version
61
+ }
62
+ } catch {
63
+ /* not a package root — keep walking up */
64
+ }
65
+ const parent = dirname(dir)
66
+ if (parent === dir) break
67
+ dir = parent
68
+ }
69
+ return null
70
+ }
71
+
72
+ /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
73
+ function hostEnv(profile: string): Record<string, string | null> {
74
+ return {
75
+ dshVersion: hostDshVersion(),
76
+ nodeVersion: process.version,
77
+ platform: process.platform,
78
+ arch: process.arch,
79
+ release: release(),
80
+ profile,
81
+ dshHome: process.env.DSH_HOME ?? join(homedir(), '.dsh'),
82
+ }
83
+ }
84
+
85
+ /** Match an install target (`github:<owner>/<repo>`) against the installed table; returns the package name when already installed. */
86
+ function installedByRepo(installed: Record<string, string>, target: string): string | null {
87
+ const needle = target.toLowerCase()
88
+ for (const [name, spec] of Object.entries(installed)) {
89
+ if (spec.toLowerCase().includes(needle)) return name
90
+ }
91
+ return null
92
+ }
93
+
94
+ function sendJson(response: ServerResponse, status: number, value: unknown): void {
95
+ response.writeHead(status, {
96
+ 'cache-control': 'no-store',
97
+ 'content-type': 'application/json; charset=utf-8',
98
+ })
99
+ response.end(JSON.stringify(value))
100
+ }
101
+
102
+ /** POST mutations are only accepted from the local web server origin. */
103
+ function isSameOrigin(request: IncomingMessage): boolean {
104
+ const origin = request.headers.origin
105
+ const host = request.headers.host
106
+ if (origin === undefined || host === undefined) return false
107
+ try {
108
+ const url = new URL(origin)
109
+ const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]'])
110
+ return url.host === host && localHostnames.has(url.hostname)
111
+ } catch {
112
+ return false
113
+ }
114
+ }
115
+
116
+ function requireMethod(request: IncomingMessage, response: ServerResponse, method: 'GET' | 'POST'): boolean {
117
+ if (request.method === method) return true
118
+ response.writeHead(405, { allow: method })
119
+ response.end()
120
+ return false
121
+ }
122
+
123
+ function requireTrustedPost(request: IncomingMessage, response: ServerResponse): boolean {
124
+ if (!requireMethod(request, response, 'POST')) return false
125
+ if (isSameOrigin(request)) return true
126
+ sendJson(response, 403, { error: 'untrusted origin' })
127
+ return false
128
+ }
129
+
130
+ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
131
+ const chunks: Buffer[] = []
132
+ let size = 0
133
+ for await (const chunk of request) {
134
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
135
+ size += buffer.length
136
+ if (size > BODY_LIMIT_BYTES) throw new Error('request body too large')
137
+ chunks.push(buffer)
138
+ }
139
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
140
+ }
141
+
142
+ /**
143
+ * Register the Plugin Hub API on the host web server and return a disposer.
144
+ * @param webServer - DSH web server service.
145
+ * @param profile - profile that owns plugin mutations.
146
+ * @param loader - running loader (optional): lets uninstall remove the entry
147
+ * immediately so the page survives a refresh without a host restart.
148
+ */
149
+ export function mountPluginHubRoutes(webServer: WebServerService, profile: string, loader?: LoaderHandle): () => void {
150
+ if (!PROFILE_RE.test(profile)) throw new Error(`invalid profile name: ${profile}`)
151
+ console.log(`[hub] routes mounted (profile=${profile}, loader=${loader === undefined ? 'undefined' : 'provided'})`)
152
+ const disposers = [
153
+ webServer.register({
154
+ kind: 'exact',
155
+ path: '/dsh-plugin-hub/debug/loader-entries',
156
+ handler: (request, response) => {
157
+ if (!requireMethod(request, response, 'GET')) return
158
+ sendJson(response, 200, { entries: dumpLoaderEntries(loader) })
159
+ },
160
+ }),
161
+ webServer.register({
162
+ kind: 'exact',
163
+ path: '/dsh-plugin-hub/install',
164
+ handler: async (request, response) => {
165
+ if (!requireTrustedPost(request, response)) return
166
+ // 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
167
+ try {
168
+ const body = await readJsonBody(request)
169
+ // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
170
+ const mode = body !== null && typeof body === 'object' && (body as { mode?: unknown }).mode === 'update'
171
+ ? 'update'
172
+ : 'install'
173
+ const rawRepo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
174
+ ? (body as { repo: string }).repo.trim()
175
+ : ''
176
+ // 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
177
+ // 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
178
+ const displayRepo = typeof body === 'object' && body !== null && typeof (body as { display?: unknown }).display === 'string'
179
+ ? (body as { display: string }).display.trim()
180
+ : rawRepo
181
+ // 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
182
+ const repoTarget = githubTarget(rawRepo)
183
+ let target: string | null = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null)
184
+ if (target === null) {
185
+ sendJson(response, 400, { error: 'unsupported install target' })
186
+ return
187
+ }
188
+ // npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
189
+ // git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
190
+ // 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
191
+ // 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
192
+ // 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
193
+ // 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
194
+ const attempts: string[] = []
195
+ if (repoTarget !== null) {
196
+ const npmName = await resolveNpmPackage(rawRepo)
197
+ if (npmName !== null) {
198
+ target = npmName
199
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``)
200
+ // 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
201
+ // 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
202
+ // 列表立即显示「已安装」,避免安装成功后仍显示可安装
203
+ recordResolvedNpmPackage(profile, rawRepo, npmName)
204
+ } else {
205
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`)
206
+ }
207
+ }
208
+ // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
209
+ // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
210
+ // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
211
+ // 命中判定双通道:npm 包名按 dependencies 键直接命中,github: 目标按 spec 值匹配。
212
+ const installed = readInstalled(profile)
213
+ const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null)
214
+ if (already !== null && mode !== 'update') {
215
+ sendJson(response, 409, { error: `already installed: ${already}` })
216
+ return
217
+ }
218
+ // 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
219
+ if (hasQueuedTarget(target)) {
220
+ sendJson(response, 409, { error: `already queued: ${target}` })
221
+ return
222
+ }
223
+ // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
224
+ // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
225
+ // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
226
+ // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
227
+ if (already === null) {
228
+ const preflight = await preflightTarget(target)
229
+ if (!preflight.ok) {
230
+ // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
231
+ // 客户端失败弹窗提 Issue 时一并贴给作者
232
+ sendJson(response, 400, {
233
+ error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
234
+ attempts,
235
+ })
236
+ return
237
+ }
238
+ }
239
+ // 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
240
+ // 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
241
+ // JS 单线程保证同一目标至多一个任务,杜绝双击/并发产生双任务
242
+ if (hasQueuedTarget(target)) {
243
+ sendJson(response, 409, { error: `already queued: ${target}` })
244
+ return
245
+ }
246
+ // Kick off the CLI in the background; the client polls /status for progress
247
+ const task = startPluginMutation({
248
+ action: 'add',
249
+ profile,
250
+ target,
251
+ // npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
252
+ displayTarget: githubTarget(displayRepo) ?? undefined,
253
+ // 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
254
+ attempts,
255
+ timeoutMs: COMMAND_TIMEOUT_MS,
256
+ env: { ...process.env, CI: 'true' },
257
+ })
258
+ sendJson(response, 200, { ok: true, task: task.id })
259
+ } catch (error) {
260
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
261
+ }
262
+ },
263
+ }),
264
+ webServer.register({
265
+ kind: 'exact',
266
+ path: '/dsh-plugin-hub/uninstall',
267
+ handler: async (request, response) => {
268
+ if (!requireTrustedPost(request, response)) return
269
+ // 与安装一致:卸载也进入同一队列,FIFO 串行执行
270
+ try {
271
+ const body = await readJsonBody(request)
272
+ const name = typeof body === 'object' && body !== null && typeof (body as { name?: unknown }).name === 'string'
273
+ ? (body as { name: string }).name
274
+ : ''
275
+ // 展示用的 owner/repo:卸载命令只需要 npm 包名,但待重启行要与安装行一致地显示仓库名
276
+ const repo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
277
+ ? (body as { repo: string }).repo
278
+ : ''
279
+ if (!validPackageName(name) || name === 'dsh-plugin') {
280
+ sendJson(response, 400, { error: 'plugin cannot be uninstalled here' })
281
+ return
282
+ }
283
+ if (readInstalled(profile)[name] === undefined) {
284
+ sendJson(response, 400, { error: 'plugin is not installed' })
285
+ return
286
+ }
287
+ // 队列级去重:同一包已在排队/执行中卸载时拒绝
288
+ if (hasQueuedTarget(name)) {
289
+ sendJson(response, 409, { error: `already queued: ${name}` })
290
+ return
291
+ }
292
+ const task = startPluginMutation({
293
+ action: 'remove',
294
+ profile,
295
+ target: name,
296
+ // 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
297
+ displayTarget: githubTarget(repo) ?? undefined,
298
+ // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
299
+ uninstallLoader: loader,
300
+ timeoutMs: COMMAND_TIMEOUT_MS,
301
+ env: { ...process.env, CI: 'true' },
302
+ })
303
+ sendJson(response, 200, { ok: true, task: task.id })
304
+ } catch (error) {
305
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
306
+ }
307
+ },
308
+ }),
309
+ webServer.register({
310
+ kind: 'exact',
311
+ path: '/dsh-plugin-hub/status',
312
+ handler: (request, response) => {
313
+ if (!requireMethod(request, response, 'GET')) return
314
+ const url = new URL(request.url ?? '/', 'http://localhost')
315
+ const id = Number(url.searchParams.get('task'))
316
+ const task = Number.isInteger(id) ? getTask(id) : undefined
317
+ if (!task) {
318
+ sendJson(response, 404, { error: 'task not found' })
319
+ return
320
+ }
321
+ sendJson(response, 200, { task })
322
+ },
323
+ }),
324
+ webServer.register({
325
+ kind: 'exact',
326
+ path: '/dsh-plugin-hub/active',
327
+ handler: (request, response) => {
328
+ if (!requireMethod(request, response, 'GET')) return
329
+ // 整个任务队列(running 在前、pending 在后)+ 待重启列表:客户端刷新后据此恢复
330
+ // 进行中/排队任务与「装完没重启」的持久提醒
331
+ sendJson(response, 200, { tasks: activeTask(), pendingRestarts: listPendingRestarts() })
332
+ },
333
+ }),
334
+ webServer.register({
335
+ kind: 'exact',
336
+ path: '/dsh-plugin-hub/env',
337
+ handler: (request, response) => {
338
+ if (!requireMethod(request, response, 'GET')) return
339
+ // 宿主机器环境快照:提交 bug 的 issue 正文会带上,便于作者复现
340
+ sendJson(response, 200, hostEnv(profile))
341
+ },
342
+ }),
343
+ webServer.register({
344
+ kind: 'exact',
345
+ path: '/dsh-plugin-hub/cancel',
346
+ handler: async (request, response) => {
347
+ if (!requireTrustedPost(request, response)) return
348
+ try {
349
+ const body = await readJsonBody(request)
350
+ const id = typeof body === 'object' && body !== null && typeof (body as { id?: unknown }).id === 'number'
351
+ ? (body as { id: number }).id
352
+ : NaN
353
+ const ok = Number.isInteger(id) && cancelTask(id)
354
+ sendJson(response, 200, { ok })
355
+ } catch (error) {
356
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
357
+ }
358
+ },
359
+ }),
360
+ webServer.register({
361
+ kind: 'exact',
362
+ path: '/dsh-plugin-hub/restart',
363
+ handler: (request, response) => {
364
+ if (!requireTrustedPost(request, response)) return
365
+ // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
366
+ const host = request.headers.host ?? ''
367
+ const portMatch = host.match(/:(\d+)$/)
368
+ const port = portMatch ? Number(portMatch[1]) : 7923
369
+ // detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
370
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
371
+ const script = [
372
+ `pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
373
+ '[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
374
+ `i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
375
+ 'mkdir -p "$HOME/.dsh/logs"',
376
+ `nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
377
+ ].join('\n')
378
+ spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref()
379
+ sendJson(response, 200, { ok: true, port })
380
+ },
381
+ }),
382
+ webServer.register({
383
+ kind: 'exact',
384
+ path: '/dsh-plugin-hub/installed-version',
385
+ handler: async (request, response) => {
386
+ if (!requireTrustedPost(request, response)) return
387
+ try {
388
+ const body = await readJsonBody(request)
389
+ const repo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
390
+ ? (body as { repo: string }).repo
391
+ : ''
392
+ if (githubTarget(repo) === null) {
393
+ sendJson(response, 400, { error: 'invalid install target' })
394
+ return
395
+ }
396
+ // version 为空 = 卸载清理记录;否则记录安装时的目录信号(最新版本 + 仓库更新时间),
397
+ // 供「有更新」比对:有版本比版本,无版本比更新时间
398
+ const version = body !== null && typeof (body as { version?: unknown }).version === 'string'
399
+ ? (body as { version: string }).version
400
+ : null
401
+ if (version === null) {
402
+ removeInstalledVersion(profile, repo)
403
+ } else {
404
+ const updatedAt = body !== null && typeof (body as { updatedAt?: unknown }).updatedAt === 'string'
405
+ ? (body as { updatedAt: string }).updatedAt
406
+ : ''
407
+ recordInstalledVersion(profile, repo, version, updatedAt)
408
+ }
409
+ sendJson(response, 200, { ok: true })
410
+ } catch (error) {
411
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
412
+ }
413
+ },
414
+ }),
415
+ webServer.register({
416
+ kind: 'exact',
417
+ path: '/dsh-plugin-hub/installed',
418
+ handler: (request, response) => {
419
+ if (!requireMethod(request, response, 'GET')) return
420
+ // installed:依赖表(包名 → spec);versions:安装时记录的目录版本(repo → 版本),
421
+ // 客户端合并两者判断「是否有更新」
422
+ sendJson(response, 200, { profile, installed: readInstalled(profile), versions: readInstalledVersions(profile) })
423
+ },
424
+ }),
425
+ ]
426
+ return () => {
427
+ for (const dispose of disposers) dispose()
428
+ }
429
+ }
430
+
431
+ /** Profile resolution shared with the client route docs. */
432
+ 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
+ }