source-code-mgmt 1.13.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -21,8 +21,8 @@ import {
21
21
  existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync,
22
22
  readdirSync, statSync, openSync, readSync, closeSync,
23
23
  } from 'node:fs'
24
- import { join, dirname } from 'node:path'
25
- import { homedir } from 'node:os'
24
+ import { join, dirname, isAbsolute, resolve } from 'node:path'
25
+ import { homedir, tmpdir } from 'node:os'
26
26
 
27
27
  import { AsyncLocalStorage } from 'node:async_hooks'
28
28
  import { fileURLToPath } from 'node:url'
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url'
30
30
  // ---------- i18n: host messages follow the client’s language (?lang= query param, per request) ----------
31
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)"};
32
32
  const requestLangStore = new AsyncLocalStorage();
33
- // 新增板块(④克隆 / ⑤PR / ⑥npm)的 host 英文消息,追加进 HOST_EN。
33
+ // 新增板块(④克隆 / ⑤npm)的 host 英文消息,追加进 HOST_EN。
34
34
  Object.assign(HOST_EN, {
35
35
  "LLM 服务不可用(未加载 llm 服务)": "LLM service unavailable (llm service not loaded)",
36
36
  "未找到默认模型配置(agent-default-model)": "No default model config found (agent-default-model)",
@@ -76,6 +76,17 @@ Object.assign(HOST_EN, {
76
76
  "创建分支失败(上游分支可能不存在,请检查规则 baseBranch)": "Failed to create branch (upstream base may not exist — check the rule's baseBranch)",
77
77
  "已打开终端窗口,请完成 npm login 后回到面板点「重新检查」": "A terminal window has been opened — complete npm login, then click \"Re-check\" in the panel",
78
78
  "未找到可用终端,请手动在目标目录运行:": "No usable terminal found — run this manually in the target directory: ",
79
+ "需要管理员密码(当前 sudo 需要密码,无法在面板内自动执行),请在终端手动执行:": "Admin password required (sudo prompts for a password here, so the panel cannot run it) — run this manually in a terminal: ",
80
+ "已触发安装,请按系统提示完成,然后点「重新检查」": "Installation started — follow the system prompt, then click \"Re-check\"",
81
+ "查询最新版本…": "Fetching latest version…",
82
+ "下载 gh 安装包…": "Downloading gh…",
83
+ "解压…": "Extracting…",
84
+ "安装到 ~/.local/bin…": "Installing to ~/.local/bin…",
85
+ "清理临时文件…": "Cleaning up…",
86
+ "执行安装命令:": "Running install command: ",
87
+ "用户级安装失败,改用系统包管理器安装…": "User-level install failed — falling back to the system package manager…",
88
+ "需要管理员密码,改用以下手动命令:": "Admin password required — run this manually: ",
89
+ "配置源是镜像,发布将使用官方源:": "Configured registry is a mirror — publishing will use the official registry: ",
79
90
  "规则文件获取失败:": "Failed to fetch rules file: ",
80
91
  "规则文件读取失败:": "Failed to read rules file: ",
81
92
  "找不到规则文件:": "Rules file not found: ",
@@ -135,7 +146,7 @@ const SSH = resolveBin('ssh', 'DSH_SCM_SSH')
135
146
  const SSH_KEYGEN = resolveBin('ssh-keygen', 'DSH_SCM_SSH_KEYGEN')
136
147
  /** Resolved path to curl (used to call the Gitee OpenAPI). */
137
148
  const CURL = resolveBin('curl', 'DSH_SCM_CURL')
138
- /** Resolved path to npm (used by the publish-npm section). */
149
+ /** Resolved path to npm (used by the publish-npm section). */
139
150
  const NPM = resolveBin('npm', 'DSH_SCM_NPM')
140
151
 
141
152
  /**
@@ -238,6 +249,12 @@ function resolveBin(name, envVar) {
238
249
  // 1) PATH hit is the most portable answer.
239
250
  const onPath = findOnPath(name)
240
251
  if (onPath) return onPath
252
+ // 1.5) Linux/macOS 用户级安装目录:免 sudo 安装的 gh(本插件的「一键安装」落点)
253
+ // 也会装到这里,即使 ~/.local/bin 不在 shell PATH 中,模块加载时也能解析到。
254
+ if (!IS_WIN) {
255
+ const userBin = probePrefix(join(homedir(), '.local', 'bin'), name)
256
+ if (userBin) return userBin
257
+ }
241
258
  // 2) Windows: git bundles ssh / ssh-keygen.
242
259
  if (IS_WIN) {
243
260
  for (const prefix of windowsGitPrefixes()) {
@@ -382,6 +399,231 @@ function run(cmd, args, opts = {}) {
382
399
  }
383
400
  }
384
401
 
402
+ /**
403
+ * 流式执行一条命令(spawn):stdout/stderr 分块实时回调 onChunk,
404
+ * 进程退出后 resolve { ok, code, stdout, stderr }。用于一键安装的进度展示。
405
+ */
406
+ function runLive(cmd, args, opts = {}) {
407
+ const { onChunk, timeout = 300_000 } = opts
408
+ const env = { ...process.env, ...(opts.env ?? {}) }
409
+ return new Promise((resolve) => {
410
+ let stdout = ''
411
+ let stderr = ''
412
+ let settled = false
413
+ let timer = null
414
+ let child
415
+ const finish = (value) => {
416
+ if (settled) return
417
+ settled = true
418
+ if (timer !== null) clearTimeout(timer)
419
+ resolve(value)
420
+ }
421
+ try {
422
+ child = spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'] })
423
+ } catch (error) {
424
+ finish({ ok: false, code: 1, stdout: '', stderr: String(error?.message ?? error) })
425
+ return
426
+ }
427
+ timer = timeout > 0 ? setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, timeout) : null
428
+ const push = (buf, stream) => {
429
+ const text = buf.toString('utf8')
430
+ if (stream === 'out') stdout += text
431
+ else stderr += text
432
+ if (onChunk) { try { onChunk(text, stream) } catch {} }
433
+ }
434
+ child.stdout?.on('data', (b) => push(b, 'out'))
435
+ child.stderr?.on('data', (b) => push(b, 'err'))
436
+ child.on('error', (error) => {
437
+ finish({ ok: false, code: 1, stdout, stderr: stderr || String(error?.message ?? error) })
438
+ })
439
+ child.on('close', (code) => {
440
+ finish({ ok: code === 0, code: code ?? 1, stdout, stderr })
441
+ })
442
+ })
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // 一键重启 dsh(机制同 dsh-update / dsh-market):detached helper 接管后按原命令
447
+ // 拉起替换进程,本进程随后退出。Windows 走 PowerShell 隐藏窗口;POSIX 走 detached spawn。
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /** The real Node executable (Android-safe argv0 preference), for spawning. */
451
+ function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
452
+ if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0)) return argv0
453
+ return execPath
454
+ }
455
+
456
+ /** Whether a bare `dsh` name must go through a shell (Windows .cmd shim). */
457
+ const winCmdShim = process.platform === 'win32'
458
+
459
+ /** The exact boot invocation the detached restart helper replays. */
460
+ function restartLaunch() {
461
+ const entry = process.argv[1]
462
+ if (entry !== undefined && entry !== '') {
463
+ // Absolute paths are required: source launches pass a relative entry,
464
+ // which the child resolves against its OWN cwd and dies with
465
+ // MODULE_NOT_FOUND. cwd near the entry keeps execArgv imports (tsx/esm)
466
+ // resolvable on source launches. Launching node + the very same entry is
467
+ // the most faithful restart and does NOT depend on `dsh` being on PATH
468
+ // (macOS/Linux bins often live inside node_modules behind pnpm/npx).
469
+ const abs = resolve(entry)
470
+ return { file: nodeExecutable(), args: [...process.execArgv, abs, ...process.argv.slice(2)], cwd: dirname(abs), viaShell: false }
471
+ }
472
+ return { file: 'dsh', args: process.argv.slice(2), cwd: undefined, viaShell: winCmdShim }
473
+ }
474
+
475
+ /** The process supervisor running this host, when one can be identified.
476
+ * systemd only, and only when this process is the unit's OWN main process
477
+ * (ppid === 1): INVOCATION_ID alone is inherited by every descendant. */
478
+ function detectedSupervisor(env = process.env, ppid = process.ppid) {
479
+ const set = (name) => (env[name] ?? '') !== ''
480
+ if ((set('INVOCATION_ID') || set('JOURNAL_STREAM')) && ppid === 1) return 'systemd'
481
+ return null
482
+ }
483
+
484
+ /** Self-restart is enabled by default, disabled by an explicit env or by a
485
+ * detected supervisor (which owns restarts). */
486
+ function restartAllowed() {
487
+ const flag = process.env.DSH_SCM_RESTART
488
+ if (flag !== undefined && flag !== '') return flag !== '0' && flag !== 'false'
489
+ return detectedSupervisor() === null
490
+ }
491
+
492
+ /** The port this process serves, read off the request that asked to restart. */
493
+ function servingPort(request) {
494
+ const host = request.headers.host
495
+ if (host === undefined) return null
496
+ const match = /:(\d{1,5})$/u.exec(host)
497
+ if (match === null) return null
498
+ const port = Number(match[1])
499
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
500
+ }
501
+
502
+ /** Whether a process-control request came directly from this host's loopback
503
+ * peer (stricter than isLoopbackRequest: also rejects proxied requests). */
504
+ function trustedRestartRequest(request) {
505
+ const address = request.socket && request.socket.remoteAddress
506
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
507
+ if (request.headers.forwarded !== undefined
508
+ || request.headers['x-forwarded-for'] !== undefined
509
+ || request.headers['x-real-ip'] !== undefined) return false
510
+ const origin = request.headers.origin
511
+ const host = request.headers.host
512
+ if (origin === undefined || host === undefined) return false
513
+ try {
514
+ const parsed = new URL(origin)
515
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
516
+ } catch {
517
+ return false
518
+ }
519
+ }
520
+
521
+ /** Platform-correct spawn for the replacement host. POSIX keeps the plain
522
+ * detached spawn; Windows relaunches through a hidden PowerShell window
523
+ * (detached:true would break the PowerShell -> node handle chain, Node #51018). */
524
+ function respawnInvocation(launch, platform = process.platform) {
525
+ if (platform !== 'win32') {
526
+ return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true, windowsHide: false }
527
+ }
528
+ const quote = (part) => `'${String(part).replace(/'/g, "''")}'`
529
+ const file = launch.viaShell && !/\.(?:cmd|bat)$/iu.test(launch.file) ? `${launch.file}.cmd` : launch.file
530
+ return {
531
+ file: 'powershell.exe',
532
+ args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
533
+ [`& ${quote(file)}`, ...launch.args.map(quote)].join(' ')],
534
+ viaShell: false,
535
+ detached: false,
536
+ windowsHide: true,
537
+ }
538
+ }
539
+
540
+ /** Source for the detached helper that outlives this process and brings the
541
+ * replacement up: wait for the port to go quiet (checked by CONNECTING, so
542
+ * the probe never holds the port), start the replacement, then CHECK that
543
+ * something came up and write a diagnosis when it did not. */
544
+ function restartHelperSource(spawned, launch, logs, port) {
545
+ return [
546
+ "const { spawn } = require('node:child_process')",
547
+ "const fs = require('node:fs')",
548
+ "const net = require('node:net')",
549
+ `const file = ${JSON.stringify(spawned.file)}`,
550
+ `const args = ${JSON.stringify(spawned.args)}`,
551
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
552
+ `const viaShell = ${JSON.stringify(spawned.viaShell)}`,
553
+ `const detached = ${JSON.stringify(spawned.detached)}`,
554
+ `const windowsHide = ${JSON.stringify(spawned.windowsHide ?? false)}`,
555
+ `const logOut = ${JSON.stringify(logs.out)}`,
556
+ `const logErr = ${JSON.stringify(logs.err)}`,
557
+ `const port = ${JSON.stringify(port)}`,
558
+ 'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
559
+ 'const note = (line) => { try { fs.appendFileSync(logErr, `[source-code-mgmt] ${line}\n`) } catch {} }',
560
+ 'const listening = () => new Promise((resolve) => {',
561
+ ' const probe = net.connect({ host: "127.0.0.1", port })',
562
+ ' const done = (value) => { probe.destroy(); resolve(value) }',
563
+ ' probe.on("connect", () => done(true))',
564
+ ' probe.on("error", () => done(false))',
565
+ ' setTimeout(() => done(false), 500)',
566
+ '})',
567
+ 'const main = async () => {',
568
+ ' if (port) {',
569
+ ' const until = Date.now() + 30000',
570
+ ' while (Date.now() < until && await listening()) await sleep(250)',
571
+ ' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
572
+ ' } else {',
573
+ ' await sleep(1500)',
574
+ ' }',
575
+ ' let child',
576
+ ' try {',
577
+ ' const out = fs.openSync(logOut, "a")',
578
+ ' const err = fs.openSync(logErr, "a")',
579
+ ' child = spawn(file, args, { cwd, detached, windowsHide, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
580
+ ' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
581
+ ' child.unref()',
582
+ ' } catch (error) {',
583
+ ' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
584
+ ' return',
585
+ ' }',
586
+ " if (!port) { await sleep(3000); return }",
587
+ ' const upBy = Date.now() + 20000',
588
+ ' while (Date.now() < upBy && !(await listening())) await sleep(500)',
589
+ ' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
590
+ '}',
591
+ 'main()',
592
+ ].join('\n')
593
+ }
594
+
595
+ /** One-shot restart latch: 防止手动点击与安装后重启重复调度 helper(两个 helper
596
+ * 会同抢端口)。 */
597
+ let restartScheduled = false
598
+
599
+ /** Relaunch this exact dsh entry after a detached handoff, then stop this
600
+ * process. The helper outlives us (detached + unref), waits for our port to
601
+ * be released before starting the replacement, and logs under tmpdir.
602
+ * Returns null when a restart is already scheduled. */
603
+ function scheduleRestart(port = null) {
604
+ if (restartScheduled) return null
605
+ restartScheduled = true
606
+ const launch = restartLaunch()
607
+ const spawned = respawnInvocation(launch)
608
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
609
+ const logOut = join(tmpdir(), `dsh-scm-restart-${stamp}.out.log`)
610
+ const logErr = join(tmpdir(), `dsh-scm-restart-${stamp}.err.log`)
611
+ try {
612
+ const helper = spawn(nodeExecutable(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
613
+ detached: true,
614
+ stdio: 'ignore',
615
+ env: process.env,
616
+ })
617
+ helper.unref()
618
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
619
+ return { pid: process.pid, helperPid: helper.pid, logOut, logErr }
620
+ } catch (error) {
621
+ // helper 拉起失败(如 EMFILE):解除 latch 并重新抛出,避免重启能力永久失效。
622
+ restartScheduled = false
623
+ throw error
624
+ }
625
+ }
626
+
385
627
  /**
386
628
  * Decode a path as printed by git. With core.quotepath ON (the default) git
387
629
  * prints paths containing non-ASCII bytes / special characters as a
@@ -456,6 +698,12 @@ function checkEnv() {
456
698
  path: SSH,
457
699
  sshKeygen: SSH_KEYGEN,
458
700
  },
701
+ // 平台自适应的可复制安装命令(与「一键安装」走同一逻辑;无可用方式时为 null)。
702
+ installHints: {
703
+ git: installCommand('git')?.label ?? null,
704
+ gh: installCommand('gh')?.label ?? null,
705
+ ssh: installCommand('ssh')?.label ?? null,
706
+ },
459
707
  }
460
708
  }
461
709
 
@@ -1772,6 +2020,103 @@ function commitDiffFlow(dir, hash) {
1772
2020
  // 工具安装(① 环境检查缺工具时的一键安装)——best-effort:按可用包管理器自动选取命令。
1773
2021
  // ---------------------------------------------------------------------------
1774
2022
 
2023
+ /** GitHub CLI Release 资产名(Linux/macOS):gh_<版本>_<os>_<arch>.tar.gz */
2024
+ function ghReleaseAsset(platform, arch) {
2025
+ const osName = platform === 'darwin' ? 'macOS' : 'linux'
2026
+ const archName = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : arch
2027
+ return { osName, archName }
2028
+ }
2029
+
2030
+ /**
2031
+ * 免 sudo 用户级安装 GitHub CLI 的单行脚本(Linux / macOS):
2032
+ * 解析官方最新版本号 → 下载对应平台/架构 tarball → 解压 → 把 bin/gh 装到
2033
+ * ~/.local/bin/gh(目录不存在则创建)→ 清理临时文件。全程不要求 root。
2034
+ * @returns {string} 可直接 `sh -c` 执行 / 复制给用户的脚本。
2035
+ */
2036
+ function ghUserLevelScript() {
2037
+ const { osName, archName } = ghReleaseAsset(process.platform, process.arch)
2038
+ const localBin = join(homedir(), '.local', 'bin')
2039
+ // 注意:VER 是脚本内的 shell 变量,用 \${VER} 转义避免被 JS 模板字面量展开。
2040
+ return [
2041
+ 'VER=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | sed -n \'s/.*"tag_name": *"v\\([^"]*\\)".*/\\1/p\')',
2042
+ 'VER=${VER#v}',
2043
+ `mkdir -p "${localBin}"`,
2044
+ `curl -fsSL "https://github.com/cli/cli/releases/download/v\${VER}/gh_\${VER}_${osName}_${archName}.tar.gz" -o /tmp/gh-scm.tgz`,
2045
+ 'tar -xzf /tmp/gh-scm.tgz -C /tmp',
2046
+ `install -m 755 "/tmp/gh_\${VER}_${osName}_${archName}/bin/gh" "${localBin}/gh"`,
2047
+ 'rm -rf /tmp/gh-scm.tgz "/tmp/gh_${VER}_' + osName + '_' + archName + '"',
2048
+ ].join(' && ')
2049
+ }
2050
+
2051
+ /**
2052
+ * gh 用户级安装的分步脚本(每步独立执行,供「一键安装」进度逐条展示)。
2053
+ * 与 ghUserLevelScript()(复制用单行版)保持同源逻辑;版本号经 /tmp/gh-scm-ver 传递。
2054
+ * @returns {Array<{label: string, cmd: string}>}
2055
+ */
2056
+ function ghUserLevelSteps() {
2057
+ const { osName, archName } = ghReleaseAsset(process.platform, process.arch)
2058
+ const localBin = join(homedir(), '.local', 'bin')
2059
+ const verFile = '/tmp/gh-scm-ver'
2060
+ const fetchVer = 'VER=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | sed -n \'s/.*"tag_name": *"v\\([^"]*\\)".*/\\1/p\'); [ -n "$VER" ] || exit 1; printf \'%s\' "${VER#v}" > ' + verFile
2061
+ const readVer = 'VER=$(cat ' + verFile + ')'
2062
+ return [
2063
+ { label: tr('查询最新版本…'), cmd: fetchVer },
2064
+ { label: tr('下载 gh 安装包…'), cmd: readVer + ' && curl -fsSL "https://github.com/cli/cli/releases/download/v${VER}/gh_${VER}_' + osName + '_' + archName + '.tar.gz" -o /tmp/gh-scm.tgz' },
2065
+ { label: tr('解压…'), cmd: 'tar -xzf /tmp/gh-scm.tgz -C /tmp' },
2066
+ { label: tr('安装到 ~/.local/bin…'), cmd: readVer + ' && mkdir -p "' + localBin + '" && install -m 755 "/tmp/gh_${VER}_' + osName + '_' + archName + '/bin/gh" "' + localBin + '/gh"' },
2067
+ { label: tr('清理临时文件…'), cmd: 'rm -rf /tmp/gh-scm.tgz ' + verFile + ' /tmp/gh_*_' + osName + '_' + archName },
2068
+ ]
2069
+ }
2070
+
2071
+ /** 安装后即时复查工具是否真的可用(不依赖模块加载时缓存的历史解析结果)。 */
2072
+ function toolInstalled(t) {
2073
+ if (t === 'gh') {
2074
+ // 用户级安装落点优先复查;不在 PATH 时也能发现。
2075
+ if (!IS_WIN) {
2076
+ const userGh = probePrefix(join(homedir(), '.local', 'bin'), 'gh')
2077
+ if (userGh && isFile(userGh)) return true
2078
+ }
2079
+ return run('gh', ['--version']).ok
2080
+ }
2081
+ // git / ssh:直接按当前 PATH 实测(系统包管理器安装后即可发现,无需重启 host)。
2082
+ if (t === 'git') return run('git', ['--version']).ok
2083
+ if (t === 'ssh') return run('ssh', ['-V']).ok
2084
+ return false
2085
+ }
2086
+
2087
+ /**
2088
+ * sudo 是否免密可用(`sudo -n true` 非交互探测)。
2089
+ * 需要密码的 sudo 在面板内无法自动输入,返回 false 以走用户级安装或手动指引。
2090
+ * @returns {boolean} 已是 root 或 sudo 免密可用时为 true。
2091
+ */
2092
+ function canSudo() {
2093
+ if (IS_WIN) return false
2094
+ if (typeof process.getuid === 'function' && process.getuid() === 0) return true
2095
+ return run('sudo', ['-n', 'true'], { timeout: 5_000 }).ok
2096
+ }
2097
+
2098
+ /** 某个绝对目录是否就在 PATH 里(尾斜杠归一化比较)。 */
2099
+ function isInPath(dir) {
2100
+ const target = dir.length > 0 && dir[dir.length - 1] !== '/' ? dir + '/' : dir
2101
+ return pathDirs().some((p) => {
2102
+ const norm = p.length > 0 && p[p.length - 1] !== '/' ? p + '/' : p
2103
+ return norm === target
2104
+ })
2105
+ }
2106
+
2107
+ /**
2108
+ * 工具装好后,当前 host 进程的检测是否要等重启才生效:
2109
+ * - 模块加载时已解析到具体路径(PATH 命中或 DSH_SCM_* 显式覆盖)→ 立即可用,无需重启;
2110
+ * - 模块加载时解析失败(bare name 兜底)且用户级落点 ~/.local/bin 又不在 PATH
2111
+ * → env 检测要等重启后经 ~/.local/bin 探测才翻新,此时提示用户重启。
2112
+ * @param {'git'|'gh'|'ssh'} t
2113
+ */
2114
+ function restartNeededFor(t) {
2115
+ if (t !== 'gh') return false
2116
+ if (GH !== 'gh') return false
2117
+ return !isInPath(join(homedir(), '.local', 'bin'))
2118
+ }
2119
+
1775
2120
  /**
1776
2121
  * Whether a command exists (probe `--version`; for shell scripts use `command -v`).
1777
2122
  * @param {string} bin - bare command name.
@@ -1784,11 +2129,10 @@ function binExists(bin) {
1784
2129
  }
1785
2130
 
1786
2131
  /**
1787
- * Build the install command to run for `tool` on this machine, or null when no
1788
- * usable package manager is available. Returns arrays usable with `run`.
2132
+ * 仅按系统包管理器选择安装命令(不含 gh 的用户级路径)。
1789
2133
  * @param {'git'|'gh'|'ssh'} tool
1790
2134
  */
1791
- function installCommand(tool) {
2135
+ function installCommandSystem(tool) {
1792
2136
  if (IS_WIN) {
1793
2137
  // SSH 客户端:Windows 内置可选功能(需管理员),走 powershell。
1794
2138
  if (tool === 'ssh') {
@@ -1809,57 +2153,127 @@ function installCommand(tool) {
1809
2153
  if (process.platform === 'darwin') {
1810
2154
  const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh'
1811
2155
  if (binExists('brew')) return { bin: 'brew', args: ['install', pkg], label: 'brew install ' + pkg }
2156
+ // macOS 无 brew:git/ssh 走 Xcode 命令行工具(系统自带安装,含 git 与 ssh)。
2157
+ if (tool === 'git' || tool === 'ssh') {
2158
+ return { bin: 'sh', args: ['-c', 'xcode-select --install'], label: 'xcode-select --install', started: true }
2159
+ }
1812
2160
  return null
1813
2161
  }
1814
2162
  // Linux:按可用包管理器选择。安装需 root,用 sudo(非交互 -n,避免挂起等待密码;已是 root 则直接跑)。
2163
+ // needsSudo 标记供上层探测:sudo 需要密码时改走手动指引,避免静默失败。
1815
2164
  const pkg = tool === 'git' ? 'git' : tool === 'gh' ? 'gh' : 'openssh-client'
1816
2165
  const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
1817
2166
  const sudo = !isRoot && binExists('sudo') ? 'sudo -n ' : ''
2167
+ const tag = sudo === '' ? {} : { needsSudo: true }
1818
2168
  if (binExists('apt-get')) {
1819
- return { bin: 'sh', args: ['-c', sudo + 'apt-get install -y ' + pkg], label: 'sudo apt-get install -y ' + pkg }
2169
+ return { bin: 'sh', args: ['-c', sudo + 'apt-get install -y ' + pkg], label: 'sudo apt-get install -y ' + pkg, ...tag }
1820
2170
  }
1821
2171
  if (binExists('dnf')) {
1822
- return { bin: 'sh', args: ['-c', sudo + 'dnf install -y ' + pkg], label: 'sudo dnf install -y ' + pkg }
2172
+ return { bin: 'sh', args: ['-c', sudo + 'dnf install -y ' + pkg], label: 'sudo dnf install -y ' + pkg, ...tag }
1823
2173
  }
1824
2174
  if (binExists('pacman')) {
1825
- return { bin: 'sh', args: ['-c', sudo + 'pacman -S --noconfirm ' + pkg], label: 'sudo pacman -S ' + pkg }
2175
+ return { bin: 'sh', args: ['-c', sudo + 'pacman -S --noconfirm ' + pkg], label: 'sudo pacman -S ' + pkg, ...tag }
1826
2176
  }
1827
2177
  return null
1828
2178
  }
1829
2179
 
1830
2180
  /**
1831
- * Install a missing tool (git / gh / ssh) using the best available package
1832
- * manager. Best-effort: reports the command it ran and its output.
2181
+ * Build the install command to run for `tool` on this machine, or null when no
2182
+ * usable package manager is available. Returns arrays usable with `run`.
2183
+ * @param {'git'|'gh'|'ssh'} tool
2184
+ */
2185
+ function installCommand(tool) {
2186
+ // Linux/macOS 的 GitHub CLI:优先免 sudo 用户级安装(官方二进制 → ~/.local/bin)。
2187
+ if (tool === 'gh' && !IS_WIN) {
2188
+ return { bin: 'sh', args: ['-c', ghUserLevelScript()], label: ghUserLevelScript(), userLevel: true }
2189
+ }
2190
+ return installCommandSystem(tool)
2191
+ }
2192
+
2193
+ /**
2194
+ * Install a missing tool (git / gh / ssh). GitHub CLI on Linux/macOS installs
2195
+ * user-level (no sudo, official binary → ~/.local/bin); git/ssh keep the
2196
+ * system package manager. Best-effort: reports the command it ran and output.
1833
2197
  * @param {string} tool - 'git' | 'gh' | 'ssh'.
2198
+ * @param {(evt: object) => void} [emit] - 进度事件回调({type:'step',label} / {type:'out',text}),
2199
+ * 由流式路由逐行转发给浏览器端弹窗。
1834
2200
  */
1835
- function installToolFlow(tool) {
2201
+ async function installToolFlow(tool, emit) {
1836
2202
  const t = String(tool || '').trim().toLowerCase()
1837
2203
  if (t !== 'git' && t !== 'gh' && t !== 'ssh') return { ok: false, error: tr('未知工具:') + tool }
1838
- // 已安装就直接返回,不重复安装。
1839
- const present = t === 'git' ? (GIT && GIT !== 'git')
1840
- : t === 'gh' ? (GH && GH !== 'gh')
1841
- : (SSH && SSH !== 'ssh' && SSH !== 'ssh.exe')
1842
- if (present) return { ok: true, alreadyInstalled: true, tool: t }
2204
+ const say = (obj) => { if (emit) { try { emit(obj) } catch {} } }
2205
+ // 已安装就直接返回,不重复安装(实测复查,不依赖模块加载时的缓存)。
2206
+ if (toolInstalled(t)) return { ok: true, alreadyInstalled: true, tool: t }
2207
+
2208
+ // Linux/macOS gh:免 sudo 用户级安装优先;失败再退回系统包管理器。
2209
+ if (t === 'gh' && !IS_WIN) {
2210
+ const steps = ghUserLevelSteps()
2211
+ let lastOut = ''
2212
+ let failed = false
2213
+ for (const step of steps) {
2214
+ say({ type: 'step', label: step.label })
2215
+ const r = await runLive('sh', ['-c', step.cmd], { timeout: 300_000, onChunk: (text) => say({ type: 'out', text }) })
2216
+ lastOut = (r.stdout + '\n' + r.stderr).trim()
2217
+ if (!r.ok) { failed = true; break }
2218
+ }
2219
+ if (!failed && toolInstalled('gh')) {
2220
+ return {
2221
+ ok: true,
2222
+ tool: t,
2223
+ command: 'GitHub CLI',
2224
+ output: lastOut || tr('安装成功'),
2225
+ userLevel: true,
2226
+ path: join(homedir(), '.local', 'bin', 'gh'),
2227
+ restartNeeded: restartNeededFor('gh'),
2228
+ }
2229
+ }
2230
+ // 用户级安装失败 → 退回系统包管理器(并说明原因)。
2231
+ const fallback = installCommandSystem(t)
2232
+ if (fallback) {
2233
+ // 回退命令需要 sudo 但 sudo 要密码时,同样改走手动指引。
2234
+ if (fallback.needsSudo && !canSudo()) {
2235
+ say({ type: 'step', label: tr('需要管理员密码,改用以下手动命令:') })
2236
+ return { ok: false, tool: t, error: tr('需要管理员密码(当前 sudo 需要密码,无法在面板内自动执行),请在终端手动执行:') + fallback.label, command: fallback.label, manual: true }
2237
+ }
2238
+ say({ type: 'step', label: tr('用户级安装失败,改用系统包管理器安装…') })
2239
+ const fr = await runLive(fallback.bin, fallback.args, { timeout: 300_000, onChunk: (text) => say({ type: 'out', text }) })
2240
+ const fout = (fr.stdout + '\n' + fr.stderr).trim()
2241
+ const fok = toolInstalled('gh')
2242
+ return { ok: fok, tool: t, command: fallback.label, output: fout || (fok ? tr('安装成功') : tr('安装失败')), restartNeeded: restartNeededFor('gh') }
2243
+ }
2244
+ return { ok: false, error: tr('未找到可用的包管理器(') + 'apt-get / dnf / pacman' + tr(')。请手动安装。'), tool: t }
2245
+ }
1843
2246
 
1844
2247
  const cmd = installCommand(t)
1845
2248
  if (!cmd) {
1846
- return { ok: false, error: tr('未找到可用的包管理器(') + (IS_WIN ? 'winget / choco / scoop' : process.platform === 'darwin' ? 'brew' : 'apt-get / dnf / pacman') + tr(')。请手动安装。'), tool: t }
2249
+ return { ok: false, error: tr('未找到可用的包管理器(') + (IS_WIN ? 'winget / choco / scoop' : process.platform === 'darwin' ? 'brew / Xcode CLT' : 'apt-get / dnf / pacman') + tr(')。请手动安装。'), tool: t }
1847
2250
  }
1848
- const r = run(cmd.bin, cmd.args, { timeout: 300_000 })
2251
+ // 需要 sudo sudo 要密码:不执行(-n 必然失败),直接给出手动命令与原因。
2252
+ if (cmd.needsSudo && !canSudo()) {
2253
+ say({ type: 'step', label: tr('需要管理员密码,改用以下手动命令:') })
2254
+ return { ok: false, tool: t, error: tr('需要管理员密码(当前 sudo 需要密码,无法在面板内自动执行),请在终端手动执行:') + cmd.label, command: cmd.label, manual: true }
2255
+ }
2256
+ say({ type: 'step', label: tr('执行安装命令:') + cmd.label })
2257
+ const r = await runLive(cmd.bin, cmd.args, { timeout: 300_000, onChunk: (text) => say({ type: 'out', text }) })
1849
2258
  const output = (r.stdout + '\n' + r.stderr).trim()
2259
+ // 启动型安装(如 macOS xcode-select):安装是系统弹窗异步完成的,返回「已触发」指引。
2260
+ if (cmd.started) {
2261
+ return { ok: false, started: true, tool: t, command: cmd.label, error: tr('已触发安装,请按系统提示完成,然后点「重新检查」') }
2262
+ }
1850
2263
  // 安装后复查是否已装上。
1851
- const ok = r.ok && (t === 'git' ? (GIT && GIT !== 'git') : t === 'gh' ? (GH && GH !== 'gh') : !(SSH === 'ssh' || SSH === 'ssh.exe'))
2264
+ const ok = toolInstalled(t)
1852
2265
  return {
1853
2266
  ok,
1854
2267
  tool: t,
1855
2268
  command: cmd.label,
1856
2269
  output: output || (ok ? tr('安装成功') : tr('安装失败')),
1857
2270
  needElevation: IS_WIN && t === 'ssh',
2271
+ restartNeeded: ok ? restartNeededFor(t) : false,
1858
2272
  }
1859
2273
  }
1860
2274
 
1861
2275
  // ---------------------------------------------------------------------------
1862
- // ④ 克隆远程仓库 / ⑤ 提交 PR / ⑥ 发布 npm 包 —— host 逻辑
2276
+ // ④ 克隆远程仓库 / ⑤ 发布 npm 包 —— host 逻辑
1863
2277
  // ---------------------------------------------------------------------------
1864
2278
 
1865
2279
  // ---------- LLM 辅助:用 DSH 的 llm 服务 + 默认模型(当前对话/默认模型) ----------
@@ -1946,27 +2360,11 @@ function cloneHomeDir() {
1946
2360
  return homedir()
1947
2361
  }
1948
2362
 
1949
- /** 默认克隆位置(⑤⑥ 等板块的工作目录兜底):DSH 默认工作区。 */
2363
+ /** 默认克隆位置(⑤ 等板块的工作目录兜底):DSH 默认工作区。 */
1950
2364
  function cloneDefaultDir() {
1951
2365
  return resolveDefaultDir(process.cwd())
1952
2366
  }
1953
2367
 
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
2368
 
1971
2369
  /** 某仓库名在 baseDir 下是否已有同名 git 仓库(含 baseDir 本身就是同名仓库的情形,
1972
2370
  * 如工作区 C:\Users\xxx\workspace 即对应仓库 workspace)。 */
@@ -2087,618 +2485,21 @@ function cloneFlow(url, dest, name) {
2087
2485
  return { ok: true, dir: full, name: repoName }
2088
2486
  }
2089
2487
 
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
2488
 
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
- }
2489
+ // ---------- 发布 npm 包(npm publish) ----------
2692
2490
 
2693
- // ---------- ⑥ 发布 npm 包(npm publish) ----------
2491
+ /** 发布/登录统一使用的官方 npm registry(镜像源只同步、不接受发布)。 */
2492
+ const NPM_OFFICIAL_REGISTRY = 'https://registry.npmjs.org'
2694
2493
 
2695
- /** npm 状态:是否安装、registry、whoami、目标目录是否存在 package.json。 */
2494
+ /** npm 状态:是否安装、registry、whoami、目标目录是否存在 package.json。
2495
+ * whoami 与发布都固定走官方源 NPM_OFFICIAL_REGISTRY(用户全局可能是镜像源)。 */
2696
2496
  function npmStatus(dir) {
2697
2497
  const npm = NPM
2698
2498
  const cwd = String(dir || '').trim() || cloneDefaultDir()
2699
2499
  const installed = npm !== 'npm' && npm !== 'npm.cmd'
2700
2500
  const registry = run(npm, ['config', 'get', 'registry'], { shell: IS_WIN })
2701
- const whoami = run(npm, ['whoami'], { shell: IS_WIN })
2501
+ const whoami = run(npm, ['whoami', '--registry=' + NPM_OFFICIAL_REGISTRY], { shell: IS_WIN })
2502
+ const configReg = registry.ok ? registry.stdout.trim() : undefined
2702
2503
  return {
2703
2504
  ok: true,
2704
2505
  installed,
@@ -2706,7 +2507,10 @@ function npmStatus(dir) {
2706
2507
  dir: cwd,
2707
2508
  dirExists: existsSync(cwd),
2708
2509
  hasPackageJson: existsSync(join(cwd, 'package.json')),
2709
- registry: registry.ok ? registry.stdout.trim() : undefined,
2510
+ registry: configReg,
2511
+ // 发布实际使用的官方源;配置源是镜像时置 warning 供面板提示。
2512
+ publishRegistry: NPM_OFFICIAL_REGISTRY,
2513
+ mirrorConfigured: configReg !== undefined && !configReg.startsWith('https://registry.npmjs.org'),
2710
2514
  whoami: whoami.ok ? whoami.stdout.trim() : undefined,
2711
2515
  whoamiError: whoami.ok ? undefined : ((whoami.stderr || whoami.stdout || '').trim() || tr('未登录 npm')),
2712
2516
  }
@@ -2714,13 +2518,19 @@ function npmStatus(dir) {
2714
2518
 
2715
2519
  /**
2716
2520
  * 打开一个终端窗口,在目标目录运行 `npm login`(交互式登录无法非交互执行,
2717
- * 需要用户在弹出的终端里完成)。Windows 用 `start cmd /k`;macOS/Linux 探测常见终端。
2521
+ * 需要用户在弹出的终端里完成)。登录固定走官方源。
2522
+ *
2523
+ * 安全要点:
2524
+ * - 先探测终端二进制真实存在(findTerminal),绝不 spawn 不存在的程序——
2525
+ * spawn 对 ENOENT 是异步 error,无监听器会抛未捕获异常把 host 进程打崩;
2526
+ * - 每个 child 都挂 'error' 监听器兜底;
2527
+ * - KDE 的 konsole 用 --separate 开独立窗口,避免与宿主所在的 konsole 实例纠缠。
2718
2528
  * @param {string} [dir] - 目标目录。
2719
2529
  * @returns {{ok:boolean, opened:boolean, command:string, detail:string}}
2720
2530
  */
2721
2531
  function npmLoginTerminal(dir) {
2722
2532
  const cwd = String(dir || '').trim() || cloneDefaultDir()
2723
- const command = 'npm login'
2533
+ const command = 'npm login --registry=' + NPM_OFFICIAL_REGISTRY
2724
2534
  const cdCmd = IS_WIN
2725
2535
  ? `cd /d "${cwd}" && ${command}`
2726
2536
  : `cd "${cwd}" && ${command}`
@@ -2728,28 +2538,40 @@ function npmLoginTerminal(dir) {
2728
2538
  if (IS_WIN) {
2729
2539
  // start cmd /k 打开独立窗口(第一引号段是窗口标题);detached 不阻塞宿主。
2730
2540
  const child = spawn('cmd', ['/c', 'start', '"npm-login"', 'cmd', '/k', cdCmd], { detached: true, stdio: 'ignore', windowsHide: false })
2541
+ child.on('error', () => { /* 防御:错误已由返回文案覆盖 */ })
2731
2542
  child.unref()
2732
2543
  return { ok: true, opened: true, command, detail: tr('已打开终端窗口,请完成 npm login 后回到面板点「重新检查」') }
2733
2544
  }
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 */ }
2545
+ const bin = findTerminal()
2546
+ if (bin === null) {
2547
+ return { ok: false, opened: false, command, detail: tr('未找到可用终端,请手动在目标目录运行:') + command }
2746
2548
  }
2747
- return { ok: false, opened: false, command, detail: tr('未找到可用终端,请手动在目标目录运行:') + command }
2549
+ const args = (bin === 'konsole')
2550
+ ? ['--separate', '-e', 'sh', '-c', cdCmd] // KDE:独立窗口,不并入宿主实例
2551
+ : (bin === 'gnome-terminal')
2552
+ ? ['--', 'sh', '-c', cdCmd]
2553
+ : ['-e', 'sh', '-c', cdCmd] // xterm 系 / x-terminal-emulator 等
2554
+ const child = spawn(bin, args, { detached: true, stdio: 'ignore' })
2555
+ child.on('error', () => { /* 防御:二进制被删等竞态也不会崩宿主 */ })
2556
+ child.unref()
2557
+ return { ok: true, opened: true, command, detail: tr('已打开终端窗口,请完成 npm login 后回到面板点「重新检查」') }
2748
2558
  } catch (e) {
2749
2559
  return { ok: false, opened: false, command, detail: String((e && e.message) || e) }
2750
2560
  }
2751
2561
  }
2752
2562
 
2563
+ /** 在 PATH 中找可用的终端程序(找不到返回 null)。 */
2564
+ function findTerminal() {
2565
+ const candidates = [
2566
+ 'x-terminal-emulator', 'konsole', 'gnome-terminal', 'xfce4-terminal',
2567
+ 'mate-terminal', 'lxterminal', 'xterm', 'kitty', 'alacritty', 'wezterm',
2568
+ ]
2569
+ for (const bin of candidates) {
2570
+ if (findOnPath(bin)) return bin
2571
+ }
2572
+ return null
2573
+ }
2574
+
2753
2575
  /** 脱敏 npm config list 输出:隐藏 token / auth 等敏感值。 */
2754
2576
  function redactNpmList(output) {
2755
2577
  return String(output || '').split(/\r?\n/).map((line) => {
@@ -2772,14 +2594,19 @@ function npmRunStep(dir, step) {
2772
2594
  switch (step) {
2773
2595
  case 'registry': {
2774
2596
  const r = runNpm(['config', 'get', 'registry'])
2775
- return { ok: true, step, output: r.ok ? r.stdout.trim() : ((r.stderr || '') + (r.stdout || '')).trim() }
2597
+ const cfg = r.ok ? r.stdout.trim() : ((r.stderr || '') + (r.stdout || '')).trim()
2598
+ // 配置源与发布源分离展示:发布固定走官方源,避免镜像源导致发布失败。
2599
+ const extra = cfg && !cfg.startsWith('https://registry.npmjs.org')
2600
+ ? '\n' + tr('配置源是镜像,发布将使用官方源:') + NPM_OFFICIAL_REGISTRY
2601
+ : ''
2602
+ return { ok: true, step, output: cfg + extra }
2776
2603
  }
2777
2604
  case 'list': {
2778
2605
  const r = runNpm(['config', 'list'])
2779
2606
  return { ok: true, step, output: redactNpmList((r.stdout || '') + (r.stderr || '')) }
2780
2607
  }
2781
2608
  case 'whoami': {
2782
- const r = runNpm(['whoami'])
2609
+ const r = runNpm(['whoami', '--registry=' + NPM_OFFICIAL_REGISTRY])
2783
2610
  return { ok: true, step, output: r.ok ? r.stdout.trim() : ((r.stderr || r.stdout || '').trim() || tr('未登录 npm')), loggedIn: r.ok }
2784
2611
  }
2785
2612
  case 'pack': {
@@ -2787,7 +2614,8 @@ function npmRunStep(dir, step) {
2787
2614
  return { ok: true, step, output: (r.stdout || '') + (r.stderr || '') }
2788
2615
  }
2789
2616
  case 'publish': {
2790
- const r = runNpm(['publish'], 180_000)
2617
+ // 发布固定走官方源(用户全局可能是 npmmirror 等镜像,镜像不接受发布)。
2618
+ const r = runNpm(['publish', '--registry=' + NPM_OFFICIAL_REGISTRY], 180_000)
2791
2619
  const blob = ((r.stdout || '') + (r.stderr || '')).trim()
2792
2620
  return { ok: r.ok, step, output: blob || tr('npm publish 已执行'), error: r.ok ? undefined : (blob || tr('npm publish 失败')) }
2793
2621
  }
@@ -3108,8 +2936,6 @@ function baseName(p) {
3108
2936
  }
3109
2937
 
3110
2938
  export function apply(ctx) {
3111
- // 启动时把预置 PR 规则(awesome-dsh-plugin 等)写入插件目录 rules/(不存在才写)。
3112
- seedPresetPrRules()
3113
2939
  const fallbackDir = resolveDefaultDir(process.cwd())
3114
2940
  // 每个请求按 ?lang= 参数跑在独立语言上下文里(AsyncLocalStorage),
3115
2941
  // 所有 flow 函数内的 tr() 都能读到当前请求的语言,并发请求互不干扰。
@@ -3119,15 +2945,57 @@ export function apply(ctx) {
3119
2945
  json(res, 200, payload)
3120
2946
  })
3121
2947
 
2948
+ // 流式变体:逐行 NDJSON 事件({type:'step'|'out'|'result'}),供一键安装弹窗展示实时进度。
2949
+ const handleStream = async (req, res, fn) => requestLangStore.run(langOf(req), async () => {
2950
+ if (!isLoopbackRequest(req)) return forbidden(res)
2951
+ res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache' })
2952
+ const emit = (obj) => { try { res.write(JSON.stringify(obj) + '\n') } catch { /* client gone */ } }
2953
+ try {
2954
+ const result = await fn(emit)
2955
+ emit({ type: 'result', result })
2956
+ } catch (error) {
2957
+ emit({ type: 'result', result: { ok: false, error: String((error && error.message) || error) } })
2958
+ }
2959
+ try { res.end() } catch { /* ignore */ }
2960
+ })
2961
+
3122
2962
  const routes = {
3123
2963
  '/env': (req, res) => handle(req, res, async () => ({ ok: true, ...checkEnv() })),
3124
2964
  '/install-tool': async (req, res) => {
3125
2965
  if (req.method !== 'POST') return methodNotAllowed(res, req.method)
3126
- await handle(req, res, async (req) => {
2966
+ await handleStream(req, res, async (emit) => {
3127
2967
  const body = JSON.parse((await readBody(req)) || '{}')
3128
- return installToolFlow(body.tool)
2968
+ return installToolFlow(body.tool, emit)
3129
2969
  })
3130
2970
  },
2971
+ '/restart': async (req, res) => {
2972
+ if (req.method !== 'POST') {
2973
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
2974
+ res.end('method not allowed')
2975
+ return
2976
+ }
2977
+ if (!trustedRestartRequest(req)) {
2978
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
2979
+ res.end(JSON.stringify({ ok: false, code: 'forbidden' }))
2980
+ return
2981
+ }
2982
+ if (!restartAllowed()) {
2983
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
2984
+ res.end(JSON.stringify({ ok: false, code: 'restart-disabled', message: '重启已被禁用(supervisor 托管或 DSH_SCM_RESTART=0)' }))
2985
+ return
2986
+ }
2987
+ // 先应答,再让 helper 接手:500ms 后本进程退出,helper 等端口释放后按原命令拉起新进程。
2988
+ let result
2989
+ try {
2990
+ result = scheduleRestart(servingPort(req))
2991
+ } catch (error) {
2992
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
2993
+ res.end(JSON.stringify({ ok: false, error: `无法启动重启进程:${error instanceof Error ? error.message : String(error)}` }))
2994
+ return
2995
+ }
2996
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
2997
+ res.end(JSON.stringify({ ok: true, ...(result ?? { already: true }) }))
2998
+ },
3131
2999
  '/ssh': (req, res) => handle(req, res, async () => ({ ok: true, ...checkSsh() })),
3132
3000
  '/gen-key': async (req, res) => {
3133
3001
  if (req.method !== 'POST') return methodNotAllowed(res, req.method)
@@ -3380,7 +3248,7 @@ export function apply(ctx) {
3380
3248
  },
3381
3249
 
3382
3250
  // ---- ④ 克隆远程仓库 ----
3383
- // 默认工作区(⑤⑥ 等板块兜底)。
3251
+ // 默认工作区(⑤ 等板块兜底)。
3384
3252
  '/clone/default-dir': (req, res) => handle(req, res, async () => {
3385
3253
  const dir = cloneDefaultDir()
3386
3254
  return { ok: true, dir, name: baseName(dir) }
@@ -3407,64 +3275,7 @@ export function apply(ctx) {
3407
3275
  })
3408
3276
  },
3409
3277
 
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 包 ----
3278
+ // ---- ⑤ 发布 npm ----
3468
3279
  '/npm/status': async (req, res) => {
3469
3280
  if (req.method !== 'GET') return methodNotAllowed(res, req.method)
3470
3281
  await handle(req, res, async (req) => {
@@ -3497,3 +3308,6 @@ export function apply(ctx) {
3497
3308
  }
3498
3309
 
3499
3310
  export default { name, inject, apply }
3311
+
3312
+ // 供插件作者 / 测试直接调用(不影响插件加载)。
3313
+ export { checkEnv, installCommand, installCommandSystem, ghUserLevelScript, ghUserLevelSteps, ghReleaseAsset, toolInstalled, canSudo, restartNeededFor, installToolFlow, restartAllowed, servingPort, trustedRestartRequest, restartLaunch, scheduleRestart, npmStatus, npmLoginTerminal, npmRunStep, findTerminal }