@noob-stupid/dsh-plugin-console 0.5.16 → 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 +16 -0
- package/README.zh.md +12 -0
- package/lib/client.js +49 -1
- package/lib/index.js +3 -3
- package/lib/server/domain/archive-source.js +209 -0
- package/lib/server/domain/git-channel.js +91 -0
- package/lib/server/domain/install-cleanup.js +62 -0
- package/lib/server/domain/install-job.js +137 -129
- package/lib/server/domain/install.js +2 -0
- package/lib/server/domain/market.js +72 -6
- package/lib/server/domain/repoland.js +289 -40
- package/lib/server/domain/sources.js +26 -2
- package/lib/server/infra/exec.js +93 -18
- package/lib/server/infra/fsx.js +194 -8
- package/lib/server/routes/components.js +11 -6
- package/lib/server/routes/skills.js +9 -5
- package/lib/server/routes/sources.js +80 -1
- package/package.json +1 -1
package/lib/server/infra/exec.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -89,9 +90,18 @@ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmR
|
|
|
89
90
|
|
|
90
91
|
/** git 非交互环境:禁止任何登录/凭据窗口弹出(私有仓库或不可达源直接失败,不做交互式重试)。 */
|
|
91
92
|
|
|
93
|
+
/** git 停滞判据的 **env 形式**(2026-09-27 加法):与 domain/repoland.js 的
|
|
94
|
+
* `-c http.lowSpeedLimit=1 -c http.lowSpeedTime=20` 同一语义(连续 20 秒 <1 B/s 就让 git 自己退出),
|
|
95
|
+
* 用途是覆盖**我们没有直接 spawn git** 的路径 —— 主要是 `pnpm add git+https://…`(pnpm 内部自己跑
|
|
96
|
+
* git,我们管不到它的命令行),以及 ai-run / skills / components 里其它 git 调用点。
|
|
97
|
+
* 实测(本机"只连不传"的 TCP 桩源):只带这两个 env、**不带** `-c`,git 同样在 20.4 秒退出并报
|
|
98
|
+
* `Operation too slow. Less than 1 bytes/sec transferred the last 20 seconds`(见 tests/test-git-stall-guard.mjs)。 */
|
|
99
|
+
const GIT_LOW_SPEED_ENV = { GIT_HTTP_LOW_SPEED_LIMIT: '1', GIT_HTTP_LOW_SPEED_TIME: '20' }
|
|
100
|
+
|
|
92
101
|
function gitEnv() {
|
|
93
102
|
return {
|
|
94
103
|
...process.env,
|
|
104
|
+
...GIT_LOW_SPEED_ENV,
|
|
95
105
|
GIT_TERMINAL_PROMPT: '0',
|
|
96
106
|
GCM_INTERACTIVE: 'never',
|
|
97
107
|
GIT_ASKPASS: 'echo',
|
|
@@ -174,23 +184,49 @@ function killProcessTree(pid, deps = {}) {
|
|
|
174
184
|
} catch { return false }
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
/** 「杀完树要等它真退出」的默认上限与轮询间隔(2026-09-26 本次改错,见 execFileWithKillTree 注释)。 */
|
|
188
|
+
const KILL_WAIT_MS = 2000
|
|
189
|
+
const KILL_WAIT_POLL_MS = 60
|
|
190
|
+
|
|
177
191
|
/** 带"超时/中断即杀整棵进程树"的 execFile 替身(2026-09-26 新增,**只加不改**:`execFileAsync` 原样保留,
|
|
178
192
|
* curl / tar / gh 等调用点继续用它)。
|
|
179
193
|
* 与 execFileAsync 的差别**只在失败路径**:① 超时(opts.timeout)② AbortSignal 中断 —— 两条都先
|
|
180
194
|
* `killProcessTree(child.pid)` 收掉整棵树,再把"超时多少毫秒 + 已终止的 pid"写进错误消息
|
|
181
195
|
* (旧代码超时只 kill 父进程,报错也只有一句 `Command failed: …`,用户与日志都看不出发生了什么)。
|
|
182
196
|
* 成功路径的 resolve 形状({stdout, stderr})与既有异常字段(message/stderr/stdout/code/killed/signal)
|
|
183
|
-
* 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。
|
|
197
|
+
* 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。
|
|
198
|
+
*
|
|
199
|
+
* 2026-09-26(本次改错):杀完树后**有界等待它真的退出**,然后才 resolve/reject。
|
|
200
|
+
* 旧代码是 `killTreeNow(); finish(killedError(…))` —— 同一个 tick 完成,**约 +1ms 就抛**"已终止整棵进程树",
|
|
201
|
+
* 而整棵树实际还要一会儿才从进程表/句柄表消失:POSIX 上 SIGKILL 的"投递 → 目标被调度死亡 → 被 init 收割"
|
|
202
|
+
* 是异步的(CI run 36246293996 实测约 120ms,见 tests/test-pnpm-kill-tree.mjs 注释);本机 Windows 上
|
|
203
|
+
* taskkill /F /T 虽是同步等待,但目录项/句柄释放仍会晚一拍(2026-09-26 探针实测 278ms 才可删)。
|
|
204
|
+
* 于是调用方(pnpmRemove 后立刻删目录、install 失败清场、.tryN 残留清理)仍会撞"文件被占用"。
|
|
205
|
+
* 现在:轮询 `processAlive(pid)`,最多 killWaitMs(默认 2000ms)、每 killWaitPollMs(默认 60ms)一次;
|
|
206
|
+
* 到点仍未退出**不阻塞**,照原路径收尾,并把 waitedMs / exited 如实写在错误对象上(上层可据此判断)。
|
|
207
|
+
* 成功路径一个字没改 —— 只有超时/中断/超 maxBuffer 这三条失败路径会走到收尾逻辑。
|
|
208
|
+
* `deps.alive / deps.sleep / deps.now` 只为单测注入,生产调用不传。 */
|
|
184
209
|
function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
185
|
-
const {
|
|
210
|
+
const {
|
|
211
|
+
killTree = killProcessTree,
|
|
212
|
+
spawnFn = spawn,
|
|
213
|
+
platform = process.platform,
|
|
214
|
+
alive = processAlive,
|
|
215
|
+
sleep = (ms) => new Promise((resolved) => { setTimeout(resolved, ms) }),
|
|
216
|
+
now = Date.now,
|
|
217
|
+
} = deps
|
|
186
218
|
const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 0
|
|
187
219
|
const maxBuffer = Number.isFinite(opts.maxBuffer) && opts.maxBuffer > 0 ? opts.maxBuffer : 1024 * 1024
|
|
220
|
+
const killWaitMs = Number.isFinite(opts.killWaitMs) && opts.killWaitMs >= 0 ? opts.killWaitMs : KILL_WAIT_MS
|
|
221
|
+
const killWaitPollMs = Number.isFinite(opts.killWaitPollMs) && opts.killWaitPollMs > 0 ? opts.killWaitPollMs : KILL_WAIT_POLL_MS
|
|
188
222
|
const signal = opts.signal ?? null
|
|
189
223
|
return new Promise((resolve, reject) => {
|
|
190
224
|
const cmd = `${bin} ${argv.join(' ')}`
|
|
191
225
|
let child = null
|
|
192
226
|
let timer = null
|
|
193
227
|
let settled = false
|
|
228
|
+
let terminating = false // 收尾进行中(要先把"树真的退出"等完)→ close/error 不许抢答
|
|
229
|
+
let terminatePromise = null
|
|
194
230
|
let stdout = ''
|
|
195
231
|
let stderr = ''
|
|
196
232
|
const baseError = () => {
|
|
@@ -200,20 +236,49 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
200
236
|
err.stderr = stderr
|
|
201
237
|
return err
|
|
202
238
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
/** 超时/中断错误:必须说清"已经终止了整棵树、pid 是多少",否则用户只能看到一句 Command failed。 */
|
|
207
|
-
const killedError = (reason) => {
|
|
239
|
+
/** 超时/中断错误:必须说清"已经终止了整棵树、pid 是多少、等它的结果是怎样",
|
|
240
|
+
* 否则用户只能看到一句 Command failed,上层也无法判断"现在删目录安不安全"。 */
|
|
241
|
+
const killedError = (reason, waited = null) => {
|
|
208
242
|
const err = baseError()
|
|
243
|
+
const waitedMs = waited === null ? 0 : waited.waitedMs
|
|
244
|
+
const exited = waited === null ? true : waited.exited === true
|
|
209
245
|
err.killed = true
|
|
210
246
|
err.signal = 'SIGTERM'
|
|
211
247
|
err.timedOut = reason === 'timeout'
|
|
212
248
|
err.timeoutMs = timeout
|
|
213
249
|
err.pid = child?.pid ?? null
|
|
214
|
-
err.
|
|
250
|
+
err.waitedMs = waitedMs // 为"树真的退出"等了多久(加法字段,旧调用方读不到也不受影响)
|
|
251
|
+
err.exited = exited // 等到上限时树是否已经退出(true=句柄已释放,可以立刻删目录)
|
|
252
|
+
const why = reason === 'timeout' ? `超时 ${timeout}ms` : (reason === 'abort' ? '收到中断信号' : 'stdout 超过 maxBuffer')
|
|
253
|
+
err.message += `\n(${why}:已终止整棵进程树 pid=${child?.pid ?? '?'},等待 ${waitedMs}ms 后${exited ? '确认已退出' : '仍未退出(到点不阻塞,按原路径继续)'})`
|
|
215
254
|
return err
|
|
216
255
|
}
|
|
256
|
+
/** 轮询等"整棵树真的退出"(有界)。进程号拿不到/已退出 → 0ms 返回 exited:true。 */
|
|
257
|
+
const waitTreeGone = async (targetPid) => {
|
|
258
|
+
const startedAt = now()
|
|
259
|
+
if (typeof targetPid !== 'number' || targetPid <= 0) return { waitedMs: 0, exited: true }
|
|
260
|
+
for (;;) {
|
|
261
|
+
let up = false
|
|
262
|
+
try { up = alive(targetPid) === true } catch { up = false }
|
|
263
|
+
if (!up) return { waitedMs: now() - startedAt, exited: true }
|
|
264
|
+
const elapsed = now() - startedAt
|
|
265
|
+
if (elapsed >= killWaitMs) return { waitedMs: elapsed, exited: false }
|
|
266
|
+
// eslint-disable-next-line no-await-in-loop
|
|
267
|
+
await sleep(Math.max(1, Math.min(killWaitPollMs, killWaitMs - elapsed)))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** 收尾(杀树 + 等退出):同一轮里只做一次(重复调用复用同一个 promise)。 */
|
|
271
|
+
const terminate = () => {
|
|
272
|
+
if (terminatePromise !== null) return terminatePromise
|
|
273
|
+
terminating = true
|
|
274
|
+
terminatePromise = (async () => {
|
|
275
|
+
let killed = false
|
|
276
|
+
try { killed = killTree(child?.pid) === true } catch { killed = false }
|
|
277
|
+
const waited = await waitTreeGone(child?.pid)
|
|
278
|
+
return { killed, waitedMs: waited.waitedMs, exited: waited.exited }
|
|
279
|
+
})()
|
|
280
|
+
return terminatePromise
|
|
281
|
+
}
|
|
217
282
|
const cleanup = () => {
|
|
218
283
|
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
219
284
|
if (signal !== null && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort)
|
|
@@ -225,7 +290,7 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
225
290
|
if (error === null) resolve(value)
|
|
226
291
|
else reject(error)
|
|
227
292
|
}
|
|
228
|
-
const onAbort = () => {
|
|
293
|
+
const onAbort = () => { void terminate().then((waited) => finish(killedError('abort', waited), null)) }
|
|
229
294
|
if (signal !== null) {
|
|
230
295
|
if (signal.aborted === true) { finish(killedError('abort'), null); return }
|
|
231
296
|
if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true })
|
|
@@ -239,16 +304,23 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
239
304
|
})
|
|
240
305
|
} catch (error) { finish(error, null); return }
|
|
241
306
|
const overflow = () => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
307
|
+
if (terminating) return // 收尾中:复用同一次杀树+等待,不重复触发
|
|
308
|
+
void terminate().then((waited) => {
|
|
309
|
+
const err = baseError()
|
|
310
|
+
err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
311
|
+
err.message = `stdout maxBuffer length exceeded\n${err.message}`
|
|
312
|
+
err.waitedMs = waited.waitedMs
|
|
313
|
+
err.exited = waited.exited
|
|
314
|
+
finish(err, null)
|
|
315
|
+
})
|
|
247
316
|
}
|
|
248
317
|
child.stdout?.on?.('data', (chunk) => { stdout += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
|
|
249
318
|
child.stderr?.on?.('data', (chunk) => { stderr += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
|
|
250
|
-
|
|
319
|
+
// 收尾(超时/中断/超限)要先把"树真的退出"等完才 settle → 这期间 close/error 不许抢答,
|
|
320
|
+
// 否则 killedError(含 pid/timedOut/exited/waitedMs)会被一句 `code=null 的普通失败` 顶掉。
|
|
321
|
+
child.on('error', (error) => { if (terminating) return; finish(error, null) })
|
|
251
322
|
child.on('close', (code) => {
|
|
323
|
+
if (terminating) return
|
|
252
324
|
if (code === 0) { finish(null, { stdout, stderr }); return }
|
|
253
325
|
const err = baseError()
|
|
254
326
|
err.code = typeof code === 'number' ? code : null
|
|
@@ -256,7 +328,7 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
256
328
|
err.signal = null
|
|
257
329
|
finish(err, null)
|
|
258
330
|
})
|
|
259
|
-
if (timeout > 0) timer = setTimeout(() => {
|
|
331
|
+
if (timeout > 0) timer = setTimeout(() => { void terminate().then((waited) => finish(killedError('timeout', waited), null)) }, timeout)
|
|
260
332
|
})
|
|
261
333
|
}
|
|
262
334
|
|
|
@@ -292,6 +364,9 @@ function buildPnpmEnv(registry, base = process.env) {
|
|
|
292
364
|
// git 通道禁止交互式凭据(与 gitEnv 同一语义):避免 Git Credential Manager 弹登录窗
|
|
293
365
|
GIT_TERMINAL_PROMPT: '0',
|
|
294
366
|
GCM_INTERACTIVE: 'never',
|
|
367
|
+
// pnpm 的 git 依赖(`git+https://…`)由 pnpm 自己 spawn git,我们传不了命令行选项 →
|
|
368
|
+
// 用 env 给它同一套停滞判据(否则一个 0 B/s 的镜像能挂满 pnpm 的 fetch 超时)
|
|
369
|
+
...GIT_LOW_SPEED_ENV,
|
|
295
370
|
...pnpmEnvOverrides(registry),
|
|
296
371
|
}
|
|
297
372
|
}
|
|
@@ -359,4 +434,4 @@ const GH_BIN_CANDIDATES = [
|
|
|
359
434
|
join(homedir(), 'scoop', 'shims', 'gh.exe'),
|
|
360
435
|
]
|
|
361
436
|
|
|
362
|
-
export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
|
|
437
|
+
export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
|
package/lib/server/infra/fsx.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
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
|
|
|
@@ -401,6 +401,85 @@ async function routeSources(req, res, rc) {
|
|
|
401
401
|
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
402
402
|
return
|
|
403
403
|
}
|
|
404
|
+
if (action === 'add-archive') {
|
|
405
|
+
// archive 通道源(批次 C-⑨):模板必须含 {owner}/{repo}({branch} 可选)
|
|
406
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
407
|
+
const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
|
|
408
|
+
if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
|
|
409
|
+
sendError(res, 400, 'archive 源模板必须同时包含 {owner} 与 {repo} 占位符({branch} 可选)')
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
if (!isAllowedGitSourceUrl(urlTemplate)) {
|
|
413
|
+
sendError(res, 400, 'archive 源地址必须是 https://(或本机/私网 http://、file://)的合法 URL')
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if ((sources.archiveSources ?? []).some((s) => s.urlTemplate === urlTemplate)) {
|
|
417
|
+
sendError(res, 400, '该 archive 源已存在')
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
sources.archiveSources = [...(sources.archiveSources ?? []), {
|
|
421
|
+
id: `arc-${Date.now().toString(36)}`,
|
|
422
|
+
name: name || urlTemplate,
|
|
423
|
+
urlTemplate,
|
|
424
|
+
primary: (sources.archiveSources ?? []).length === 0,
|
|
425
|
+
}]
|
|
426
|
+
await writeSources(sources)
|
|
427
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
if (action === 'edit-archive') {
|
|
431
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
432
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
433
|
+
const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
|
|
434
|
+
if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
|
|
435
|
+
sendError(res, 400, 'archive 源模板必须同时包含 {owner} 与 {repo} 占位符({branch} 可选)')
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
if (!isAllowedGitSourceUrl(urlTemplate)) {
|
|
439
|
+
sendError(res, 400, 'archive 源地址必须是 https://(或本机/私网 http://、file://)的合法 URL')
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const target = (sources.archiveSources ?? []).find((s) => s.id === id)
|
|
443
|
+
if (!target) {
|
|
444
|
+
sendError(res, 404, '没有这个 archive 源')
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
target.name = name || urlTemplate
|
|
448
|
+
target.urlTemplate = urlTemplate
|
|
449
|
+
await writeSources(sources)
|
|
450
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
if (action === 'set-archive-primary') {
|
|
454
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
455
|
+
const list = sources.archiveSources ?? []
|
|
456
|
+
if (!list.some((s) => s.id === id)) {
|
|
457
|
+
sendError(res, 404, '没有这个 archive 源')
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
for (const s of list) s.primary = s.id === id
|
|
461
|
+
await writeSources(sources)
|
|
462
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (action === 'remove-archive') {
|
|
466
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
467
|
+
const list = sources.archiveSources ?? []
|
|
468
|
+
if (!list.some((s) => s.id === id)) {
|
|
469
|
+
sendError(res, 404, '没有这个 archive 源')
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
const rest = list.filter((s) => s.id !== id)
|
|
473
|
+
if (rest.length === 0) {
|
|
474
|
+
sendError(res, 400, '至少保留一个 archive 源(可先添加自建镜像再删除默认源)')
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (!rest.some((s) => s.primary)) rest[0].primary = true
|
|
478
|
+
sources.archiveSources = rest
|
|
479
|
+
await writeSources(sources)
|
|
480
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
481
|
+
return
|
|
482
|
+
}
|
|
404
483
|
if (action === 'reset') {
|
|
405
484
|
const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
|
|
406
485
|
setMarketIndexCache(null)
|
|
@@ -430,7 +509,7 @@ async function routeSources(req, res, rc) {
|
|
|
430
509
|
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
431
510
|
return
|
|
432
511
|
}
|
|
433
|
-
sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / gitee-setup / gitee-clear / reset)')
|
|
512
|
+
sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / add-archive / edit-archive / set-archive-primary / remove-archive / gitee-setup / gitee-clear / reset)')
|
|
434
513
|
return
|
|
435
514
|
}
|
|
436
515
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.18",
|
|
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",
|