@noob-stupid/dsh-plugin-console 0.5.17 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,6 +236,22 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
236
236
  with a client-side fallback;
237
237
  - **Type badges**: 官方 / 聚合 / 技能 (repo contains SKILL.md) recognized automatically.
238
238
 
239
+ > **Network environment notes (why a multi-source setup can look "all dead")**
240
+ > - **Accelerators/proxies that rewrite hosts kill the direct sources**: tools like Steam++ often point
241
+ > `github.com` at `127.0.0.1`, so the GitHub direct source, `raw.githubusercontent.com` and
242
+ > `api.github.com` all become unreachable — the plugin is not broken. The console now reports this
243
+ > honestly as "local proxy / certificate interception (…) — an accelerator or proxy was detected,
244
+ > turn it off and retry" instead of a vague "network unreachable".
245
+ > - **Free mirrors are unreliable for the git protocol on large packs, while archive/raw are usually
246
+ > fast**: measured on the same ghproxy.net host, archive (plain HTTP) ran at **4 MB/s** while the git
247
+ > protocol moved **0 B/s**. Since this version the git channel has a stall criterion (switch source
248
+ > after 20 s below 1 B/s) plus its own budget, and falls back to the **archive channel** (download the
249
+ > tarball, extract, create the repo — equivalent to a clone) when all git sources fail. No manual
250
+ > source switching needed.
251
+ > - To reorder transports, use **Source Manager**: **git sources**, **archive sources** and **index
252
+ > sources** all support primary→backup plus self-hosted mirrors (internal Gitea/GitLab, local
253
+ > `file://` bare repos).
254
+
239
255
  ### Static index market (plugin & skill tabs)
240
256
 
241
257
  > **Hybrid architecture**: browsing uses the static index (instant, zero GitHub API calls),
package/README.zh.md CHANGED
@@ -180,6 +180,18 @@ git clone https://github.com/Noob-stupid/dsh-plugin-hub /tmp/dsh-plugin-console
180
180
  - **类型徽标**:官方 / 聚合 / 技能(仓库含 SKILL.md)自动识别;
181
181
  - 「添加到本地」= 按当前源安装(registry 失败自动回退 git 通道)+ 写入启用条目,HMR 生效。
182
182
 
183
+ > **网络环境提醒(多源为什么有时"看起来都失效")**
184
+ > - **加速器/代理改 hosts 会让"直连源"失效**:Steam++ 这类工具常把 `github.com` 指到 `127.0.0.1`,
185
+ > 于是 GitHub 直连源、`raw.githubusercontent.com`、`api.github.com` 全部不可达 —— 这不是插件坏了。
186
+ > 本控制台会把这类失败**如实报成**「本地代理/证书拦截(…)—— 检测到本机加速器/代理,建议关闭后重试」,
187
+ > 而不是笼统的"网络不可达"。
188
+ > - **免费镜像的 git 协议对大 pack 不可靠,archive / raw 通常很快**:真机实测同一个 ghproxy.net 域名下
189
+ > archive(普通 HTTP 下载)**4 MB/s**、git 协议 **0 B/s**。所以本版起 git 通道有停滞判据
190
+ > (连续 20 秒 <1 B/s 就换源)与独立预算,并在 git 全部失败后**自动改走 archive 通道**
191
+ > (下载压缩包再解压建仓,结果与 clone 等价)—— 你不需要手动换源。
192
+ > - 想让某条通道优先,去「软件源」里调整顺序:**Git 源**、**archive 源**、**索引源** 都支持主→备与
193
+ > 自建镜像(内网 Gitea/GitLab、本地 `file://` 裸仓库均可)。
194
+
183
195
  ### 静态索引市场(插件 / 技能双 tab)
184
196
 
185
197
  > **混合架构**:浏览走静态索引(秒开、零 API 调用),搜索走实时通道(GitHub 搜索 API / 多源并行)——
package/lib/client.js CHANGED
@@ -453,6 +453,10 @@ window.__ModuleLoader__.load({
453
453
  addIndexSource: "添加索引源",
454
454
  indexUrlPlaceholder: "索引 JSON 地址(https:// 或本机/私网 http://)",
455
455
  gitSourcesTitle: "Git 源",
456
+ archiveSourcesTitle: "archive 源(HTTP 压缩包)",
457
+ archiveSourcesDesc: "git 协议拉不动时的替代通道:直接用 HTTP 下载仓库压缩包再解压建仓(真机实测同一镜像下 archive 4 MB/s、git 协议 0 B/s)。模板用 {owner}/{repo} 占位符,可选 {branch};主源优先,失败自动回退下一个。示例:https://ghproxy.net/https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.tar.gz",
458
+ archiveUrlPlaceholder: "https://ghproxy.net/https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.tar.gz",
459
+ addArchiveSource: "添加 archive 源",
456
460
  gitSourcesDesc: "克隆仓库时用的地址模板({owner}/{repo} 占位符)。下面这个列表就是「自定义 Git 源」——内置两条是默认值,点「添加 Git 源」即可换成你自己的:Gitee = https://gitee.com/{owner}/{repo}.git;GitLab = https://gitlab.com/{owner}/{repo}.git;自建 Gitea = https://你的域名/{owner}/{repo}.git;镜像代理 = https://镜像站/https://github.com/{owner}/{repo}.git;本地裸仓库 = file:///D:/repos/{owner}/{repo}.git。主源优先,失败自动回退下一个",
457
461
  addGitSource: "添加 Git 源",
458
462
  gitUrlPlaceholder: "如 https://gitee.com/{owner}/{repo}.git",
@@ -963,6 +967,10 @@ window.__ModuleLoader__.load({
963
967
  addIndexSource: "Add index source",
964
968
  indexUrlPlaceholder: "Index JSON URL (https:// or local/intranet http://)",
965
969
  gitSourcesTitle: "Git sources",
970
+ archiveSourcesTitle: "Archive sources (HTTP tarball)",
971
+ archiveSourcesDesc: "Fallback transport when the git protocol cannot move data: download the repo tarball over plain HTTP, extract, and create a repo from it (measured: on the same mirror archive ran at 4 MB/s while git moved 0 B/s). Templates use {owner}/{repo}, optionally {branch}; primary first, falls back on failure.",
972
+ archiveUrlPlaceholder: "https://ghproxy.net/https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.tar.gz",
973
+ addArchiveSource: "Add archive source",
966
974
  gitSourcesDesc: "URL template for cloning ({owner}/{repo} placeholders). This list IS the custom git-source list — the two built-ins are just defaults; click Add git source to swap in your own: Gitee = https://gitee.com/{owner}/{repo}.git; GitLab = https://gitlab.com/{owner}/{repo}.git; self-hosted Gitea = https://your-domain/{owner}/{repo}.git; mirror proxy = https://mirror/https://github.com/{owner}/{repo}.git; local bare repo = file:///D:/repos/{owner}/{repo}.git. Primary first, falls back on failure",
967
975
  addGitSource: "Add Git source",
968
976
  gitUrlPlaceholder: "e.g. https://gitee.com/{owner}/{repo}.git",
@@ -2007,8 +2015,12 @@ window.__ModuleLoader__.load({
2007
2015
  const [gitSourceName, setGitSourceName] = react.useState("");
2008
2016
  const [gitSourceUrl, setGitSourceUrl] = react.useState("");
2009
2017
  const [editGit, setEditGit] = react.useState(null);
2018
+ // archive 源(批次 C-⑨):git 协议拉不动时改走普通 HTTP 下载压缩包;模板与 git 源同形
2019
+ const [archiveSourceName, setArchiveSourceName] = react.useState("");
2020
+ const [archiveSourceUrl, setArchiveSourceUrl] = react.useState("");
2021
+ const [editArchive, setEditArchive] = react.useState(null);
2010
2022
  // 软件源弹窗折叠分区:软件源默认展开,其余收起(避免弹窗过长)
2011
- const [openSections, setOpenSections] = react.useState({ registries: true, index: false, git: false, search: false, gitee: false });
2023
+ const [openSections, setOpenSections] = react.useState({ registries: true, index: false, git: false, archive: false, search: false, gitee: false });
2012
2024
  // 官方风格入口(新 tab)传入 section 时就地展开对应功能。
2013
2025
  // 不传 section 时(= 原有 tab 的调用方式)行为与改动前**完全一致** —— 纯加法。
2014
2026
  react.useEffect(() => {
@@ -2024,6 +2036,8 @@ window.__ModuleLoader__.load({
2024
2036
  const [searchSourcesList, setSearchSourcesList] = react.useState(null);
2025
2037
  // Git 源列表(「仓库落地」提示动态显示当前主源,避免写死 GitHub)
2026
2038
  const [gitSourcesList, setGitSourcesList] = react.useState(null);
2039
+ // archive 源列表(「软件源 → archive 源」)
2040
+ const [archiveSourcesList, setArchiveSourcesList] = react.useState(null);
2027
2041
  // 自定义搜索源添加表单
2028
2042
  const [searchSourceName, setSearchSourceName] = react.useState("");
2029
2043
  const [searchSourceUrl, setSearchSourceUrl] = react.useState("");
@@ -2074,6 +2088,7 @@ window.__ModuleLoader__.load({
2074
2088
  setSourcesData((prev) => (prev === null ? data.sources : prev));
2075
2089
  setSearchSourcesList(data.sources.searchSources ?? []);
2076
2090
  setGitSourcesList(data.sources.gitSources ?? []);
2091
+ setArchiveSourcesList(data.sources.archiveSources ?? []);
2077
2092
  },
2078
2093
  () => {},
2079
2094
  );
@@ -3060,6 +3075,17 @@ window.__ModuleLoader__.load({
3060
3075
  if (editIndex === null) return;
3061
3076
  sourcesAction({ action: "edit-index", id: editIndex.id, name: editIndex.name, url: editIndex.url }, () => setEditIndex(null));
3062
3077
  };
3078
+ /** archive 源(批次 C-⑨):与 git 源同一套操作语义(添加 / 编辑 / 设主源 / 删除)。 */
3079
+ const addArchiveSource = () => {
3080
+ const tpl2 = archiveSourceUrl.trim();
3081
+ if (!tpl2.includes("{owner}") || !tpl2.includes("{repo}")) { setMessage(t("archiveUrlPlaceholder")); return; }
3082
+ sourcesAction({ action: "add-archive", name: archiveSourceName.trim(), urlTemplate: tpl2 }, () => { setArchiveSourceName(""); setArchiveSourceUrl(""); });
3083
+ };
3084
+ const saveEditArchive = () => {
3085
+ if (editArchive === null) return;
3086
+ sourcesAction({ action: "edit-archive", id: editArchive.id, name: editArchive.name, urlTemplate: editArchive.urlTemplate }, () => setEditArchive(null));
3087
+ };
3088
+
3063
3089
  const addGitSource = () => {
3064
3090
  const tpl = gitSourceUrl.trim();
3065
3091
  if (!tpl.includes("{owner}") || !tpl.includes("{repo}")) {
@@ -5035,6 +5061,28 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5035
5061
  el("input", { type: "text", placeholder: t("gitUrlPlaceholder"), value: gitSourceUrl, onChange: (event) => setGitSourceUrl(event.currentTarget.value), style: { flex: 2, minWidth: 0 } }),
5036
5062
  el("button", { type: "button", className: styles.toggle, disabled: sourcesBusy, onClick: addGitSource }, t("addGitSource"))),
5037
5063
  ]),
5064
+ accSection("archive", t("archiveSourcesTitle"), [
5065
+ el("p", { className: styles.message }, t("archiveSourcesDesc")),
5066
+ (sourcesData.archiveSources ?? []).map((s) =>
5067
+ editArchive !== null && editArchive.id === s.id
5068
+ ? el("div", { key: s.id, className: styles.rowTop, style: { justifyContent: "space-between" } },
5069
+ el("input", { type: "text", value: editArchive.name, onChange: (event) => setEditArchive({ ...editArchive, name: event.currentTarget.value }), style: { flex: 1, minWidth: 80 } }),
5070
+ el("input", { type: "text", value: editArchive.urlTemplate, onChange: (event) => setEditArchive({ ...editArchive, urlTemplate: event.currentTarget.value }), style: { flex: 2, minWidth: 120 } }),
5071
+ el("button", { type: "button", className: styles.toggle, disabled: sourcesBusy, onClick: saveEditArchive }, t("saveSource")),
5072
+ el("button", { type: "button", className: styles.toggle, onClick: () => setEditArchive(null) }, t("cancelEdit")))
5073
+ : el("div", { key: s.id, className: styles.rowTop, style: { justifyContent: "space-between" } },
5074
+ el("div", { className: styles.rowTop, style: { flex: 1, minWidth: 0 } },
5075
+ el("strong", { className: styles.name, style: { flex: "none", maxWidth: "35%" } }, s.name + (s.primary ? "(" + t("sourcePrimary") + ")" : "")),
5076
+ el("code", { className: styles.srcUrl }, s.urlTemplate)),
5077
+ el("div", { className: styles.rowTop },
5078
+ s.primary ? null : el("button", { type: "button", className: styles.toggle, title: t("setPrimaryHint"), disabled: sourcesBusy, onClick: () => sourcesAction({ action: "set-archive-primary", id: s.id }) }, t("setPrimary")),
5079
+ el("button", { type: "button", className: styles.toggle, title: t("editSourceHint"), disabled: sourcesBusy, onClick: () => setEditArchive({ id: s.id, name: s.name, urlTemplate: s.urlTemplate }) }, t("editSource")),
5080
+ el("button", { type: "button", className: styles.toggle, title: t("removeSourceHint"), disabled: sourcesBusy, onClick: () => sourcesAction({ action: "remove-archive", id: s.id }) }, t("removeSource"))))),
5081
+ el("div", { className: styles.rowTop },
5082
+ el("input", { type: "text", placeholder: t("sourceName"), value: archiveSourceName, onChange: (event) => setArchiveSourceName(event.currentTarget.value), style: { flex: 1, minWidth: 0 } }),
5083
+ el("input", { type: "text", placeholder: t("archiveUrlPlaceholder"), value: archiveSourceUrl, onChange: (event) => setArchiveSourceUrl(event.currentTarget.value), style: { flex: 2, minWidth: 0 } }),
5084
+ el("button", { type: "button", className: styles.toggle, disabled: sourcesBusy, onClick: addArchiveSource }, t("addArchiveSource"))),
5085
+ ]),
5038
5086
  accSection("search", t("searchSourcesTitle"), [
5039
5087
  el("p", { className: styles.message }, t("searchSourcesDesc")),
5040
5088
  (sourcesData.searchSources ?? []).map((s) =>
@@ -0,0 +1,209 @@
1
+ // L1 · domain —— archive-source.js(archive 通道:用 HTTP 下载仓库压缩包替代 git 协议取仓库内容)
2
+ //
3
+ // 为什么需要它(2026-09-27 真机实测):**同一个 ghproxy.net 域名**
4
+ // · archive(普通 HTTP GET)能跑到 4 MB/s —— 429 MB / 105 秒下完;
5
+ // · git 协议 0 B/s —— `git clone` 挂到停滞判据判死为止,一个字节都没有。
6
+ // 于是"git 拉不动"不该等于"这个仓库装不上":下载 tar.gz → 解压 → 建仓,得到与 clone **等价的结果**,
7
+ // 让上层(套装装配 / 仓库落地)无感。
8
+ //
9
+ // 设计(沿用既有工程约定):
10
+ // · 源模板与 gitSources **同形**({owner}/{repo} 占位符 + 主→备 + 可在「软件源」里配置),
11
+ // 另支持 {branch} 占位符;默认两条:ghproxy 镜像 / GitHub codeload 官方。
12
+ // · 超时/杀树/等退出一律复用 infra 的 execFileWithKillTree(它超时会杀整棵进程树并等它退出);
13
+ // · 半成品目录清理一律复用 infra 的 disposeDir(删不掉就改名降级成 .trash-*);
14
+ // · 失败记录带上"本次已下载多少字节"(与 git 通道同一套事实口径,见 repoland.js 的 measureProgressBytes)。
15
+
16
+ import { existsSync, mkdirSync, statSync, renameSync, readdirSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { tmpdir } from 'node:os'
19
+ import { execFileWithKillTree, gitEnv } from '../infra/exec.js'
20
+ import { disposeDir, disposeNote } from '../infra/fsx.js'
21
+ import { readSources, DEFAULT_SOURCES } from './sources.js'
22
+
23
+ /** 单次 archive 下载的超时(默认 180 秒):真机 429 MB / 4 MB/s ≈ 105 秒,留出余量。
24
+ * 比 git 通道的 60 秒宽 —— archive 是"能跑满带宽"的传输,用 git 的短超时反而会误杀大仓库。 */
25
+ const ARCHIVE_TIMEOUT_MS = 180000
26
+
27
+ /** 解压 + `git init/add/commit` 的超时(大仓库的首个 commit 会慢,但失败不致命,见 archiveRepo)。 */
28
+ const ARCHIVE_COMMIT_TIMEOUT_MS = 120000
29
+
30
+ /** 分支候选顺序:调用方给的分支打头,再回退 main / master / dev,最多试 3 条
31
+ * (真机 dsh-web 的默认分支就是 dev;分支不对时 archive 会 404,必须能自己找回来)。 */
32
+ function archiveBranchOrder(branch, defaults = ['main', 'master', 'dev']) {
33
+ const list = Array.isArray(branch) ? branch : [branch]
34
+ const merged = [...list, ...defaults].filter((b) => typeof b === 'string' && b.trim() !== '')
35
+ return [...new Set(merged.map((b) => b.trim()))].slice(0, 3)
36
+ }
37
+
38
+ /** archive 候选(带源 id/名称/模板/最终 URL):与 gitCloneCandidates 同形的排序逻辑。
39
+ * 模板里没有 {branch} 时照旧替换(同一 URL 对任何分支都成立,由站点自己决定)。 */
40
+ function archiveCandidates(repo, branch = 'main', sources = null) {
41
+ const full = String(repo ?? '')
42
+ const parts = full.split('/')
43
+ const owner = parts[0] ?? ''
44
+ const repoName = parts[1] ?? ''
45
+ let list = []
46
+ try {
47
+ list = (sources ?? readSources().archiveSources) ?? DEFAULT_SOURCES.archiveSources
48
+ } catch {
49
+ list = DEFAULT_SOURCES.archiveSources
50
+ }
51
+ const ordered = [...list].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
52
+ const out = []
53
+ for (const s of ordered) {
54
+ const tpl = typeof s?.urlTemplate === 'string' ? s.urlTemplate : ''
55
+ if (tpl === '') continue
56
+ const url = tpl
57
+ .replace(/\{owner\}/gu, owner)
58
+ .replace(/\{repo\}/gu, repoName)
59
+ .replace(/\{branch\}/gu, String(branch ?? 'main'))
60
+ if (url === '') continue
61
+ out.push({ id: typeof s.id === 'string' ? s.id : '', name: typeof s.name === 'string' ? s.name : '', urlTemplate: tpl, url })
62
+ }
63
+ if (out.length === 0) {
64
+ out.push({
65
+ id: 'codeload-archive',
66
+ name: 'GitHub codeload',
67
+ urlTemplate: 'https://codeload.github.com/{owner}/{repo}/tar.gz/refs/heads/{branch}',
68
+ url: `https://codeload.github.com/${full}/tar.gz/refs/heads/${String(branch ?? 'main')}`,
69
+ })
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** 解压出来的顶层目录名不可预测(GitHub 的 tar.gz 顶层是 `{repo}-{branch}/`),
75
+ * 统一用 `--strip-components=1` 摊平;万一某天的 tar 不认这个选项,这里再兜一层:
76
+ * 若目标目录里只有一个子目录且没有 package.json,就把它的内容提上来。 */
77
+ function flattenSingleDir(part, deps = {}) {
78
+ const readdir = deps.readdir ?? readdirSync
79
+ const exists = deps.exists ?? existsSync
80
+ const rename = deps.rename ?? renameSync
81
+ if (exists(join(part, 'package.json'))) return false
82
+ let entries = []
83
+ try { entries = readdir(part, { withFileTypes: true }) } catch { return false }
84
+ const dirs = entries.filter((e) => e.isDirectory())
85
+ if (dirs.length !== 1) return false
86
+ const inner = join(part, dirs[0].name)
87
+ let innerEntries = []
88
+ try { innerEntries = readdir(inner, { withFileTypes: true }) } catch { return false }
89
+ for (const entry of innerEntries) {
90
+ try { rename(join(inner, entry.name), join(part, entry.name)) } catch { return false }
91
+ }
92
+ try { disposeDir(inner) } catch {}
93
+ return true
94
+ }
95
+
96
+ /** 下载 + 解压 + 建仓,把结果落到 dest。成功返回
97
+ * `{ url, dir, source, archive: true, branch, bytes, gitNote }`,全部候选失败则抛错。
98
+ * deps(exec/removeDir/renameDir/exists/now/branch/timeoutMs/sources/curlBin/tarBin/gitInit)只为注入。 */
99
+ async function archiveRepo(repo, dest, { branch = 'main', timeoutMs = ARCHIVE_TIMEOUT_MS, deps = {} } = {}) {
100
+ const {
101
+ exec = execFileWithKillTree,
102
+ removeDir = disposeDir,
103
+ renameDir = renameSync,
104
+ exists = existsSync,
105
+ now = Date.now,
106
+ curlBin = process.platform === 'win32' ? 'curl.exe' : 'curl',
107
+ tarBin = 'tar',
108
+ gitBinName = process.platform === 'win32' ? 'git.exe' : 'git',
109
+ sources = null,
110
+ gitInit = true,
111
+ commitTimeoutMs = ARCHIVE_COMMIT_TIMEOUT_MS,
112
+ } = deps
113
+ const errors = []
114
+ const branches = archiveBranchOrder(branch)
115
+ const candidates = archiveCandidates(repo, branches[0], sources)
116
+ const workRoot = join(tmpdir(), `pc-archive-${now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
117
+ mkdirSync(workRoot, { recursive: true })
118
+ let seq = 0
119
+ for (const cand of candidates) {
120
+ for (const br of branches) {
121
+ seq += 1
122
+ const part = `${dest}.archive${seq}`
123
+ const url = cand.urlTemplate.includes('{branch}')
124
+ ? cand.urlTemplate.replace(/\{owner\}/gu, String(repo).split('/')[0] ?? '').replace(/\{repo\}/gu, String(repo).split('/')[1] ?? '').replace(/\{branch\}/gu, br)
125
+ : cand.url
126
+ const tgz = join(workRoot, `repo-${seq}.tar.gz`)
127
+ const startedAt = now()
128
+ try { removeDir(part) } catch {}
129
+ mkdirSync(part, { recursive: true })
130
+ try {
131
+ // ① 下载(curl:-f 让 HTTP 错误码直接失败;--max-time 与 exec 超时双保险)
132
+ // eslint-disable-next-line no-await-in-loop
133
+ await exec(curlBin, ['-f', '-L', '-sS', '--max-time', String(Math.max(5, Math.round(timeoutMs / 1000))), '-o', tgz, url], {
134
+ timeout: timeoutMs + 5000,
135
+ windowsHide: true,
136
+ maxBuffer: 4 * 1024 * 1024,
137
+ })
138
+ // ② 解压(--strip-components=1 摊平 {repo}-{branch}/ 顶层目录)
139
+ // eslint-disable-next-line no-await-in-loop
140
+ await exec(tarBin, ['-xzf', tgz, '-C', part, '--strip-components=1'], { timeout: 120000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
141
+ flattenSingleDir(part, { readdir: readdirSync, exists, rename: renameDir })
142
+ // 判据是"解压出来有没有东西",**不是**"有没有 package.json":
143
+ // 套装仓库、纯技能仓库、示例仓库(真机验收用的 octocat/Hello-World)都没有根 package.json,
144
+ // 拿它当成功判据会把它们全部误判成失败。分支不对时站点回 404(curl -f 直接失败),
145
+ // 若站点对不存在的路径回 200 + 错误页,tar 解压会自己报错 —— 两条都在下面被如实记录。
146
+ let extracted = 0
147
+ try { extracted = readdirSync(part).length } catch {}
148
+ if (extracted === 0) {
149
+ errors.push({ url, message: `archive 解压后目录为空(可能分支 ${br} 不存在或仓库为空)`, branch: br, bytes: 0 })
150
+ continue
151
+ }
152
+ // ③ 建仓:让"下载来的目录"与 `git clone` 的结果等价(有 .git、有首个 commit)
153
+ let gitNote = null
154
+ if (gitInit) {
155
+ try {
156
+ // eslint-disable-next-line no-await-in-loop
157
+ await exec(gitBinName, ['init', '-q'], { cwd: part, timeout: commitTimeoutMs, windowsHide: true, env: gitEnv() })
158
+ // eslint-disable-next-line no-await-in-loop
159
+ await exec(gitBinName, ['add', '-A'], { cwd: part, timeout: commitTimeoutMs, windowsHide: true, env: gitEnv() })
160
+ // eslint-disable-next-line no-await-in-loop
161
+ await exec(gitBinName, ['-c', 'user.email=plugin-console@local', '-c', 'user.name=plugin-console', 'commit', '-qm', `archive ${repo}@${br}`], { cwd: part, timeout: commitTimeoutMs, windowsHide: true, env: gitEnv() })
162
+ } catch (error) {
163
+ // 内容已经拿到手了:建仓失败只记 note,绝不因此让"仓库内容可用"这件事失败
164
+ gitNote = `已下载并解压,但建仓(git init/add/commit)失败:${error instanceof Error ? error.message : String(error)}`.slice(0, 300)
165
+ }
166
+ }
167
+ let bytes = 0
168
+ try { bytes = statSync(tgz).size } catch {}
169
+ try {
170
+ removeDir(dest)
171
+ renameDir(part, dest)
172
+ } catch (error) {
173
+ errors.push({ url, message: `archive 内容就绪但落地失败(${error instanceof Error ? error.message : String(error)})`, branch: br, bytes })
174
+ continue
175
+ }
176
+ return { url, dir: dest, source: 'archive', archive: true, branch: br, sourceId: cand.id, bytes, gitNote, extracted, tries: seq, ms: now() - startedAt }
177
+ } catch (error) {
178
+ let bytes = 0
179
+ try { bytes = exists(tgz) ? statSync(tgz).size : 0 } catch {}
180
+ const cleared = removeDir(part)
181
+ const trashNote = cleared && typeof cleared.trashPath === 'string' && cleared.trashPath !== '' ? disposeNote(cleared) : ''
182
+ // 超时有两种来源,都要认:① 我们自己的 exec 超时(error.timedOut = 杀树收尾)
183
+ // ② curl 自己的 --max-time(`curl: (28) Operation timed out`)—— 后者不带 timedOut 字段,
184
+ // 只按字段判会把"超时"误报成普通失败,用户就看不出"镜像卡住了"。
185
+ const raw = String(error?.message ?? error)
186
+ const timedOut = error?.timedOut === true || /timed out|timeout|超时/iu.test(raw)
187
+ errors.push({
188
+ url,
189
+ branch: br,
190
+ bytes,
191
+ bytesReceived: bytes, // 与 git 通道同一字段名:汇总文案(summarizeCloneErrors)直接复用
192
+ timedOut,
193
+ message: timedOut
194
+ ? `archive 下载超时(${bytes > 0 ? `本次已下载 ${bytes} B` : '本次仅收到 0 B'})`
195
+ : `archive 通道失败:${raw.split(/\r?\n/u)[0].slice(0, 200)}`,
196
+ ...(trashNote === '' ? {} : { trashed: cleared.trashPath, trashNote }),
197
+ })
198
+ } finally {
199
+ try { removeDir(tgz) } catch {}
200
+ }
201
+ }
202
+ }
203
+ try { removeDir(workRoot) } catch {}
204
+ const first = errors[0]
205
+ const triedList = errors.map((e) => `${e.url}(${e.timedOut === true ? (e.bytes > 0 ? `超时,已下载 ${e.bytes} B` : '超时,仅收到 0 B') : (e.bytes > 0 ? `失败,已下载 ${e.bytes} B` : '失败')})`).join(';')
206
+ throw new Error(`archive 通道失败(首个错误:${first?.message ?? '未知'});已尝试 ${errors.length} 次:${triedList}`)
207
+ }
208
+
209
+ export { ARCHIVE_TIMEOUT_MS, ARCHIVE_COMMIT_TIMEOUT_MS, archiveBranchOrder, archiveCandidates, archiveRepo, flattenSingleDir }
@@ -0,0 +1,91 @@
1
+ // L1 · domain —— git-channel.js(git 通道的独立预算与尝试顺序;通道备注的统一样子)
2
+ // 2026-09-27 抽出:install-job.js 已到 600 行硬顶(测试 test-architecture-guard.mjs 的行数棘轮),
3
+ // 而"git 通道怎么用预算"本来就是一块自洽的策略,独立成模块后 install-job 只留一行调用。
4
+ // 抽出只搬移 + 收窄形参,**语义一字未改**(原实现见 commit 8deb112 之前的 tryCandidateChannels 尾段)。
5
+
6
+ import { gitCloneUrls } from './sources.js'
7
+
8
+ /** git 通道的**独立预算**(2026-09-27 加法):默认 120 秒,可用环境变量
9
+ * `DSH_GIT_CHANNEL_BUDGET_MS` 配置(夹在 15 秒 ~ 8 分钟之间,避免被写成 0 或无穷大)。
10
+ * 为什么必须独立封顶:真机实测 git 协议在镜像上可能是 **0 B/s**,一个 git 源就能把 8 分钟的作业预算
11
+ * 吃光 —— 后面的候选与本该可用的通道再没机会试(用户看到的是"卡了 8 分钟然后失败")。
12
+ * 调用点还有一条硬约束:**不得超过作业剩余预算**(见 tryGitChannel 的 deadline)。
13
+ * 纯函数 + env 注入,便于离线断言。 */
14
+ const GIT_CHANNEL_BUDGET_MS = 120000
15
+ function gitChannelBudgetMs(env = process.env) {
16
+ const raw = Number.parseInt(String(env?.DSH_GIT_CHANNEL_BUDGET_MS ?? ''), 10)
17
+ if (!Number.isFinite(raw)) return GIT_CHANNEL_BUDGET_MS
18
+ return Math.min(8 * 60 * 1000, Math.max(15000, raw))
19
+ }
20
+
21
+ /** git 通道里**单个** git 规格的 pnpm 超时上限(与旧代码的 60000 一致;预算更少时取剩余预算)。 */
22
+ const GIT_SPEC_TIMEOUT_MS = 60000
23
+
24
+ /** 剩余预算低于这个值就不必再起一个 git 规格了(起也来不及握手,只是白占一个进程位)。 */
25
+ const GIT_MIN_ATTEMPT_MS = 250
26
+
27
+ /** 通道备注(2026-09-27 加法,面板可见):通道为什么没试/为什么被跳过,必须留在 job 上。
28
+ * 旧代码只在"没有任何更具体错误"时才写原因,用户什么都看不到。 */
29
+ function noteChannel(job, text) {
30
+ if (job === null || job === undefined || typeof text !== 'string' || text === '') return
31
+ if (!Array.isArray(job.channelNotes)) job.channelNotes = []
32
+ if (!job.channelNotes.includes(text)) job.channelNotes.push(text)
33
+ }
34
+
35
+ /** 预算时长的可读写法(≥10 秒按秒、否则按毫秒)——只服务面板文案。 */
36
+ function formatBudgetMs(ms) {
37
+ const n = Math.max(0, Number(ms) || 0)
38
+ return n >= 10000 ? `${Math.round(n / 1000)} 秒` : `${Math.round(n)} 毫秒`
39
+ }
40
+
41
+ /** git 通道(守卫③):只对**请求里那个仓库**试 git 规格,且只在"同一作业第一次"试。
42
+ * 返回 { installedName, lastError }(与 tryCandidateChannels 的返回同形,便于原地替换)。
43
+ * 预算规则(2026-09-27):
44
+ * · 通道自己的预算与**作业剩余预算**取小(deadline 为 null 时只有通道预算);
45
+ * · 单个规格的 pnpm 超时 = min(60 秒, 剩余);
46
+ * · 预算耗尽就停手,并在 job 上留一条面板可见的原因(不静默跳过)。
47
+ * 依赖注入:ch(通道实现)与 budget 由调用方传入 —— 与 install-job 的注入缝同一套语义。 */
48
+ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null }) {
49
+ let installedName = null
50
+ let lastError = null
51
+ // gitChannelBlocked:作业层面已判定"这个仓库不该走 git"(根包未发布 / 首选候选来自市场索引)。
52
+ // 调用方算的 repoChannelAllowed 已经含它,这里再独立判一次 —— 语义写在离执行最近的地方,
53
+ // 单测注入 job 时也不会因为漏算一个布尔量而意外去 clone 一个几百 MB 的仓库。
54
+ if (!repoChannelAllowed || expanded || job?.gitChannelBlocked === true) return { installedName, lastError }
55
+ // 独立预算:同一作业里 git 通道只进一次,budget.git/gitDeadline 就是全局那份
56
+ if (budget.git === undefined) {
57
+ const cap = Math.max(0, gitChannelBudgetMs())
58
+ const remain = deadline === null ? cap : Math.max(0, deadline - Date.now())
59
+ budget.git = Math.min(cap, remain)
60
+ budget.gitDeadline = Date.now() + budget.git
61
+ if (budget.git <= 0) noteChannel(job, 'git 通道因预算不足被跳过(作业剩余时间已不足)')
62
+ }
63
+ const gitDeadline = budget.gitDeadline ?? (Date.now() + (budget.git ?? 0))
64
+ const gitSpecs = job.source === 'gitee'
65
+ ? [`git+https://gitee.com/${job.repo}.git`]
66
+ : [
67
+ ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
68
+ `github:${job.repo}`,
69
+ ]
70
+ let gitTried = 0
71
+ let gitBudgetHit = false
72
+ for (const spec of gitSpecs) {
73
+ const remain = gitDeadline - Date.now()
74
+ if (remain <= GIT_MIN_ATTEMPT_MS) { gitBudgetHit = true; break }
75
+ gitTried += 1
76
+ try {
77
+ // eslint-disable-next-line no-await-in-loop
78
+ await ch.pnpmInstall(profileDir, spec, undefined, Math.min(GIT_SPEC_TIMEOUT_MS, remain))
79
+ installedName = name
80
+ break
81
+ } catch (gitError) {
82
+ lastError = gitError
83
+ }
84
+ }
85
+ if (installedName === null && gitBudgetHit && gitTried > 0) {
86
+ noteChannel(job, `git 通道预算(${formatBudgetMs(budget.git ?? 0)})已用尽,剩余的 git 源不再尝试`)
87
+ }
88
+ return { installedName, lastError }
89
+ }
90
+
91
+ export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel }
@@ -0,0 +1,62 @@
1
+ // L1 · domain —— install-cleanup.js(安装失败清场 + 授权被拒/超时后的失败文案)
2
+ // 2026-09-27 从 install-job.js 原样搬出(install-job.js 触到 600 行硬顶,见 test-architecture-guard.mjs)。
3
+ // 只搬移、未改逻辑;install-job.js 继续 re-export 这两个名字,调用点与测试的 import 面一个字都没变。
4
+
5
+ import { existsSync, readdirSync } from 'node:fs'
6
+ import { basename, dirname, join } from 'node:path'
7
+ import { AI_CONSENT_TIMEOUT_MS } from './install.js'
8
+ import { disposeDir, disposeNote } from '../infra/fsx.js'
9
+
10
+ /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
11
+ * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
12
+ * 2026-09-26(改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
13
+ * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
14
+ function cleanupAttemptedCandidates(profileDir, candidates) {
15
+ const cleaned = []
16
+ const failed = []
17
+ const trashed = []
18
+ const dispose = (label, dir) => {
19
+ const result = disposeDir(dir)
20
+ if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
21
+ if (result.ok === true) return 1
22
+ failed.push({ name: label, path: dir, error: result.reason, note: disposeNote(result) })
23
+ return 0
24
+ }
25
+ for (const name of candidates) {
26
+ const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
27
+ const parent = dirname(dir)
28
+ const base = basename(dir)
29
+ let touched = 0
30
+ if (existsSync(dir)) touched += dispose(name, dir)
31
+ try {
32
+ for (const entry of readdirSync(parent)) {
33
+ if (!entry.startsWith(`${base}_tmp_`)) continue
34
+ touched += dispose(`${name}(临时目录)`, join(parent, entry))
35
+ }
36
+ } catch {}
37
+ if (touched > 0) cleaned.push(name)
38
+ }
39
+ return { cleaned, failed, trashed }
40
+ }
41
+
42
+ /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
43
+ * 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。
44
+ * 2026-09-26(改错):**不再出现「请手动删除」**——降级成 `.trash-*` 的项由后台清理接手。 */
45
+ function aiConsentFailureText(decision, leftovers, timeoutMs = AI_CONSENT_TIMEOUT_MS) {
46
+ const waitMinutes = Math.max(1, Math.round((Number(timeoutMs) || AI_CONSENT_TIMEOUT_MS) / 60000))
47
+ const base = decision?.timeout === true
48
+ ? `等待授权超时(${waitMinutes} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
49
+ : '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
50
+ const cleaned = leftovers?.cleaned ?? []
51
+ const failed = leftovers?.failed ?? []
52
+ const trashed = leftovers?.trashed ?? []
53
+ const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
54
+ const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
55
+ const trashedNote = trashed.length > 0
56
+ ? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
57
+ : ''
58
+ const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
59
+ return `${base}${cleanedNote}${trashedNote}${failedNote}`
60
+ }
61
+
62
+ export { cleanupAttemptedCandidates, aiConsentFailureText }