@noob-stupid/dsh-plugin-console 0.3.66 → 0.4.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 (50) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/lib/client.js +127 -32
  4. package/lib/index.js +60 -9687
  5. package/lib/server/domain/ai-run.js +479 -0
  6. package/lib/server/domain/ai.js +246 -0
  7. package/lib/server/domain/compat.js +474 -0
  8. package/lib/server/domain/components.js +108 -0
  9. package/lib/server/domain/dep-source.js +122 -0
  10. package/lib/server/domain/format-contract.js +265 -0
  11. package/lib/server/domain/format-scan.js +431 -0
  12. package/lib/server/domain/framework.js +393 -0
  13. package/lib/server/domain/install-job.js +561 -0
  14. package/lib/server/domain/install.js +599 -0
  15. package/lib/server/domain/jobs.js +28 -0
  16. package/lib/server/domain/market.js +409 -0
  17. package/lib/server/domain/patch.js +203 -0
  18. package/lib/server/domain/presets.js +93 -0
  19. package/lib/server/domain/quarantine.js +224 -0
  20. package/lib/server/domain/release-source.js +504 -0
  21. package/lib/server/domain/repoland.js +119 -0
  22. package/lib/server/domain/revoke.js +184 -0
  23. package/lib/server/domain/runtime.js +118 -0
  24. package/lib/server/domain/selfupdate.js +319 -0
  25. package/lib/server/domain/skills.js +234 -0
  26. package/lib/server/domain/sources.js +297 -0
  27. package/lib/server/domain/suite.js +220 -0
  28. package/lib/server/infra/exec.js +98 -0
  29. package/lib/server/infra/fsx.js +163 -0
  30. package/lib/server/infra/fw-integrity-check.js +37 -0
  31. package/lib/server/infra/http.js +373 -0
  32. package/lib/server/infra/httpd.js +51 -0
  33. package/lib/server/infra/mask.js +19 -0
  34. package/lib/server/infra/paths.js +177 -0
  35. package/lib/server/infra/semver.js +168 -0
  36. package/lib/server/routes/ai.js +172 -0
  37. package/lib/server/routes/components.js +254 -0
  38. package/lib/server/routes/framework-preflight.js +154 -0
  39. package/lib/server/routes/framework-upgrade.js +679 -0
  40. package/lib/server/routes/framework.js +544 -0
  41. package/lib/server/routes/github-login.js +198 -0
  42. package/lib/server/routes/index.js +128 -0
  43. package/lib/server/routes/install.js +116 -0
  44. package/lib/server/routes/market.js +415 -0
  45. package/lib/server/routes/plugins.js +562 -0
  46. package/lib/server/routes/skills.js +107 -0
  47. package/lib/server/routes/sources.js +437 -0
  48. package/lib/server/routes/state.js +125 -0
  49. package/lib/server/state.js +22 -0
  50. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ // L2 · routes —— GitHub 登录两条通道:
2
+ // · POST /github-login 粘贴 token 换登录名并写入 <DSH_HOME>/github-auth.json
3
+ // (与独立插件 dsh-github-login 同一份文件、同一格式 { token, login })
4
+ // · POST /github-open-login 唤起 dsh-github-login 的**设备码登录窗口**(首选通道,失败即降级)
5
+ //
6
+ // 为什么需要 token 那条:市场页那个「未登录 GitHub」徽章原来只是展示,用户点了没反应;而 token 文件
7
+ // 此前只能靠独立插件写入。补一条"直接粘贴 token"的通道后,用户不必再装第二个插件。
8
+ //
9
+ // 为什么还要窗口那条:用户想要的是"在 GitHub 页面上输账号密码"的正规体验(设备码流程),
10
+ // 而不是手工去 GitHub 后台生成 PAT;token 只该是**兜底**。
11
+ //
12
+ // 三条安全约束(改这里时不要破):
13
+ // ① token 只进不出 —— 响应只回 login,绝不回显 token,也不写任何日志(日志会进 console 文件)
14
+ // ② 先校验形状再发网络请求 —— 明显不是 PAT 的串不浪费一次 GitHub 往返
15
+ // ③ 落盘尽量收紧权限(POSIX 0600;Windows 无此权限模型,失败忽略)
16
+
17
+ import { chmodSync, mkdirSync } from 'node:fs'
18
+ import { writeFile } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { GITHUB_API, collectBody, githubRequest, githubViaGh } from '../infra/http.js'
21
+ import { sendError, sendJson } from '../infra/httpd.js'
22
+ import { dshHome } from '../infra/paths.js'
23
+
24
+ /** GitHub personal access token 形状:classic(ghp_/gho_/ghu_/ghs_/ghr_)与 fine-grained(github_pat_)。 */
25
+ const GH_TOKEN_PATTERN = /^(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255})$/u
26
+
27
+ /** node:https 通道(官方 + 镜像):认证头就是用户给的这个 token。 */
28
+ async function viaHttps(token) {
29
+ const res = await githubRequest(`${GITHUB_API}/user`, { token })
30
+ const status = res.statusCode ?? 0
31
+ const body = await collectBody(res)
32
+ // 与 githubJson 同一套错误语义:限流单独说明,其余只报 HTTP 状态(都不含 token)
33
+ if (status === 403 && Number(res.headers['x-ratelimit-remaining'] ?? '1') === 0) {
34
+ throw new Error('GitHub 接口限流已用尽,请稍后再试')
35
+ }
36
+ if (status < 200 || status >= 300) throw new Error(`GitHub 请求失败 (HTTP ${status})`)
37
+ return JSON.parse(body)
38
+ }
39
+
40
+ /**
41
+ * 用**给定 token 专属**的通道取当前用户。
42
+ *
43
+ * 为什么不能直接用 githubJson:它内部会与 gh CLI 通道并行竞速(raceFirst2xx),而 gh 默认用
44
+ * 本机 keyring 里已有的凭据、**完全忽略我们传入的 token**。本机装了已登录的 gh 时,一个填错的
45
+ * token 也会因 gh 通道 2xx 而"验证成功"(2026-09-20 实测:本机 gh 登录 Noob-stupid,ghp_aaa…
46
+ * 拿到的是 Noob-stupid),于是错误 token 被写进 github-auth.json,把用户真实登录态顶掉。
47
+ *
48
+ * 两条通道都只认用户给的这个 token(gh 走 GH_TOKEN 环境变量覆盖 keyring 凭据):
49
+ * · https:通用主通道,无子进程
50
+ * · gh:node:https 被中间设备劫持时(本机实测 "unable to verify the first certificate")
51
+ * 的唯一可用通道 —— 少了它,本机粘对 token 也会被判成"令牌无效"
52
+ * 两条通道认证的是同一个 token,所以谁先成功都等价;一旦某条给出 401/403(令牌本身不对),
53
+ * 立刻采信,不等另一条慢超时。
54
+ */
55
+ async function fetchGithubUser(token) {
56
+ const attempts = [
57
+ viaHttps(token),
58
+ githubViaGh('/user', token),
59
+ ]
60
+ return await new Promise((resolve, reject) => {
61
+ let pending = attempts.length
62
+ let firstError = null
63
+ for (const attempt of attempts) {
64
+ attempt.then(resolve, (error) => {
65
+ const message = error instanceof Error ? error.message : String(error)
66
+ if (/HTTP (?:401|403)/u.test(message)) {
67
+ reject(error)
68
+ return
69
+ }
70
+ if (firstError === null) firstError = error
71
+ pending -= 1
72
+ if (pending === 0) reject(firstError)
73
+ })
74
+ }
75
+ })
76
+ }
77
+
78
+ async function routeGithubLogin(req, res, rc) {
79
+ const token = typeof rc.body?.token === 'string' ? rc.body.token.trim() : ''
80
+ if (!GH_TOKEN_PATTERN.test(token)) {
81
+ sendError(res, 400, '令牌格式不正确(应为 GitHub personal access token,ghp_/github_pat_ 开头)')
82
+ return
83
+ }
84
+ let login = null
85
+ try {
86
+ const me = await fetchGithubUser(token)
87
+ login = typeof me?.login === 'string' && me.login !== '' ? me.login : null
88
+ } catch (error) {
89
+ // gh 通道的原始报错带 execFile 的 "Command failed: gh api …" 命令回显与多行 stderr,
90
+ // 压成一行再给用户看(原文只含状态与命令名,不含 token —— 我们从不把 token 传进命令行)
91
+ const message = (error instanceof Error ? error.message : String(error))
92
+ .replace(/Command failed:[^\n]*\n?/gu, '')
93
+ .replace(/\s+/gu, ' ')
94
+ .trim()
95
+ // 与 A 条同一课:**"没验成"不等于"令牌无效"**。通道全挂(证书/超时/DNS)时报"令牌无效"
96
+ // 会把用户往错的方向带(去重新生成 token),必须分开说;文案里仍然不带 token 本身。
97
+ const transport = /证书|certificate|超时|timeout|ECONN|ENOTFOUND|EAI_AGAIN|getaddrinfo|socket|network|aborted|未找到 gh CLI/iu.test(message)
98
+ sendError(res, 400, transport
99
+ ? `无法连接 GitHub 校验令牌(网络/证书问题,不代表令牌无效,可重试):${message}`
100
+ : `令牌无效或权限不足:${message}`)
101
+ return
102
+ }
103
+ if (login === null) {
104
+ sendError(res, 400, '令牌无效或权限不足:GitHub 未返回登录名')
105
+ return
106
+ }
107
+ const file = join(dshHome(), 'github-auth.json')
108
+ try {
109
+ mkdirSync(dshHome(), { recursive: true })
110
+ // mode 只在新建时生效,所以再显式 chmod 一次:老文件可能是 0644(token 不该对同机其他用户可读)
111
+ await writeFile(file, `${JSON.stringify({ token, login }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
112
+ try { chmodSync(file, 0o600) } catch {}
113
+ } catch (error) {
114
+ sendError(res, 500, `写入登录状态失败:${error instanceof Error ? error.message : String(error)}`)
115
+ return
116
+ }
117
+ sendJson(res, 200, { ok: true, login })
118
+ }
119
+
120
+ // ── 设备码登录(窗口登录):把"唤起另一个插件的登录窗口"包成一个本插件路由 ──────────────
121
+ //
122
+ // 为什么是"调另一个插件的 HTTP 接口"而不是自己实现设备码:
123
+ // 设备码流程(GitHub Device Flow + 桌面窗口 + 令牌落盘 + 写 gh hosts.yml)已经由独立插件
124
+ // dsh-github-login 实现好了,它把自己挂在**同一个宿主 webServer** 上,暴露:
125
+ // POST /github-auth/open → 唤起它的登录窗口(spawn 桌面 exe)
126
+ // GET /github-auth/status → { ok, loggedIn, login }
127
+ // (源码:<profile>/node_modules/dsh-github-login/lib/index.js;本机实测的响应形状:
128
+ // 成功 { ok:true, launched:"<exe 路径>" };没找到 exe { ok:true, launched:false, hint:"…" })
129
+ // 重写一套只会多一份要维护的 GitHub OAuth 代码,还会和它抢同一份令牌文件。
130
+
131
+ /** 另一个插件(dsh-github-login v0.1.0)的两个环回接口。 */
132
+ const LOGIN_PLUGIN_OPEN_PATH = '/github-auth/open'
133
+ const LOGIN_PLUGIN_STATUS_PATH = '/github-auth/status'
134
+ /** 唤起窗口的等待上限:本机环回调用正常是毫秒级,8s 只用来兜住"端口被占着但不回包"。
135
+ * 超时**不当作错误**——一律降级成 started:false,让前端立刻给出 token 兜底入口。 */
136
+ const OPEN_LOGIN_TIMEOUT_MS = 8000
137
+ /** 透传登录态的预算:open 已经成功了说明对方在,这一枪只是顺带,2s 足够,失败就当 null。 */
138
+ const OPEN_LOGIN_STATUS_TIMEOUT_MS = 2000
139
+
140
+ /** 把异常压成一行可读文案:fetch 的 "fetch failed" 不带 cause 等于没说,超时原文也没有信息量。 */
141
+ function shortOpenError(error) {
142
+ const message = error instanceof Error ? error.message : String(error)
143
+ if (/abort/iu.test(message)) return `调用超时(${OPEN_LOGIN_TIMEOUT_MS}ms)`
144
+ const cause = error?.cause?.code ?? error?.cause?.message ?? ''
145
+ return cause ? `${message}(${cause})` : message
146
+ }
147
+
148
+ /**
149
+ * POST /plugin-console/github-open-login —— 唤起 dsh-github-login 的设备码登录窗口。
150
+ *
151
+ * 为什么失败也回 200(而不是 500):这个能力是**外挂**的,装没装、装的是不是带 exe 的版本、
152
+ * 当前 profile 有没有把它挂到 webServer 上,全都不由本插件决定(2026-09-20 实测:本机虽然装了
153
+ * 该插件、exe 也在 D:\dsh\dsh-github-login\dist\,但当前 profile 下 GET /github-auth/status 仍是
154
+ * 404)。这些都是"这个通道现在不可用",不是"服务端出错":
155
+ * · 回 500 → 前端弹一句看不懂的报错,还看不出"该走 token 兜底"这条正路;
156
+ * · 回 200 + started:false + reason → 前端照原样把原因念给用户,并自动展开 token 粘贴面板。
157
+ * 所以这里**先探测再回退**:能唤起才算成功,其余一律降级。
158
+ */
159
+ async function routeGithubOpenLogin(req, res, rc) {
160
+ const port = rc.deps.webPort(rc.ctx)
161
+ const base = `http://127.0.0.1:${port}`
162
+ const unavailable = '未检测到 dsh-github-login 插件(或当前平台不支持它的登录窗口)'
163
+ // 原因文案=给用户看的一句话 +(有的话)技术细节,便于用户排障时一眼看出是 404 还是连不上
164
+ const fallback = (detail) => {
165
+ sendJson(res, 200, { ok: true, started: false, reason: detail ? `${unavailable}:${detail}` : unavailable })
166
+ }
167
+ let opened = null
168
+ try {
169
+ const response = await fetch(`${base}${LOGIN_PLUGIN_OPEN_PATH}`, {
170
+ method: 'POST',
171
+ signal: AbortSignal.timeout(OPEN_LOGIN_TIMEOUT_MS),
172
+ })
173
+ if (!response.ok) {
174
+ fallback(`对方接口返回 HTTP ${response.status}`)
175
+ return
176
+ }
177
+ opened = await response.json()
178
+ } catch (error) {
179
+ // 连接被拒(插件没注册前缀路由/宿主没起)、超时、对方回的不是 JSON —— 全部走同一条降级路
180
+ fallback(shortOpenError(error))
181
+ return
182
+ }
183
+ // 对方"接口在、但本机没有登录工具 exe"时回 { ok:true, launched:false, hint }:窗口其实没起来。
184
+ // 这里必须跟着报 started:false —— 报 true 会让前端开 60s 轮询,等一个永远不会出现的登录。
185
+ if (opened?.launched === false) {
186
+ fallback(typeof opened.hint === 'string' && opened.hint !== '' ? opened.hint : '对方未启动登录窗口(未找到 DSH-GitHub-Login 可执行文件)')
187
+ return
188
+ }
189
+ // 顺手把对方的登录态透传给前端(best-effort:拿不到就 status:null,不影响"窗口已打开"这个结论)
190
+ let status = null
191
+ try {
192
+ const stateRes = await fetch(`${base}${LOGIN_PLUGIN_STATUS_PATH}`, { signal: AbortSignal.timeout(OPEN_LOGIN_STATUS_TIMEOUT_MS) })
193
+ if (stateRes.ok) status = await stateRes.json()
194
+ } catch {}
195
+ sendJson(res, 200, { ok: true, started: true, status })
196
+ }
197
+
198
+ export { routeGithubLogin, routeGithubOpenLogin }
@@ -0,0 +1,128 @@
1
+ // L2 · routes —— 路由装配:前缀常量 + 两张路由表 + 宿主服务注入(ports)+ 分发入口 handle()
2
+ //
3
+ // 分层 Step 8c-3 从 lib/index.js 搬出,至此 index.js 只剩「插件元信息 + apply 装配」。
4
+ // 两个分发点的原因(勿合并):ROUTES_EARLY 在 405 守卫**之前**(方法限定的只读接口,
5
+ // 如 GET /state —— 原实现里它们就在守卫前 return),ROUTES 在守卫 + readBody **之后**(能吃 body)。
6
+
7
+ import { aiEmpowerExecute, aiEmpowerPlan } from '../domain/ai-run.js'
8
+ import { detectAdoptablePending, detectCompat, frameworkCompatReportFor, rowIdModuleMap } from '../domain/compat.js'
9
+ import { backupProfileSnapshot, currentFrameworkVersion, detectFrameworkUpgrade, preflightDisableIncompatible } from '../domain/framework.js'
10
+ import { pnpmRemove, readExtraBundleRows, runInstallJob } from '../domain/install-job.js'
11
+ import { isProtectedModule, listEntries, webPort } from '../domain/runtime.js'
12
+ import { DEFAULT_SEARCH } from '../domain/sources.js'
13
+ import { runSuiteInstallJob } from '../domain/suite.js'
14
+ import { readBody, sendError } from '../infra/httpd.js'
15
+ import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
16
+ import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
17
+ import { routeFrameworkUpgrade } from './framework-upgrade.js'
18
+ import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
19
+ import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
20
+ import { routeGithubLogin, routeGithubOpenLogin } from './github-login.js'
21
+ import { routeInstall, routeInstallStatus } from './install.js'
22
+ import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages } from './market.js'
23
+ import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
24
+ import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
25
+ import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
26
+ import { routeDetails, routeStateGet } from './state.js'
27
+
28
+ const ROUTE_PREFIX = '/plugin-console'
29
+
30
+ const ROUTES_EARLY = [
31
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/state`, handler: routeStateGet },
32
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
33
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
34
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
35
+ { methods: ['GET'], path: `${ROUTE_PREFIX}/skills-installed`, handler: routeSkillsInstalledGet }, { methods: ['GET'], path: `${ROUTE_PREFIX}/framework-upgrade-status`, handler: routeFrameworkUpgradeStatusGet },
36
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-relaunch`, handler: routeFrameworkRelaunch },
37
+
38
+ ]
39
+
40
+ function routeDeps() {
41
+ return { runInstallJob, runSuiteInstallJob, webPort, readExtraBundleRows, detectAdoptablePending, listEntries, detectCompat, detectFrameworkUpgrade, DEFAULT_SEARCH, aiEmpowerExecute, aiEmpowerPlan, backupProfileSnapshot, currentFrameworkVersion, frameworkCompatReportFor, isProtectedModule, pnpmRemove, preflightDisableIncompatible, rowIdModuleMap }
42
+ }
43
+
44
+ async function handle(ctx, req, res) {
45
+ const url = new URL(req.url ?? '/', 'http://x')
46
+ const pathname = url.pathname
47
+ const method = req.method ?? 'GET'
48
+
49
+ // GET 兼容白名单:只读且无副作用的接口允许 GET 调用
50
+ // (market-index 曾被客户端以 GET 调用,落进 405 后错误被静默吞掉,导致静态索引长期未生效)
51
+ const GET_COMPAT = new Set([`${ROUTE_PREFIX}/market-index`])
52
+ // 守卫之前先命中「方法限定」的路由(原实现里它们就在 405 守卫之前返回)
53
+ const earlyHit = ROUTES_EARLY.find((r) => r.methods.includes(method) && r.path === pathname)
54
+ if (earlyHit) {
55
+ await earlyHit.handler(req, res, { ctx, url, pathname, method, deps: routeDeps() })
56
+ return
57
+ }
58
+
59
+ if (method !== 'POST' && !(method === 'GET' && GET_COMPAT.has(pathname))) {
60
+ sendError(res, 405, '不支持的方法')
61
+ return
62
+ }
63
+
64
+ const body = await readBody(req)
65
+
66
+ // ── 其余路由表(位于 405 守卫 + readBody 之后)──────────────────────────────
67
+
68
+ // 守卫之后命中其余路由(这些能吃 body)
69
+ const hit = ROUTES.find((r) => r.methods.includes(method) && r.path === pathname)
70
+ if (hit) {
71
+ await hit.handler(req, res, { ctx, url, pathname, method, body, deps: routeDeps() })
72
+ return
73
+ }
74
+
75
+ // 软件源扫描:并发探测每个 npm 源的「可达性 / 延迟 / 该源上的最新版本」。
76
+ // 用于判断主源是否最优(内网私服 vs 公共镜像),结果直接在前端软件源列表里显示。
77
+
78
+ sendError(res, 404, `未知接口 ${pathname}`)
79
+ }
80
+
81
+ const ROUTES = [
82
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/details`, handler: routeDetails },
83
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/registry-scan`, handler: routeRegistryScan },
84
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/sources`, handler: routeSources },
85
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/skill-remove`, handler: routeSkillRemove },
86
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/skill-toggle`, handler: routeSkillToggle },
87
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/install`, handler: routeInstall },
88
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/install-status`, handler: routeInstallStatus }, { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-check`, handler: routeFrameworkCheck },
89
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-gate`, handler: routeCompatGate },
90
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/check-update`, handler: routeCheckUpdate },
91
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-upgrade`, handler: routeFrameworkUpgrade },
92
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight`, handler: routeFrameworkPreflight },
93
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight-patch`, handler: routeFrameworkPreflightPatch },
94
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
95
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
96
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
97
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/uninstall`, handler: routeUninstall },
98
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock`, handler: routeAdaptUnlock },
99
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/adapt-unlock-all`, handler: routeAdaptUnlockAll },
100
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/clean-residuals`, handler: routeCleanResiduals },
101
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/self-update`, handler: routeSelfUpdate },
102
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/search`, handler: routeSearch },
103
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/enrich`, handler: routeEnrich },
104
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo`, handler: routeRepo },
105
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/subpackages`, handler: routeSubpackages },
106
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/market-index`, handler: routeMarketIndex },
107
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/github-login`, handler: routeGithubLogin },
108
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/github-open-login`, handler: routeGithubOpenLogin },
109
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-consent`, handler: routeAiConsent },
110
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-empower/plan`, handler: routeAiEmpowerPlan },
111
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-empower/status`, handler: routeAiEmpowerStatus },
112
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-empower/list`, handler: routeAiEmpowerList },
113
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-empower/run`, handler: routeAiEmpowerRun },
114
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/ai-empower/cancel`, handler: routeAiEmpowerCancel },
115
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/components`, handler: routeComponents },
116
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo-clone`, handler: routeRepoClone },
117
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo-list`, handler: routeRepoList },
118
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo-land-config`, handler: routeRepoLandConfig },
119
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo-remove`, handler: routeRepoRemove },
120
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/repo-open`, handler: routeRepoOpen },
121
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/component/autostart`, handler: routeComponentAutostart },
122
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/component/start`, handler: routeComponentStart },
123
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/component/stop`, handler: routeComponentStop },
124
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/component/status`, handler: routeComponentStatus },
125
+
126
+ ]
127
+
128
+ export { ROUTE_PREFIX, handle }
@@ -0,0 +1,116 @@
1
+ // L2 · routes —— 安装(POST /install · POST /install-status)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { installJobView } from '../domain/install.js'
5
+ import { runInstallJob } from '../domain/install-job.js'
6
+ import { githubRepoInfo } from '../domain/market.js'
7
+ import { runSkillInstallJob } from '../domain/skills.js'
8
+ import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from '../domain/suite.js'
9
+ import { sendError, sendJson } from '../infra/httpd.js'
10
+ import { installJobs, nextInstallJobSeq } from '../state.js'
11
+
12
+ async function routeInstall(req, res, rc) {
13
+ const ctx = rc.ctx
14
+ const url = rc.url
15
+ const pathname = rc.pathname
16
+ const method = rc.method
17
+ const runSuiteInstallJob = rc.deps.runSuiteInstallJob
18
+ const runInstallJob = rc.deps.runInstallJob
19
+ const body = rc.body
20
+ // repo 允许为空:纯 registry 更新(已安装插件的"检测更新"走此路径,无 git 兜底)
21
+ const rawRepo = typeof body.repo === 'string' ? body.repo.trim() : ''
22
+ let repo = ''
23
+ if (rawRepo !== '') {
24
+ try { repo = githubRepoInfo(rawRepo) } catch {}
25
+ }
26
+ const givenName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
27
+ // 框架本体拦截:deepseek-harness 仓库与其根包 @deepseek-ai/dsh-root 是 DSH 框架自身,
28
+ // 作为插件安装会试图构建整个框架源码——直接拒绝并提示
29
+ if (repo === 'deepseek-ai/deepseek-harness'
30
+ || givenName === '@deepseek-ai/dsh-root'
31
+ || givenName === '@deepseek-ai/dsh') {
32
+ sendError(res, 400, 'deepseek-harness 是 DSH 框架本体,不是插件——无需安装(升级请用官方 dsh 升级方式)')
33
+ return
34
+ }
35
+ const requestedKind = body.kind === 'skill' ? 'skill' : body.kind === 'suite' ? 'suite' : 'plugin'
36
+ if ((requestedKind === 'skill' || requestedKind === 'suite') && repo === '') {
37
+ sendError(res, 400, `${requestedKind === 'skill' ? '技能' : '套装'}安装必须提供仓库(owner/name)`)
38
+ return
39
+ }
40
+ const source = body.source === 'gitee' ? body.source : 'github'
41
+ const npmNamePattern = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
42
+ if (givenName !== '' && (!npmNamePattern.test(givenName) || givenName.length > 214)) {
43
+ sendError(res, 400, 'packageName 不是合法的 npm 包名')
44
+ return
45
+ }
46
+ // 防重(事故教训):同一插件已在安装/更新中时拒绝新任务——否则反复点更新/安装会产生
47
+ // 几十个并发下载任务(同一插件重复安装、消息刷屏、浪费流量)
48
+ const dupJob = [...installJobs.values()].some((j) => j.status === 'installing'
49
+ && ((repo !== '' && j.repo === repo) || (givenName !== '' && j.packageName === givenName)))
50
+ if (dupJob) {
51
+ sendError(res, 409, '该插件正在安装/更新中,请等待当前任务完成后再试')
52
+ return
53
+ }
54
+ // 套装请求必须复检内容(2026-09-19 事故):前端标记可能来自 24h enrich 缓存的误判,
55
+ // 探测内容不像 .gitmodules 就按普通插件安装——别把用户送进注定失败的套装通道。
56
+ let kind = requestedKind
57
+ let suiteNote = null
58
+ if (requestedKind === 'suite') {
59
+ kind = resolveInstallKind('suite', await probeGitmodules(repo))
60
+ if (kind !== 'suite') suiteNote = '探测未发现有效的 .gitmodules(不是 submodule 套装仓库),已按普通插件安装'
61
+ }
62
+ const job = {
63
+ id: `job-${nextInstallJobSeq()}`,
64
+ repo,
65
+ source,
66
+ packageName: givenName || null,
67
+ status: 'installing',
68
+ stage: 'preparing',
69
+ error: null,
70
+ startedAt: Date.now(),
71
+ finishedAt: null,
72
+ entryId: null,
73
+ bundle: false,
74
+ ai: false,
75
+ aiNote: null,
76
+ subpackages: null,
77
+ lastError: null,
78
+ update: body.update === true,
79
+ kind,
80
+ }
81
+ if (suiteNote !== null) job.suiteNote = suiteNote
82
+ installJobs.set(job.id, job)
83
+ // 套装通道兜底:探测说有、clone 下来却没有 .gitmodules(假阳性 / 仓库已重构)时
84
+ // 回落普通插件安装,而不是给用户一个「未找到 .gitmodules」的失败。
85
+ const runSuiteThenFallback = async () => {
86
+ const result = await runSuiteInstallJob(job, ctx)
87
+ if (result?.notASuite !== true) return
88
+ job.kind = 'plugin'
89
+ job.suiteNote = '仓库实际内容与探测不符(没有 .gitmodules),已自动回落普通插件安装'
90
+ await runInstallJob(job, ctx)
91
+ }
92
+ // 后台执行:请求立即返回,安装不受客户端断开/离开面板影响
93
+ void (kind === 'skill' ? runSkillInstallJob(job, ctx) : kind === 'suite' ? runSuiteThenFallback() : runInstallJob(job, ctx))
94
+ sendJson(res, 200, { ok: true, jobId: job.id, status: 'installing', kind, ...(suiteNote !== null ? { suiteNote } : {}) })
95
+ return
96
+ }
97
+
98
+ async function routeInstallStatus(req, res, rc) {
99
+ const ctx = rc.ctx
100
+ const url = rc.url
101
+ const pathname = rc.pathname
102
+ const method = rc.method
103
+ const runSuiteInstallJob = rc.deps.runSuiteInstallJob
104
+ const runInstallJob = rc.deps.runInstallJob
105
+ const body = rc.body
106
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
107
+ const job = installJobs.get(jobId)
108
+ if (!job) {
109
+ sendError(res, 404, '没有这个安装任务')
110
+ return
111
+ }
112
+ sendJson(res, 200, { ok: true, ...installJobView(job) })
113
+ return
114
+ }
115
+
116
+ export { routeInstall, routeInstallStatus }