source-code-mgmt 1.11.0 → 1.13.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/lib/index.js CHANGED
@@ -25,10 +25,62 @@ import { join, dirname } from 'node:path'
25
25
  import { homedir } from 'node:os'
26
26
 
27
27
  import { AsyncLocalStorage } from 'node:async_hooks'
28
+ import { fileURLToPath } from 'node:url'
28
29
 
29
30
  // ---------- i18n: host messages follow the client’s language (?lang= query param, per request) ----------
30
31
  const HOST_EN = {"尚未配置 Gitee 私人令牌(请打开 ③ 代码管理输入令牌)":"No Gitee personal token configured (enter one in ③ Code Management)","Gitee API 请求失败(需要 curl)":"Gitee API request failed (curl required)","not a git repository(尚未 git init,可用「新建仓库并推送」初始化为 git 仓库并上传)":"not a git repository (run \"Create repo & push\" to init one and upload)","超过 GitHub 100MB 单文件限制(${}),已被 .gitignore 的 \"${}\" 排除":"Exceeds GitHub's 100MB file limit (${}) — already excluded by .gitignore entry \"${}\"","超过 GitHub 100MB 单文件限制(${}),${} 所在的一级目录「${}」整体忽略(该文件夹为一整体)":"Exceeds GitHub's 100MB file limit (${}) — the whole first-level folder \"${}\" of ${} is ignored (the folder is one unit)","超过 GitHub 100MB 单文件限制(${}),已忽略单个文件「${}」":"Exceeds GitHub's 100MB file limit (${}) — the file \"${}\" was ignored individually","尚未配置远程仓库 origin,请使用「新建仓库」创建远程仓库。":"No origin remote configured — use \"Create repo\" to create a remote repo.","not a git repository(尚未 git init,无法拉取)":"not a git repository (no git init yet — cannot pull)","尚未配置远程仓库 origin,无法拉取。":"No origin remote configured — cannot pull.","拉取存在冲突:本地有未合并改动或与远程冲突,请手动 git pull 处理合并。":"Pull conflicts: you have unmerged local changes or conflicts with the remote — resolve them manually with git pull.","git pull 失败":"git pull failed","not a git repository(尚未 git init)":"not a git repository (run git init first)","拉取并推送失败:合并存在冲突,请手动解决后重试。":"Pull-and-push failed: merge conflicts — resolve them manually and retry.","pull --rebase 失败":"pull --rebase failed","push 失败":"push failed","push --force 失败":"push --force failed","强制拉取存在冲突,请手动处理。":"Force-pull conflicts — handle them manually.","git pull --force 失败":"git pull --force failed","缺少仓库名称":"Repo name required","仓库名称包含非法字符(仅允许字母、数字、点、下划线、横线)":"Repo name contains invalid characters (only letters, digits, dots, underscores, dashes)","git add 失败:${}":"git add failed: ${}","需要先配置 Gitee 私人令牌":"Configure the Gitee personal token first","Gitee 创建仓库失败":"Failed to create the Gitee repo","缺少文件路径":"File path required","无法识别当前分支":"Cannot determine the current branch","尚未配置远程仓库 origin,无法对齐。":"No origin remote configured — cannot align.","git fetch 失败":"git fetch failed","git reset --hard 失败":"git reset --hard failed","git init 失败":"git init failed","git add 失败":"git add failed","git reset 失败":"git reset failed","提交信息不能为空":"Commit message cannot be empty","没有已暂存(staged)的改动可提交":"Nothing staged to commit","git commit 失败":"git commit failed","缺少分支名":"Branch name required","切换分支失败":"Failed to switch branch","git log 失败":"git log failed","缺少 commit hash":"Commit hash required","revert 失败(可能有冲突,请手动处理)":"revert failed (possible conflicts — handle manually)","cherry-pick 失败(可能有冲突,请手动处理)":"cherry-pick failed (possible conflicts — handle manually)","读取提交 diff 失败":"Failed to read the commit diff","未知工具:":"Unknown tool: ","未找到可用的包管理器(":"No usable package manager found (",")。请手动安装。":"). Please install manually.","安装成功":"Installed successfully","安装失败":"Installation failed","$dlg.Description = '选择本地目录';":"$dlg.Description = 'Choose a local folder';","Gitee 修改可见性失败":"Failed to change Gitee visibility","需要先登录 GitHub CLI(gh)":"Log in to the GitHub CLI (gh) first","修改可见性失败:${}":"Failed to change visibility: ${}","gh repo edit 失败":"gh repo edit failed","缺少目录路径":"Folder path required","未选择目录":"No folder selected","目录不存在或不是文件夹:":"Folder does not exist or is not a directory: ","令牌不能为空":"Token cannot be empty","Gitee 令牌无效,请检查(需有 projects 权限)":"Invalid Gitee token — check it (needs projects permission)"};
31
32
  const requestLangStore = new AsyncLocalStorage();
33
+ // 新增板块(④克隆 / ⑤PR / ⑥npm)的 host 英文消息,追加进 HOST_EN。
34
+ Object.assign(HOST_EN, {
35
+ "LLM 服务不可用(未加载 llm 服务)": "LLM service unavailable (llm service not loaded)",
36
+ "未找到默认模型配置(agent-default-model)": "No default model config found (agent-default-model)",
37
+ "目标目录不存在:": "Target directory does not exist: ",
38
+ "已存在同名目录:": "A directory with that name already exists: ",
39
+ "git clone 失败": "git clone failed",
40
+ "无法识别仓库地址:": "Cannot parse repo URL: ",
41
+ "没有可用的 PR 步骤(请先让 AI 分析规则)": "No PR steps available (run AI rule analysis first)",
42
+ "确保 fork": "Ensure fork",
43
+ "已 fork 或 fork 已存在": "Forked already, or fork exists",
44
+ "克隆 fork": "Clone fork",
45
+ "已存在本地副本:": "Local copy already exists: ",
46
+ "已克隆到 ": "Cloned to ",
47
+ "克隆 fork 失败": "Failed to clone fork",
48
+ "添加上游": "Add upstream",
49
+ "拉取上游": "Fetch upstream",
50
+ "拉取失败": "Fetch failed",
51
+ "新建分支": "Create branch",
52
+ "创建分支失败": "Failed to create branch",
53
+ "写入条目": "Write entry",
54
+ "缺少条目文件路径": "Missing entry file path",
55
+ "重生成 README": "Regenerate README",
56
+ "无命令,跳过": "No command, skipped",
57
+ "执行失败": "Command failed",
58
+ "提交": "Commit",
59
+ "推送": "Push",
60
+ "创建 PR": "Create PR",
61
+ "创建 PR 失败": "Failed to create PR",
62
+ "未知步骤": "Unknown step",
63
+ "未知步骤类型": "Unknown step type",
64
+ "缺少仓库地址": "Repo URL required",
65
+ "未登录 npm": "Not logged in to npm",
66
+ "npm publish 已执行": "npm publish executed",
67
+ "npm publish 失败": "npm publish failed",
68
+ "已提交": "Committed",
69
+ "已创建 PR": "PR created",
70
+ "推送上游失败": "Failed to push",
71
+ "AI 未输出有效规则": "AI produced no valid rules",
72
+ "AI 输出无法解析": "AI output could not be parsed",
73
+ "推送失败": "Push failed",
74
+ "条目文件路径不安全(已拦截)": "Entry file path unsafe (blocked)",
75
+ "重生成命令不安全(已拦截)": "Regenerate command unsafe (blocked)",
76
+ "创建分支失败(上游分支可能不存在,请检查规则 baseBranch)": "Failed to create branch (upstream base may not exist — check the rule's baseBranch)",
77
+ "已打开终端窗口,请完成 npm login 后回到面板点「重新检查」": "A terminal window has been opened — complete npm login, then click \"Re-check\" in the panel",
78
+ "未找到可用终端,请手动在目标目录运行:": "No usable terminal found — run this manually in the target directory: ",
79
+ "规则文件获取失败:": "Failed to fetch rules file: ",
80
+ "规则文件读取失败:": "Failed to read rules file: ",
81
+ "找不到规则文件:": "Rules file not found: ",
82
+ "未获取到规则内容(文件为空或无法读取)": "Could not read any rule content (file empty or unreadable)",
83
+ })
32
84
  function langOf(req) { try { const l = new URL(req.url ?? "", "http://localhost").searchParams.get("lang"); return l === "en" ? "en" : "zh" } catch { return "zh" } }
33
85
  function lang() { return requestLangStore.getStore() ?? "zh" }
34
86
  function tr(key, params) {
@@ -83,6 +135,8 @@ const SSH = resolveBin('ssh', 'DSH_SCM_SSH')
83
135
  const SSH_KEYGEN = resolveBin('ssh-keygen', 'DSH_SCM_SSH_KEYGEN')
84
136
  /** Resolved path to curl (used to call the Gitee OpenAPI). */
85
137
  const CURL = resolveBin('curl', 'DSH_SCM_CURL')
138
+ /** Resolved path to npm (used by the ⑥ publish-npm section). */
139
+ const NPM = resolveBin('npm', 'DSH_SCM_NPM')
86
140
 
87
141
  /**
88
142
  * Preferred ssh for git remote operations (injected via GIT_SSH).
@@ -305,8 +359,13 @@ function run(cmd, args, opts = {}) {
305
359
  // `-c core.quotepath=false` 让 git 直接输出原始 UTF-8 路径,Node 以 utf8
306
360
  // 解码后即得到正确的中文文件名(git diff / git show 头部路径也一并修复)。
307
361
  const finalArgs = cmd === GIT ? ['-c', 'core.quotepath=false', ...args] : args
362
+ // Windows + shell 模式:execFileSync 把 cmd 与参数拼成一条命令行交给 cmd.exe。
363
+ // 若可执行文件路径含空格(如 "C:\Program Files\nodejs\npm.cmd"),不引号包裹会
364
+ // 被截断成 "'C:\Program' 不是内部命令"。这里只包裹可执行文件本身(参数保持数组,
365
+ // 由 execFileSync 逐个安全拼接),不触碰用户输入,无注入面。
366
+ const execCmd = (opts.shell && IS_WIN && /\s/.test(cmd)) ? '"' + cmd + '"' : cmd
308
367
  try {
309
- const out = execFileSync(cmd, finalArgs, {
368
+ const out = execFileSync(execCmd, finalArgs, {
310
369
  encoding: 'utf8',
311
370
  stdio: ['ignore', 'pipe', 'pipe'],
312
371
  timeout: opts.timeout ?? 30_000,
@@ -1799,6 +1858,944 @@ function installToolFlow(tool) {
1799
1858
  }
1800
1859
  }
1801
1860
 
1861
+ // ---------------------------------------------------------------------------
1862
+ // ④ 克隆远程仓库 / ⑤ 提交 PR / ⑥ 发布 npm 包 —— host 逻辑
1863
+ // ---------------------------------------------------------------------------
1864
+
1865
+ // ---------- LLM 辅助:用 DSH 的 llm 服务 + 默认模型(当前对话/默认模型) ----------
1866
+
1867
+ /**
1868
+ * 读取 DSH 默认模型配置(agent-default-model)。优先走 settings 服务(ctx.settings
1869
+ * 在 host 侧同进程可用),拿不到时兜底解析 ~/.dsh/settings.yaml 的该段。
1870
+ * @returns {{provider: string, model: string}|undefined}
1871
+ */
1872
+ function resolveDefaultModel(ctx) {
1873
+ try {
1874
+ if (ctx && typeof ctx.get === 'function') {
1875
+ const settings = ctx.get('settings')
1876
+ if (settings && typeof settings.get === 'function') {
1877
+ const v = settings.get('agent-default-model')
1878
+ if (v && typeof v === 'object' && typeof v.provider === 'string' && v.provider && typeof v.model === 'string' && v.model) {
1879
+ return { provider: v.provider, model: v.model }
1880
+ }
1881
+ }
1882
+ }
1883
+ } catch { /* fall through */ }
1884
+ try {
1885
+ const file = join(homedir(), '.dsh', 'settings.yaml')
1886
+ if (existsSync(file)) {
1887
+ const raw = readFileSync(file, 'utf8')
1888
+ // 捕获 `agent-default-model:` 后所有缩进(开头空白)的连续行,直到下一个顶格键。
1889
+ const m = /^\s*agent-default-model\s*:\s*\n((?:[ \t]+[^\n]*(?:\n|$))*)/m.exec(raw)
1890
+ if (m) {
1891
+ const block = m[1]
1892
+ const prov = /^\s*provider\s*:\s*"?([^\s"#]+)"?/m.exec(block)
1893
+ const mod = /^\s*model\s*:\s*"?([^\s"#]+)"?/m.exec(block)
1894
+ if (prov && mod) return { provider: prov[1], model: mod[1] }
1895
+ }
1896
+ }
1897
+ } catch { /* ignore */ }
1898
+ return undefined
1899
+ }
1900
+
1901
+ /**
1902
+ * 调用 DSH 的 LLM(默认模型),收集完整文本。messages 传 { role: 'user'|'assistant', text } 简写。
1903
+ * @param {*} ctx - cordis context(apply 注入)。
1904
+ * @param {{system?: string, messages: Array<{role:string,text:string}>, maxTokens?: number, temperature?: number}} opts
1905
+ * @returns {Promise<{ok:boolean, text?:string, provider?:string, model?:string, error?:string}>}
1906
+ */
1907
+ async function llmComplete(ctx, opts) {
1908
+ let llm = null
1909
+ try {
1910
+ if (ctx && typeof ctx.get === 'function') llm = ctx.get('llm')
1911
+ if (!llm && ctx && ctx.llm) llm = ctx.llm
1912
+ } catch { llm = null }
1913
+ if (!llm || typeof llm.stream !== 'function') {
1914
+ return { ok: false, error: tr('LLM 服务不可用(未加载 llm 服务)') }
1915
+ }
1916
+ const cfg = resolveDefaultModel(ctx)
1917
+ if (!cfg) return { ok: false, error: tr('未找到默认模型配置(agent-default-model)') }
1918
+ const blocks = (Array.isArray(opts.messages) ? opts.messages : []).map((m) => ({
1919
+ role: m.role === 'assistant' ? 'assistant' : 'user',
1920
+ content: [{ type: 'text', text: String(m.text ?? '') }],
1921
+ }))
1922
+ if (blocks.length === 0) blocks.push({ role: 'user', content: [{ type: 'text', text: '' }] })
1923
+ const chunks = []
1924
+ try {
1925
+ const stream = llm.stream({
1926
+ provider: cfg.provider,
1927
+ model: cfg.model,
1928
+ system: opts.system ? String(opts.system) : undefined,
1929
+ messages: blocks,
1930
+ maxTokens: opts.maxTokens ?? 2048,
1931
+ temperature: opts.temperature ?? 0.6,
1932
+ })
1933
+ for await (const chunk of stream) {
1934
+ if (chunk && chunk.type === 'text-delta') chunks.push(chunk.text)
1935
+ }
1936
+ } catch (e) {
1937
+ return { ok: false, error: String((e && e.message) || e) }
1938
+ }
1939
+ return { ok: true, text: chunks.join(''), provider: cfg.provider, model: cfg.model }
1940
+ }
1941
+
1942
+ // ---------- ④ 克隆远程仓库(Clone) ----------
1943
+
1944
+ /** 用户 home 目录(④ 克隆默认位置:Windows=C:\Users\xxx,Linux/macOS=/home/xxx)。 */
1945
+ function cloneHomeDir() {
1946
+ return homedir()
1947
+ }
1948
+
1949
+ /** 默认克隆位置(⑤⑥ 等板块的工作目录兜底):DSH 默认工作区。 */
1950
+ function cloneDefaultDir() {
1951
+ return resolveDefaultDir(process.cwd())
1952
+ }
1953
+
1954
+ /**
1955
+ * 从仓库 URL / SSH 地址 / owner/repo 解析平台、owner、repo。
1956
+ * @param {string} raw - https://github.com/owner/repo | git@github.com:owner/repo.git | owner/repo
1957
+ * @returns {{provider:'github'|'gitee', owner:string, repo:string}|{}}
1958
+ */
1959
+ function parseRepoUrl(raw) {
1960
+ const url = String(raw || '').trim()
1961
+ if (!url) return {}
1962
+ const gh = /github\.com[/:]([^/\s?#]+)\/([^/\s?#]+)/i.exec(url)
1963
+ if (gh) return { provider: 'github', owner: gh[1], repo: gh[2].replace(/\.git$/i, '') }
1964
+ const gt = /gitee\.com[/:]([^/\s?#]+)\/([^/\s?#]+)/i.exec(url)
1965
+ if (gt) return { provider: 'gitee', owner: gt[1], repo: gt[2].replace(/\.git$/i, '') }
1966
+ const bare = /^([\w.-]+)\/([\w.-]+)$/.exec(url)
1967
+ if (bare) return { provider: 'github', owner: bare[1], repo: bare[2].replace(/\.git$/i, '') }
1968
+ return {}
1969
+ }
1970
+
1971
+ /** 某仓库名在 baseDir 下是否已有同名 git 仓库(含 baseDir 本身就是同名仓库的情形,
1972
+ * 如工作区 C:\Users\xxx\workspace 即对应仓库 workspace)。 */
1973
+ function localRepoExists(baseDir, name) {
1974
+ try {
1975
+ if (!baseDir || !name) return false
1976
+ // baseDir/<name>/.git 存在
1977
+ if (existsSync(join(baseDir, name)) && existsSync(join(baseDir, name, '.git'))) return true
1978
+ // baseDir 本身就是同名仓库
1979
+ if (baseName(baseDir) === name && existsSync(join(baseDir, '.git'))) return true
1980
+ return false
1981
+ } catch { return false }
1982
+ }
1983
+
1984
+ /**
1985
+ * 判断仓库在「当前选中的克隆目录」或「任何已登记的工作区/自定义目录」下是否已存在。
1986
+ * 用于 ④ 克隆仓库的「本地已有」标记:不遍历全盘,只查 ③代码管理登记过的目录——
1987
+ * 用户若把克隆目录选到其他(已登记)位置,那里的同名仓库同样算「本地已有」。
1988
+ * @param {string} name - 仓库名
1989
+ * @param {string} [chosenDir] - 当前下拉选中的克隆目标目录(可为空)
1990
+ */
1991
+ function localRepoExistsAnywhere(name, chosenDir) {
1992
+ try {
1993
+ if (!name) return false
1994
+ if (localRepoExists(String(chosenDir || '').trim(), name)) return true
1995
+ for (const dir of listWorkspaces()) {
1996
+ if (localRepoExists(dir, name)) return true
1997
+ }
1998
+ return false
1999
+ } catch { return false }
2000
+ }
2001
+
2002
+ /**
2003
+ * 列出已登录账号(GitHub / Gitee)的所有远程仓库,并标记本地是否已存在。
2004
+ * @param {string} [provider] - 'github' (default) | 'gitee'
2005
+ * @param {string} [baseDir] - 用于「本地是否已有」判定的目录(默认克隆位置)。
2006
+ */
2007
+ async function listRemoteRepos(provider, baseDir) {
2008
+ const p = provider === 'gitee' ? 'gitee' : 'github'
2009
+ const base = baseDir || cloneDefaultDir()
2010
+ if (p === 'gitee') {
2011
+ const owner = giteeOwner()
2012
+ if (!owner) return { ok: true, provider: 'gitee', owner: undefined, repos: [], error: tr('需要先配置 Gitee 私人令牌') }
2013
+ // 分页拉取账号下所有仓库(每页 100,避免 20/页的默认截断遗漏)。
2014
+ const allRepos = []
2015
+ let page = 1
2016
+ while (page <= 10) {
2017
+ const api = giteeApi('GET', 'user/repos?per_page=100&page=' + page)
2018
+ if (!api.ok) {
2019
+ if (allRepos.length === 0) {
2020
+ return { ok: true, provider: 'gitee', owner, repos: [], error: api.error || tr('Gitee API 请求失败(需要 curl)') }
2021
+ }
2022
+ break
2023
+ }
2024
+ if (!Array.isArray(api.data)) break
2025
+ allRepos.push(...api.data)
2026
+ if (api.data.length < 100) break
2027
+ page++
2028
+ }
2029
+ const repos = allRepos.map((r) => ({
2030
+ name: String(r.name || ''),
2031
+ fullName: String(r.full_name || ''),
2032
+ owner,
2033
+ visibility: r.private === true ? 'private' : (r.private === false ? 'public' : undefined),
2034
+ description: typeof r.description === 'string' ? r.description : undefined,
2035
+ sshUrl: typeof r.ssh_url === 'string' ? r.ssh_url : undefined,
2036
+ htmlUrl: typeof r.html_url === 'string' ? r.html_url : undefined,
2037
+ local: localRepoExistsAnywhere(String(r.name || ''), base),
2038
+ }))
2039
+ return { ok: true, provider: 'gitee', owner, repos }
2040
+ }
2041
+ const owner = ghOwner()
2042
+ if (!owner) return { ok: true, provider: 'github', owner: undefined, repos: [], error: tr('需要先登录 GitHub CLI(gh)') }
2043
+ const r = run(GH, ['repo', 'list', owner, '--limit', '1000', '--json', 'name,owner,visibility,description,updatedAt,sshUrl'], { timeout: 60_000 })
2044
+ let parsed = []
2045
+ if (r.ok) {
2046
+ try {
2047
+ const arr = JSON.parse(r.stdout)
2048
+ if (Array.isArray(arr)) {
2049
+ parsed = arr.map((x) => ({
2050
+ name: String(x.name || ''),
2051
+ fullName: String((x.owner && x.owner.login ? x.owner.login + '/' : '') + (x.name || '')),
2052
+ owner: x.owner && x.owner.login,
2053
+ visibility: String(x.visibility || 'private').toLowerCase(),
2054
+ description: typeof x.description === 'string' ? x.description : undefined,
2055
+ sshUrl: typeof x.sshUrl === 'string' ? x.sshUrl : `git@github.com:${owner}/${x.name}.git`,
2056
+ htmlUrl: `https://github.com/${owner}/${x.name}`,
2057
+ updatedAt: typeof x.updatedAt === 'string' ? x.updatedAt : undefined,
2058
+ }))
2059
+ }
2060
+ } catch { /* ignore */ }
2061
+ }
2062
+ return { ok: true, provider: 'github', owner, repos: parsed.map((x) => ({ ...x, local: localRepoExistsAnywhere(x.name, base) })) }
2063
+ }
2064
+
2065
+ /**
2066
+ * 执行 git clone:克隆到 dest(默认=默认工作区)下的 <name> 目录。成功后加入自定义目录列表。
2067
+ * @param {string} url - 远程仓库 URL(SSH 或 HTTPS)。
2068
+ * @param {string} [dest] - 目标父目录,空=默认克隆位置。
2069
+ * @param {string} [name] - 本地目录名,空=从 URL 推导。
2070
+ */
2071
+ function cloneFlow(url, dest, name) {
2072
+ const target = String(dest || '').trim() || cloneDefaultDir()
2073
+ const repoName = String(name || '').trim()
2074
+ const full = repoName ? join(target, repoName) : ''
2075
+ if (!repoName) return { ok: false, error: tr('缺少仓库名称') }
2076
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
2077
+ return { ok: false, error: tr('目标目录不存在:') + target }
2078
+ }
2079
+ if (existsSync(full)) {
2080
+ return { ok: false, error: tr('已存在同名目录:') + full }
2081
+ }
2082
+ const r = run(GIT, ['clone', String(url || '').trim(), full], { timeout: 600_000, env: { GIT_SSH: WORKING_SSH } })
2083
+ if (!r.ok) {
2084
+ return { ok: false, error: (r.stderr || r.stdout || '').trim() || tr('git clone 失败') }
2085
+ }
2086
+ saveCustomDir(full)
2087
+ return { ok: true, dir: full, name: repoName }
2088
+ }
2089
+
2090
+ // ---------- ⑤ 提交 PR(Pull Request) ----------
2091
+
2092
+ /** 用户填写的 PR 目标仓库列表(记录到 ~/.dsh/storages,不写入插件目录)。 */
2093
+ function prTargetsFile() {
2094
+ return join(homedir(), '.dsh', 'storages', 'source-code-mgmt-pr-targets.json')
2095
+ }
2096
+ function readPrTargets() {
2097
+ try {
2098
+ const f = prTargetsFile()
2099
+ if (!existsSync(f)) return []
2100
+ const data = JSON.parse(readFileSync(f, 'utf8'))
2101
+ return Array.isArray(data) ? data : []
2102
+ } catch { return [] }
2103
+ }
2104
+ function savePrTarget(url) {
2105
+ const { provider, owner, repo } = parseRepoUrl(url)
2106
+ if (!owner || !repo) return readPrTargets()
2107
+ const list = readPrTargets()
2108
+ const clean = list.filter((t) => String(t.url || '').trim().toLowerCase() !== String(url).trim().toLowerCase())
2109
+ clean.unshift({ url: String(url).trim(), provider, owner, repo, addedAt: new Date().toISOString() })
2110
+ try {
2111
+ const f = prTargetsFile()
2112
+ mkdirSync(dirname(f), { recursive: true })
2113
+ writeFileSync(f, JSON.stringify(clean.slice(0, 50), null, 2), 'utf8')
2114
+ } catch { /* ignore */ }
2115
+ return clean
2116
+ }
2117
+ function removePrTarget(url) {
2118
+ const list = readPrTargets().filter((t) => String(t.url || '').trim().toLowerCase() !== String(url || '').trim().toLowerCase())
2119
+ try {
2120
+ const f = prTargetsFile()
2121
+ mkdirSync(dirname(f), { recursive: true })
2122
+ writeFileSync(f, JSON.stringify(list, null, 2), 'utf8')
2123
+ } catch { /* ignore */ }
2124
+ return list
2125
+ }
2126
+
2127
+ /** 插件根目录(用于存放 AI 思考出的 PR 规则缓存)。 */
2128
+ function pluginRoot() {
2129
+ return dirname(dirname(fileURLToPath(import.meta.url)))
2130
+ }
2131
+ function prRulesDir() {
2132
+ return join(pluginRoot(), 'rules')
2133
+ }
2134
+ function prRulePath(owner, repo) {
2135
+ return join(prRulesDir(), owner + '__' + repo + '.json')
2136
+ }
2137
+ function readPrRule(owner, repo) {
2138
+ try {
2139
+ const p = prRulePath(owner, repo)
2140
+ if (!existsSync(p)) return undefined
2141
+ const data = JSON.parse(readFileSync(p, 'utf8'))
2142
+ return data && typeof data === 'object' ? data : undefined
2143
+ } catch { return undefined }
2144
+ }
2145
+ function writePrRule(owner, repo, rule) {
2146
+ try {
2147
+ mkdirSync(prRulesDir(), { recursive: true })
2148
+ writeFileSync(prRulePath(owner, repo), JSON.stringify(rule, null, 2), 'utf8')
2149
+ return true
2150
+ } catch { return false }
2151
+ }
2152
+
2153
+ /** 从 { 到 } 尽量提取 JSON(AI 可能包裹 markdown 代码块)。 */
2154
+ function parseJsonLoose(text) {
2155
+ const s = String(text || '').trim()
2156
+ try { return JSON.parse(s) } catch { /* continue */ }
2157
+ const start = s.indexOf('{')
2158
+ const end = s.lastIndexOf('}')
2159
+ if (start === -1 || end <= start) return null
2160
+ try { return JSON.parse(s.slice(start, end + 1)) } catch { return null }
2161
+ }
2162
+
2163
+ /** 读取 GitHub 仓库内某文件的文本内容(gh api),失败返回 undefined。 */
2164
+ function ghFileText(owner, repo, path) {
2165
+ const r = run(GH, ['api', 'repos/' + owner + '/' + repo + '/contents/' + path, '--jq', '.content'], { timeout: 30_000 })
2166
+ if (!r.ok) return undefined
2167
+ try {
2168
+ const b64 = r.stdout.trim()
2169
+ if (!b64) return undefined
2170
+ return Buffer.from(b64, 'base64').toString('utf8')
2171
+ } catch { return undefined }
2172
+ }
2173
+
2174
+ /** 抓取目标仓库的关键文档(README / CONTRIBUTING / PR 模板 / package.json scripts),供 AI 分析 PR 规则。 */
2175
+ async function gatherRepoDocs(provider, owner, repo) {
2176
+ const docs = {}
2177
+ const candidates = [
2178
+ 'README.md', 'CONTRIBUTING.md', '.github/CONTRIBUTING.md',
2179
+ '.github/PULL_REQUEST_TEMPLATE.md', '.github/pull_request_template.md', 'package.json',
2180
+ ]
2181
+ for (const c of candidates) {
2182
+ if (provider === 'gitee') {
2183
+ const api = giteeApi('GET', 'repos/' + owner + '/' + repo + '/contents/' + c)
2184
+ if (api.ok && api.data && typeof api.data.content === 'string') {
2185
+ try { docs[c] = Buffer.from(api.data.content, 'base64').toString('utf8') } catch {}
2186
+ }
2187
+ } else {
2188
+ const txt = ghFileText(owner, repo, c)
2189
+ if (txt !== undefined) docs[c] = txt
2190
+ }
2191
+ }
2192
+ return docs
2193
+ }
2194
+
2195
+ /** AI 思考 PR 规则的 system 提示词:要求输出结构化 JSON(步骤数组)。 */
2196
+ const PR_RULE_SYSTEM = [
2197
+ '你是一个开源仓库 PR 流程分析器。用户要往一个 GitHub/Gitee 仓库提交 Pull Request(提交内容通常是「把某个 DSH 插件收录进该仓库的插件列表 / 或按该仓库规则提交一份变更」)。',
2198
+ '请先分析该仓库的 PR 规则(基于 README / CONTRIBUTING / PR 模板 / package.json scripts),然后输出一个 JSON(不要任何多余文字、不要 markdown 代码块,直接输出 JSON 对象):',
2199
+ '{',
2200
+ ' "baseBranch": "上游默认分支,如 main 或 master",',
2201
+ ' "entryFilePath": "新增条目文件的相对路径(若适用;如 data/plugins/owner__repo.yml),否则 null",',
2202
+ ' "entryTemplate": "条目文件的内容模板(含占位符,如 {NAME} {DESCRIPTION}),若适用;否则 null",',
2203
+ ' "regenerateCommands": ["重生成 README 的命令数组(如 [\\"node\\", \\"scripts/generate-readme.mjs\\"]),没有则为 []"],',
2204
+ ' "prTitle": "PR 标题建议,如 Add owner/repo",',
2205
+ ' "prBodyHint": "PR 描述应包含的要点(中文一句话概括)",',
2206
+ ' "steps": [',
2207
+ ' { "type": "ensure-fork" },',
2208
+ ' { "type": "clone-fork" },',
2209
+ ' { "type": "add-upstream" },',
2210
+ ' { "type": "fetch-upstream" },',
2211
+ ' { "type": "branch", "name": "add-<插件名>" },',
2212
+ ' { "type": "write-entry" },',
2213
+ ' { "type": "regenerate-readme" },',
2214
+ ' { "type": "commit", "message": "Add owner/repo" },',
2215
+ ' { "type": "push" },',
2216
+ ' { "type": "create-pr" }',
2217
+ ' ]',
2218
+ '}',
2219
+ '要求:steps 按真实执行顺序排列,只包含上面列出的 type;不确定的字段给 null 或空数组,不要编造。',
2220
+ ].join('\n')
2221
+
2222
+ /**
2223
+ * 用户已提供规则(手动文本或规则文件)时的 system 提示词:忠实整理为结构化 JSON,
2224
+ * 不额外抓取仓库文档、不编造——精简提示词以节省 token。
2225
+ */
2226
+ const PR_RULE_SYSTEM_USER = [
2227
+ '你是一个开源仓库 PR 流程规则整理器。用户已直接提供该仓库的 PR 规则(手写文本或规则文件,如 README / CONTRIBUTING)。',
2228
+ '请把用户提供的规则忠实整理成下面的 JSON(不要任何多余文字、不要 markdown 代码块,直接输出 JSON 对象):',
2229
+ '{',
2230
+ ' "baseBranch": "上游默认分支,如 main 或 master",',
2231
+ ' "entryFilePath": "新增条目文件的相对路径(若适用;如 data/plugins/owner__repo.yml),否则 null",',
2232
+ ' "entryTemplate": "条目文件的内容模板(含占位符,如 {NAME} {DESCRIPTION}),若适用;否则 null",',
2233
+ ' "regenerateCommands": ["重生成 README 的命令数组(如 [\\"node\\", \\"scripts/generate-readme.mjs\\"]),没有则为 []"],',
2234
+ ' "prTitle": "PR 标题建议,如 Add owner/repo",',
2235
+ ' "prBodyHint": "PR 描述应包含的要点(中文一句话概括)",',
2236
+ ' "steps": [',
2237
+ ' { "type": "ensure-fork" },',
2238
+ ' { "type": "clone-fork" },',
2239
+ ' { "type": "add-upstream" },',
2240
+ ' { "type": "fetch-upstream" },',
2241
+ ' { "type": "branch", "name": "add-<插件名>" },',
2242
+ ' { "type": "write-entry" },',
2243
+ ' { "type": "regenerate-readme" },',
2244
+ ' { "type": "commit", "message": "Add owner/repo" },',
2245
+ ' { "type": "push" },',
2246
+ ' { "type": "create-pr" }',
2247
+ ' ]',
2248
+ '}',
2249
+ '要求:steps 按真实执行顺序排列,只包含上面列出的 type;只依据用户提供的规则整理,不要额外假设、不要编造用户没提到的字段。',
2250
+ ].join('\n')
2251
+
2252
+ /**
2253
+ * 解析用户提供的规则来源:ruleText(手写文本)或 ruleFile(本地路径 / http(s) URL)。
2254
+ * @param {string} [ruleText] - 用户手动填写的 PR 规则文本。
2255
+ * @param {string} [ruleFile] - 规则文件路径或 URL(如某仓库 README)。
2256
+ * @returns {Promise<{text?:string, error?:string}>} 读取到的文本或错误。
2257
+ */
2258
+ async function resolveRuleInput(ruleText, ruleFile) {
2259
+ const text = String(ruleText || '').trim()
2260
+ if (text) return { text }
2261
+ const file = String(ruleFile || '').trim()
2262
+ if (!file) return {}
2263
+ if (/^https?:\/\//i.test(file)) {
2264
+ // 远程 URL:优先 Node 全局 fetch,失败回退 curl。
2265
+ try {
2266
+ if (typeof fetch === 'function') {
2267
+ const ctrl = new AbortController()
2268
+ const timer = setTimeout(() => ctrl.abort(), 20000)
2269
+ try {
2270
+ const resp = await fetch(file, { redirect: 'follow', signal: ctrl.signal })
2271
+ if (resp.ok) {
2272
+ const body = await resp.text()
2273
+ if (body) return { text: body }
2274
+ }
2275
+ } finally { clearTimeout(timer) }
2276
+ }
2277
+ } catch { /* 继续尝试 curl */ }
2278
+ const r = run('curl', ['-sL', '--max-time', '20', file], { timeout: 30000 })
2279
+ if (r.ok && r.stdout) return { text: r.stdout }
2280
+ return { error: tr('规则文件获取失败:') + file }
2281
+ }
2282
+ if (existsSync(file)) {
2283
+ try { return { text: readFileSync(file, 'utf8') } }
2284
+ catch (e) { return { error: tr('规则文件读取失败:') + String((e && e.message) || e) } }
2285
+ }
2286
+ return { error: tr('找不到规则文件:') + file }
2287
+ }
2288
+
2289
+ /**
2290
+ * 分析某仓库的 PR 规则:命中插件目录 rules/ 缓存则直接返回;否则抓取仓库文档(或用户提供的
2291
+ * 规则文本/文件)→ LLM 思考 → 保存缓存。
2292
+ * @param {*} ctx - cordis context。
2293
+ * @param {{url: string, force?: boolean, ruleText?: string, ruleFile?: string}} input -
2294
+ * 目标仓库 URL;force=true 忽略缓存重新分析;ruleText/ruleFile 为用户提供的规则来源(可选)。
2295
+ */
2296
+ async function prAnalyzeFlow(ctx, { url, force, ruleText, ruleFile }) {
2297
+ const { provider, owner, repo } = parseRepoUrl(url)
2298
+ if (!owner || !repo) return { ok: false, error: tr('无法识别仓库地址:') + String(url || '') }
2299
+ // 用户提供了规则(文本或文件)→ 视为新的分析输入,忽略缓存直接分析。
2300
+ const hasUserInput = !!String(ruleText || '').trim() || !!String(ruleFile || '').trim()
2301
+ if (!force && !hasUserInput) {
2302
+ const cached = readPrRule(owner, repo)
2303
+ if (cached) return { ok: true, cached: true, provider, owner, repo, rule: cached }
2304
+ }
2305
+ let userRule = ''
2306
+ if (hasUserInput) {
2307
+ const r = await resolveRuleInput(ruleText, ruleFile)
2308
+ if (r.error) return { ok: false, error: r.error }
2309
+ userRule = String(r.text || '').trim()
2310
+ if (!userRule) return { ok: false, error: tr('未获取到规则内容(文件为空或无法读取)') }
2311
+ }
2312
+ let system, messages, docSources
2313
+ if (userRule) {
2314
+ system = PR_RULE_SYSTEM_USER
2315
+ messages = [{
2316
+ role: 'user',
2317
+ text: '目标仓库:' + provider + ' ' + owner + '/' + repo + '\n\n用户提供的该仓库 PR 规则(请忠实整理,不要编造):\n' + userRule.slice(0, 20000),
2318
+ }]
2319
+ docSources = []
2320
+ } else {
2321
+ const docs = await gatherRepoDocs(provider, owner, repo)
2322
+ system = PR_RULE_SYSTEM
2323
+ messages = [{
2324
+ role: 'user',
2325
+ text: '目标仓库:' + provider + ' ' + owner + '/' + repo + '\n\n仓库关键文档(尽力抓取,可能不全):\n'
2326
+ + (Object.keys(docs).length ? JSON.stringify(docs, null, 2).slice(0, 12000) : '(无可用文档)'),
2327
+ }]
2328
+ docSources = Object.keys(docs)
2329
+ }
2330
+ const res = await llmComplete(ctx, { system, messages, maxTokens: 3000, temperature: 0.3 })
2331
+ if (!res.ok) return res
2332
+ const rule = parseJsonLoose(res.text)
2333
+ if (!rule || typeof rule !== 'object') return { ok: false, error: tr('AI 未输出有效规则') }
2334
+ if (!Array.isArray(rule.steps)) rule.steps = []
2335
+ rule.owner = owner; rule.repo = repo; rule.provider = provider
2336
+ rule.generatedAt = new Date().toISOString()
2337
+ rule.model = res.model
2338
+ rule.docSources = docSources
2339
+ if (userRule) rule.userProvided = true
2340
+ writePrRule(owner, repo, rule)
2341
+ return { ok: true, cached: false, provider, owner, repo, rule, model: res.model }
2342
+ }
2343
+
2344
+ /**
2345
+ * 用 AI 生成 PR 内容(标题 + 描述 + 条目文件内容)。生成后由用户在 UI 里修改,不直接提交。
2346
+ * 当规则自带 entryTemplate 且插件信息齐全时,直接按模板本地填充(零 AI 消耗、可编辑);
2347
+ * 否则调用 LLM 生成。
2348
+ * @param {*} ctx - cordis context。
2349
+ * @param {{url: string, rule?: object, pluginInfo?: object}} input
2350
+ */
2351
+ async function prGenerateFlow(ctx, { url, rule, pluginInfo }) {
2352
+ const { owner, repo } = parseRepoUrl(url)
2353
+ if (!owner || !repo) return { ok: false, error: tr('无法识别仓库地址:') + String(url || '') }
2354
+ const info = (pluginInfo && typeof pluginInfo === 'object') ? pluginInfo : {}
2355
+ const vars = { owner, repo, info }
2356
+
2357
+ // 模板直填路径:规则带 entryTemplate 时按 {VAR} 占位符本地生成(零 AI,仍可编辑)。
2358
+ if (rule && typeof rule.entryTemplate === 'string' && rule.entryTemplate !== '') {
2359
+ const entry = fillPrTemplate(rule.entryTemplate, vars)
2360
+ const title = fillPrTemplate(String(rule.prTitle || 'Add {NAME}'), vars) || ('Add ' + owner + '/' + repo)
2361
+ const body = fillPrTemplate(String(rule.prBodyHint || ''), vars)
2362
+ return {
2363
+ ok: true,
2364
+ title,
2365
+ body,
2366
+ entry,
2367
+ fromTemplate: true,
2368
+ provider: rule.provider || undefined,
2369
+ model: rule.model || 'template',
2370
+ }
2371
+ }
2372
+
2373
+ const system = [
2374
+ '你是 PR 内容生成助手。下面给出目标仓库的 PR 规则与用户要提交的插件信息,请生成三样内容并输出 JSON:',
2375
+ '{ "title": "PR 标题", "body": "PR 描述(markdown,说明改动内容、已满足的前置条件)", "entry": "条目文件内容(若规则有 entryFilePath/entryTemplate 则按模板生成;否则 null)" }',
2376
+ '直接输出 JSON,不要 markdown 代码块,不要多余文字。',
2377
+ ].join('\n')
2378
+ const messages = [{
2379
+ role: 'user',
2380
+ text: '目标仓库规则:\n' + JSON.stringify(rule || {}, null, 2) + '\n\n用户要提交的插件信息:\n' + JSON.stringify(info, null, 2),
2381
+ }]
2382
+ const res = await llmComplete(ctx, { system, messages, maxTokens: 3000, temperature: 0.5 })
2383
+ if (!res.ok) return res
2384
+ const parsed = parseJsonLoose(res.text)
2385
+ if (!parsed || typeof parsed !== 'object') return { ok: false, error: tr('AI 输出无法解析') }
2386
+ return {
2387
+ ok: true,
2388
+ title: String(parsed.title || ''),
2389
+ body: String(parsed.body || ''),
2390
+ entry: parsed.entry == null ? null : String(parsed.entry),
2391
+ provider: res.provider,
2392
+ model: res.model,
2393
+ }
2394
+ }
2395
+
2396
+ /** 当前分支名(git rev-parse --abbrev-ref HEAD),失败回退 'main'。 */
2397
+ function currentBranch(dir) {
2398
+ const r = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
2399
+ return r.ok && r.stdout.trim() && r.stdout.trim() !== 'HEAD' ? r.stdout.trim() : 'main'
2400
+ }
2401
+
2402
+ /**
2403
+ * 校验 PR 规则里的条目文件路径:必须是仓库内相对路径,且解析后不得逃出 cloneDir
2404
+ * (防路径穿越 —— 规则来自 AI/缓存,可能被恶意仓库文档诱导出 "../.." 路径)。
2405
+ * @param {string} cloneDir - fork 克隆目录。
2406
+ * @param {string} p - 规则给出的 entryFilePath。
2407
+ * @returns {boolean} 安全则 true。
2408
+ */
2409
+ function safeEntryPath(cloneDir, p) {
2410
+ if (typeof p !== 'string' || p === '') return false
2411
+ // 绝对路径(Windows 盘符 / POSIX 根 / 反斜杠)一律拒绝。
2412
+ if (/^([a-zA-Z]:[\\/]|\/|\\)/.test(p)) return false
2413
+ // 任何 .. 段(含 \..\、/../、..\ 前缀、../ 前缀)一律拒绝。
2414
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(p)) return false
2415
+ const root = join(cloneDir, p)
2416
+ const base = cloneDir.replace(/[\\/]+$/, '')
2417
+ return root === base || root.startsWith(base + '\\') || root.startsWith(base + '/')
2418
+ }
2419
+
2420
+ /**
2421
+ * 校验 PR 规则里的重生成命令:只允许常见构建/脚本命令 + 纯参数,拒绝绝对路径、
2422
+ * 环境变量展开、shell 元字符、重定向与明显危险的命令名。命令本身仍是数组传递
2423
+ * (execFileSync 不经过 shell),此校验是纵深防御,防止 AI 规则注入恶意命令。
2424
+ * @param {string[]} cmds - [bin, ...args]
2425
+ * @returns {boolean}
2426
+ */
2427
+ const SAFE_REGENERATE_BINS = new Set([
2428
+ 'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun',
2429
+ 'python', 'python3', 'py',
2430
+ 'make', 'cmake',
2431
+ 'sh', 'bash', 'zsh',
2432
+ 'ruby', 'perl', 'php',
2433
+ 'dotnet', 'go', 'cargo', 'rustc',
2434
+ 'git', 'awk', 'sed', 'grep', 'cat',
2435
+ ])
2436
+ // 参数不允许 shell 元字符 / 重定向 / 命令替换。命令名已由白名单约束(不经 shell,
2437
+ // 数组传递),此处只拦会被 shell 语义扩展、可能被后续误用的字符。
2438
+ const SHELL_META_PATTERN = /[|&;<>`$]/
2439
+ function safeRegenerateCommand(cmds) {
2440
+ // 兼容两种形态:单条 [bin, ...args] 或多条 [[bin,...],[bin,...]](顺序执行)。
2441
+ const list = (Array.isArray(cmds) && cmds.length > 0 && Array.isArray(cmds[0])) ? cmds : [cmds]
2442
+ for (const c of list) {
2443
+ if (!Array.isArray(c) || c.length === 0) return false
2444
+ const bin = String(c[0] || '').trim()
2445
+ if (bin === '') return false
2446
+ // 命令名必须是裸名称(不含路径分隔符),且在白名单内。
2447
+ if (/[\\/]/.test(bin)) return false
2448
+ if (!SAFE_REGENERATE_BINS.has(bin)) return false
2449
+ // 参数不允许 shell 元字符 / 重定向。
2450
+ for (let i = 1; i < c.length; i++) {
2451
+ const a = String(c[i] ?? '')
2452
+ if (SHELL_META_PATTERN.test(a)) return false
2453
+ }
2454
+ }
2455
+ return true
2456
+ }
2457
+
2458
+ /**
2459
+ * 用 {VAR} 占位符填充规则模板(OWNER/REPO/NAME/CATEGORY/DESC_EN/DESC_ZH)。
2460
+ * @param {string} tpl
2461
+ * @param {{owner:string,repo:string,info:object}} vars
2462
+ */
2463
+ function fillPrTemplate(tpl, vars) {
2464
+ const map = {
2465
+ OWNER: vars.owner,
2466
+ REPO: vars.repo,
2467
+ NAME: String(vars.info && vars.info.name ? vars.info.name : vars.owner + '/' + vars.repo),
2468
+ CATEGORY: String(vars.info && vars.info.category ? vars.info.category : ''),
2469
+ DESC_EN: String(vars.info && vars.info.descriptionEn ? vars.info.descriptionEn : ''),
2470
+ DESC_ZH: String(vars.info && vars.info.descriptionZh ? vars.info.descriptionZh : ''),
2471
+ }
2472
+ return String(tpl ?? '').replace(/\{(OWNER|REPO|NAME|CATEGORY|DESC_EN|DESC_ZH)\}/g, (_, k) => map[k] ?? '')
2473
+ }
2474
+
2475
+ /**
2476
+ * 预置规则:awesome-dsh-plugin 插件收录 PR(依据 提示词.md §2 流程)。
2477
+ * 写入插件目录 rules/awesome-dsh-plugin__awesome-dsh-plugin.json;已存在则不覆盖
2478
+ * (用户/AI 更新过的规则优先)。
2479
+ */
2480
+ const PRESET_PR_RULES = [{
2481
+ owner: 'awesome-dsh-plugin',
2482
+ repo: 'awesome-dsh-plugin',
2483
+ rule: {
2484
+ provider: 'github',
2485
+ owner: 'awesome-dsh-plugin',
2486
+ repo: 'awesome-dsh-plugin',
2487
+ baseBranch: 'main',
2488
+ entryFilePath: 'data/plugins/{OWNER}__{REPO}.yml',
2489
+ entryTemplate: [
2490
+ 'url: https://github.com/{OWNER}/{REPO}',
2491
+ 'name: {NAME}',
2492
+ 'category: {CATEGORY}',
2493
+ 'description:',
2494
+ " en: '{DESC_EN}'",
2495
+ " zh: '{DESC_ZH}'",
2496
+ '',
2497
+ ].join('\n'),
2498
+ regenerateCommands: [['npm', 'ci'], ['node', 'scripts/generate-readme.mjs']],
2499
+ prTitle: 'Add {NAME}',
2500
+ prBodyHint: '新增条目文件 data/plugins/{OWNER}__{REPO}.yml;已运行重生成命令更新 README;仓库已满足 dsh.bundle 声明、满 1 天、提交数 ≥10、dsh-plugin topic;描述已对照源码核实。',
2501
+ steps: [
2502
+ { type: 'ensure-fork' },
2503
+ { type: 'clone-fork' },
2504
+ { type: 'add-upstream' },
2505
+ { type: 'fetch-upstream' },
2506
+ { type: 'branch', name: 'add-{REPO}' },
2507
+ { type: 'write-entry' },
2508
+ { type: 'regenerate-readme' },
2509
+ { type: 'commit', message: 'Add {NAME}' },
2510
+ { type: 'push' },
2511
+ { type: 'create-pr' },
2512
+ ],
2513
+ preset: true,
2514
+ generatedAt: '2026-09 (preset)',
2515
+ model: 'preset',
2516
+ },
2517
+ }]
2518
+
2519
+ /** 启动时把预置规则写入插件目录 rules/(仅当对应文件不存在)。 */
2520
+ function seedPresetPrRules() {
2521
+ for (const p of PRESET_PR_RULES) {
2522
+ if (readPrRule(p.owner, p.repo)) continue
2523
+ writePrRule(p.owner, p.repo, p.rule)
2524
+ }
2525
+ }
2526
+
2527
+ /**
2528
+ * 按规则执行 PR:fork → 克隆 fork → 加 upstream → 拉取 → 建分支 → 写条目 → 重生成 README →
2529
+ * 提交 → 推送 → 开 PR(GitHub 走 gh,Gitee 走 OpenAPI)。
2530
+ * @param {*} ctx - cordis context。
2531
+ * @param {{url: string, workdir?: string, rule?: object, entryContent?: string, title?: string, body?: string, base?: string}} input
2532
+ */
2533
+ async function prExecuteFlow(ctx, { url, workdir, rule, entryContent, title, body, base }) {
2534
+ const { provider, owner, repo } = parseRepoUrl(url)
2535
+ if (!owner || !repo) return { ok: false, error: tr('无法识别仓库地址:') + String(url || '') }
2536
+ const steps = rule && Array.isArray(rule.steps) ? rule.steps : []
2537
+ if (steps.length === 0) return { ok: false, error: tr('没有可用的 PR 步骤(请先让 AI 分析规则)') }
2538
+ const work = String(workdir || '').trim() || cloneDefaultDir()
2539
+ const cloneDir = join(work, repo)
2540
+ const upstreamUrl = provider === 'gitee'
2541
+ ? 'https://gitee.com/' + owner + '/' + repo + '.git'
2542
+ : 'https://github.com/' + owner + '/' + repo + '.git'
2543
+ const baseBranch = base || (rule && rule.baseBranch) || 'main'
2544
+ const logs = []
2545
+ const stepRun = (label, cmd, args, opts = {}) => {
2546
+ const r = run(cmd, args, { ...opts, timeout: opts.timeout ?? 300_000, cwd: opts.cwd || cloneDir, env: { ...process.env, GIT_SSH: WORKING_SSH, ...(opts.env || {}) } })
2547
+ logs.push({ label, ok: r.ok, output: ((r.stdout || '') + (r.stderr || '')).trim() })
2548
+ return r
2549
+ }
2550
+ for (const s of steps) {
2551
+ const type = s && typeof s === 'object' ? String(s.type || '') : String(s || '')
2552
+ switch (type) {
2553
+ case 'ensure-fork': {
2554
+ if (provider === 'gitee') {
2555
+ const o = giteeOwner()
2556
+ if (!o) return { ok: false, logs, error: tr('需要先配置 Gitee 私人令牌') }
2557
+ const api = giteeApi('POST', 'repos/' + owner + '/' + repo + '/forks')
2558
+ logs.push({ label: tr('确保 fork'), ok: api.ok || giteeApiFailed(api.data) === false, output: (api.error || tr('已 fork 或 fork 已存在')) })
2559
+ } else {
2560
+ const fr = run(GH, ['repo', 'fork', owner + '/' + repo, '--remote=false'], { timeout: 120_000, env: { GIT_SSH: WORKING_SSH } })
2561
+ const blob = ((fr.stdout || '') + (fr.stderr || '')).trim()
2562
+ // gh repo fork 在 fork 已存在时以非零退出并提示 "already exists",视为成功;
2563
+ // 其他失败(网络/权限)才记为失败。
2564
+ const already = /already exists/i.test(blob)
2565
+ logs.push({ label: tr('确保 fork'), ok: fr.ok || already, output: blob || tr('已 fork 或 fork 已存在') })
2566
+ }
2567
+ break
2568
+ }
2569
+ case 'clone-fork': {
2570
+ if (existsSync(cloneDir) && existsSync(join(cloneDir, '.git'))) {
2571
+ logs.push({ label: tr('克隆 fork'), ok: true, output: tr('已存在本地副本:') + cloneDir })
2572
+ } else {
2573
+ const me = provider === 'gitee' ? (giteeOwner() || owner) : (ghOwner() || owner)
2574
+ const forkUrl = provider === 'gitee'
2575
+ ? ('git@gitee.com:' + me + '/' + repo + '.git')
2576
+ : ('git@github.com:' + me + '/' + repo + '.git')
2577
+ const r = run(GIT, ['clone', forkUrl, cloneDir], { timeout: 600_000, env: { GIT_SSH: WORKING_SSH } })
2578
+ logs.push({ label: tr('克隆 fork'), ok: r.ok, output: ((r.stdout || '') + (r.stderr || '')).trim() || tr('已克隆到 ') + cloneDir })
2579
+ if (!r.ok) return { ok: false, logs, error: tr('克隆 fork 失败') }
2580
+ }
2581
+ break
2582
+ }
2583
+ case 'add-upstream': {
2584
+ let r = stepRun(tr('添加上游'), GIT, ['remote', 'add', 'upstream', upstreamUrl])
2585
+ if (!r.ok) r = stepRun(tr('添加上游'), GIT, ['remote', 'set-url', 'upstream', upstreamUrl])
2586
+ logs[logs.length - 1].output = ((r.stdout || '') + (r.stderr || '')).trim() || upstreamUrl
2587
+ break
2588
+ }
2589
+ case 'fetch-upstream': {
2590
+ const r = stepRun(tr('拉取上游'), GIT, ['fetch', 'upstream'], { timeout: 120_000 })
2591
+ if (!r.ok) logs[logs.length - 1].output = ((r.stdout || '') + (r.stderr || '')).trim() || tr('拉取失败')
2592
+ break
2593
+ }
2594
+ case 'branch': {
2595
+ const raw = (s && s.name) || ('add-' + repo)
2596
+ const b = fillPrTemplate(String(raw), { owner, repo, info: {} })
2597
+ const r = stepRun(tr('新建分支'), GIT, ['checkout', '-B', b, 'upstream/' + baseBranch])
2598
+ if (!r.ok) {
2599
+ logs[logs.length - 1].output = ((r.stdout || '') + (r.stderr || '')).trim() || tr('创建分支失败')
2600
+ // 分支创建失败必须中止:后续 write-entry/commit/push 会在错误的当前分支上
2601
+ // 继续(例如推送到 fork 默认分支),可能造成非预期提交。
2602
+ return { ok: false, logs, error: tr('创建分支失败(上游分支可能不存在,请检查规则 baseBranch)') }
2603
+ }
2604
+ break
2605
+ }
2606
+ case 'write-entry': {
2607
+ const p = fillPrTemplate(String((s && s.path) || (rule && rule.entryFilePath) || '').trim(), { owner, repo, info: {} })
2608
+ if (!p) { logs.push({ label: tr('写入条目'), ok: false, output: tr('缺少条目文件路径') }); break }
2609
+ // 防路径穿越:条目路径必须安全落在 cloneDir 内(规则来自 AI/缓存,可能被诱导)。
2610
+ if (!safeEntryPath(cloneDir, p)) {
2611
+ logs.push({ label: tr('写入条目'), ok: false, output: tr('条目文件路径不安全(已拦截)') + ': ' + p })
2612
+ return { ok: false, logs, error: tr('条目文件路径不安全(已拦截)') }
2613
+ }
2614
+ try {
2615
+ mkdirSync(dirname(join(cloneDir, p)), { recursive: true })
2616
+ writeFileSync(join(cloneDir, p), entryContent || '', 'utf8')
2617
+ logs.push({ label: tr('写入条目'), ok: true, output: p })
2618
+ } catch (e) { logs.push({ label: tr('写入条目'), ok: false, output: String(e) }) }
2619
+ break
2620
+ }
2621
+ case 'regenerate-readme': {
2622
+ const raw = (Array.isArray(s && s.commands) ? s.commands : (rule && rule.regenerateCommands)) || []
2623
+ // 兼容两种形态:单条 [bin, ...args] 或多条 [[bin,...],[bin,...]](顺序执行)。
2624
+ const cmds = (Array.isArray(raw) && raw.length > 0 && Array.isArray(raw[0])) ? raw : [raw]
2625
+ if (cmds.length > 0) {
2626
+ // 防恶意规则:只允许白名单构建命令 + 安全参数(AI 规则可能注入任意命令)。
2627
+ if (!safeRegenerateCommand(cmds)) {
2628
+ logs.push({ label: tr('重生成 README'), ok: false, output: tr('重生成命令不安全(已拦截)') + ': ' + String(cmds[0] && cmds[0][0] || '') })
2629
+ return { ok: false, logs, error: tr('重生成命令不安全(已拦截)') }
2630
+ }
2631
+ let failed = false
2632
+ for (const c of cmds) {
2633
+ const r = stepRun(tr('重生成 README'), c[0], c.slice(1), { timeout: 180_000 })
2634
+ if (!r.ok) { logs[logs.length - 1].output = ((r.stdout || '') + (r.stderr || '')).trim() || tr('执行失败'); failed = true; break }
2635
+ }
2636
+ if (failed) return { ok: false, logs, error: tr('执行失败') }
2637
+ } else {
2638
+ logs.push({ label: tr('重生成 README'), ok: true, output: tr('无命令,跳过') })
2639
+ }
2640
+ break
2641
+ }
2642
+ case 'commit': {
2643
+ run(GIT, ['-C', cloneDir, 'add', '-A'])
2644
+ const ident = run(GIT, ['-C', cloneDir, 'config', 'user.email'])
2645
+ if (!ident.ok) {
2646
+ run(GIT, ['-C', cloneDir, 'config', 'user.name', 'DSH User'])
2647
+ run(GIT, ['-C', cloneDir, 'config', 'user.email', 'dsh@localhost'])
2648
+ }
2649
+ const msg = fillPrTemplate(String((s && s.message) || '').trim(), { owner, repo, info: {} }) || ('Add ' + owner + '/' + repo)
2650
+ const r = run(GIT, ['-C', cloneDir, 'commit', '-m', msg], { timeout: 60_000 })
2651
+ logs.push({ label: tr('提交'), ok: r.ok || /nothing to commit|no changes added/i.test(((r.stdout || '') + (r.stderr || ''))), output: ((r.stdout || '') + (r.stderr || '')).trim() || tr('已提交') })
2652
+ break
2653
+ }
2654
+ case 'push': {
2655
+ const r = stepRun(tr('推送'), GIT, ['push', '-u', 'origin', currentBranch(cloneDir)], { timeout: 180_000 })
2656
+ if (!r.ok) {
2657
+ logs[logs.length - 1].output = ((r.stdout || '') + (r.stderr || '')).trim() || tr('推送失败')
2658
+ return { ok: false, logs, error: tr('推送失败') }
2659
+ }
2660
+ break
2661
+ }
2662
+ case 'create-pr': {
2663
+ const titleTxt = String(title || '').trim()
2664
+ || fillPrTemplate(String((s && s.title) || (rule && rule.prTitle) || ''), { owner, repo, info: {} })
2665
+ || ('Add ' + owner + '/' + repo)
2666
+ const bodyTxt = String(body || '').trim()
2667
+ const branchName = currentBranch(cloneDir)
2668
+ if (provider === 'gitee') {
2669
+ const tok = readGiteeToken()
2670
+ const o = giteeOwner()
2671
+ if (!o || !tok) return { ok: false, logs, error: tr('需要先配置 Gitee 私人令牌') }
2672
+ const api = giteeApi('POST', 'repos/' + owner + '/' + repo + '/pulls', { title: titleTxt, head: o + ':' + branchName, base: baseBranch, body: bodyTxt || undefined }, tok)
2673
+ const urlM = api.ok && api.data && typeof api.data.html_url === 'string' ? api.data.html_url : undefined
2674
+ logs.push({ label: tr('创建 PR'), ok: api.ok, output: urlM || (api.error || (api.data && api.data.message) || tr('创建 PR 失败')) })
2675
+ return { ok: api.ok, logs, prUrl: urlM, error: api.ok ? undefined : (api.error || (api.data && api.data.message) || tr('创建 PR 失败')) }
2676
+ }
2677
+ const me = ghOwner() || owner
2678
+ const args = ['pr', 'create', '--repo', owner + '/' + repo, '--head', me + ':' + branchName, '--base', baseBranch, '--title', titleTxt]
2679
+ if (bodyTxt) args.push('--body', bodyTxt)
2680
+ const r = run(GH, args, { timeout: 180_000, cwd: cloneDir, env: { GIT_SSH: WORKING_SSH } })
2681
+ const blob = ((r.stdout || '') + (r.stderr || '')).trim()
2682
+ const urlM = /https:\/\/github\.com\/[^\s"]+/.exec(blob)
2683
+ logs.push({ label: tr('创建 PR'), ok: r.ok, output: blob || tr('已创建 PR') })
2684
+ return { ok: r.ok, logs, prUrl: urlM ? urlM[0] : undefined, error: r.ok ? undefined : (blob || tr('创建 PR 失败')) }
2685
+ }
2686
+ default:
2687
+ logs.push({ label: type || tr('未知步骤'), ok: false, output: tr('未知步骤类型') })
2688
+ }
2689
+ }
2690
+ return { ok: true, logs }
2691
+ }
2692
+
2693
+ // ---------- ⑥ 发布 npm 包(npm publish) ----------
2694
+
2695
+ /** npm 状态:是否安装、registry、whoami、目标目录是否存在 package.json。 */
2696
+ function npmStatus(dir) {
2697
+ const npm = NPM
2698
+ const cwd = String(dir || '').trim() || cloneDefaultDir()
2699
+ const installed = npm !== 'npm' && npm !== 'npm.cmd'
2700
+ const registry = run(npm, ['config', 'get', 'registry'], { shell: IS_WIN })
2701
+ const whoami = run(npm, ['whoami'], { shell: IS_WIN })
2702
+ return {
2703
+ ok: true,
2704
+ installed,
2705
+ npmPath: npm,
2706
+ dir: cwd,
2707
+ dirExists: existsSync(cwd),
2708
+ hasPackageJson: existsSync(join(cwd, 'package.json')),
2709
+ registry: registry.ok ? registry.stdout.trim() : undefined,
2710
+ whoami: whoami.ok ? whoami.stdout.trim() : undefined,
2711
+ whoamiError: whoami.ok ? undefined : ((whoami.stderr || whoami.stdout || '').trim() || tr('未登录 npm')),
2712
+ }
2713
+ }
2714
+
2715
+ /**
2716
+ * 打开一个终端窗口,在目标目录运行 `npm login`(交互式登录无法非交互执行,
2717
+ * 需要用户在弹出的终端里完成)。Windows 用 `start cmd /k`;macOS/Linux 探测常见终端。
2718
+ * @param {string} [dir] - 目标目录。
2719
+ * @returns {{ok:boolean, opened:boolean, command:string, detail:string}}
2720
+ */
2721
+ function npmLoginTerminal(dir) {
2722
+ const cwd = String(dir || '').trim() || cloneDefaultDir()
2723
+ const command = 'npm login'
2724
+ const cdCmd = IS_WIN
2725
+ ? `cd /d "${cwd}" && ${command}`
2726
+ : `cd "${cwd}" && ${command}`
2727
+ try {
2728
+ if (IS_WIN) {
2729
+ // start cmd /k 打开独立窗口(第一引号段是窗口标题);detached 不阻塞宿主。
2730
+ const child = spawn('cmd', ['/c', 'start', '"npm-login"', 'cmd', '/k', cdCmd], { detached: true, stdio: 'ignore', windowsHide: false })
2731
+ child.unref()
2732
+ return { ok: true, opened: true, command, detail: tr('已打开终端窗口,请完成 npm login 后回到面板点「重新检查」') }
2733
+ }
2734
+ const terms = [
2735
+ ['x-terminal-emulator', ['-e', 'sh', '-c', cdCmd]],
2736
+ ['gnome-terminal', ['--', 'sh', '-c', cdCmd]],
2737
+ ['konsole', ['-e', 'sh', '-c', cdCmd]],
2738
+ ['xterm', ['-e', 'sh', '-c', cdCmd]],
2739
+ ]
2740
+ for (const [bin, args] of terms) {
2741
+ try {
2742
+ const child = spawn(bin, args, { detached: true, stdio: 'ignore' })
2743
+ child.unref()
2744
+ return { ok: true, opened: true, command, detail: tr('已打开终端窗口,请完成 npm login 后回到面板点「重新检查」') }
2745
+ } catch { /* try next terminal */ }
2746
+ }
2747
+ return { ok: false, opened: false, command, detail: tr('未找到可用终端,请手动在目标目录运行:') + command }
2748
+ } catch (e) {
2749
+ return { ok: false, opened: false, command, detail: String((e && e.message) || e) }
2750
+ }
2751
+ }
2752
+
2753
+ /** 脱敏 npm config list 输出:隐藏 token / auth 等敏感值。 */
2754
+ function redactNpmList(output) {
2755
+ return String(output || '').split(/\r?\n/).map((line) => {
2756
+ if (/(token|_auth|authtoken|password|\bauth\b)/i.test(line)) {
2757
+ return line.replace(/=.*$/, '=[REDACTED]')
2758
+ }
2759
+ return line
2760
+ }).join('\n')
2761
+ }
2762
+
2763
+ /**
2764
+ * 执行 npm 流程的一步(registry / list / whoami / pack / publish)。
2765
+ * @param {string} [dir] - 目标目录(package.json 所在处)。
2766
+ * @param {string} step - 'registry'|'list'|'whoami'|'pack'|'publish'。
2767
+ */
2768
+ function npmRunStep(dir, step) {
2769
+ const npm = NPM
2770
+ const cwd = String(dir || '').trim() || cloneDefaultDir()
2771
+ const runNpm = (args, timeout) => run(npm, args, { shell: IS_WIN, cwd, timeout: timeout ?? 60_000 })
2772
+ switch (step) {
2773
+ case 'registry': {
2774
+ const r = runNpm(['config', 'get', 'registry'])
2775
+ return { ok: true, step, output: r.ok ? r.stdout.trim() : ((r.stderr || '') + (r.stdout || '')).trim() }
2776
+ }
2777
+ case 'list': {
2778
+ const r = runNpm(['config', 'list'])
2779
+ return { ok: true, step, output: redactNpmList((r.stdout || '') + (r.stderr || '')) }
2780
+ }
2781
+ case 'whoami': {
2782
+ const r = runNpm(['whoami'])
2783
+ return { ok: true, step, output: r.ok ? r.stdout.trim() : ((r.stderr || r.stdout || '').trim() || tr('未登录 npm')), loggedIn: r.ok }
2784
+ }
2785
+ case 'pack': {
2786
+ const r = runNpm(['pack', '--dry-run'], 120_000)
2787
+ return { ok: true, step, output: (r.stdout || '') + (r.stderr || '') }
2788
+ }
2789
+ case 'publish': {
2790
+ const r = runNpm(['publish'], 180_000)
2791
+ const blob = ((r.stdout || '') + (r.stderr || '')).trim()
2792
+ return { ok: r.ok, step, output: blob || tr('npm publish 已执行'), error: r.ok ? undefined : (blob || tr('npm publish 失败')) }
2793
+ }
2794
+ default:
2795
+ return { ok: false, step, output: tr('未知步骤') }
2796
+ }
2797
+ }
2798
+
1802
2799
  // ---------- route handlers ----------
1803
2800
 
1804
2801
  function json(res, status, payload) {
@@ -2111,6 +3108,8 @@ function baseName(p) {
2111
3108
  }
2112
3109
 
2113
3110
  export function apply(ctx) {
3111
+ // 启动时把预置 PR 规则(awesome-dsh-plugin 等)写入插件目录 rules/(不存在才写)。
3112
+ seedPresetPrRules()
2114
3113
  const fallbackDir = resolveDefaultDir(process.cwd())
2115
3114
  // 每个请求按 ?lang= 参数跑在独立语言上下文里(AsyncLocalStorage),
2116
3115
  // 所有 flow 函数内的 tr() 都能读到当前请求的语言,并发请求互不干扰。
@@ -2379,6 +3378,114 @@ export function apply(ctx) {
2379
3378
  return methodNotAllowed(res, req.method)
2380
3379
  }
2381
3380
  },
3381
+
3382
+ // ---- ④ 克隆远程仓库 ----
3383
+ // 默认工作区(⑤⑥ 等板块兜底)。
3384
+ '/clone/default-dir': (req, res) => handle(req, res, async () => {
3385
+ const dir = cloneDefaultDir()
3386
+ return { ok: true, dir, name: baseName(dir) }
3387
+ }),
3388
+ // home 目录(④ 克隆默认:Windows C:\Users\xxx,Linux/macOS /home/xxx)。
3389
+ '/clone/home': (req, res) => handle(req, res, async () => {
3390
+ const dir = cloneHomeDir()
3391
+ return { ok: true, dir, name: baseName(dir) }
3392
+ }),
3393
+ '/clone/repos': async (req, res) => {
3394
+ if (req.method !== 'GET') return methodNotAllowed(res, req.method)
3395
+ await handle(req, res, async (req) => {
3396
+ const q = new URL(req.url ?? '', 'http://localhost')
3397
+ const provider = q.searchParams.get('provider') || 'github'
3398
+ const baseDir = q.searchParams.get('dir') || cloneHomeDir()
3399
+ return listRemoteRepos(provider, baseDir)
3400
+ })
3401
+ },
3402
+ '/clone/run': async (req, res) => {
3403
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3404
+ await handle(req, res, async (req) => {
3405
+ const body = JSON.parse((await readBody(req)) || '{}')
3406
+ return cloneFlow(body.url, body.dest, body.name)
3407
+ })
3408
+ },
3409
+
3410
+ // ---- ⑤ 提交 PR ----
3411
+ '/pr/targets': async (req, res) => {
3412
+ if (req.method === 'GET') {
3413
+ await handle(req, res, async () => ({ ok: true, targets: readPrTargets() }))
3414
+ } else if (req.method === 'POST') {
3415
+ await handle(req, res, async (req) => {
3416
+ const body = JSON.parse((await readBody(req)) || '{}')
3417
+ const url = String(body.url || '').trim()
3418
+ if (!url) return { ok: false, error: tr('缺少仓库地址') }
3419
+ if (body.remove) return { ok: true, targets: removePrTarget(url) }
3420
+ return { ok: true, targets: savePrTarget(url) }
3421
+ })
3422
+ } else {
3423
+ return methodNotAllowed(res, req.method)
3424
+ }
3425
+ },
3426
+ // 只读查询某仓库是否有缓存规则(命中插件目录 rules/,不触发 AI,不生成)。
3427
+ '/pr/rule': async (req, res) => {
3428
+ if (req.method !== 'GET') return methodNotAllowed(res, req.method)
3429
+ await handle(req, res, async (req) => {
3430
+ const q = new URL(req.url ?? '', 'http://localhost')
3431
+ const url = q.searchParams.get('url') || ''
3432
+ const { provider, owner, repo } = parseRepoUrl(url)
3433
+ if (!owner || !repo) return { ok: true, hasRule: false, url: String(url) }
3434
+ const cached = readPrRule(owner, repo)
3435
+ return {
3436
+ ok: true, hasRule: !!cached, url: String(url),
3437
+ provider, owner, repo,
3438
+ rule: cached || null,
3439
+ }
3440
+ })
3441
+ },
3442
+ '/pr/analyze': async (req, res) => {
3443
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3444
+ await handle(req, res, async (req) => {
3445
+ const body = JSON.parse((await readBody(req)) || '{}')
3446
+ return prAnalyzeFlow(ctx, { url: body.url, force: !!body.force, ruleText: body.ruleText, ruleFile: body.ruleFile })
3447
+ })
3448
+ },
3449
+ '/pr/generate': async (req, res) => {
3450
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3451
+ await handle(req, res, async (req) => {
3452
+ const body = JSON.parse((await readBody(req)) || '{}')
3453
+ return prGenerateFlow(ctx, { url: body.url, rule: body.rule, pluginInfo: body.pluginInfo })
3454
+ })
3455
+ },
3456
+ '/pr/execute': async (req, res) => {
3457
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3458
+ await handle(req, res, async (req) => {
3459
+ const body = JSON.parse((await readBody(req)) || '{}')
3460
+ return prExecuteFlow(ctx, {
3461
+ url: body.url, workdir: body.workdir, rule: body.rule,
3462
+ entryContent: body.entryContent, title: body.title, body: body.body, base: body.base,
3463
+ })
3464
+ })
3465
+ },
3466
+
3467
+ // ---- ⑥ 发布 npm 包 ----
3468
+ '/npm/status': async (req, res) => {
3469
+ if (req.method !== 'GET') return methodNotAllowed(res, req.method)
3470
+ await handle(req, res, async (req) => {
3471
+ const q = new URL(req.url ?? '', 'http://localhost')
3472
+ return npmStatus(q.searchParams.get('dir') || '')
3473
+ })
3474
+ },
3475
+ '/npm/login': async (req, res) => {
3476
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3477
+ await handle(req, res, async (req) => {
3478
+ const body = JSON.parse((await readBody(req)) || '{}')
3479
+ return npmLoginTerminal(body.dir)
3480
+ })
3481
+ },
3482
+ '/npm/step': async (req, res) => {
3483
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3484
+ await handle(req, res, async (req) => {
3485
+ const body = JSON.parse((await readBody(req)) || '{}')
3486
+ return npmRunStep(body.dir, body.step)
3487
+ })
3488
+ },
2382
3489
  }
2383
3490
 
2384
3491
  ctx.effect(() => {