@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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +127 -32
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +431 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +154 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- 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 }
|