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.
- package/README.en.md +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- 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'
|