@noob-stupid/dsh-plugin-console 0.3.67 → 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 (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 +431 -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 +154 -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,234 @@
1
+ // L1 · domain —— skills.js(分层 Step 从 lib/index.js 搬出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三
3
+
4
+ import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { gitCloneUrls } from './sources.js'
8
+ import { execFileAsync, gitEnv } from '../infra/exec.js'
9
+ import { copyTree } from '../infra/fsx.js'
10
+ import { GITHUB_RAW, curlJson, curlText, rawTextWithFallback } from '../infra/http.js'
11
+ import { dshHome } from '../infra/paths.js'
12
+
13
+ /** 探测仓库是否为技能仓库:根目录或第一层子目录存在 SKILL.md。
14
+ * 返回 { hasSkill, skillDir }(skillDir 为相对仓库根的目录,'' 表示根)。
15
+ * raw 双通道竞速,3 秒封顶,失败静默。 */
16
+ async function detectSkillRepo(repo, branch = 'main') {
17
+ const branchEnc = encodeURIComponent(branch)
18
+ try {
19
+ const root = await Promise.any([
20
+ curlText(`${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
21
+ curlText(`https://ghproxy.net/${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
22
+ curlText(`https://cdn.jsdelivr.net/gh/${repo}@${branchEnc}/SKILL.md`, 3000),
23
+ ]).catch(() => null)
24
+ if (root !== null) return { hasSkill: true, skillDir: '' }
25
+ // 根没有时再查一层子目录(常用布局:skills/<name>/SKILL.md、<name>/SKILL.md)
26
+ const tree = await curlJson(`https://api.github.com/repos/${repo}/git/trees/${branchEnc}?recursive=1`, 5000).catch(() => null)
27
+ const skillPaths = (tree?.tree ?? [])
28
+ .filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path))
29
+ .map((n) => n.path)
30
+ if (skillPaths.length > 0) {
31
+ const dir = skillPaths[0].slice(0, -'SKILL.md'.length).replace(/\/$/u, '')
32
+ return { hasSkill: true, skillDir: dir }
33
+ }
34
+ } catch {}
35
+ return { hasSkill: false, skillDir: null }
36
+ }
37
+
38
+ /** 提取 SKILL.md frontmatter 摘要(name / description / whenToUse,与客户端 summarizeSkillFrontmatter 同规则)。 */
39
+ function summarizeSkillFrontmatter(text) {
40
+ if (!text.startsWith('---')) return null
41
+ const fmEnd = text.indexOf('\n---', 3)
42
+ if (fmEnd === -1) return null
43
+ const fm = text.slice(3, fmEnd)
44
+ const pick = (key) => {
45
+ const re = new RegExp(`^${key}:\\s*(.*)$`, 'mu')
46
+ const m = fm.match(re)
47
+ if (!m) return ''
48
+ const first = m[1].trim()
49
+ if (first.startsWith('|')) {
50
+ const rest = fm.slice(m.index + m[0].length)
51
+ const lines = []
52
+ for (const line of rest.split('\n')) {
53
+ if (/^[a-zA-Z][\w-]*\s*:/u.test(line)) break
54
+ const v = line.trim()
55
+ if (v) lines.push(v)
56
+ if (lines.join(' ').length > 240) break
57
+ }
58
+ return lines.join(' ').slice(0, 500)
59
+ }
60
+ return first.slice(0, 200)
61
+ }
62
+ const name = pick('name')
63
+ const description = pick('description')
64
+ const whenToUse = pick('whenToUse')
65
+ if (!name && !description && !whenToUse) return null
66
+ return { name, description, whenToUse }
67
+ }
68
+
69
+ /** 读取仓库 SKILL.md 的 frontmatter 摘要(raw 双通道,失败静默返回 null)。 */
70
+ async function fetchSkillMeta(repo, branch, skillDir) {
71
+ try {
72
+ const path = skillDir ? `${skillDir}/SKILL.md` : 'SKILL.md'
73
+ const body = await rawTextWithFallback(repo, branch, path)
74
+ if (body === null) return null
75
+ return summarizeSkillFrontmatter(body)
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** 技能安装:git clone 仓库 → 定位 SKILL.md(根或第一层子目录)→ 复制到 ~/.dsh/skills/<name>/。
82
+ * 技能由 dsh-skill-filesystem 插件扫描(发现根:<dshHome>/skills),与桌面版共享。 */
83
+ async function runSkillInstallJob(job) {
84
+ const tmpDir = join(tmpdir(), `dsh-skill-${job.id}-${Date.now()}`)
85
+ try {
86
+ job.stage = 'preparing'
87
+ const shortName = String(job.repo).split('/').pop() || job.repo
88
+ mkdirSync(tmpDir, { recursive: true })
89
+ const urls = gitCloneUrls(job.repo, job.source)
90
+ let cloned = false
91
+ let lastError = null
92
+ for (const url of urls) {
93
+ try {
94
+ await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, tmpDir], {
95
+ timeout: 120000,
96
+ windowsHide: true,
97
+ env: gitEnv(),
98
+ })
99
+ cloned = true
100
+ break
101
+ } catch (error) {
102
+ lastError = error
103
+ }
104
+ }
105
+ if (!cloned) throw new Error(`git clone 失败:${lastError?.message ?? '未知'}`)
106
+ job.stage = 'detecting'
107
+ // 定位 SKILL.md:根目录优先,其次第一层子目录(常用布局 skills/<name>/SKILL.md)
108
+ let skillDir = ''
109
+ if (!existsSync(join(tmpDir, 'SKILL.md'))) {
110
+ const sub = readdirSync(tmpDir, { withFileTypes: true })
111
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
112
+ .find((d) => existsSync(join(tmpDir, d.name, 'SKILL.md')))
113
+ if (sub) skillDir = sub.name
114
+ }
115
+ if (!existsSync(join(tmpDir, skillDir, 'SKILL.md'))) {
116
+ // 根与第一层子目录都没有:区分「技能集合仓库」与「非技能仓库」,给出可操作提示
117
+ let collection = false
118
+ try {
119
+ const tree = await curlJson(`https://api.github.com/repos/${job.repo}/git/trees/${encodeURIComponent(job.source === 'gitee' ? 'master' : 'main')}?recursive=1`, 6000).catch(() => null)
120
+ collection = (tree?.tree ?? []).filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path)).length > 1
121
+ } catch {}
122
+ if (collection) {
123
+ throw new Error('这是技能集合仓库(含多个 SKILL.md),请安装其中单个技能仓库(根或第一层子目录含 SKILL.md 的仓库)')
124
+ }
125
+ throw new Error('仓库内未找到 SKILL.md(检查根目录或第一层子目录)')
126
+ }
127
+ // 技能名:SKILL.md frontmatter 的 name(kebab-case)优先,否则用仓库短名
128
+ let skillName = shortName
129
+ try {
130
+ const text = readFileSync(join(tmpDir, skillDir, 'SKILL.md'), 'utf8')
131
+ const m = text.match(/^name:\s*([a-z0-9][a-z0-9-]{0,63})/mu)
132
+ if (m) skillName = m[1]
133
+ } catch {}
134
+ const skillsRoot = join(dshHome(), 'skills')
135
+ const dest = join(skillsRoot, skillName)
136
+ mkdirSync(skillsRoot, { recursive: true })
137
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
138
+ copyTree(join(tmpDir, skillDir), dest)
139
+ job.kind = 'skill'
140
+ job.skillName = skillName
141
+ job.skillDir = dest
142
+ job.status = 'done'
143
+ job.stage = 'done'
144
+ job.skillNote = `已安装技能「${skillName}」到 ${dest}。技能由 dsh-skill-filesystem 插件扫描发现(用户根 ~/.dsh/skills);若当前 profile 未启用该插件,请在 profile 的 cordis.yml 启用 @deepseek-ai/dsh-skill-filesystem 后重启即可生效。`
145
+ } catch (error) {
146
+ job.status = 'failed'
147
+ job.error = error instanceof Error ? error.message : String(error)
148
+ } finally {
149
+ // 无论成败都清理克隆临时目录(cpSync EIO 时代曾泄漏在 TEMP)
150
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
151
+ job.finishedAt = Date.now()
152
+ }
153
+ }
154
+
155
+ /** 返回 SKILL.md frontmatter 内容区间(不含首尾 --- 行);无 frontmatter 时 { has: false }。 */
156
+ function skillFrontmatterBounds(text) {
157
+ if (!text.startsWith('---')) return { has: false }
158
+ const nl = text.indexOf('\n')
159
+ if (nl === -1) return { has: false }
160
+ const end = text.indexOf('\n---', nl + 1)
161
+ if (end === -1) return { has: false }
162
+ return { has: true, start: nl + 1, end }
163
+ }
164
+
165
+ /** 技能是否已停用(frontmatter 含 disable-model-invocation: true)。 */
166
+ function isSkillDisabled(skillFile) {
167
+ try {
168
+ const text = readFileSync(skillFile, 'utf8')
169
+ const fm = skillFrontmatterBounds(text)
170
+ if (!fm.has) return false
171
+ return /^\s*disable-model-invocation:\s*(true|yes|on|1)\s*$/mu.test(text.slice(fm.start, fm.end))
172
+ } catch {
173
+ return false
174
+ }
175
+ }
176
+
177
+ /** 停用/启用技能(可逆):停用 = 备份原始 SKILL.md 到同目录 .dsh-skill-fm.bak 后在
178
+ * frontmatter 注入调用策略行;启用 = 恢复备份(无备份则移除注入行)。 */
179
+ function setSkillEnabled(skillFile, enabled) {
180
+ const backup = join(dirname(skillFile), '.dsh-skill-fm.bak')
181
+ const text = readFileSync(skillFile, 'utf8')
182
+ const fm = skillFrontmatterBounds(text)
183
+ if (!enabled) {
184
+ if (!existsSync(backup)) writeFileSync(backup, text, 'utf8')
185
+ const inject = `${SKILL_DISABLE_LINES.join('\n')}\n`
186
+ if (!fm.has) {
187
+ writeFileSync(skillFile, `---\n${inject}---\n\n${text}`, 'utf8')
188
+ } else {
189
+ writeFileSync(skillFile, `${text.slice(0, fm.start)}${inject}${text.slice(fm.start)}`, 'utf8')
190
+ }
191
+ } else if (existsSync(backup)) {
192
+ writeFileSync(skillFile, readFileSync(backup, 'utf8'), 'utf8')
193
+ rmSync(backup, { force: true })
194
+ } else if (fm.has) {
195
+ const content = text.slice(fm.start, fm.end)
196
+ const cleaned = content.split('\n')
197
+ .filter((l) => !/^\s*(disable-model-invocation|user-invocable)\s*:/u.test(l))
198
+ .join('\n')
199
+ writeFileSync(skillFile, `${text.slice(0, fm.start)}${cleaned}${text.slice(fm.end)}`, 'utf8')
200
+ }
201
+ }
202
+
203
+ /** 列出已安装技能(~/.dsh/skills 下一层含 SKILL.md 的目录 + 平铺 .md 文件)。
204
+ * 点号开头(如 .system)是系统技能根(dsh-skill-filesystem 保留目录),标记 system: true,
205
+ * 前端展示「系统」标签且不提供删除。disabled = 已按官方调用策略停用。 */
206
+ function listInstalledSkills() {
207
+ const root = join(dshHome(), 'skills')
208
+ const skills = []
209
+ try {
210
+ if (!existsSync(root)) return skills
211
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
212
+ const system = entry.name.startsWith('.')
213
+ if (entry.isDirectory()) {
214
+ const skillFile = join(root, entry.name, 'SKILL.md')
215
+ if (existsSync(skillFile)) {
216
+ skills.push({ name: entry.name, path: skillFile, system, disabled: isSkillDisabled(skillFile) })
217
+ }
218
+ } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
219
+ const skillFile = join(root, entry.name)
220
+ skills.push({ name: entry.name.slice(0, -3), path: skillFile, system, disabled: isSkillDisabled(skillFile) })
221
+ }
222
+ }
223
+ } catch {}
224
+ return skills
225
+ }
226
+
227
+ /** 技能市场收录的 topic(/search skills 分支三 topic 并行合并)。 */
228
+ const SKILL_TOPICS = ['agent-skills', 'claude-skills', 'dsh-skill']
229
+
230
+ /** 技能停用注入的 frontmatter 行(官方调用策略:disable-model-invocation 从模型目录/loader 排除,
231
+ * user-invocable 从用户命令排除;两者同设 = 完整停用)。 */
232
+ const SKILL_DISABLE_LINES = ['disable-model-invocation: true', 'user-invocable: false']
233
+
234
+ export { detectSkillRepo, summarizeSkillFrontmatter, fetchSkillMeta, runSkillInstallJob, skillFrontmatterBounds, isSkillDisabled, setSkillEnabled, listInstalledSkills, SKILL_TOPICS, SKILL_DISABLE_LINES }
@@ -0,0 +1,297 @@
1
+ // L1 · domain —— sources.js(分层 Step 从 lib/index.js 搬出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三
3
+
4
+ import { readFileSync, rmSync } from 'node:fs'
5
+ import { writeFile } from 'node:fs/promises'
6
+ import { sourcesFile, sourcesSecretsFile } from '../infra/paths.js'
7
+
8
+ /** 读取软件源配置(损坏/缺失时回退默认)。 */
9
+ function readSources() {
10
+ const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
11
+ try {
12
+ const data = JSON.parse(readFileSync(sourcesFile(), 'utf8'))
13
+ const secrets = readSourceSecrets()
14
+ const registries = (Array.isArray(data.registries) ? data.registries : [])
15
+ .filter((r) => r && typeof r.url === 'string' && isAllowedSourceUrl(r.url))
16
+ .map((r) => ({
17
+ id: String(r.id ?? '').slice(0, 40) || `src-${Math.random().toString(36).slice(2, 8)}`,
18
+ name: String(r.name ?? r.url).slice(0, 60) || r.url,
19
+ url: r.url,
20
+ primary: r.primary === true,
21
+ }))
22
+ const searchSources = (Array.isArray(data.searchSources) ? data.searchSources : [])
23
+ .map((s) => {
24
+ if (s && (s.id === 'github' || s.id === 'gitee')) {
25
+ return { id: String(s.id), name: String(s.name ?? s.id), type: 'builtin' }
26
+ }
27
+ if (s && typeof s.url === 'string' && s.url.includes('{q}')) {
28
+ let headers = {}
29
+ const secretHeaders = secrets.headers?.[s.id]
30
+ if (secretHeaders && typeof secretHeaders === 'object') {
31
+ headers = { ...secretHeaders }
32
+ } else if (Array.isArray(s.headers)) {
33
+ for (const h of s.headers) {
34
+ if (h && typeof h.name === 'string' && h.name !== '' && typeof h.value === 'string') {
35
+ headers[h.name] = h.value
36
+ }
37
+ }
38
+ } else if (s.headers && typeof s.headers === 'object') {
39
+ headers = { ...s.headers }
40
+ }
41
+ return {
42
+ id: String(s.id ?? `search-${Math.random().toString(36).slice(2, 8)}`).slice(0, 40),
43
+ name: String(s.name ?? s.url).slice(0, 60),
44
+ type: 'custom',
45
+ url: s.url,
46
+ headers,
47
+ }
48
+ }
49
+ return null
50
+ })
51
+ .filter((s) => s !== null)
52
+ const giteeBase = readGiteeConfig(data)
53
+ // 索引源:老配置无该字段时用默认;URL 必须是合法 https 或本机/私网 http
54
+ const indexSources = (Array.isArray(data.indexSources) ? data.indexSources : [])
55
+ .filter((s) => s && typeof s.url === 'string' && isAllowedSourceUrl(s.url))
56
+ .map((s) => ({
57
+ id: String(s.id ?? '').slice(0, 40) || `idx-${Math.random().toString(36).slice(2, 8)}`,
58
+ name: String(s.name ?? s.url).slice(0, 60) || s.url,
59
+ url: s.url,
60
+ primary: s.primary === true,
61
+ }))
62
+ const indexFinal = indexSources.length > 0
63
+ ? (() => {
64
+ if (!indexSources.some((s) => s.primary)) indexSources[0].primary = true
65
+ return indexSources
66
+ })()
67
+ : defaults.indexSources
68
+ // Git 克隆源:模板必须同时含 {owner} 与 {repo};老配置无该字段时用默认
69
+ const gitSources = (Array.isArray(data.gitSources) ? data.gitSources : [])
70
+ .filter((s) => s && typeof s.urlTemplate === 'string' && s.urlTemplate.includes('{owner}') && s.urlTemplate.includes('{repo}') && isAllowedGitSourceUrl(s.urlTemplate))
71
+ .map((s) => ({
72
+ id: String(s.id ?? '').slice(0, 40) || `git-${Math.random().toString(36).slice(2, 8)}`,
73
+ name: String(s.name ?? s.urlTemplate).slice(0, 60) || s.urlTemplate,
74
+ urlTemplate: s.urlTemplate,
75
+ primary: s.primary === true,
76
+ }))
77
+ const gitFinal = gitSources.length > 0
78
+ ? (() => {
79
+ if (!gitSources.some((s) => s.primary)) gitSources[0].primary = true
80
+ return gitSources
81
+ })()
82
+ : defaults.gitSources
83
+ const gitee = {
84
+ ...giteeBase,
85
+ clientSecret: secrets.gitee?.clientSecret ?? giteeBase.clientSecret,
86
+ token: secrets.gitee?.token ?? giteeBase.token,
87
+ }
88
+ if (registries.length > 0) {
89
+ if (!registries.some((r) => r.primary)) registries[0].primary = true
90
+ return { registries, searchSources, indexSources: indexFinal, gitSources: gitFinal, indexMerge: data.indexMerge === true, gitee }
91
+ }
92
+ } catch {}
93
+ return defaults
94
+ }
95
+
96
+ async function writeSources(sources) {
97
+ const secrets = { gitee: {}, headers: {} }
98
+ const cleanSearch = (sources.searchSources ?? []).map((s) => {
99
+ if (!s) return s
100
+ if (s.headers && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
101
+ secrets.headers[s.id] = { ...s.headers }
102
+ const { headers, ...rest } = s
103
+ return rest
104
+ }
105
+ return s
106
+ })
107
+ const gitee = readGiteeConfig(sources)
108
+ if (typeof gitee.clientSecret === 'string' && gitee.clientSecret !== '') secrets.gitee.clientSecret = gitee.clientSecret
109
+ if (typeof gitee.token === 'string' && gitee.token !== '') secrets.gitee.token = gitee.token
110
+ const cleanGitee = { ...(sources.gitee ?? {}), clientSecret: undefined, token: undefined }
111
+ const main = { ...sources, searchSources: cleanSearch, gitee: cleanGitee }
112
+ await writeFile(sourcesFile(), JSON.stringify(main, null, 2) + '\n', 'utf8')
113
+ await writeSourceSecrets(secrets)
114
+ }
115
+
116
+ /**
117
+ * 凭据脱敏(安全审查发现):/sources 响应不得携带明文密钥——
118
+ * - Gitee clientSecret / token:绝不回传(clientId 打码保留前 8 位供识别)
119
+ * - 自定义搜索源的 headers(可能含 Authorization: Bearer xxx):value 打码
120
+ * 前端需要"已配置"状态时用 giteeStatusView 的布尔字段。
121
+ */
122
+ function maskSources(sources) {
123
+ const gitee = readGiteeConfig(sources)
124
+ const maskedGitee = {
125
+ clientId: gitee.clientId === '' ? '' : `${gitee.clientId.slice(0, 8)}…`,
126
+ clientConfigured: gitee.clientId !== '',
127
+ hasToken: gitee.token !== '',
128
+ login: gitee.login,
129
+ }
130
+ return {
131
+ registries: sources.registries,
132
+ indexSources: sources.indexSources ?? DEFAULT_SOURCES.indexSources,
133
+ indexMerge: sources.indexMerge === true,
134
+ gitSources: sources.gitSources ?? DEFAULT_SOURCES.gitSources,
135
+ searchSources: (sources.searchSources ?? []).map((s) => {
136
+ if (s && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
137
+ const masked = {}
138
+ for (const [k, v] of Object.entries(s.headers)) {
139
+ masked[k] = typeof v === 'string' && v.length > 8 ? `${v.slice(0, 4)}…${v.slice(-4)}` : (v === '' ? '' : '••••')
140
+ }
141
+ return { ...s, headers: masked }
142
+ }
143
+ return s
144
+ }),
145
+ gitee: maskedGitee,
146
+ }
147
+ }
148
+
149
+ function readSourceSecrets() {
150
+ try {
151
+ const data = JSON.parse(readFileSync(sourcesSecretsFile(), 'utf8'))
152
+ return data && typeof data === 'object' ? data : {}
153
+ } catch {
154
+ return {}
155
+ }
156
+ }
157
+
158
+ async function writeSourceSecrets(secrets) {
159
+ const gitee = secrets.gitee ?? {}
160
+ const headers = secrets.headers ?? {}
161
+ const hasGitee = typeof gitee.clientSecret === 'string' && gitee.clientSecret !== '' || typeof gitee.token === 'string' && gitee.token !== ''
162
+ const hasHeaders = Object.keys(headers).some((id) => { const h = headers[id]; return h && typeof h === 'object' && Object.keys(h).length > 0 })
163
+ if (!hasGitee && !hasHeaders) {
164
+ try { rmSync(sourcesSecretsFile(), { force: true }) } catch {}
165
+ return
166
+ }
167
+ await writeFile(sourcesSecretsFile(), JSON.stringify({ gitee, headers }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
168
+ }
169
+
170
+ /** 源地址校验(模块顶层,readSources 与 sources 路由共用):https 任意;http 仅限私网/本机地址(内网 npm registry、内网搜索服务常用 http)。 */
171
+ function isAllowedSourceUrl(url) {
172
+ if (/^https:\/\/\S+$/u.test(url)) return true
173
+ if (!/^http:\/\/\S+$/u.test(url)) return false
174
+ try {
175
+ const host = new URL(url).hostname.toLowerCase()
176
+ if (host === 'localhost' || host === '::1' || host === '[::1]') return true
177
+ if (/^127\.\d+\.\d+\.\d+$/u.test(host)) return true
178
+ if (/^10\.\d+\.\d+\.\d+$/u.test(host)) return true
179
+ if (/^192\.168\.\d+\.\d+$/u.test(host)) return true
180
+ if (/^169\.254\.\d+\.\d+$/u.test(host)) return true
181
+ const m = host.match(/^172\.(\d+)\.\d+\.\d+$/u)
182
+ if (m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return true
183
+ if (/^[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){2,7}$/iu.test(host)) return true
184
+ return false
185
+ } catch {
186
+ return false
187
+ }
188
+ }
189
+
190
+ /** Git 源地址校验:在通用校验之上额外允许 file:// 本地裸仓库(完全离线/内网共享盘场景)。 */
191
+ function isAllowedGitSourceUrl(url) {
192
+ if (/^file:\/\/\/\S+$/u.test(url)) return true
193
+ return isAllowedSourceUrl(url)
194
+ }
195
+
196
+ /** 读取 Gitee OAuth 配置(clientId/clientSecret/token/login)。 */
197
+ function readGiteeConfig(data) {
198
+ const gitee = data && typeof data === 'object' && data.gitee && typeof data.gitee === 'object' ? data.gitee : {}
199
+ return {
200
+ clientId: typeof gitee.clientId === 'string' ? gitee.clientId : '',
201
+ clientSecret: typeof gitee.clientSecret === 'string' ? gitee.clientSecret : '',
202
+ token: typeof gitee.token === 'string' ? gitee.token : '',
203
+ login: typeof gitee.login === 'string' ? gitee.login : '',
204
+ }
205
+ }
206
+
207
+ /** Gitee 配置状态视图(布尔 + login,无任何凭据)。 */
208
+ function giteeStatusView(sources) {
209
+ const gitee = readGiteeConfig(sources)
210
+ return { clientConfigured: gitee.clientId !== '', hasToken: gitee.token !== '', login: gitee.login }
211
+ }
212
+
213
+ /** 按主→备顺序返回 registry URL 列表。 */
214
+ function orderedRegistries(sources) {
215
+ const list = [...sources.registries]
216
+ return [...list.filter((r) => r.primary), ...list.filter((r) => !r.primary)].map((r) => r.url)
217
+ }
218
+
219
+ function createGiteeOAuthState() {
220
+ const state = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
221
+ const now = Date.now()
222
+ // 只保留最近 10 分钟内的 state
223
+ for (const [key, at] of GITEE_OAUTH_STATES) {
224
+ if (now - at > 10 * 60 * 1000) GITEE_OAUTH_STATES.delete(key)
225
+ }
226
+ GITEE_OAUTH_STATES.set(state, now)
227
+ return state
228
+ }
229
+
230
+ function consumeGiteeOAuthState(state) {
231
+ if (typeof state !== 'string' || state === '') return false
232
+ const at = GITEE_OAUTH_STATES.get(state)
233
+ if (at === undefined) return false
234
+ GITEE_OAUTH_STATES.delete(state)
235
+ return Date.now() - at <= 10 * 60 * 1000
236
+ }
237
+
238
+ const DEFAULT_SOURCES = {
239
+ registries: [
240
+ { id: 'npmmirror', name: 'npmmirror(国内镜像)', url: 'https://registry.npmmirror.com', primary: true },
241
+ { id: 'npmjs', name: 'npmjs(官方源)', url: 'https://registry.npmjs.org', primary: false },
242
+ ],
243
+ searchSources: [
244
+ { id: 'github', name: 'GitHub', type: 'builtin' },
245
+ { id: 'gitee', name: 'Gitee', type: 'builtin' },
246
+ ],
247
+ // 市场静态索引源(按主→备依次尝试;内网可整体替换为自建镜像,实现完全离线的市场浏览)
248
+ // 2026-09-20 扩容 2 → 5:只有 jsDelivr + ghproxy 两个源时,两者同时不可达就会让整个市场退化成
249
+ // 「只能搜 GitHub 实时结果」——收录清单(含 ★7800 全家桶)与本地索引模糊匹配一起失效
250
+ // (另一位用户实测报「市场索引加载失败:网络不可达(2 个索引源全部失败):GitHub 请求超时」)。
251
+ // jsDelivr 官方多入口互为主备:cdn(主)/ gcore / fastly;再加 ghproxy 与 raw 直连兜底。
252
+ indexSources: [
253
+ { id: 'jsdelivr', name: 'jsDelivr CDN', url: 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-gating-hub@main/marketplace/index.json', primary: true },
254
+ { id: 'jsdelivr-gcore', name: 'jsDelivr (gcore)', url: 'https://gcore.jsdelivr.net/gh/Noob-stupid/dsh-plugin-gating-hub@main/marketplace/index.json', primary: false },
255
+ { id: 'jsdelivr-fastly', name: 'jsDelivr (fastly)', url: 'https://fastly.jsdelivr.net/gh/Noob-stupid/dsh-plugin-gating-hub@main/marketplace/index.json', primary: false },
256
+ { id: 'ghproxy', name: 'ghproxy 镜像', url: 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-gating-hub/main/marketplace/index.json', primary: false },
257
+ { id: 'raw', name: 'GitHub raw 直连', url: 'https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-gating-hub/main/marketplace/index.json', primary: false },
258
+ ],
259
+ // Git 克隆源({owner}/{repo} 占位符;按主→备依次尝试)。
260
+ // 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。
261
+ gitSources: [
262
+ { id: 'ghproxy-git', name: 'ghproxy 镜像', urlTemplate: 'https://ghproxy.net/https://github.com/{owner}/{repo}.git', primary: true },
263
+ { id: 'github-git', name: 'GitHub 直连', urlTemplate: 'https://github.com/{owner}/{repo}.git', primary: false },
264
+ ],
265
+ // 索引合并模式:true = 所有索引源结果合并去重(公共索引 + 内网私有索引同时可见);
266
+ // false = 主→备只用一个(内网优先,更快)
267
+ indexMerge: false,
268
+ gitee: { clientId: '', clientSecret: '', token: '', login: '' },
269
+ }
270
+
271
+ /** Gitee OAuth state 一次性凭证(防止登录 CSRF / token 替换)。 */
272
+ const GITEE_OAUTH_STATES = new Map()
273
+
274
+ /** Git 克隆 URL 列表(按主→备顺序)。来源在「功能包 → 软件源 → Git 源」中自定义,
275
+ * 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。 */
276
+ function gitCloneUrls(repoFullName, source = 'github') {
277
+ if (source === 'gitee') return [`https://gitee.com/${repoFullName}.git`]
278
+ const [owner = '', repo = ''] = String(repoFullName).split('/')
279
+ let list = []
280
+ try {
281
+ list = readSources().gitSources ?? []
282
+ } catch {
283
+ list = DEFAULT_SOURCES.gitSources
284
+ }
285
+ const ordered = [...list].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
286
+ const urls = ordered
287
+ .map((s) => String(s.urlTemplate).replace(/\{owner\}/gu, owner).replace(/\{repo\}/gu, repo))
288
+ .filter((u) => u !== '')
289
+ return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
290
+ }
291
+
292
+ /** Gitee OAuth 端点(第三方应用需在 gitee.com → 数据管理 → 第三方应用 创建)。 */
293
+ const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
294
+
295
+ const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
296
+ const DEFAULT_SEARCH = 'dsh-plugin'
297
+ export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }