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

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
@@ -24,7 +24,7 @@ import { request as httpsRequest } from 'node:https'
24
24
  import { aiJobsFile, componentsFile, dshHome, entryPkgMeta, findPatchPath, marketIndexCacheFile, packageNameOf, pluginRoot, profileDirOf, repoLandConfFile, resolvePackageJson, rowIdOf } from './server/infra/paths.js'
25
25
  import { isFrameworkVersionNewer, parseFrameworkVersion, semverRangeMatch, semverRangeMatchLoose } from './server/infra/semver.js'
26
26
  import { GITHUB_API, curlJson, fetchJsonUrl, githubJson, postJsonUrl, rawTextWithFallback } from './server/infra/http.js'
27
- import { cleanupStalePackageDir, copyTree, queuedWrite } from './server/infra/fsx.js'
27
+ import { cleanupStalePackageDir, copyTree, queuedWrite, startTrashCleanup, trashScanRoots } from './server/infra/fsx.js'
28
28
  import { execFileAsync, gitEnv, processAlive } from './server/infra/exec.js'
29
29
  import { escapeRegExp, maskUrl } from './server/infra/mask.js'
30
30
  import { isAllowedWriteOrigin, isLoopback, readBody, sendError, sendJson } from './server/infra/httpd.js'
@@ -95,8 +95,8 @@ export function apply(ctx) {
95
95
  try { writeFileSync(join(dshHome(), 'plugin-console', 'fw-merge-error.log'), `${new Date().toISOString()} 清单对账:补 moduleName ${fixed.backfilled} 行,已启用转已适配 ${fixed.adopted} 行\n`, { flag: 'a' }) } catch {}
96
96
  }
97
97
  } catch (error) { logQuarantineMergeError(error) }
98
- // 启动时清掉上次升级/回滚/重启留下的僵尸计划任务(脚本被强杀时来不及自删)
99
- try { cleanupStaleFwTasks() } catch {}
98
+ // 启动时清掉上次升级/回滚/重启留下的僵尸计划任务(脚本被强杀时来不及自删)+ 后台清掉 `.trash-*` 降级目录(2026-09-26 加法;覆盖 tmpdir / profile node_modules / skills / repos,即发即忘、绝不阻塞启动)
99
+ try { cleanupStaleFwTasks(); startTrashCleanup({ roots: trashScanRoots({ profileDir: profileDirOf(ctx) ?? '', extra: [join(dshHome(), 'skills'), getReposDir()] }) }) } catch {}
100
100
  // DSH 启动时自动拉起标记了「自启动」的服务器组件(幂等,不阻塞启动)
101
101
  autostartComponents().catch(() => {})
102
102
  ctx.effect(() => {
@@ -7,8 +7,10 @@ import { readFile } from 'node:fs/promises'
7
7
  import { basename, dirname, join, resolve } from 'node:path'
8
8
  import { aiRepair } from './ai-run.js'
9
9
  import { maybeAutoAdaptCompat } from './compat.js'
10
+ import { getReposDir } from './repoland.js'
10
11
  import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
11
12
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
13
+ import { jobLog } from './jobs.js'
12
14
  import { verifyInstalledEntry } from './install-verify.js'
13
15
  import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
14
16
  import { declareProfileDependency } from './manifest.js'
@@ -19,55 +21,59 @@ import { gitCloneUrls, orderedRegistries, readSources } from './sources.js'
19
21
  import { reconcileLockfile } from './selfupdate.js'
20
22
  import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
21
23
  import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
22
- import { cleanupStalePackageDir, removeDirVerified } from '../infra/fsx.js'
24
+ import { cleanupStalePackageDir, disposeDir, disposeNote, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
23
25
  import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
24
26
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
25
27
 
26
- /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、
27
- * 什么没清掉。为什么需要(2026-09-20 真装演练):11 个子包的聚合仓库跑 19 分钟后失败,node_modules 里
28
- * 留着 `@captain1275/dsh-full-stats_tmp_56272_2` 这类半成品和一个真包,而面板只报"安装失败"——
29
- * 用户既不知道有东西被落盘,也不知道要不要手动清。删除一律走 removeDirVerified(本机 C 盘/`%TEMP%`
30
- * 下 rmSync 会静默落空,不核实就会谎报干净)。 */
28
+ /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
29
+ * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
30
+ * 2026-09-26(本次改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
31
+ * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
31
32
  function cleanupAttemptedCandidates(profileDir, candidates) {
32
33
  const cleaned = []
33
34
  const failed = []
35
+ const trashed = []
36
+ const dispose = (label, dir) => {
37
+ const result = disposeDir(dir)
38
+ if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
39
+ if (result.ok === true) return 1
40
+ failed.push({ name: label, path: dir, error: result.reason, note: disposeNote(result) })
41
+ return 0
42
+ }
34
43
  for (const name of candidates) {
35
44
  const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
36
45
  const parent = dirname(dir)
37
46
  const base = basename(dir)
38
47
  let touched = 0
39
- if (existsSync(dir)) {
40
- const result = removeDirVerified(dir)
41
- if (result.ok) touched += 1
42
- else failed.push({ name, path: dir, error: result.error })
43
- }
48
+ if (existsSync(dir)) touched += dispose(name, dir)
44
49
  try {
45
50
  for (const entry of readdirSync(parent)) {
46
51
  if (!entry.startsWith(`${base}_tmp_`)) continue
47
- const tmpPath = join(parent, entry)
48
- const result = removeDirVerified(tmpPath)
49
- if (result.ok) touched += 1
50
- else failed.push({ name: `${name}(临时目录)`, path: tmpPath, error: result.error })
52
+ touched += dispose(`${name}(临时目录)`, join(parent, entry))
51
53
  }
52
54
  } catch {}
53
55
  if (touched > 0) cleaned.push(name)
54
56
  }
55
- return { cleaned, failed }
57
+ return { cleaned, failed, trashed }
56
58
  }
57
59
 
58
60
  /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
59
- * 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。 */
61
+ * 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。
62
+ * 2026-09-26(本次改错):**不再出现「请手动删除」**——降级成 `.trash-*` 的项由后台清理接手。 */
60
63
  function aiConsentFailureText(decision, leftovers) {
61
64
  const base = decision?.timeout === true
62
65
  ? `等待授权超时(${Math.round(AI_CONSENT_TIMEOUT_MS / 60000)} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
63
66
  : '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
64
67
  const cleaned = leftovers?.cleaned ?? []
65
68
  const failed = leftovers?.failed ?? []
69
+ const trashed = leftovers?.trashed ?? []
70
+ const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
66
71
  const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
67
- const failedNote = failed.length > 0
68
- ? `;**有 ${failed.length} 项没能清理**(当前环境可能禁止删除,请手动删除):${failed.map((f) => f.path).join('、')}`
72
+ const trashedNote = trashed.length > 0
73
+ ? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
69
74
  : ''
70
- return `${base}${cleanedNote}${failedNote}`
75
+ const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
76
+ return `${base}${cleanedNote}${trashedNote}${failedNote}`
71
77
  }
72
78
 
73
79
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
@@ -345,6 +351,8 @@ async function runInstallJob(job, ports) {
345
351
  job.stage = 'installing'
346
352
  const patchPath = findPatchPath(ports)
347
353
  const profileDir = dirname(patchPath)
354
+ // 每次安装开始前:后台清掉上次留下的 `.trash-*` 降级目录(2026-09-26 加法;即发即忘、最多 20 个 / 单个 ≤1s,绝不阻塞本次安装)
355
+ try { void startTrashCleanup({ roots: trashScanRoots({ profileDir, extra: [getReposDir()] }), log: (message) => jobLog(job, message) }) } catch {}
348
356
  const taken = new Set(listEntries(ports).map((entry) => entry.rowId))
349
357
  const patch = await readPatchState(patchPath)
350
358
  for (const id of [...patch.inserts, ...patch.disables, ...patch.forced]) taken.add(id)
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path'
7
7
  import { homedir } from 'node:os'
8
8
  import { gitCloneCandidates } from './sources.js'
9
9
  import { execFileAsync, gitEnv, killProcessTree } from '../infra/exec.js'
10
- import { removeDirVerifiedWithRetry } from '../infra/fsx.js'
10
+ import { disposeDir, disposeNote } from '../infra/fsx.js'
11
11
  import { dshHome, repoLandConfFile } from '../infra/paths.js'
12
12
 
13
13
  /** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
@@ -113,7 +113,9 @@ function orderGitCandidates(candidates, preferredTemplate) {
113
113
  * 2026-09-26 真机(官方桌面端里装 git 源插件):ghproxy 卡死 → 我们的超时到了但**没杀 git 进程**,
114
114
  * 于是 `git clone` / `git remote-https` / `index-pack --shallow-file …\.git\shallow.lock` 常驻,
115
115
  * Windows 不允许删除被打开的文件 → 目标目录清不掉 → 旧代码把它写成"环境禁止删除"并**放弃后续源**。
116
- * 现在措辞如实:说清"仍有 git 占用(已尝试结束)",并给出可复制的手动删除命令。 */
116
+ * 2026-09-26(本次改错):措辞与处置都跟着 `disposeDir` 走 —— 残留目录删不掉时不再让用户去命令行
117
+ * (旧文案是「可手动删除后重试:Remove-Item -Recurse -Force …」):能改名降级就**已经在后台让开了**,
118
+ * 真的连改名都失败才如实说"控制台会稍后自动重试清理"。 */
117
119
  function summarizeCloneErrors(errors) {
118
120
  const first = errors[0]
119
121
  const tried = errors.map((e) => {
@@ -127,9 +129,11 @@ function summarizeCloneErrors(errors) {
127
129
  const stuck = errors.filter((e) => e.unclean === true)
128
130
  const stuckNote = stuck.length === 0
129
131
  ? ''
130
- : `;注意:${stuck[0].message}。可手动删除后重试:Remove-Item -Recurse -Force '${stuck[0].dir}'`
132
+ : `;注意:${stuck[0].message}。控制台会在后台自动重试清理,无需手动处理`
133
+ const trashedRecord = errors.find((e) => typeof e.trashNote === 'string' && e.trashNote !== '')
134
+ const trashedNote = trashedRecord === undefined ? '' : `;${trashedRecord.trashNote}`
131
135
  const sourceCount = new Set(errors.map((e) => e.url)).size
132
- return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${sourceCount} 个源(共 ${errors.length} 次尝试):${tried}${stuckNote}`
136
+ return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${sourceCount} 个源(共 ${errors.length} 次尝试):${tried}${trashedNote}${stuckNote}`
133
137
  }
134
138
 
135
139
  /** 结束**整棵**进程树。超时/中断后必须做:git 会派生 remote-https / index-pack 子进程,
@@ -204,13 +208,16 @@ async function probeSourceAlive(url, timeoutMs = 4000) {
204
208
  * 杀树成功但 Windows 句柄晚一拍释放时,不再"一次定生死"、也不再误报"残留被占用";
205
209
  * ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,
206
210
  * 并把**上次成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy,
207
- * 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。 */
211
+ * 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。
212
+ * 2026-09-26(本次改错):清理默认实现由 removeDirVerifiedWithRetry 换成 **disposeDir**
213
+ * (删不掉就改名降级成同父目录的 `.trash-<ts>-<rand>`,见 infra/fsx.js)——残留目录不再需要用户
214
+ * 手动删除,也不再因为"清不掉"而放弃后面本来可用的源。`deps.removeDir` 这个注入口名字没变。 */
208
215
  async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, deps = {}) {
209
216
  const {
210
217
  spawnFn = spawn,
211
218
  killTree = killProcessTree,
212
219
  probe = probeSourceAlive,
213
- removeDir = removeDirVerifiedWithRetry,
220
+ removeDir = disposeDir,
214
221
  renameDir = renameSync,
215
222
  exitWaitMs = 800,
216
223
  retryFactor = 1.75,
@@ -250,17 +257,21 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, dep
250
257
  break
251
258
  }
252
259
  }
253
- // 失败:先尽力清掉半成品目录(带重试的核实删除);清不掉就**如实说明**(不再谎报"环境禁止删除",
254
- // 也不再把"句柄晚一拍释放"误报成"被占用")
260
+ // 失败:先尽力清掉半成品目录(带重试的核实删除 → 删不掉则**改名降级**成 .trash-*);
261
+ // 只有连改名都失败才如实说明(不再谎报"环境禁止删除",也不再让用户去命令行手动删)
255
262
  const cleared = removeDir(part)
263
+ const trashNote = cleared !== null && cleared !== undefined && typeof cleared.trashPath === 'string' && cleared.trashPath !== ''
264
+ ? disposeNote(cleared)
265
+ : ''
266
+ const trashField = trashNote === '' ? {} : { trashed: cleared.trashPath, trashNote }
256
267
  if (cleared && cleared.ok === false) {
257
268
  errors.push({
258
269
  url,
259
270
  message: res.timedOut === true
260
271
  ? (res.exited === false
261
- ? `克隆超时;git 进程在等待后仍未退出(清理已重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮),残留目录清不掉:${part}`
262
- : `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能被其它程序/杀软占用):${part}`)
263
- : `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能仍被 git 占用):${part}`,
272
+ ? `克隆超时;git 进程在等待后仍未退出(清理已重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮、改名降级也失败),残留目录清不掉:${part}`
273
+ : `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(改名降级也失败,多为杀软/其它程序占用):${part}`)
274
+ : `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(改名降级也失败):${part}`,
264
275
  stderr: res.stderr,
265
276
  unclean: true,
266
277
  timedOut: res.timedOut === true,
@@ -271,10 +282,10 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, dep
271
282
  }
272
283
  if (res.timedOut === true && round === 0) {
273
284
  // ★ 同源、更长超时,重试一次(真机:ghproxy 卡死时短超时不够,长超时能过)
274
- errors.push({ url, message: `克隆超时(${useTimeout}ms),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, dir: part })
285
+ errors.push({ url, message: `克隆超时(${useTimeout}ms),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, dir: part, ...trashField })
275
286
  continue
276
287
  }
277
- errors.push({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part })
288
+ errors.push({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part, ...trashField })
278
289
  break
279
290
  }
280
291
  }
@@ -66,7 +66,8 @@ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmR
66
66
  const wrapped = new Error(`${String(error?.message ?? 'pnpm 执行失败')}|真实输出:${detail}`)
67
67
  // 保真:包装新 Error 时别把"结构性信息"丢掉(是不是超时/被杀、哪个 pid、哪个退出码)。
68
68
  // 丢了这些字段,上层就无法只对"网络类超时"做定向长超时重试(见 domain/install-diagnose.js)。
69
- for (const key of ['killed', 'timedOut', 'timeoutMs', 'signal', 'code', 'pid', 'cmd']) {
69
+ // 2026-09-26(加法):waitedMs / exited 也要带上 —— 上一条修复靠它们说明"树是不是真的退出了"。
70
+ for (const key of ['killed', 'timedOut', 'timeoutMs', 'signal', 'code', 'pid', 'cmd', 'waitedMs', 'exited']) {
70
71
  if (error?.[key] !== undefined) wrapped[key] = error[key]
71
72
  }
72
73
  return wrapped
@@ -174,23 +175,49 @@ function killProcessTree(pid, deps = {}) {
174
175
  } catch { return false }
175
176
  }
176
177
 
178
+ /** 「杀完树要等它真退出」的默认上限与轮询间隔(2026-09-26 本次改错,见 execFileWithKillTree 注释)。 */
179
+ const KILL_WAIT_MS = 2000
180
+ const KILL_WAIT_POLL_MS = 60
181
+
177
182
  /** 带"超时/中断即杀整棵进程树"的 execFile 替身(2026-09-26 新增,**只加不改**:`execFileAsync` 原样保留,
178
183
  * curl / tar / gh 等调用点继续用它)。
179
184
  * 与 execFileAsync 的差别**只在失败路径**:① 超时(opts.timeout)② AbortSignal 中断 —— 两条都先
180
185
  * `killProcessTree(child.pid)` 收掉整棵树,再把"超时多少毫秒 + 已终止的 pid"写进错误消息
181
186
  * (旧代码超时只 kill 父进程,报错也只有一句 `Command failed: …`,用户与日志都看不出发生了什么)。
182
187
  * 成功路径的 resolve 形状({stdout, stderr})与既有异常字段(message/stderr/stdout/code/killed/signal)
183
- * 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。 */
188
+ * 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。
189
+ *
190
+ * 2026-09-26(本次改错):杀完树后**有界等待它真的退出**,然后才 resolve/reject。
191
+ * 旧代码是 `killTreeNow(); finish(killedError(…))` —— 同一个 tick 完成,**约 +1ms 就抛**"已终止整棵进程树",
192
+ * 而整棵树实际还要一会儿才从进程表/句柄表消失:POSIX 上 SIGKILL 的"投递 → 目标被调度死亡 → 被 init 收割"
193
+ * 是异步的(CI run 36246293996 实测约 120ms,见 tests/test-pnpm-kill-tree.mjs 注释);本机 Windows 上
194
+ * taskkill /F /T 虽是同步等待,但目录项/句柄释放仍会晚一拍(2026-09-26 探针实测 278ms 才可删)。
195
+ * 于是调用方(pnpmRemove 后立刻删目录、install 失败清场、.tryN 残留清理)仍会撞"文件被占用"。
196
+ * 现在:轮询 `processAlive(pid)`,最多 killWaitMs(默认 2000ms)、每 killWaitPollMs(默认 60ms)一次;
197
+ * 到点仍未退出**不阻塞**,照原路径收尾,并把 waitedMs / exited 如实写在错误对象上(上层可据此判断)。
198
+ * 成功路径一个字没改 —— 只有超时/中断/超 maxBuffer 这三条失败路径会走到收尾逻辑。
199
+ * `deps.alive / deps.sleep / deps.now` 只为单测注入,生产调用不传。 */
184
200
  function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
185
- const { killTree = killProcessTree, spawnFn = spawn, platform = process.platform } = deps
201
+ const {
202
+ killTree = killProcessTree,
203
+ spawnFn = spawn,
204
+ platform = process.platform,
205
+ alive = processAlive,
206
+ sleep = (ms) => new Promise((resolved) => { setTimeout(resolved, ms) }),
207
+ now = Date.now,
208
+ } = deps
186
209
  const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 0
187
210
  const maxBuffer = Number.isFinite(opts.maxBuffer) && opts.maxBuffer > 0 ? opts.maxBuffer : 1024 * 1024
211
+ const killWaitMs = Number.isFinite(opts.killWaitMs) && opts.killWaitMs >= 0 ? opts.killWaitMs : KILL_WAIT_MS
212
+ const killWaitPollMs = Number.isFinite(opts.killWaitPollMs) && opts.killWaitPollMs > 0 ? opts.killWaitPollMs : KILL_WAIT_POLL_MS
188
213
  const signal = opts.signal ?? null
189
214
  return new Promise((resolve, reject) => {
190
215
  const cmd = `${bin} ${argv.join(' ')}`
191
216
  let child = null
192
217
  let timer = null
193
218
  let settled = false
219
+ let terminating = false // 收尾进行中(要先把"树真的退出"等完)→ close/error 不许抢答
220
+ let terminatePromise = null
194
221
  let stdout = ''
195
222
  let stderr = ''
196
223
  const baseError = () => {
@@ -200,20 +227,49 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
200
227
  err.stderr = stderr
201
228
  return err
202
229
  }
203
- const killTreeNow = () => {
204
- try { return killTree(child?.pid) } catch { return false }
205
- }
206
- /** 超时/中断错误:必须说清"已经终止了整棵树、pid 是多少",否则用户只能看到一句 Command failed。 */
207
- const killedError = (reason) => {
230
+ /** 超时/中断错误:必须说清"已经终止了整棵树、pid 是多少、等它的结果是怎样",
231
+ * 否则用户只能看到一句 Command failed,上层也无法判断"现在删目录安不安全"。 */
232
+ const killedError = (reason, waited = null) => {
208
233
  const err = baseError()
234
+ const waitedMs = waited === null ? 0 : waited.waitedMs
235
+ const exited = waited === null ? true : waited.exited === true
209
236
  err.killed = true
210
237
  err.signal = 'SIGTERM'
211
238
  err.timedOut = reason === 'timeout'
212
239
  err.timeoutMs = timeout
213
240
  err.pid = child?.pid ?? null
214
- err.message += `\n(${reason === 'timeout' ? `超时 ${timeout}ms` : '收到中断信号'}:已终止整棵进程树 pid=${child?.pid ?? '?'})`
241
+ err.waitedMs = waitedMs // 为"树真的退出"等了多久(加法字段,旧调用方读不到也不受影响)
242
+ err.exited = exited // 等到上限时树是否已经退出(true=句柄已释放,可以立刻删目录)
243
+ const why = reason === 'timeout' ? `超时 ${timeout}ms` : (reason === 'abort' ? '收到中断信号' : 'stdout 超过 maxBuffer')
244
+ err.message += `\n(${why}:已终止整棵进程树 pid=${child?.pid ?? '?'},等待 ${waitedMs}ms 后${exited ? '确认已退出' : '仍未退出(到点不阻塞,按原路径继续)'})`
215
245
  return err
216
246
  }
247
+ /** 轮询等"整棵树真的退出"(有界)。进程号拿不到/已退出 → 0ms 返回 exited:true。 */
248
+ const waitTreeGone = async (targetPid) => {
249
+ const startedAt = now()
250
+ if (typeof targetPid !== 'number' || targetPid <= 0) return { waitedMs: 0, exited: true }
251
+ for (;;) {
252
+ let up = false
253
+ try { up = alive(targetPid) === true } catch { up = false }
254
+ if (!up) return { waitedMs: now() - startedAt, exited: true }
255
+ const elapsed = now() - startedAt
256
+ if (elapsed >= killWaitMs) return { waitedMs: elapsed, exited: false }
257
+ // eslint-disable-next-line no-await-in-loop
258
+ await sleep(Math.max(1, Math.min(killWaitPollMs, killWaitMs - elapsed)))
259
+ }
260
+ }
261
+ /** 收尾(杀树 + 等退出):同一轮里只做一次(重复调用复用同一个 promise)。 */
262
+ const terminate = () => {
263
+ if (terminatePromise !== null) return terminatePromise
264
+ terminating = true
265
+ terminatePromise = (async () => {
266
+ let killed = false
267
+ try { killed = killTree(child?.pid) === true } catch { killed = false }
268
+ const waited = await waitTreeGone(child?.pid)
269
+ return { killed, waitedMs: waited.waitedMs, exited: waited.exited }
270
+ })()
271
+ return terminatePromise
272
+ }
217
273
  const cleanup = () => {
218
274
  if (timer !== null) { clearTimeout(timer); timer = null }
219
275
  if (signal !== null && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort)
@@ -225,7 +281,7 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
225
281
  if (error === null) resolve(value)
226
282
  else reject(error)
227
283
  }
228
- const onAbort = () => { killTreeNow(); finish(killedError('abort'), null) }
284
+ const onAbort = () => { void terminate().then((waited) => finish(killedError('abort', waited), null)) }
229
285
  if (signal !== null) {
230
286
  if (signal.aborted === true) { finish(killedError('abort'), null); return }
231
287
  if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true })
@@ -239,16 +295,23 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
239
295
  })
240
296
  } catch (error) { finish(error, null); return }
241
297
  const overflow = () => {
242
- killTreeNow()
243
- const err = baseError()
244
- err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
245
- err.message = `stdout maxBuffer length exceeded\n${err.message}`
246
- finish(err, null)
298
+ if (terminating) return // 收尾中:复用同一次杀树+等待,不重复触发
299
+ void terminate().then((waited) => {
300
+ const err = baseError()
301
+ err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
302
+ err.message = `stdout maxBuffer length exceeded\n${err.message}`
303
+ err.waitedMs = waited.waitedMs
304
+ err.exited = waited.exited
305
+ finish(err, null)
306
+ })
247
307
  }
248
308
  child.stdout?.on?.('data', (chunk) => { stdout += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
249
309
  child.stderr?.on?.('data', (chunk) => { stderr += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
250
- child.on('error', (error) => { finish(error, null) })
310
+ // 收尾(超时/中断/超限)要先把"树真的退出"等完才 settle → 这期间 close/error 不许抢答,
311
+ // 否则 killedError(含 pid/timedOut/exited/waitedMs)会被一句 `code=null 的普通失败` 顶掉。
312
+ child.on('error', (error) => { if (terminating) return; finish(error, null) })
251
313
  child.on('close', (code) => {
314
+ if (terminating) return
252
315
  if (code === 0) { finish(null, { stdout, stderr }); return }
253
316
  const err = baseError()
254
317
  err.code = typeof code === 'number' ? code : null
@@ -256,7 +319,7 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
256
319
  err.signal = null
257
320
  finish(err, null)
258
321
  })
259
- if (timeout > 0) timer = setTimeout(() => { killTreeNow(); finish(killedError('timeout'), null) }, timeout)
322
+ if (timeout > 0) timer = setTimeout(() => { void terminate().then((waited) => finish(killedError('timeout', waited), null)) }, timeout)
260
323
  })
261
324
  }
262
325
 
@@ -359,4 +422,4 @@ const GH_BIN_CANDIDATES = [
359
422
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
360
423
  ]
361
424
 
362
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
425
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
@@ -1,8 +1,9 @@
1
1
  // 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
2
2
  // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
3
3
 
4
- import { existsSync, rmSync, readdirSync, mkdirSync, copyFileSync, chmodSync, lstatSync } from 'node:fs'
4
+ import { existsSync, rmSync, readdirSync, mkdirSync, copyFileSync, chmodSync, lstatSync, renameSync } from 'node:fs'
5
5
  import { dirname, join, basename } from 'node:path'
6
+ import { tmpdir } from 'node:os'
6
7
  import { execFile } from 'node:child_process'
7
8
  import { execFileSync } from 'node:child_process'
8
9
  import { promisify } from 'node:util'
@@ -119,13 +120,13 @@ function waitGone(dir, timeoutMs) {
119
120
  }
120
121
 
121
122
  /** 外部删除兜底:实测本机 PowerShell/.NET 能删掉 Node `rmSync` 静默删不掉的树。 */
122
- async function removeViaShell(dir) {
123
+ async function removeViaShell(dir, timeoutMs = 120000) {
123
124
  try {
124
125
  if (process.platform === 'win32') {
125
- await execFileAsync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: 120000 })
126
+ await execFileAsync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs })
126
127
  return { ok: true, method: 'rmdir' }
127
128
  }
128
- await execFileAsync('rm', ['-rf', '--', dir], { timeout: 120000 })
129
+ await execFileAsync('rm', ['-rf', '--', dir], { timeout: timeoutMs })
129
130
  return { ok: true, method: 'rm' }
130
131
  } catch (error) {
131
132
  return { ok: false, method: null, error: error instanceof Error ? error.message : String(error) }
@@ -138,8 +139,9 @@ async function removeViaShell(dir) {
138
139
  * 为什么不能只用 `rmSync` + 立刻 `existsSync`:真机上出现过「`rmSync` 不抛错、目录仍在」,
139
140
  * 面板于是报「有 N 项没能删除(目录仍存在)——当前环境可能禁止删除」,把用户引向并不存在的权限问题;
140
141
  * 实测同一棵树用 .NET/PowerShell 能删掉,所以这里补上兜底与轮询,并把真实错误码带回去。
141
- */
142
- async function removeDirVerifiedAsync(dir, { attempts = 2, pollMs = 600 } = {}) {
142
+ * `shellTimeoutMs` 是 2026-09-26 的加法参数(默认 120000=旧行为),只为后台清理用:它不想让一条
143
+ * 卡住的 `rmdir` 占着两分钟。 */
144
+ async function removeDirVerifiedAsync(dir, { attempts = 2, pollMs = 600, shellTimeoutMs = 120000 } = {}) {
143
145
  let lastError = null
144
146
  let method = null
145
147
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -151,7 +153,7 @@ async function removeDirVerifiedAsync(dir, { attempts = 2, pollMs = 600 } = {})
151
153
  lastError = error
152
154
  }
153
155
  if (await waitGone(dir, pollMs)) return { ok: true, attempts: attempt, method: 'rmSync', error: null }
154
- const shell = await removeViaShell(dir)
156
+ const shell = await removeViaShell(dir, shellTimeoutMs)
155
157
  if (shell.ok && await waitGone(dir, pollMs * 3)) return { ok: true, attempts: attempt, method: shell.method, error: null }
156
158
  if (shell.error !== undefined && shell.error !== null) lastError = shell.error
157
159
  }
@@ -209,4 +211,188 @@ function removeDirVerifiedWithRetry(dir, { attempts = 3, pollMs = 250, remover =
209
211
  return { ok: false, attempts: last?.attempts ?? 0, rounds: attempts, method: null, error: last?.error ?? null }
210
212
  }
211
213
 
212
- export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue }
214
+ /** 「删除失败就改名降级」用的占用判据(2026-09-26 加法)。
215
+ * 借自 2BingLing/dsh-market 的 plugin/core/src/installer.ts#isLockFailure(正则逐字沿用):
216
+ * 命中即"占用/权限类失败"——这类失败**重试也不会好**,该做的是把目录改名让开、交给后台清理,
217
+ * 而不是把「请手动删除」甩给用户。 */
218
+ function isLockFailure(outputOrMessage) {
219
+ return /EPERM|EACCES|EBUSY|being used by another process|resource busy|in use by another|Access is denied|Cannot create file/iu.test(String(outputOrMessage ?? ''))
220
+ }
221
+
222
+ const TRASH_PREFIX = '.trash-'
223
+ const TRASH_RE = /^\.trash-\d+-[A-Za-z0-9]{1,16}$/u
224
+ const TRASH_SCAN_MAX_DEPTH = 2
225
+ const TRASH_CLEAN_LIMIT = 20
226
+ const TRASH_CLEAN_ITEM_MS = 1000
227
+
228
+ /** `.trash-<时间戳>-<随机>`:与目标**同父目录**(同卷 rename 才可能成功)。 */
229
+ function trashPathFor(dir, { now = Date.now, random = Math.random } = {}) {
230
+ const suffix = Math.floor(random() * 0xffffffff).toString(36).slice(0, 8) || '0'
231
+ return join(dirname(dir), `${TRASH_PREFIX}${now()}-${suffix}`)
232
+ }
233
+
234
+ /**
235
+ * 目录删除的唯一健壮入口(2026-09-26 加法):**先删,删不掉就改名降级**。
236
+ * ① `removeDirVerifiedWithRetry`(清只读位 → rmSync → 轮询核实 → `rmdir /s /q` 兜底,3 轮 × 250ms)
237
+ * ② 仍失败 → 同父目录 rename 成 `.trash-<ts>-<rand>`,返回 `{ status:'trashed', trashPath }`
238
+ * 为什么 rename 能救(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync):
239
+ * rename **不动目录内容**、只改目录项,所以"目录里有进程正在用的文件"这类占用通常挡不住它;
240
+ * 改完原路径就空出来了,调用方可以继续(install 能落新包、`.tryN` 能重来、卸载能收尾)。
241
+ * ⚠️ 实测边界(2026-09-26 本机探针,真机段见 tests/test-trash-fallback.mjs)——占用形态决定成败:
242
+ * · 目录里有**正在运行的 exe**(该文件句柄带 FILE_SHARE_DELETE)→ 删不掉,但**改名成功** ✅
243
+ * · 目录是活进程的 cwd → 改名 EBUSY(内核不允许改 cwd 的名字)
244
+ * · 目录内有以 share=None 打开的**文件句柄** → 改名 EPERM(父目录项被锁)
245
+ * 后两种改名也失败时如实返回 `status:'failed'` + 明确原因;**任何分支都不再出现「请手动删除」**,
246
+ * 因为后台清理(cleanupTrashDirs)会继续试、下一次安装前也会再试。
247
+ * 返回:`{ status:'removed'|'trashed'|'failed', removed, trashed, ok, path, trashPath, reason,
248
+ * attempts, rounds, method, lockFailure }` —— `ok` 的含义是"**原路径已经让开**"。
249
+ * deps(remover/removerOpts/rename/exists/now/random)只为单测注入,生产调用不传。 */
250
+ function disposeDir(dir, deps = {}) {
251
+ const {
252
+ remover = removeDirVerifiedWithRetry,
253
+ removerOpts = {},
254
+ rename = renameSync,
255
+ exists = existsSync,
256
+ now = Date.now,
257
+ random = Math.random,
258
+ } = deps
259
+ const target = String(dir ?? '')
260
+ const base = { path: target, trashPath: null, attempts: 0, rounds: 0, method: null, lockFailure: false }
261
+ if (target === '') return { ...base, status: 'failed', removed: false, trashed: false, ok: false, reason: '没有给出目录路径' }
262
+ if (!exists(target)) return { ...base, status: 'removed', removed: true, trashed: false, ok: true, reason: 'already-gone', method: 'already-gone' }
263
+ let first = null
264
+ try {
265
+ first = remover(target, removerOpts)
266
+ } catch (error) {
267
+ first = { ok: false, attempts: 0, rounds: 0, error: error instanceof Error ? error.message : String(error) }
268
+ }
269
+ const attempts = first?.attempts ?? 0
270
+ const rounds = first?.rounds ?? 0
271
+ const reason = first?.error ?? '删除后目录仍存在(未抛出错误:Windows 删除挂起或占用)'
272
+ if (first?.ok === true) {
273
+ return { ...base, status: 'removed', removed: true, trashed: false, ok: true, reason: first.method ?? 'rmSync', attempts, rounds, method: first.method ?? 'rmSync' }
274
+ }
275
+ // ② 改名降级:同父目录(同卷);候选名撞了就换一个(最多 3 次)
276
+ // lockFailure 的判据除了 isLockFailure 的正则,还包括"**没报错但目录仍在**"这种静默失败 ——
277
+ // removeDirVerifiedWithRetry 自己把这种形态注释为「Windows 删除挂起或占用」,真机实测也确实如此
278
+ // (占用中 rmSync 不抛、目录原封不动)。判成占用,前端口径才与事实一致;判错的代价只是措辞。
279
+ const lockFailure = isLockFailure(reason) || first?.error === null || first?.error === undefined
280
+ let trashPath = null
281
+ let renameError = null
282
+ for (let round = 0; round < 3 && trashPath === null; round += 1) {
283
+ const candidate = trashPathFor(target, { now, random })
284
+ try {
285
+ if (exists(candidate)) { renameError = new Error(`降级目标名已存在:${basename(candidate)}`); continue }
286
+ rename(target, candidate)
287
+ trashPath = candidate
288
+ } catch (error) { renameError = error }
289
+ }
290
+ if (trashPath === null) {
291
+ const detail = renameError instanceof Error ? renameError.message : String(renameError ?? '未知')
292
+ return { ...base, status: 'failed', removed: false, trashed: false, ok: false, attempts, rounds, lockFailure, reason: `${lockFailure ? '目录被占用' : '删除未成功'},改名降级也失败(${detail});原删除失败原因:${reason}` }
293
+ }
294
+ return { ...base, status: 'trashed', removed: false, trashed: true, ok: true, trashPath, reason, attempts, rounds, method: 'rename', lockFailure }
295
+ }
296
+
297
+ /** 面向前端的短句(纯函数,单测覆盖):**不再出现「请手动删除」**——
298
+ * 删不掉时我们自己做了 rename 降级 + 后台重试,用户不需要去命令行干活。 */
299
+ function disposeNote(result) {
300
+ const status = result?.status
301
+ if (status === 'removed') return '已删除'
302
+ if (status === 'trashed') {
303
+ const name = basename(String(result?.trashPath ?? '')) || `${TRASH_PREFIX}*`
304
+ return `${result?.lockFailure === true ? '目录正被占用' : '删除未成功'},已改名降级为 ${name}(${TRASH_PREFIX}*),稍后自动清理`
305
+ }
306
+ return `删除未成功,改名降级也没成(${result?.reason ?? '原因未知'});控制台会在后台自动重试清理`
307
+ }
308
+
309
+ /** 常见降级目录扫描根:系统 tmpdir + profile 的 node_modules(含一级作用域目录)+ 调用方补充的目录(如 repos 根)。 */
310
+ function trashScanRoots({ profileDir = '', extra = [] } = {}) {
311
+ const roots = [tmpdir()]
312
+ if (typeof profileDir === 'string' && profileDir !== '') roots.push(join(profileDir, 'node_modules'))
313
+ for (const dir of Array.isArray(extra) ? extra : [extra]) if (typeof dir === 'string' && dir !== '') roots.push(dir)
314
+ return roots
315
+ }
316
+
317
+ /** 扫出根目录下(深度 ≤ maxDepth)的 `.trash-*`,按名字升序(= 由旧到新,先清最老的)。
318
+ * 任何一步读不到就跳过、绝不抛 —— 后台清理是尽力而为,不能让一个坏目录把主流程带崩。 */
319
+ function findTrashDirs(roots, { maxDepth = TRASH_SCAN_MAX_DEPTH, limit = 40, readdir = readdirSync, exists = existsSync } = {}) {
320
+ const out = []
321
+ const walk = (dir, depth) => {
322
+ if (out.length >= limit || depth > maxDepth) return
323
+ let entries
324
+ try { entries = readdir(dir, { withFileTypes: true }) } catch { return }
325
+ for (const entry of entries) {
326
+ if (out.length >= limit) return
327
+ if (!entry.isDirectory()) continue
328
+ if (TRASH_RE.test(entry.name)) { out.push(join(dir, entry.name)); continue }
329
+ if (depth < maxDepth) walk(join(dir, entry.name), depth + 1)
330
+ }
331
+ }
332
+ for (const root of Array.isArray(roots) ? roots : [roots]) {
333
+ const dir = String(root ?? '')
334
+ if (dir === '') continue
335
+ try { if (exists(dir)) walk(dir, 1) } catch {}
336
+ }
337
+ return out.sort()
338
+ }
339
+
340
+ /** 后台清理 `.trash-*`(2026-09-26 加法:尽力而为、绝不阻塞主流程、绝不抛):
341
+ * 扫 tmpdir / profile node_modules / repos 等根目录下的降级目录,能删就删,删不掉留着下次;
342
+ * 上限:最多处理 limit 个(默认 20)、单个最多等 perItemMs(默认 1000ms,到点就不管它、继续下一个);
343
+ * 任何异常都吞掉只记日志。返回 `{ scanned, removed, kept, skipped, more, ms, dirs, error }`(如实,不假装成功):
344
+ * `scanned` 是本次**扫到**的数量(扫到 limit+1 就收手 → `more:true` 表示盘上还有更多)、
345
+ * `skipped` = 扫到但没处理的数量。deps(remover/find/now/log)只为单测注入。 */
346
+ async function cleanupTrashDirs({
347
+ roots = [tmpdir()],
348
+ limit = TRASH_CLEAN_LIMIT,
349
+ perItemMs = TRASH_CLEAN_ITEM_MS,
350
+ maxDepth = TRASH_SCAN_MAX_DEPTH,
351
+ remover = removeDirVerifiedAsync,
352
+ find = findTrashDirs,
353
+ now = Date.now,
354
+ log = null,
355
+ } = {}) {
356
+ const startedAt = now()
357
+ const stats = { scanned: 0, removed: 0, kept: 0, skipped: 0, more: false, ms: 0, dirs: [], error: null }
358
+ try {
359
+ const found = find(roots, { maxDepth, limit: limit + 1 }) ?? []
360
+ stats.scanned = found.length
361
+ stats.skipped = Math.max(0, found.length - limit)
362
+ stats.more = found.length > limit // 扫到 limit+1 就收手:盘上至少还有更多(如实标注,不假装扫全了)
363
+ for (const dir of found.slice(0, limit)) {
364
+ try {
365
+ const timeout = new Promise((resolved) => {
366
+ // 注意:这里**不能** unref —— 单个删不掉的降级目录会让事件循环无事可做,
367
+ // unref 过的定时器不阻止退出,本函数就会"永远不 settle"(本测试抓到的真实缺陷)。
368
+ setTimeout(() => resolved({ ok: false, method: null, error: `超过 ${perItemMs}ms 没删完(留到下次)` }), perItemMs)
369
+ })
370
+ // eslint-disable-next-line no-await-in-loop
371
+ const r = await Promise.race([remover(dir, { attempts: 1, pollMs: Math.min(400, Math.max(0, perItemMs)), shellTimeoutMs: Math.max(4000, perItemMs * 4) }), timeout])
372
+ if (r?.ok === true) { stats.removed += 1; stats.dirs.push({ path: dir, ok: true, method: r.method ?? null }) }
373
+ else { stats.kept += 1; stats.dirs.push({ path: dir, ok: false, error: r?.error ?? null }) }
374
+ } catch (error) {
375
+ stats.kept += 1
376
+ stats.dirs.push({ path: dir, ok: false, error: error instanceof Error ? error.message : String(error) })
377
+ }
378
+ }
379
+ } catch (error) {
380
+ stats.error = error instanceof Error ? error.message : String(error)
381
+ }
382
+ stats.ms = now() - startedAt
383
+ try {
384
+ if (typeof log === 'function') log(`[trash] 扫描 ${stats.scanned} 个 ${TRASH_PREFIX}*,删除 ${stats.removed}、保留 ${stats.kept}、跳过 ${stats.skipped},用时 ${stats.ms}ms`)
385
+ } catch {}
386
+ return stats
387
+ }
388
+
389
+ /** 启动钩子/安装前调用的**即发即忘**入口:绝不抛、绝不阻塞调用方(返回的 promise 自带兜底 catch)。 */
390
+ function startTrashCleanup(opts = {}) {
391
+ try {
392
+ return Promise.resolve(cleanupTrashDirs(opts)).catch((error) => ({ scanned: 0, removed: 0, kept: 0, skipped: 0, ms: 0, dirs: [], error: error instanceof Error ? error.message : String(error) }))
393
+ } catch (error) {
394
+ return Promise.resolve({ scanned: 0, removed: 0, kept: 0, skipped: 0, ms: 0, dirs: [], error: error instanceof Error ? error.message : String(error) })
395
+ }
396
+ }
397
+
398
+ export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue, isLockFailure, trashPathFor, disposeDir, disposeNote, trashScanRoots, findTrashDirs, cleanupTrashDirs, startTrashCleanup, TRASH_PREFIX, TRASH_RE, TRASH_CLEAN_LIMIT, TRASH_CLEAN_ITEM_MS }
@@ -8,7 +8,7 @@ import { compFind, compStart, compStatus, compStop, compUiUrl, compUpsert, findC
8
8
  import { getReposDir, listLandedRepos, setReposDir } from '../domain/repoland.js'
9
9
  import { gitCloneUrls } from '../domain/sources.js'
10
10
  import { execFileAsync, gitBin, gitEnv } from '../infra/exec.js'
11
- import { removeDirVerified } from '../infra/fsx.js'
11
+ import { disposeDir, disposeNote } from '../infra/fsx.js'
12
12
  import { sendError, sendJson } from '../infra/httpd.js'
13
13
 
14
14
  async function routeComponents(req, res, rc) {
@@ -75,7 +75,8 @@ async function routeRepoClone(req, res, rc) {
75
75
  } catch (error) {
76
76
  lastError = error
77
77
  // 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败
78
- removeDirVerified(target)
78
+ // (删不掉时 disposeDir 会改名降级成 .trash-*,原路径照样让开)
79
+ disposeDir(target)
79
80
  }
80
81
  }
81
82
  if (!cloned) throw lastError ?? new Error('未知错误')
@@ -135,12 +136,16 @@ async function routeRepoRemove(req, res, rc) {
135
136
  }
136
137
  // 核实删除结果:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
137
138
  // 删不掉却回 {ok:true} 会让用户以为仓库已清理。
138
- const result = removeDirVerified(target)
139
- if (!result.ok) {
140
- sendError(res, 500, `删除失败:目录仍存在(${target})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除`)
139
+ // 2026-09-26(本次改错):走 disposeDir —— 删不掉就改名降级成同父目录的 `.trash-<ts>`,
140
+ // 原路径已经让开就算成功,并如实把降级结果告诉用户(不再出现「请手动删除」)。
141
+ const result = disposeDir(target)
142
+ if (result.ok !== true) {
143
+ sendError(res, 500, `删除失败:目录仍存在(${target})——${disposeNote(result)}`)
141
144
  return
142
145
  }
143
- sendJson(res, 200, { ok: true })
146
+ sendJson(res, 200, result.trashed === true
147
+ ? { ok: true, trashed: true, trashPath: result.trashPath, note: disposeNote(result) }
148
+ : { ok: true })
144
149
  return
145
150
  }
146
151
 
@@ -4,7 +4,7 @@
4
4
  import { existsSync } from 'node:fs'
5
5
  import { join, resolve } from 'node:path'
6
6
  import { listInstalledSkills, setSkillEnabled } from '../domain/skills.js'
7
- import { removeDirVerified } from '../infra/fsx.js'
7
+ import { disposeDir, disposeNote } from '../infra/fsx.js'
8
8
  import { sendError, sendJson } from '../infra/httpd.js'
9
9
  import { dshHome } from '../infra/paths.js'
10
10
 
@@ -56,12 +56,16 @@ async function routeSkillRemove(req, res, rc) {
56
56
  }
57
57
  // 删完必须核实:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
58
58
  // 旧代码删完直接 {ok:true} → 用户以为删了,技能其实还在(2026-09-20 演练实测)。
59
- const result = removeDirVerified(dest)
60
- if (!result.ok) {
61
- sendError(res, 500, `删除技能失败:目录仍存在(${dest})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除它`)
59
+ // 2026-09-26(本次改错):走 disposeDir —— 删不掉就改名降级成同父目录的 `.trash-<ts>`,
60
+ // 原路径让开即算完成;失败文案只讲真实原因与"后台会自动重试",不再要求用户手动删除。
61
+ const result = disposeDir(dest)
62
+ if (result.ok !== true) {
63
+ sendError(res, 500, `删除技能失败:目录仍存在(${dest})——${disposeNote(result)}`)
62
64
  return
63
65
  }
64
- sendJson(res, 200, { ok: true, name })
66
+ sendJson(res, 200, result.trashed === true
67
+ ? { ok: true, name, trashed: true, trashPath: result.trashPath, note: disposeNote(result) }
68
+ : { ok: true, name })
65
69
  return
66
70
  }
67
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",