@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.1

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 (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +434 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +160 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,168 @@
1
+ // 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
3
+
4
+
5
+ /** 最小 semver:解析(含 prerelease/build)。 */
6
+
7
+ /** 单段范围匹配(^ ~ >= <= > < = 精确;返回 true = 满足)。 */
8
+
9
+ /** 范围匹配:支持多个段以逗号/空白分隔(AND)与 `||`(OR)。 */
10
+
11
+ /**
12
+ * 宽松声明匹配(仅用于插件「显式声明兼容范围」):prerelease 版本按同线发布版判定——
13
+ * 作者声明 `>=0.1.2` 即代表支持 0.1.2 线,框架运行在 0.1.2-rc.1 应判定兼容。
14
+ */
15
+
16
+ /** 解析 DSH 框架版本号为可比较对象;正式版(无预发布段)视为 rc.∞。 */
17
+
18
+ /** 判断 candidate 是否比 current 更新(候选与当前必须是合法版本号,否则视为不可比)。 */
19
+
20
+ function parseSemverText(v) {
21
+ const m = String(v ?? '').trim().replace(/^v/u, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u)
22
+ if (!m) return null
23
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
24
+ }
25
+ function compareSemverText(a, b) {
26
+ if (a.major !== b.major) return a.major - b.major
27
+ if (a.minor !== b.minor) return a.minor - b.minor
28
+ if (a.patch !== b.patch) return a.patch - b.patch
29
+ if (a.pre === null && b.pre === null) return 0
30
+ if (a.pre === null) return 1
31
+ if (b.pre === null) return -1
32
+ const pa = a.pre.split('.')
33
+ const pb = b.pre.split('.')
34
+ const len = Math.max(pa.length, pb.length)
35
+ for (let i = 0; i < len; i += 1) {
36
+ const xa = pa[i]
37
+ const xb = pb[i]
38
+ if (xa === undefined) return -1
39
+ if (xb === undefined) return 1
40
+ const na = /^\d+$/u.test(xa)
41
+ const nb = /^\d+$/u.test(xb)
42
+ if (na && nb) { const d = Number(xa) - Number(xb); if (d !== 0) return d; continue }
43
+ if (na) return -1
44
+ if (nb) return 1
45
+ const d = xa < xb ? -1 : xa > xb ? 1 : 0
46
+ if (d !== 0) return d
47
+ }
48
+ return 0
49
+ }
50
+ function semverCompareOne(v, raw) {
51
+ const rText = String(raw).trim()
52
+ const m = rText.match(/^(\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/u)
53
+ if (!m) return true
54
+ const op = m[1] ?? ''
55
+ const base = { major: Number(m[2]), minor: m[3] === undefined ? 0 : Number(m[3]), patch: m[4] === undefined ? 0 : Number(m[4]), pre: m[5] ?? null }
56
+ const hasMinor = m[3] !== undefined
57
+ const hasPatch = m[4] !== undefined
58
+ // npm 语义:prerelease 版本只与同 [major,minor,patch] 且范围带 prerelease 的声明匹配
59
+ const preAllowed = v.pre === null || (v.major === base.major && v.minor === base.minor && v.patch === base.patch && base.pre !== null)
60
+ switch (op) {
61
+ case '':
62
+ case '=':
63
+ return preAllowed && compareSemverText(v, base) === 0
64
+ case '<':
65
+ return preAllowed && compareSemverText(v, base) < 0
66
+ case '<=':
67
+ return preAllowed && compareSemverText(v, base) <= 0
68
+ case '>':
69
+ return preAllowed && compareSemverText(v, base) > 0
70
+ case '>=':
71
+ return preAllowed && compareSemverText(v, base) >= 0
72
+ case '~': {
73
+ if (!preAllowed) return false
74
+ if (!hasMinor) return v.major === base.major
75
+ if (!hasPatch) return v.major === base.major && v.minor === base.minor
76
+ return compareSemverText(v, base) >= 0 && !(v.major === base.major && v.minor > base.minor) && v.major === base.major
77
+ }
78
+ case '^': {
79
+ if (!preAllowed) return false
80
+ const upper = base.major === 0
81
+ ? (base.minor === 0 ? { major: 0, minor: 0, patch: base.patch + 1, pre: null } : { major: 0, minor: base.minor + 1, patch: 0, pre: null })
82
+ : { major: base.major + 1, minor: 0, patch: 0, pre: null }
83
+ return compareSemverText(v, base) >= 0 && compareSemverText(v, upper) < 0
84
+ }
85
+ default:
86
+ return true
87
+ }
88
+ }
89
+ function semverRangeMatch(versionText, rangeText) {
90
+ const v = parseSemverText(versionText)
91
+ if (!v) return false
92
+ const alternatives = String(rangeText ?? '').split(/\s*\|\|\s*/u).filter(Boolean)
93
+ if (alternatives.length === 0) return false
94
+ return alternatives.some((alt) => {
95
+ const parts = alt.split(/\s*[,\s]\s*/u).filter(Boolean)
96
+ return parts.length > 0 && parts.every((part) => semverCompareOne(v, part))
97
+ })
98
+ }
99
+ function semverRangeMatchLoose(versionText, rangeText) {
100
+ if (semverRangeMatch(versionText, rangeText)) return true
101
+ const v = parseSemverText(versionText)
102
+ if (v === null || v.pre === null) return false
103
+ return semverRangeMatch(`${v.major}.${v.minor}.${v.patch}`, rangeText)
104
+ }
105
+ function parseFrameworkVersion(value) {
106
+ const m = String(value ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-(?:[a-z]+\.)?(\d+))?$/iu)
107
+ if (!m) return -1
108
+ const [, maj, min, pat, rc] = m
109
+ return {
110
+ maj: Number.parseInt(maj, 10),
111
+ min: Number.parseInt(min, 10),
112
+ pat: Number.parseInt(pat, 10),
113
+ rc: rc === undefined ? Number.POSITIVE_INFINITY : Number.parseInt(rc, 10),
114
+ }
115
+ }
116
+ function isFrameworkVersionNewer(candidate, current) {
117
+ const a = parseFrameworkVersion(candidate)
118
+ const b = parseFrameworkVersion(current)
119
+ if (a === -1 || b === -1) return false
120
+ if (a.maj !== b.maj) return a.maj > b.maj
121
+ if (a.min !== b.min) return a.min > b.min
122
+ if (a.pat !== b.pat) return a.pat > b.pat
123
+ return a.rc > b.rc
124
+ }
125
+ /**
126
+ * 框架升级候选列表(2026-09-23 用户要求:「有的版本都加上,测试版也可以有列表」)。
127
+ *
128
+ * 为什么需要它:升级面板原先只有**一个**目标(`latest` 优先、否则 `next`),用户看到
129
+ * 「可升级到 0.1.5-rc.3(latest 0.1.5-rc.3 · next 0.1.7-rc.1)」却**没法自己选**——
130
+ * 想上 0.1.7-rc.1 或某个 alpha 只能手敲 pnpm 命令。
131
+ *
132
+ * 规则(安全边界,都在这里钉死):
133
+ * · 只收 **严格比 current 新** 的版本(用 `compareSemverText`,保留完整 prerelease 语义:
134
+ * 0.1.5-rc.3 < 0.1.6-alpha.1 < 0.1.7-alpha.1 < 0.1.7-rc.1 < 0.1.7 正式版);
135
+ * 等于或低于当前的**一律不进列表**——降级另有「回滚到上一版」那条路,不从这里走。
136
+ * · 注册表顺序(按发布时间)不可信,这里按语义版本**降序**排(最新在最前);
137
+ * · `latest` / `next` / `beta` / `alpha` 等 dist-tag 命中的版本带 `channel` 标注,
138
+ * 供前端打「稳定版 / 预发布」标签;`latest` 同时作为 `tagDefault`(默认选中项,保持原行为)。
139
+ *
140
+ * @param {object|null|undefined} meta registry 元数据({ 'dist-tags', versions })
141
+ * @param {string|null} current 当前已装框架版本
142
+ * @returns {{ versions: Array<{version: string, channel: string|null, isLatest: boolean}>, tagDefault: string|null }}
143
+ */
144
+ function frameworkUpgradeCandidates(meta, current) {
145
+ const tags = meta !== null && typeof meta === 'object' && meta['dist-tags'] !== null && typeof meta['dist-tags'] === 'object'
146
+ ? meta['dist-tags']
147
+ : {}
148
+ const cur = parseSemverText(current)
149
+ const all = meta !== null && typeof meta === 'object' && meta.versions !== null && typeof meta.versions === 'object'
150
+ ? Object.keys(meta.versions)
151
+ : []
152
+ const newer = []
153
+ for (const v of all) {
154
+ const parsed = parseSemverText(v)
155
+ if (parsed === null) continue
156
+ if (cur !== null && compareSemverText(parsed, cur) <= 0) continue
157
+ newer.push(v)
158
+ }
159
+ newer.sort((a, b) => compareSemverText(parseSemverText(b), parseSemverText(a)))
160
+ const tagDefault = typeof tags.latest === 'string' && newer.includes(tags.latest) ? tags.latest : null
161
+ const versions = newer.map((v) => {
162
+ const channel = Object.keys(tags).find((name) => tags[name] === v) ?? null
163
+ return { version: v, channel, isLatest: channel === 'latest' }
164
+ })
165
+ return { versions, tagDefault }
166
+ }
167
+
168
+ export { parseSemverText, compareSemverText, semverCompareOne, semverRangeMatch, semverRangeMatchLoose, parseFrameworkVersion, isFrameworkVersionNewer, frameworkUpgradeCandidates }
@@ -0,0 +1,172 @@
1
+ // L2 · routes —— AI 赋能(/ai-consent · /ai-empower/*)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { dirname } from 'node:path'
5
+ import { aiEmpowerExecute, aiEmpowerPlan } from '../domain/ai-run.js'
6
+ import { aiJobView, aiJobs, builtinPlanFor, saveAiJobs } from '../domain/ai.js'
7
+ import { frameworkCompatReportFor } from '../domain/compat.js'
8
+ import { sendError, sendJson } from '../infra/httpd.js'
9
+ import { findPatchPath } from '../infra/paths.js'
10
+ import { installJobs, nextAiJobSeq } from '../state.js'
11
+
12
+ async function routeAiConsent(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 body = rc.body
18
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
19
+ const approved = body.approved === true
20
+ const job = installJobs.get(jobId)
21
+ if (!job) {
22
+ sendError(res, 404, '没有这个安装任务')
23
+ return
24
+ }
25
+ if (job.stage !== 'ai-consent' || typeof job.aiWait?.then !== 'function') {
26
+ sendError(res, 400, '该任务不在等待 AI 授权状态')
27
+ return
28
+ }
29
+ const resolver = job.aiPending?.resolver
30
+ job.aiWait = null
31
+ job.aiPending = null
32
+ if (typeof resolver === 'function') resolver({ approved })
33
+ sendJson(res, 200, { ok: true, jobId, approved })
34
+ return
35
+ }
36
+
37
+ async function routeAiEmpowerPlan(req, res, rc) {
38
+ const frameworkCompatReportFor = rc.deps.frameworkCompatReportFor
39
+ const aiEmpowerPlan = rc.deps.aiEmpowerPlan
40
+ const ctx = rc.ctx
41
+ const url = rc.url
42
+ const pathname = rc.pathname
43
+ const method = rc.method
44
+ const body = rc.body
45
+ const source = typeof body.source === 'string' ? body.source.trim() : ''
46
+ if (source === '' || source.length > 200) {
47
+ sendError(res, 400, '请提供要部署的组件来源(npm 包名或 GitHub 仓库)')
48
+ return
49
+ }
50
+ const patchPath = findPatchPath(ctx)
51
+ const profileDir = dirname(patchPath)
52
+ const jobId = `ai-${Date.now()}-${nextAiJobSeq()}`
53
+ const job = { id: jobId, source, status: 'running', stage: 'planning', createdAt: Date.now(), logText: '', stepStates: [] }
54
+ // 框架适配预检(兼容门 + registry 声明):规划期即给出权威说明,供子代理引用与用户查看
55
+ try { job.frameworkCheck = await frameworkCompatReportFor(source, ctx, profileDir) } catch { job.frameworkCheck = null }
56
+ aiJobs.set(jobId, job)
57
+ const builtin = builtinPlanFor(source)
58
+ if (builtin !== null) {
59
+ job.plan = builtin
60
+ job.status = 'plan-ready'
61
+ job.stage = 'builtin'
62
+ job.finishedAt = Date.now()
63
+ saveAiJobs()
64
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
65
+ return
66
+ }
67
+ aiEmpowerPlan(job, ctx, profileDir).catch((error) => {
68
+ job.status = 'failed'
69
+ job.error = `规划任务异常:${error instanceof Error ? error.message : String(error)}`
70
+ job.finishedAt = Date.now()
71
+ })
72
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
73
+ return
74
+ }
75
+
76
+ async function routeAiEmpowerStatus(req, res, rc) {
77
+ const ctx = rc.ctx
78
+ const url = rc.url
79
+ const pathname = rc.pathname
80
+ const method = rc.method
81
+ const body = rc.body
82
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
83
+ const job = aiJobs.get(jobId)
84
+ if (!job) {
85
+ sendError(res, 404, '没有这个 AI 赋能任务')
86
+ return
87
+ }
88
+ sendJson(res, 200, { ok: true, ...aiJobView(job) })
89
+ return
90
+ }
91
+
92
+ async function routeAiEmpowerList(req, res, rc) {
93
+ const ctx = rc.ctx
94
+ const url = rc.url
95
+ const pathname = rc.pathname
96
+ const method = rc.method
97
+ const body = rc.body
98
+ // 并发任务列表(轻量视图,不含日志全文):面板展示/切换多个并发 AI 赋能任务
99
+ const list = [...aiJobs.values()].slice(-10).reverse().map((job) => ({
100
+ jobId: job.id,
101
+ source: job.source,
102
+ status: job.status,
103
+ stage: job.stage,
104
+ type: job.plan?.type ?? null,
105
+ displayName: job.plan?.displayName ?? null,
106
+ progress: job.progress ?? { done: 0, total: (job.plan?.steps ?? []).length },
107
+ error: job.error ?? null,
108
+ createdAt: job.createdAt,
109
+ }))
110
+ sendJson(res, 200, { ok: true, tasks: list })
111
+ return
112
+ }
113
+
114
+ async function routeAiEmpowerRun(req, res, rc) {
115
+ const aiEmpowerExecute = rc.deps.aiEmpowerExecute
116
+ const ctx = rc.ctx
117
+ const url = rc.url
118
+ const pathname = rc.pathname
119
+ const method = rc.method
120
+ const body = rc.body
121
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
122
+ const selected = Array.isArray(body.steps) ? body.steps : null
123
+ // 纵深防御:必须显式确认(前端「同意并部署」按钮携带 confirmed:true),防止任何绕过同意直接执行
124
+ if (body.confirmed !== true) {
125
+ sendError(res, 400, '未确认部署:请先在面板勾选步骤并点击「同意并部署」')
126
+ return
127
+ }
128
+ const job = aiJobs.get(jobId)
129
+ if (!job) {
130
+ sendError(res, 404, '没有这个 AI 赋能任务')
131
+ return
132
+ }
133
+ if (job.status !== 'plan-ready') {
134
+ sendError(res, 400, '任务状态不是 plan-ready,无法执行')
135
+ return
136
+ }
137
+ const patchPath = findPatchPath(ctx)
138
+ const profileDir = dirname(patchPath)
139
+ aiEmpowerExecute(job, ctx, profileDir, selected).catch((error) => {
140
+ job.status = 'failed'
141
+ job.error = `执行任务异常:${error instanceof Error ? error.message : String(error)}`
142
+ job.finishedAt = Date.now()
143
+ saveAiJobs()
144
+ })
145
+ sendJson(res, 200, { ok: true, jobId })
146
+ return
147
+ }
148
+
149
+ async function routeAiEmpowerCancel(req, res, rc) {
150
+ const ctx = rc.ctx
151
+ const url = rc.url
152
+ const pathname = rc.pathname
153
+ const method = rc.method
154
+ const body = rc.body
155
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
156
+ const job = aiJobs.get(jobId)
157
+ if (!job) {
158
+ sendError(res, 404, '没有这个 AI 赋能任务')
159
+ return
160
+ }
161
+ try { job.abort?.abort() } catch {}
162
+ if (job.status === 'running' && job.stage === 'executing') {
163
+ job.status = 'failed'
164
+ job.error = '已取消执行(用户中断)'
165
+ job.finishedAt = Date.now()
166
+ saveAiJobs()
167
+ }
168
+ sendJson(res, 200, { ok: true, jobId })
169
+ return
170
+ }
171
+
172
+ export { routeAiConsent, routeAiEmpowerPlan, routeAiEmpowerStatus, routeAiEmpowerList, routeAiEmpowerRun, routeAiEmpowerCancel }
@@ -0,0 +1,254 @@
1
+ // L2 · routes —— 组件与仓库落地(/components · /component/* · /repo-*)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { existsSync, mkdirSync } from 'node:fs'
5
+ import { execFile } from 'node:child_process'
6
+ import { join } from 'node:path'
7
+ import { compFind, compStart, compStatus, compStop, compUiUrl, compUpsert, findComponents } from '../domain/components.js'
8
+ import { getReposDir, listLandedRepos, setReposDir } from '../domain/repoland.js'
9
+ import { gitCloneUrls } from '../domain/sources.js'
10
+ import { execFileAsync, gitBin, gitEnv } from '../infra/exec.js'
11
+ import { removeDirVerified } from '../infra/fsx.js'
12
+ import { sendError, sendJson } from '../infra/httpd.js'
13
+
14
+ async function routeComponents(req, res, rc) {
15
+ const ctx = rc.ctx
16
+ const url = rc.url
17
+ const pathname = rc.pathname
18
+ const method = rc.method
19
+ const body = rc.body
20
+ const list = findComponents()
21
+ const enriched = []
22
+ for (const c of list) {
23
+ try {
24
+ enriched.push(await compStatus(c.id))
25
+ } catch {
26
+ enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null, autoStart: c.autoStart === true })
27
+ }
28
+ }
29
+ sendJson(res, 200, { ok: true, components: enriched.map((x) => ({ ...x, uiUrl: compUiUrl(compFind(x.id) ?? {}) })) })
30
+ return
31
+ }
32
+
33
+ async function routeRepoClone(req, res, rc) {
34
+ const ctx = rc.ctx
35
+ const pathname = rc.pathname
36
+ const method = rc.method
37
+ const body = rc.body
38
+ // 仓库落地:克隆任意项目到 <配置路径>/<owner>/<name>(地址由「软件源 → Git 源」决定,
39
+ // 因此这里接受任意平台的仓库链接:GitHub / Gitee / GitLab / 自建 Gitea / 镜像代理前缀)
40
+ const raw = typeof body.repo === 'string' ? body.repo.trim() : ''
41
+ // 循环剥离协议/域名前缀:镜像代理链接可能叠两层(ghproxy.net/https://github.com/...)
42
+ let repo = raw
43
+ for (let guard = 0; guard < 4; guard += 1) {
44
+ const next = repo
45
+ .replace(/^https?:\/\/[^/]+\//u, '')
46
+ .replace(/^git@[^:]+:/u, '')
47
+ if (next === repo) break
48
+ repo = next
49
+ }
50
+ repo = repo.replace(/\.git$/u, '').replace(/\/+$/u, '')
51
+ const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(repo)
52
+ if (!m) {
53
+ sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或仓库链接(如 https://gitee.com/owner/repo)')
54
+ return
55
+ }
56
+ const [, owner, name] = m
57
+ const root = getReposDir()
58
+ const target = join(root, owner, name)
59
+ if (existsSync(target)) {
60
+ sendError(res, 400, `已存在:${target}(如需更新请先手动处理该目录)`)
61
+ return
62
+ }
63
+ try {
64
+ mkdirSync(root, { recursive: true })
65
+ const urls = gitCloneUrls(`${owner}/${name}`)
66
+ let lastError = null
67
+ let cloned = false
68
+ let usedUrl = ''
69
+ for (const url of urls) {
70
+ try {
71
+ await execFileAsync(gitBin(), ['clone', '--depth', '1', url, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: gitEnv() })
72
+ cloned = true
73
+ usedUrl = url
74
+ break
75
+ } catch (error) {
76
+ lastError = error
77
+ // 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败
78
+ removeDirVerified(target)
79
+ }
80
+ }
81
+ if (!cloned) throw lastError ?? new Error('未知错误')
82
+ sendJson(res, 200, { ok: true, owner, name, path: target, url: usedUrl })
83
+ } catch (error) {
84
+ sendError(res, 500, `克隆失败:${error instanceof Error ? error.message : String(error)}`)
85
+ }
86
+ return
87
+ }
88
+
89
+ async function routeRepoList(req, res, rc) {
90
+ const ctx = rc.ctx
91
+ const url = rc.url
92
+ const pathname = rc.pathname
93
+ const method = rc.method
94
+ const body = rc.body
95
+ try {
96
+ sendJson(res, 200, { ok: true, dir: getReposDir(), repos: listLandedRepos() })
97
+ } catch (error) {
98
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
99
+ }
100
+ return
101
+ }
102
+
103
+ async function routeRepoLandConfig(req, res, rc) {
104
+ const ctx = rc.ctx
105
+ const url = rc.url
106
+ const pathname = rc.pathname
107
+ const method = rc.method
108
+ const body = rc.body
109
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
110
+ if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
111
+ sendError(res, 400, '保存路径不合法:请提供绝对路径(如 D:\\dsh\\repos)')
112
+ return
113
+ }
114
+ try {
115
+ setReposDir(dir)
116
+ sendJson(res, 200, { ok: true, dir: getReposDir() })
117
+ } catch (error) {
118
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
119
+ }
120
+ return
121
+ }
122
+
123
+ async function routeRepoRemove(req, res, rc) {
124
+ const ctx = rc.ctx
125
+ const url = rc.url
126
+ const pathname = rc.pathname
127
+ const method = rc.method
128
+ const body = rc.body
129
+ const target = typeof body.path === 'string' ? body.path : ''
130
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
131
+ const tNorm = target.replace(/[\\/]+$/u, '')
132
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm || !existsSync(target)) {
133
+ sendError(res, 400, '路径不在仓库落地目录内或不存在')
134
+ return
135
+ }
136
+ // 核实删除结果:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
137
+ // 删不掉却回 {ok:true} 会让用户以为仓库已清理。
138
+ const result = removeDirVerified(target)
139
+ if (!result.ok) {
140
+ sendError(res, 500, `删除失败:目录仍存在(${target})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除`)
141
+ return
142
+ }
143
+ sendJson(res, 200, { ok: true })
144
+ return
145
+ }
146
+
147
+ async function routeRepoOpen(req, res, rc) {
148
+ const ctx = rc.ctx
149
+ const url = rc.url
150
+ const pathname = rc.pathname
151
+ const method = rc.method
152
+ const body = rc.body
153
+ const target = typeof body.path === 'string' ? body.path : ''
154
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
155
+ const tNorm = target.replace(/[\\/]+$/u, '')
156
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm) {
157
+ sendError(res, 400, '路径不在仓库落地目录内')
158
+ return
159
+ }
160
+ if (!existsSync(target)) {
161
+ sendError(res, 404, `目录不存在:${target}`)
162
+ return
163
+ }
164
+ try {
165
+ execFile('explorer.exe', [target], { windowsHide: true, detached: true }).unref()
166
+ sendJson(res, 200, { ok: true })
167
+ } catch (error) {
168
+ sendError(res, 500, `打开失败:${error instanceof Error ? error.message : String(error)}`)
169
+ }
170
+ return
171
+ }
172
+
173
+ async function routeComponentAutostart(req, res, rc) {
174
+ const ctx = rc.ctx
175
+ const url = rc.url
176
+ const pathname = rc.pathname
177
+ const method = rc.method
178
+ const body = rc.body
179
+ const id = typeof body.id === 'string' ? body.id : ''
180
+ const enabled = body.enabled === true
181
+ if (id === '') {
182
+ sendError(res, 400, '缺少组件 id')
183
+ return
184
+ }
185
+ if (!compFind(id)) {
186
+ sendError(res, 404, '组件不存在')
187
+ return
188
+ }
189
+ compUpsert({ id, autoStart: enabled })
190
+ if (enabled) {
191
+ compStart(id).catch(() => {})
192
+ }
193
+ sendJson(res, 200, { ok: true, id, autoStart: enabled })
194
+ return
195
+ }
196
+
197
+ async function routeComponentStart(req, res, rc) {
198
+ const ctx = rc.ctx
199
+ const url = rc.url
200
+ const pathname = rc.pathname
201
+ const method = rc.method
202
+ const body = rc.body
203
+ const id = typeof body.id === 'string' ? body.id : ''
204
+ if (id === '') {
205
+ sendError(res, 400, '缺少组件 id')
206
+ return
207
+ }
208
+ try {
209
+ sendJson(res, 200, { ok: true, ...(await compStart(id)) })
210
+ } catch (error) {
211
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
212
+ }
213
+ return
214
+ }
215
+
216
+ async function routeComponentStop(req, res, rc) {
217
+ const ctx = rc.ctx
218
+ const url = rc.url
219
+ const pathname = rc.pathname
220
+ const method = rc.method
221
+ const body = rc.body
222
+ const id = typeof body.id === 'string' ? body.id : ''
223
+ if (id === '') {
224
+ sendError(res, 400, '缺少组件 id')
225
+ return
226
+ }
227
+ try {
228
+ sendJson(res, 200, { ok: true, ...(await compStop(id)) })
229
+ } catch (error) {
230
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
231
+ }
232
+ return
233
+ }
234
+
235
+ async function routeComponentStatus(req, res, rc) {
236
+ const ctx = rc.ctx
237
+ const url = rc.url
238
+ const pathname = rc.pathname
239
+ const method = rc.method
240
+ const body = rc.body
241
+ const id = typeof body.id === 'string' ? body.id : ''
242
+ if (id === '') {
243
+ sendError(res, 400, '缺少组件 id')
244
+ return
245
+ }
246
+ try {
247
+ sendJson(res, 200, { ok: true, ...(await compStatus(id)) })
248
+ } catch (error) {
249
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
250
+ }
251
+ return
252
+ }
253
+
254
+ export { routeComponents, routeRepoClone, routeRepoList, routeRepoLandConfig, routeRepoRemove, routeRepoOpen, routeComponentAutostart, routeComponentStart, routeComponentStop, routeComponentStatus }