@noob-stupid/dsh-plugin-console 0.3.52 → 0.3.54
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 +4 -4
- package/README.zh.md +5 -4
- package/lib/client.js +294 -73
- package/lib/index.js +508 -42
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -591,6 +591,250 @@ async function removeBundleFromManifest(profileDir, bundlePkg) {
|
|
|
591
591
|
})
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
+
// ============================================================
|
|
595
|
+
// 撤销「已安装但尚未生效」的安装(POST /uninstall { jobId } 的落地实现)
|
|
596
|
+
//
|
|
597
|
+
// 为什么需要(2026-09-20 真装真卸演练实测):面板装完一个 bundle 型插件后 /install 返回 entryId: null、
|
|
598
|
+
// GET /state 里**新增 loader 条目 = 0**(bundle 型要重启才被加载),而 /uninstall 只按**运行中** loader
|
|
599
|
+
// 条目查找 → 恒 404「没有名为 X 的插件条目」——刚装错的插件在重启前无法从面板卸载,用户只能手改 patch
|
|
600
|
+
// + package.json + 删 node_modules 才能撤回。安装任务里其实记着撤销所需的信息:
|
|
601
|
+
// job.packageName(装成了什么)/ job.bundle(是否注册进 dsh.profile.bundles)/ job.entryId(追加的补丁行)。
|
|
602
|
+
//
|
|
603
|
+
// ★ 三个子项各自 try/catch + **回读核实**,绝不 throw:补丁行 / bundles 清单 / 包目录。删除类操作在本机
|
|
604
|
+
// 某些环境下会**静默落空**(不抛错、目录仍在,见 removeDirVerified 注释),删完直接报成功就是对用户撒谎。
|
|
605
|
+
// ============================================================
|
|
606
|
+
|
|
607
|
+
/** 插件控制台自身的包名(撤销分支禁止删自己;@deepseek-ai/* 由通用护栏拒绝)。 */
|
|
608
|
+
const CONSOLE_PACKAGE = name
|
|
609
|
+
|
|
610
|
+
/** profile 内 node_modules/<pkg> 的目录(作用域包按 / 分段,与 pnpm 布局一致)。 */
|
|
611
|
+
function packageDirIn(profileDir, packageName) {
|
|
612
|
+
const name = String(packageName)
|
|
613
|
+
const segments = name.startsWith('@') ? name.split('/') : [name]
|
|
614
|
+
return join(profileDir, 'node_modules', ...segments)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** profile 的 package.json 是否还把这个包当依赖(pnpm add 会写进 dependencies)。 */
|
|
618
|
+
async function manifestRefsPackage(profileDir, packageName) {
|
|
619
|
+
try {
|
|
620
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
|
|
621
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
622
|
+
.some((key) => manifest?.[key] !== null && typeof manifest?.[key] === 'object'
|
|
623
|
+
&& Object.prototype.hasOwnProperty.call(manifest[key], packageName))
|
|
624
|
+
} catch {
|
|
625
|
+
return false
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 这个包是否还需要一次 pnpm remove:目录还在盘上,或 manifest 还引用它。
|
|
630
|
+
* 两者都不成立时拉 pnpm 只会换来一句没有信息量的 `Command failed: corepack pnpm remove …`(演练实测)
|
|
631
|
+
* —— 但 manifest 仍引用时必须跑,否则 package.json 会留下指向空目录的幽灵依赖。 */
|
|
632
|
+
async function needsPnpmRemove(profileDir, packageName) {
|
|
633
|
+
return existsSync(packageDirIn(profileDir, packageName)) || await manifestRefsPackage(profileDir, packageName)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** bundle 包自带 patch(node_modules/<pkg>/cordis.patch.yml)里**归属该包**的 insert 行 id。
|
|
637
|
+
* 为什么要它:这类行由 bundle 的补丁在下次启动时组合进树,profile 补丁里没有 insert 块,但可能留着
|
|
638
|
+
* 它们的 `- id: X` + `disabled: true` 覆盖块(聚合包完整性检查自动禁用 / 用户手动停用)。
|
|
639
|
+
* 只认模块名等于该包或它的子路径的行 —— 聚合包补丁里常引用**别人家**的包(如 @deepseek-ai/dsh-root),
|
|
640
|
+
* 那些行的覆盖块不属于本次撤销,动了就是越界。 */
|
|
641
|
+
async function bundleOwnRowIds(profileDir, packageName) {
|
|
642
|
+
const ids = new Set()
|
|
643
|
+
try {
|
|
644
|
+
const text = await readFile(join(packageDirIn(profileDir, packageName), 'cordis.patch.yml'), 'utf8')
|
|
645
|
+
for (const [rowId, moduleName] of parseInsertNames(text)) {
|
|
646
|
+
if (moduleName === packageName || String(moduleName).startsWith(`${packageName}/`)) ids.add(rowId)
|
|
647
|
+
}
|
|
648
|
+
} catch {}
|
|
649
|
+
return ids
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 撤销一个「已安装但未生效」的安装任务。三个子项各自 try/catch,成败如实汇报;本函数不抛错。
|
|
654
|
+
* 返回 { packageName, bundle, rowIds, verified: { patchClean, bundlesClean, packageGone }, warn, uninstallError }
|
|
655
|
+
*/
|
|
656
|
+
export async function revokePendingInstall(job, { profileDir, patchPath, pnpmRemove: removeFn }) {
|
|
657
|
+
const packageName = typeof job?.packageName === 'string' ? job.packageName.trim() : ''
|
|
658
|
+
const isBundle = job?.bundle === true
|
|
659
|
+
// 兜底(路由已先拦):没有包名就什么都别碰 —— 靠 entryId 单点删除可能误伤别的包
|
|
660
|
+
if (packageName === '') {
|
|
661
|
+
return {
|
|
662
|
+
packageName, bundle: isBundle, rowIds: [],
|
|
663
|
+
verified: { patchClean: false, bundlesClean: false, packageGone: false },
|
|
664
|
+
warn: '该安装任务没有记录包名,未执行任何删除(补丁行 / bundles 清单 / 包目录都未处理)',
|
|
665
|
+
uninstallError: null,
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// ── a) 补丁行:删掉这次安装注册的 insert 行 + 它的 disabled/forced 覆盖块 ──────────
|
|
669
|
+
const rowIds = new Set()
|
|
670
|
+
let patchClean = false
|
|
671
|
+
let patchProblem = null
|
|
672
|
+
try {
|
|
673
|
+
const before = await readPatchState(patchPath)
|
|
674
|
+
const declared = parseInsertNames(before.text) // insert 行 id → 模块名
|
|
675
|
+
if (typeof job?.entryId === 'string' && job.entryId !== '') {
|
|
676
|
+
const owner = declared.get(job.entryId)
|
|
677
|
+
// 补丁里这一行若明确属于**别的包**,绝不按 entryId 删(归属判定以补丁文本为准)
|
|
678
|
+
if (owner === undefined || owner === packageName) rowIds.add(job.entryId)
|
|
679
|
+
}
|
|
680
|
+
for (const [rowId, moduleName] of declared) if (moduleName === packageName) rowIds.add(rowId)
|
|
681
|
+
for (const rowId of await bundleOwnRowIds(profileDir, packageName)) rowIds.add(rowId)
|
|
682
|
+
for (const rowId of rowIds) {
|
|
683
|
+
if (before.inserts.includes(rowId)) await removeInsertRow(patchPath, rowId)
|
|
684
|
+
await removeDisableBlock(patchPath, rowId)
|
|
685
|
+
}
|
|
686
|
+
// 回读核实:目标行(insert / disabled / forced)都不在了,补丁文本也不该再出现这个包名
|
|
687
|
+
const after = await readPatchState(patchPath)
|
|
688
|
+
const leakedRows = [...rowIds].filter((id) => after.inserts.includes(id) || after.disables.includes(id) || after.forced.includes(id))
|
|
689
|
+
const leakedNames = [...parseInsertNames(after.text).values()].filter((name) => name === packageName)
|
|
690
|
+
patchClean = leakedRows.length === 0 && leakedNames.length === 0 && !String(after.text ?? '').includes(packageName)
|
|
691
|
+
if (!patchClean) patchProblem = `仍有残留行 ${[...new Set([...leakedRows, ...leakedNames])].join('、') || packageName}`
|
|
692
|
+
} catch (error) {
|
|
693
|
+
patchProblem = error instanceof Error ? error.message : String(error)
|
|
694
|
+
}
|
|
695
|
+
// ── b) bundles 清单:只删这一个包名(保留顺序、保留其余项)──────────────────────
|
|
696
|
+
let bundlesClean = !isBundle
|
|
697
|
+
let bundlesProblem = null
|
|
698
|
+
if (isBundle) {
|
|
699
|
+
try {
|
|
700
|
+
await removeBundleFromManifest(profileDir, packageName)
|
|
701
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
|
|
702
|
+
bundlesClean = !(manifest?.dsh?.profile?.bundles ?? []).includes(packageName)
|
|
703
|
+
if (!bundlesClean) bundlesProblem = `dsh.profile.bundles 里仍有 ${packageName}`
|
|
704
|
+
} catch (error) {
|
|
705
|
+
bundlesProblem = error instanceof Error ? error.message : String(error)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// ── c) 包目录:pnpm remove(与安装同一管理器)──────────────────────────────────
|
|
709
|
+
const packageDir = packageDirIn(profileDir, packageName)
|
|
710
|
+
let uninstallError = null
|
|
711
|
+
if (await needsPnpmRemove(profileDir, packageName)) {
|
|
712
|
+
try {
|
|
713
|
+
await removeFn(profileDir, packageName)
|
|
714
|
+
} catch (error) {
|
|
715
|
+
uninstallError = error instanceof Error ? error.message : String(error)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const packageGone = !existsSync(packageDir)
|
|
719
|
+
// ── d) 核实汇总:任一子项没清干净 → ok 仍为 true,但必须带 warn 说清是哪项、路径在哪 ──
|
|
720
|
+
const verified = { patchClean, bundlesClean, packageGone }
|
|
721
|
+
const problems = []
|
|
722
|
+
if (!patchClean) problems.push(`补丁未清干净(${patchPath}):${patchProblem ?? '仍有残留行'}`)
|
|
723
|
+
if (!bundlesClean) problems.push(`bundles 清单未清干净(${join(profileDir, 'package.json')}):${bundlesProblem ?? `仍有 ${packageName}`}`)
|
|
724
|
+
if (!packageGone) problems.push(`包目录仍在(${packageDir})${uninstallError !== null ? `,pnpm remove 失败:${uninstallError}` : ''}`)
|
|
725
|
+
const warn = problems.length > 0 ? `撤销未完全生效 —— ${problems.join(';')};请手动处理后重试` : null
|
|
726
|
+
return { packageName, bundle: isBundle, rowIds: [...rowIds], verified, warn, uninstallError }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 「已安装 · 重启后生效」清单:status === 'done'、未撤销、有包名,且该包**不在**运行中的 loader 条目里。
|
|
731
|
+
* bundle 型插件要重启才被加载(演练实测:装完新增 loader 条目 = 0),前端据此把这类任务渲染成带待重启
|
|
732
|
+
* 徽标的行,并允许按 jobId 撤销。entries 传 listEntries(ctx) 的结果(含子路径条目 → 归一到根包名再比)。
|
|
733
|
+
*/
|
|
734
|
+
function pendingRestartJobs(jobs, entries) {
|
|
735
|
+
const running = new Set()
|
|
736
|
+
for (const entry of entries ?? []) {
|
|
737
|
+
const moduleName = entry?.moduleName
|
|
738
|
+
if (typeof moduleName !== 'string' || moduleName === '' || moduleName.startsWith('cordis:')) continue
|
|
739
|
+
running.add(moduleName)
|
|
740
|
+
const root = packageNameOf(moduleName)
|
|
741
|
+
if (typeof root === 'string' && root !== '') running.add(root)
|
|
742
|
+
}
|
|
743
|
+
// 同一包名的多次安装/更新(安装表里会有多条 done 记录)只留最后一次:
|
|
744
|
+
// 否则一个插件在面板上会出现好几行「已安装·重启后生效」。
|
|
745
|
+
const latest = new Map()
|
|
746
|
+
for (const job of jobs ?? []) {
|
|
747
|
+
if (job?.status !== 'done' || job.revokedAt !== undefined) continue
|
|
748
|
+
if (typeof job.packageName !== 'string' || job.packageName.trim() === '') continue
|
|
749
|
+
if (running.has(job.packageName)) continue
|
|
750
|
+
latest.set(job.packageName, job)
|
|
751
|
+
}
|
|
752
|
+
return [...latest.values()].map((job) => ({
|
|
753
|
+
jobId: job.id,
|
|
754
|
+
repo: job.repo ?? null,
|
|
755
|
+
packageName: job.packageName,
|
|
756
|
+
bundle: job.bundle === true,
|
|
757
|
+
finishedAt: job.finishedAt ?? null,
|
|
758
|
+
}))
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** 把与这次删除对应的「已安装·重启后生效」任务记为已撤销(/state 的 pendingRestart 据此过滤)。
|
|
762
|
+
* 为什么两处都要调:无论按 entryId(重启后从列表删)还是按 jobId(重启前撤销)删掉一个包,它的安装任务
|
|
763
|
+
* 若还挂着 status==='done',/state 就会继续显示一行删不掉的幽灵行。同一包名的多条记录(装过又更新过)
|
|
764
|
+
* 一并作废 —— 它们指向同一个包。 */
|
|
765
|
+
function markJobsRevoked(packageName, rowId) {
|
|
766
|
+
for (const job of installJobs.values()) {
|
|
767
|
+
if (job.status !== 'done' || job.revokedAt !== undefined) continue
|
|
768
|
+
if (job.packageName === packageName || (typeof rowId === 'string' && rowId !== '' && job.entryId === rowId)) {
|
|
769
|
+
job.revokedAt = Date.now()
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* 按安装任务撤销「已安装但尚未生效」的安装(/uninstall 的 jobId 分支)。
|
|
776
|
+
* 安全护栏与 entry 分支完全一致:@deepseek-ai/* · isProtectedModule · 控制台自身。
|
|
777
|
+
*/
|
|
778
|
+
async function uninstallByJobId(ctx, res, jobId) {
|
|
779
|
+
const job = installJobs.get(jobId)
|
|
780
|
+
if (job === undefined) {
|
|
781
|
+
sendError(res, 404, `没有这个安装任务(jobId=${jobId})——无法撤销`)
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
if (job.revokedAt !== undefined) {
|
|
785
|
+
sendError(res, 400, `该安装任务已经撤销过了(${new Date(job.revokedAt).toISOString()}),无需重复操作`)
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
if (job.status !== 'done') {
|
|
789
|
+
sendError(res, 400, job.status === 'installing'
|
|
790
|
+
? `该安装任务还在进行中(stage=${job.stage ?? '?'}),完成后才能撤销`
|
|
791
|
+
: `该安装任务没有成功装成(status=${job.status}${job.error ? `:${job.error}` : ''}),没有可撤销的安装`)
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
const packageName = typeof job.packageName === 'string' ? job.packageName.trim() : ''
|
|
795
|
+
if (packageName === '') {
|
|
796
|
+
sendError(res, 400, '该安装任务没有记录包名(可能未装成、或已由现有聚合包提供),无法按任务撤销')
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
if (packageName.startsWith('@deepseek-ai/')) {
|
|
800
|
+
sendError(res, 403, `${packageName} 是 DSH 框架官方包,禁止删除`)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (isProtectedModule(packageName)) {
|
|
804
|
+
sendError(res, 403, `${packageName} 属于宿主基础设施,禁止删除`)
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const rowId = typeof job.entryId === 'string' && job.entryId !== '' ? job.entryId : deriveEntryId(packageName, new Set())
|
|
808
|
+
if (rowId === 'plugin-console' || packageName === CONSOLE_PACKAGE) {
|
|
809
|
+
sendError(res, 400, '不能删除插件控制台自身')
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
// 本分支只服务「已安装但尚未生效」:包名若已在运行中的 loader 条目里(重启已完成),撤销会留下
|
|
813
|
+
// 「包已删、模块还挂在内存里」的半状态 —— 如实拒绝并指路(按列表条目删除)。
|
|
814
|
+
if (pendingRestartJobs([job], listEntries(ctx)).length === 0) {
|
|
815
|
+
sendError(res, 400, `「${packageName}」已经在运行中的插件列表里(重启已完成)——请直接在列表里删除该条目,不必按 jobId 撤销`)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const patchPath = findPatchPath(ctx)
|
|
819
|
+
const profileDir = dirname(patchPath)
|
|
820
|
+
const result = await revokePendingInstall(job, { profileDir, patchPath, pnpmRemove })
|
|
821
|
+
// 只有包目录真的没了才算「这次安装已撤销」:此后不再出现在 /state 的 pendingRestart 里。
|
|
822
|
+
// 包还在盘上时不打这个标记 —— 保留待重启条目让用户能再点一次删除,比假装干净好。
|
|
823
|
+
if (result.verified.packageGone === true) markJobsRevoked(result.packageName, typeof job.entryId === 'string' ? job.entryId : null)
|
|
824
|
+
sendJson(res, 200, {
|
|
825
|
+
ok: true,
|
|
826
|
+
removed: 'pending-install',
|
|
827
|
+
jobId,
|
|
828
|
+
packageName: result.packageName,
|
|
829
|
+
bundle: result.bundle,
|
|
830
|
+
restart: false,
|
|
831
|
+
rowIds: result.rowIds,
|
|
832
|
+
verified: result.verified,
|
|
833
|
+
warn: result.warn,
|
|
834
|
+
uninstallError: result.uninstallError,
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
594
838
|
/** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
|
|
595
839
|
* 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。 */
|
|
596
840
|
export async function pnpmRemove(profileDir, packageName) {
|
|
@@ -632,6 +876,53 @@ function cleanupStalePackageDir(profileDir, packageName) {
|
|
|
632
876
|
return removed
|
|
633
877
|
}
|
|
634
878
|
|
|
879
|
+
/** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、
|
|
880
|
+
* 什么没清掉。为什么需要(2026-09-20 真装演练):11 个子包的聚合仓库跑 19 分钟后失败,node_modules 里
|
|
881
|
+
* 留着 `@captain1275/dsh-full-stats_tmp_56272_2` 这类半成品和一个真包,而面板只报"安装失败"——
|
|
882
|
+
* 用户既不知道有东西被落盘,也不知道要不要手动清。删除一律走 removeDirVerified(本机 C 盘/`%TEMP%`
|
|
883
|
+
* 下 rmSync 会静默落空,不核实就会谎报干净)。 */
|
|
884
|
+
export function cleanupAttemptedCandidates(profileDir, candidates) {
|
|
885
|
+
const cleaned = []
|
|
886
|
+
const failed = []
|
|
887
|
+
for (const name of candidates) {
|
|
888
|
+
const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
|
|
889
|
+
const parent = dirname(dir)
|
|
890
|
+
const base = basename(dir)
|
|
891
|
+
let touched = 0
|
|
892
|
+
if (existsSync(dir)) {
|
|
893
|
+
const result = removeDirVerified(dir)
|
|
894
|
+
if (result.ok) touched += 1
|
|
895
|
+
else failed.push({ name, path: dir, error: result.error })
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
for (const entry of readdirSync(parent)) {
|
|
899
|
+
if (!entry.startsWith(`${base}_tmp_`)) continue
|
|
900
|
+
const tmpPath = join(parent, entry)
|
|
901
|
+
const result = removeDirVerified(tmpPath)
|
|
902
|
+
if (result.ok) touched += 1
|
|
903
|
+
else failed.push({ name: `${name}(临时目录)`, path: tmpPath, error: result.error })
|
|
904
|
+
}
|
|
905
|
+
} catch {}
|
|
906
|
+
if (touched > 0) cleaned.push(name)
|
|
907
|
+
}
|
|
908
|
+
return { cleaned, failed }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
|
|
912
|
+
* 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。 */
|
|
913
|
+
export function aiConsentFailureText(decision, leftovers) {
|
|
914
|
+
const base = decision?.timeout === true
|
|
915
|
+
? `等待授权超时(${Math.round(AI_CONSENT_TIMEOUT_MS / 60000)} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
|
|
916
|
+
: '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
|
|
917
|
+
const cleaned = leftovers?.cleaned ?? []
|
|
918
|
+
const failed = leftovers?.failed ?? []
|
|
919
|
+
const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
|
|
920
|
+
const failedNote = failed.length > 0
|
|
921
|
+
? `;**有 ${failed.length} 项没能清理**(当前环境可能禁止删除,请手动删除):${failed.map((f) => f.path).join('、')}`
|
|
922
|
+
: ''
|
|
923
|
+
return `${base}${cleanedNote}${failedNote}`
|
|
924
|
+
}
|
|
925
|
+
|
|
635
926
|
/** 软件源配置文件(registry 列表,安装链按主→备依次尝试)。 */
|
|
636
927
|
const SOURCES_FILE = join(dshHome(), 'plugin-console-sources.json')
|
|
637
928
|
/** 敏感凭据单独落盘(避免与可分享配置混存):自定义搜索源 headers + Gitee secret/token。 */
|
|
@@ -2824,7 +3115,7 @@ async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath, tr
|
|
|
2824
3115
|
* 后台安装任务注册表:请求立即返回,安装继续在服务端执行;
|
|
2825
3116
|
* 面板通过 /install-status 轮询进度(stage + status),离开面板不中断。
|
|
2826
3117
|
*/
|
|
2827
|
-
const installJobs = new Map()
|
|
3118
|
+
export const installJobs = new Map()
|
|
2828
3119
|
let installJobSeq = 0
|
|
2829
3120
|
/** AI 赋能任务注册表(规划/执行均常驻服务端,面板轮询进度)。 */
|
|
2830
3121
|
const aiJobs = new Map()
|
|
@@ -2900,6 +3191,9 @@ function loadAiJobs() {
|
|
|
2900
3191
|
/** 静态插件索引内存缓存(/market-index 用)。 */
|
|
2901
3192
|
let marketIndexCache = null
|
|
2902
3193
|
|
|
3194
|
+
/** 本地 AI 兜底授权的等待上限(10 分钟):超时即视为用户未授权(见 runInstallJob 的 ai-consent 段)。 */
|
|
3195
|
+
const AI_CONSENT_TIMEOUT_MS = 600000
|
|
3196
|
+
|
|
2903
3197
|
function installJobView(job) {
|
|
2904
3198
|
return {
|
|
2905
3199
|
jobId: job.id,
|
|
@@ -2926,6 +3220,16 @@ function installJobView(job) {
|
|
|
2926
3220
|
suiteReport: job.suiteReport ?? null,
|
|
2927
3221
|
suiteNote: job.suiteNote ?? null,
|
|
2928
3222
|
hint: job.hint ?? null,
|
|
3223
|
+
// 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
|
|
3224
|
+
// "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 runInstallJob 候选循环维护的
|
|
3225
|
+
// candidateXxx;套装通道来自 runSuiteInstallJob 维护的 suiteProgress(clone/装配两阶段)。
|
|
3226
|
+
progress: job.suiteProgress
|
|
3227
|
+
? { channel: 'suite', ...job.suiteProgress }
|
|
3228
|
+
: job.candidateTotal > 0
|
|
3229
|
+
? { channel: 'subpackage', phase: 'install', index: job.candidateIndex ?? 0, total: job.candidateTotal, name: job.candidateName ?? null, done: job.candidateDone === true }
|
|
3230
|
+
: null,
|
|
3231
|
+
// 授权请求(等本地 AI 兜底同意):面板要显示倒计时 + 同意/取消,所以时间与最后错误一起下发
|
|
3232
|
+
aiConsent: { pending: job.aiPending != null, since: job.aiPendingSince ?? null, timeoutMs: job.aiConsentTimeoutMs ?? AI_CONSENT_TIMEOUT_MS, lastError: job.aiPending?.lastError ?? job.lastError ?? null },
|
|
2929
3233
|
}
|
|
2930
3234
|
}
|
|
2931
3235
|
|
|
@@ -3098,15 +3402,55 @@ function gitCloneUrls(repoFullName, source = 'github') {
|
|
|
3098
3402
|
return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
|
|
3099
3403
|
}
|
|
3100
3404
|
|
|
3405
|
+
/**
|
|
3406
|
+
* 删除目录树并**核实删除结果**。理由:本机某些环境(受限令牌/沙箱/杀软占用)下 `rmSync` 会
|
|
3407
|
+
* **静默落空**——不抛错、目录仍在。删完直接报成功就是对用户撒谎(2026-09-20 多类型演练实测:
|
|
3408
|
+
* 同一个 `rmSync` 在 `D:\dsh\repos` 删得掉,在 `C:\Users\<user>\.dsh\...` 与 `%TEMP%` 下返回成功
|
|
3409
|
+
* 但目录原封不动;技能删不掉、残留清理假装清干净、套装子模块重试撞"目录非空"都是它引起的)。
|
|
3410
|
+
*/
|
|
3411
|
+
export function removeDirVerified(dir) {
|
|
3412
|
+
let lastError = null
|
|
3413
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
3414
|
+
try {
|
|
3415
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 })
|
|
3416
|
+
} catch (error) {
|
|
3417
|
+
lastError = error
|
|
3418
|
+
}
|
|
3419
|
+
if (!existsSync(dir)) return { ok: true, attempts: attempt }
|
|
3420
|
+
}
|
|
3421
|
+
return { ok: false, attempts: 2, error: lastError instanceof Error ? lastError.message : null }
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
/** 取 git 自己说的话(stderr 末两行)。`--quiet` 只静音进度,真实原因(HTTP 502/无法解析主机/
|
|
3425
|
+
* 认证失败)仍在 stderr 里;只报 `Command failed: git clone …` 等于没告诉用户任何信息。 */
|
|
3426
|
+
function gitErrorDetail(error) {
|
|
3427
|
+
const raw = typeof error?.stderr === 'string' && error.stderr.trim() !== ''
|
|
3428
|
+
? error.stderr
|
|
3429
|
+
: (typeof error?.message === 'string' ? error.message : '')
|
|
3430
|
+
return raw
|
|
3431
|
+
.split(/\r?\n/u)
|
|
3432
|
+
.map((line) => line.trim())
|
|
3433
|
+
.filter((line) => line !== '' && !/^Command failed/u.test(line))
|
|
3434
|
+
.slice(-2)
|
|
3435
|
+
.join(' | ')
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3101
3438
|
/** 逐条尝试的错误汇总(纯函数,单测覆盖):报**第一个**错误(真实原因)+ 尝试清单。
|
|
3102
3439
|
* 为什么要这样:多源重试时若第一次失败留下半成品目录,第二次会以
|
|
3103
3440
|
* `fatal: destination path '…' already exists and is not an empty directory` 失败,
|
|
3104
3441
|
* 旧代码把它当 lastError 抛出去 → 用户只看到"目录非空",真实原因(镜像/网络不可达)被完全掩盖。
|
|
3105
|
-
* 2026-09-20
|
|
3442
|
+
* 2026-09-20 另一位用户实测报的就是这句;同日演练又发现:**清理半成品本身失败**时(环境禁止删除
|
|
3443
|
+
* %TEMP%),第二个源的好戏只有"目录非空",必须把"清不掉"讲明白,否则用户永远猜不到真相。 */
|
|
3106
3444
|
export function summarizeCloneErrors(errors) {
|
|
3107
3445
|
const first = errors[0]
|
|
3108
|
-
const tried = errors.map((e) => (
|
|
3109
|
-
|
|
3446
|
+
const tried = errors.map((e) => (e.unclean === true
|
|
3447
|
+
? `${e.url}(目标目录清不掉,未重试)`
|
|
3448
|
+
: (/already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url))).join(';')
|
|
3449
|
+
const detail = gitErrorDetail(first)
|
|
3450
|
+
const uncleanNote = errors.some((e) => e.unclean === true)
|
|
3451
|
+
? ';注意:上一次失败留下的半成品目录无法清理(当前环境禁止删除),多源重试因此无效——请手动删除该目录后重试'
|
|
3452
|
+
: ''
|
|
3453
|
+
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${errors.length} 个源:${tried}${uncleanNote}`
|
|
3110
3454
|
}
|
|
3111
3455
|
|
|
3112
3456
|
/** git clone(镜像→直连;gitee 直连),返回 { url } 或抛错。 */
|
|
@@ -3116,7 +3460,13 @@ export async function gitCloneRepo(repo, dest, source = 'github', timeout = 1800
|
|
|
3116
3460
|
for (const [attempt, url] of urls.entries()) {
|
|
3117
3461
|
// 每次尝试前都清掉目标目录:上一次可能留下半成品(git 会先建目录再传输),
|
|
3118
3462
|
// 不清就会让第二次以"目录非空"失败并掩盖真实原因(见 summarizeCloneErrors 注释)。
|
|
3119
|
-
|
|
3463
|
+
// 关键是**核实**清理结果:清不掉就别再试下一个源了——那只会得到一条"目录非空",
|
|
3464
|
+
// 把第一个源的真实错误也一起搅浑(本机 %TEMP% 下删除被静默忽略时就是这样)。
|
|
3465
|
+
const cleared = removeDirVerified(dest)
|
|
3466
|
+
if (!cleared.ok) {
|
|
3467
|
+
errors.push({ url, message: `克隆目标目录无法清理(环境禁止删除):${dest}`, unclean: true })
|
|
3468
|
+
break
|
|
3469
|
+
}
|
|
3120
3470
|
try {
|
|
3121
3471
|
// eslint-disable-next-line no-await-in-loop
|
|
3122
3472
|
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
@@ -3126,7 +3476,7 @@ export async function gitCloneRepo(repo, dest, source = 'github', timeout = 1800
|
|
|
3126
3476
|
})
|
|
3127
3477
|
return { url, attempt: attempt + 1 }
|
|
3128
3478
|
} catch (error) {
|
|
3129
|
-
errors.push({ url, message: error?.message ?? String(error) })
|
|
3479
|
+
errors.push({ url, message: error?.message ?? String(error), stderr: error?.stderr })
|
|
3130
3480
|
}
|
|
3131
3481
|
}
|
|
3132
3482
|
throw new Error(summarizeCloneErrors(errors))
|
|
@@ -3187,7 +3537,7 @@ export function resolveInstallKind(requestKind, probeText) {
|
|
|
3187
3537
|
/** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
|
|
3188
3538
|
* clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
|
|
3189
3539
|
* 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
|
|
3190
|
-
async function runSuiteInstallJob(job, ctx) {
|
|
3540
|
+
export async function runSuiteInstallJob(job, ctx) {
|
|
3191
3541
|
const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
|
|
3192
3542
|
const report = []
|
|
3193
3543
|
try {
|
|
@@ -3205,7 +3555,10 @@ async function runSuiteInstallJob(job, ctx) {
|
|
|
3205
3555
|
const patchPath = findPatchPath(ctx)
|
|
3206
3556
|
const profileDir = dirname(patchPath)
|
|
3207
3557
|
// 手动拉取子模块(git 的 insteadOf 重写对 submodule 不生效,按镜像 URL 逐个 clone)
|
|
3208
|
-
|
|
3558
|
+
// 套装级进度:套装动辄几分钟(clone 每个子模块 + 逐个装配),面板要能显示"第 i/n 个子模块"
|
|
3559
|
+
for (let si = 0; si < subs.length; si += 1) {
|
|
3560
|
+
const sub = subs[si]
|
|
3561
|
+
job.suiteProgress = { phase: 'clone', index: si + 1, total: subs.length, name: sub.name, done: false }
|
|
3209
3562
|
const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
|
|
3210
3563
|
const dest = join(tmpDir, sub.path)
|
|
3211
3564
|
try {
|
|
@@ -3215,7 +3568,9 @@ async function runSuiteInstallJob(job, ctx) {
|
|
|
3215
3568
|
report.push({ component: sub.name, type: 'clone', ok: false, note: `子模块拉取失败:${error.message}` })
|
|
3216
3569
|
}
|
|
3217
3570
|
}
|
|
3218
|
-
for (
|
|
3571
|
+
for (let pi = 0; pi < subs.length; pi += 1) {
|
|
3572
|
+
const sub = subs[pi]
|
|
3573
|
+
job.suiteProgress = { phase: 'assemble', index: pi + 1, total: subs.length, name: sub.name, done: false }
|
|
3219
3574
|
const subDir = join(tmpDir, sub.path)
|
|
3220
3575
|
if (!existsSync(subDir)) continue
|
|
3221
3576
|
const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
|
|
@@ -3271,7 +3626,12 @@ async function runSuiteInstallJob(job, ctx) {
|
|
|
3271
3626
|
// d. 普通 npm 插件(无 bundle 且无预设/技能)
|
|
3272
3627
|
if (!handled && pkg && typeof pkg.name === 'string') {
|
|
3273
3628
|
const target = join(profileDir, 'node_modules', pkg.name)
|
|
3274
|
-
if (existsSync(target))
|
|
3629
|
+
if (existsSync(target)) {
|
|
3630
|
+
// 核实清理结果:删不掉就明确报错,别把新包**合并**进旧目录(半新半旧最难查),
|
|
3631
|
+
// 也别让后续 rename/copyTree 以 EPERM 之类的次生错误掩盖真实原因(见 removeDirVerified)。
|
|
3632
|
+
const cleared = removeDirVerified(target)
|
|
3633
|
+
if (!cleared.ok) throw new Error(`无法清理已存在的目录(当前环境禁止删除):${target}${cleared.error ? `,原因:${cleared.error}` : ''}——请手动删除后重试`)
|
|
3634
|
+
}
|
|
3275
3635
|
mkdirSync(dirname(target), { recursive: true })
|
|
3276
3636
|
copyTree(subDir, target)
|
|
3277
3637
|
const taken = new Set(listEntries(ctx).map((e) => e.rowId))
|
|
@@ -3287,6 +3647,8 @@ async function runSuiteInstallJob(job, ctx) {
|
|
|
3287
3647
|
job.status = 'done'
|
|
3288
3648
|
job.stage = 'done'
|
|
3289
3649
|
job.suiteReport = report
|
|
3650
|
+
// 装配全部走完:进度标记完成(保留最后一轮的 i/n 与名字,前端显示"已完成 n/n 个子模块")
|
|
3651
|
+
if (job.suiteProgress) job.suiteProgress = { ...job.suiteProgress, done: true }
|
|
3290
3652
|
const okCount = report.filter((r) => r.ok).length
|
|
3291
3653
|
const failCount = report.filter((r) => !r.ok).length
|
|
3292
3654
|
const bundleCount = report.filter((r) => r.type === 'bundle' && r.ok).length
|
|
@@ -3473,9 +3835,11 @@ async function fetchSubpackageNames(repo, branch, auth) {
|
|
|
3473
3835
|
}
|
|
3474
3836
|
}
|
|
3475
3837
|
|
|
3476
|
-
/** 子包候选:聚合包(名字带 all)优先,上限 8 个。
|
|
3838
|
+
/** 子包候选:聚合包(名字带 all)优先,上限 8 个。
|
|
3839
|
+
* 2026-09-20 真装演练:`@dsh-suite/all` 这种 **scope 根形式**(`/all` 结尾)不被 `(^|-)all$` 命中,
|
|
3840
|
+
* 聚合包没能排到最前(那次纯属仓库目录顺序碰巧第一)。补上 `/all$`。 */
|
|
3477
3841
|
async function subpackageCandidates(repo, branch, auth) {
|
|
3478
|
-
const isAll = (name) => /(^|-)all$/u.test(name) || /-all-/u.test(name)
|
|
3842
|
+
const isAll = (name) => /(^|-)all$/u.test(name) || /-all-/u.test(name) || /\/all$/u.test(name)
|
|
3479
3843
|
const subs = await fetchSubpackageNames(repo, branch, auth)
|
|
3480
3844
|
return subs
|
|
3481
3845
|
.slice()
|
|
@@ -4888,7 +5252,7 @@ async function runInstallJob(job, ctx) {
|
|
|
4888
5252
|
job.status = 'failed'
|
|
4889
5253
|
// 文案要点:说清"这是读不到、不是没有",并给出可直接复制的安装命令 ——
|
|
4890
5254
|
// 用户看到"未发现子包"会去翻仓库找,而真相多半只是本次网络没读成。
|
|
4891
|
-
job.error = `仓库 ${job.repo} 的根包未发布到 npm(private: true),且本次没能读到它的子包列表(多为网络受限/超时,不代表没有子包)。请重试;或在「查看」详情里确认子包名后直接安装,例如:dsh plugin --profile web add
|
|
5255
|
+
job.error = `仓库 ${job.repo} 的根包未发布到 npm(private: true:${pkg.name}),且本次没能读到它的子包列表(多为网络受限/超时,不代表没有子包)。请重试;或在「查看」详情里确认子包名后直接安装,例如:dsh plugin --profile web add <子包名>;若该仓库根包本身即可作为插件,也可直接:dsh plugin --profile web add github:${job.repo}`
|
|
4892
5256
|
job.probeReason = 'subpackages-unreadable'
|
|
4893
5257
|
return
|
|
4894
5258
|
}
|
|
@@ -4924,9 +5288,16 @@ async function runInstallJob(job, ctx) {
|
|
|
4924
5288
|
// 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
|
|
4925
5289
|
const registries = orderedRegistries(readSources())
|
|
4926
5290
|
const deadline = Date.now() + 8 * 60 * 1000
|
|
5291
|
+
// 子包级进度(2026-09-20 真装实测:11 个子包的聚合仓库跑了 19 分钟,job.stage 一直停在
|
|
5292
|
+
// installing,面板只有一个不动的进度条)。candidateTotal/Index/Name 每轮开始时更新,
|
|
5293
|
+
// 由 installJobView 折算成 progress{index,total,name} 下发给面板。
|
|
5294
|
+
job.candidateTotal = candidates.length
|
|
5295
|
+
job.candidateDone = false
|
|
4927
5296
|
for (let index = 0; index < candidates.length && installedName === null; index += 1) {
|
|
4928
5297
|
if (Date.now() > deadline) break
|
|
4929
5298
|
const name = candidates[index]
|
|
5299
|
+
job.candidateIndex = index + 1
|
|
5300
|
+
job.candidateName = name
|
|
4930
5301
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
4931
5302
|
if (installedName === null) {
|
|
4932
5303
|
const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
|
|
@@ -5049,12 +5420,15 @@ async function runInstallJob(job, ctx) {
|
|
|
5049
5420
|
if (subs.length > 0) {
|
|
5050
5421
|
const extra = subs
|
|
5051
5422
|
.slice()
|
|
5052
|
-
|
|
5423
|
+
// 聚合包优先:`@dsh-suite/all` 这类 scope 根形式(/all 结尾)也要认(演练实测)
|
|
5424
|
+
.sort((a, b) => Number(/(^|-)all$/u.test(b.name) || /-all-/u.test(b.name) || /\/all$/u.test(b.name)) - Number(/(^|-)all$/u.test(a.name) || /-all-/u.test(a.name) || /\/all$/u.test(a.name)))
|
|
5053
5425
|
.map((sub) => sub.name)
|
|
5054
5426
|
.filter((n) => !candidates.includes(n))
|
|
5055
5427
|
.slice(0, 8)
|
|
5056
5428
|
if (extra.length > 0) {
|
|
5057
5429
|
candidates = [...candidates, ...extra]
|
|
5430
|
+
// 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
|
|
5431
|
+
job.candidateTotal = candidates.length
|
|
5058
5432
|
if (!Array.isArray(job.subpackages)) job.subpackages = []
|
|
5059
5433
|
for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
|
|
5060
5434
|
}
|
|
@@ -5065,10 +5439,14 @@ async function runInstallJob(job, ctx) {
|
|
|
5065
5439
|
// 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
|
|
5066
5440
|
job.stage = 'ai-consent'
|
|
5067
5441
|
job.aiPending = { lastError: lastError?.message ?? null }
|
|
5442
|
+
// 等授权期间要能展示"为什么卡住、还能等多久":请求时间 + 超时上限 + 最后一个确定性错误
|
|
5443
|
+
job.aiPendingSince = Date.now()
|
|
5444
|
+
job.aiConsentTimeoutMs = AI_CONSENT_TIMEOUT_MS
|
|
5445
|
+
job.lastError = lastError?.message ?? null
|
|
5068
5446
|
job.aiWait = new Promise((resolve) => { job.aiPending.resolver = resolve })
|
|
5069
5447
|
const decision = await Promise.race([
|
|
5070
5448
|
job.aiWait,
|
|
5071
|
-
new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }),
|
|
5449
|
+
new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), AI_CONSENT_TIMEOUT_MS)),
|
|
5072
5450
|
])
|
|
5073
5451
|
job.aiPending = null
|
|
5074
5452
|
job.aiWait = null
|
|
@@ -5076,12 +5454,17 @@ async function runInstallJob(job, ctx) {
|
|
|
5076
5454
|
await aiRepair(job, ctx, profileDir, candidates, lastError?.message ?? null)
|
|
5077
5455
|
} else {
|
|
5078
5456
|
job.status = 'failed'
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5457
|
+
// 失败/取消时清场并如实汇报(见 cleanupAttemptedCandidates 注释):
|
|
5458
|
+
// 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
|
|
5459
|
+
const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
|
|
5460
|
+
job.leftovers = leftovers
|
|
5461
|
+
job.error = aiConsentFailureText(decision, leftovers)
|
|
5082
5462
|
}
|
|
5083
5463
|
return
|
|
5084
5464
|
}
|
|
5465
|
+
// 装成功:进度标记完成(index 停在真正装上的那一轮,前端显示"第 i/n 个:<包名>"即为成功项)
|
|
5466
|
+
job.candidateDone = true
|
|
5467
|
+
job.candidateName = installedName
|
|
5085
5468
|
job.packageName = installedName
|
|
5086
5469
|
// 聚合包子包版本对齐(确保更新后所有子包也到新版声明版本)
|
|
5087
5470
|
let syncedNames = []
|
|
@@ -5284,6 +5667,10 @@ async function handle(ctx, req, res) {
|
|
|
5284
5667
|
const auth = readGithubAuth()
|
|
5285
5668
|
const jobs = [...installJobs.values()].filter((job) => job.status === 'installing').map(installJobView)
|
|
5286
5669
|
const recentFailures = [...installJobs.values()].filter((job) => job.status === 'failed').slice(-3).map(installJobView)
|
|
5670
|
+
// 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,装完
|
|
5671
|
+
// /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出「装了但还没
|
|
5672
|
+
// 生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
|
|
5673
|
+
const pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx))
|
|
5287
5674
|
// 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
|
|
5288
5675
|
let framework = null
|
|
5289
5676
|
try { framework = detectFrameworkUpgrade(ctx) } catch {}
|
|
@@ -5298,7 +5685,7 @@ async function handle(ctx, req, res) {
|
|
|
5298
5685
|
const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
5299
5686
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
5300
5687
|
} catch {}
|
|
5301
|
-
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
5688
|
+
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
5302
5689
|
return
|
|
5303
5690
|
}
|
|
5304
5691
|
|
|
@@ -5555,6 +5942,55 @@ async function handle(ctx, req, res) {
|
|
|
5555
5942
|
return
|
|
5556
5943
|
}
|
|
5557
5944
|
|
|
5945
|
+
if (pathname === `${ROUTE_PREFIX}/github-open-login`) {
|
|
5946
|
+
// 设备码(窗口)登录:唤起独立插件 dsh-github-login 的登录窗口 —— 登录的首选通道,token 只是兜底。
|
|
5947
|
+
//
|
|
5948
|
+
// 为什么是"调另一个插件的 HTTP 接口"而不是自己实现设备码:Device Flow + 桌面窗口 + 令牌落盘
|
|
5949
|
+
// 已经由那个插件实现好了,它挂在**同一个宿主 webServer** 上(源码:<profile>/node_modules/
|
|
5950
|
+
// dsh-github-login/lib/index.js):
|
|
5951
|
+
// POST /github-auth/open → { ok:true, launched:"<exe 路径>" } 或 { ok:true, launched:false, hint }
|
|
5952
|
+
// GET /github-auth/status → { ok, loggedIn, login }
|
|
5953
|
+
// 为什么失败也回 200:这个能力是**外挂**的 —— 插件可能没装、装了也可能没打包 exe、当前 profile
|
|
5954
|
+
// 也可能没把它挂上(2026-09-20 实测:本机装了它,但跑着的宿主里 GET /github-auth/status 仍是 404)。
|
|
5955
|
+
// 那是"这条通道现在不可用",不是"服务端出错":回 500 只会让用户看到一句看不懂的报错,还看不出
|
|
5956
|
+
// "该走 token 兜底"这条正路。所以统一回 200 + started:false + 可读 reason,由前端决定降级。
|
|
5957
|
+
const port = webPort(ctx)
|
|
5958
|
+
const base = `http://127.0.0.1:${port}`
|
|
5959
|
+
const unavailable = '未检测到 dsh-github-login 插件(或当前平台不支持它的登录窗口)'
|
|
5960
|
+
const fallback = (detail) => {
|
|
5961
|
+
sendJson(res, 200, { ok: true, started: false, reason: detail ? `${unavailable}:${detail}` : unavailable })
|
|
5962
|
+
}
|
|
5963
|
+
let opened = null
|
|
5964
|
+
try {
|
|
5965
|
+
const response = await fetch(`${base}/github-auth/open`, { method: 'POST', signal: AbortSignal.timeout(8000) })
|
|
5966
|
+
if (!response.ok) {
|
|
5967
|
+
fallback(`对方接口返回 HTTP ${response.status}`)
|
|
5968
|
+
return
|
|
5969
|
+
}
|
|
5970
|
+
opened = await response.json()
|
|
5971
|
+
} catch (error) {
|
|
5972
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
5973
|
+
// fetch 的 "fetch failed" 不带 cause 等于没说;超时原文也没有信息量 —— 都压成一行可读文案
|
|
5974
|
+
const cause = error?.cause?.code ?? error?.cause?.message ?? ''
|
|
5975
|
+
fallback(/abort/iu.test(message) ? '调用超时(8000ms)' : (cause ? `${message}(${cause})` : message))
|
|
5976
|
+
return
|
|
5977
|
+
}
|
|
5978
|
+
// 对方"接口在、但本机没有登录工具 exe"时回 { ok:true, launched:false, hint }:窗口其实没起来。
|
|
5979
|
+
// 这里必须跟着报 started:false —— 报 true 会让前端开 60s 轮询,等一个永远不会出现的登录。
|
|
5980
|
+
if (opened?.launched === false) {
|
|
5981
|
+
fallback(typeof opened.hint === 'string' && opened.hint !== '' ? opened.hint : '对方未启动登录窗口(未找到 DSH-GitHub-Login 可执行文件)')
|
|
5982
|
+
return
|
|
5983
|
+
}
|
|
5984
|
+
// 顺手把对方的登录态透传给前端(best-effort:拿不到就 status:null,不影响"窗口已打开"这个结论)
|
|
5985
|
+
let status = null
|
|
5986
|
+
try {
|
|
5987
|
+
const stateRes = await fetch(`${base}/github-auth/status`, { signal: AbortSignal.timeout(2000) })
|
|
5988
|
+
if (stateRes.ok) status = await stateRes.json()
|
|
5989
|
+
} catch {}
|
|
5990
|
+
sendJson(res, 200, { ok: true, started: true, status })
|
|
5991
|
+
return
|
|
5992
|
+
}
|
|
5993
|
+
|
|
5558
5994
|
if (pathname === `${ROUTE_PREFIX}/details`) {
|
|
5559
5995
|
const { entryId } = body
|
|
5560
5996
|
if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
|
|
@@ -5664,13 +6100,22 @@ async function handle(ctx, req, res) {
|
|
|
5664
6100
|
}
|
|
5665
6101
|
|
|
5666
6102
|
if (pathname === `${ROUTE_PREFIX}/uninstall`) {
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
6103
|
+
// 入参两种形态:entryId(运行中的 loader 条目)· jobId(已安装但尚未生效的安装任务,见 revokePendingInstall)
|
|
6104
|
+
const { entryId, jobId } = body
|
|
6105
|
+
const entryIdOk = typeof entryId === 'string' && /^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)
|
|
6106
|
+
const jobIdOk = typeof jobId === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/u.test(jobId)
|
|
6107
|
+
if (!entryIdOk && !jobIdOk) {
|
|
6108
|
+
sendError(res, 400, 'entryId 无效(撤销尚未生效的安装请改传 jobId)')
|
|
5670
6109
|
return
|
|
5671
6110
|
}
|
|
5672
|
-
const entry = ctx.loader.entries().find((candidate) => candidate.id === entryId)
|
|
6111
|
+
const entry = entryIdOk ? ctx.loader.entries().find((candidate) => candidate.id === entryId) : undefined
|
|
5673
6112
|
if (!entry) {
|
|
6113
|
+
// 2026-09-20 演练实测缺口:bundle 型插件装完要重启才进 loader,过去这里恒 404 →
|
|
6114
|
+
// 「刚装错的插件在重启前无法从面板卸载」。带 jobId 时改走安装任务撤销。
|
|
6115
|
+
if (jobIdOk) {
|
|
6116
|
+
await uninstallByJobId(ctx, res, jobId)
|
|
6117
|
+
return
|
|
6118
|
+
}
|
|
5674
6119
|
sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
|
|
5675
6120
|
return
|
|
5676
6121
|
}
|
|
@@ -5711,11 +6156,17 @@ async function handle(ctx, req, res) {
|
|
|
5711
6156
|
}
|
|
5712
6157
|
await removeInsertRow(patchPath, rowId)
|
|
5713
6158
|
let uninstallError = null
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
6159
|
+
// 目录已不在、manifest 也没引用 → 不必再拉一次 pnpm:那只会在本机换来一句没有信息量的
|
|
6160
|
+
// "Command failed: corepack pnpm remove …"(演练实测)。判断规则与 jobId 撤销分支同一套。
|
|
6161
|
+
if (await needsPnpmRemove(profileDir, moduleName)) {
|
|
6162
|
+
try {
|
|
6163
|
+
await pnpmRemove(profileDir, moduleName)
|
|
6164
|
+
} catch (error) {
|
|
6165
|
+
uninstallError = error instanceof Error ? error.message : String(error)
|
|
6166
|
+
}
|
|
5718
6167
|
}
|
|
6168
|
+
// 任务记录是否作废看**事实**(包目录真的没了),不看 pnpm 的退出码;删不干净时留着让用户重试
|
|
6169
|
+
if (!existsSync(packageDirIn(profileDir, moduleName))) markJobsRevoked(moduleName, rowId)
|
|
5719
6170
|
sendJson(res, 200, { ok: true, removed: 'entry', packageName: moduleName, restart: false, uninstallError })
|
|
5720
6171
|
return
|
|
5721
6172
|
}
|
|
@@ -7465,6 +7916,7 @@ async function handle(ctx, req, res) {
|
|
|
7465
7916
|
const profileDir = dirname(findPatchPath(ctx))
|
|
7466
7917
|
const nodeModules = join(profileDir, 'node_modules')
|
|
7467
7918
|
const removed = []
|
|
7919
|
+
const failed = []
|
|
7468
7920
|
// 1) 删除 .old-* 残余备份(顶层 + 作用域目录)
|
|
7469
7921
|
const scanDirs = [nodeModules]
|
|
7470
7922
|
try { if (existsSync(join(nodeModules, '@linxin666'))) scanDirs.push(join(nodeModules, '@linxin666')) } catch {}
|
|
@@ -7474,8 +7926,10 @@ async function handle(ctx, req, res) {
|
|
|
7474
7926
|
if (!entry.isDirectory()) continue
|
|
7475
7927
|
if (!/\.old-/u.test(entry.name)) continue
|
|
7476
7928
|
const target = join(dir, entry.name)
|
|
7477
|
-
|
|
7478
|
-
|
|
7929
|
+
// 核实后再计入 removed:删不掉却报成功会让用户以为清干净了(见 removeDirVerified 注释)
|
|
7930
|
+
const result = removeDirVerified(target)
|
|
7931
|
+
if (result.ok) removed.push(entry.name)
|
|
7932
|
+
else failed.push({ name: entry.name, path: target, error: result.error })
|
|
7479
7933
|
}
|
|
7480
7934
|
} catch {}
|
|
7481
7935
|
}
|
|
@@ -7493,12 +7947,18 @@ async function handle(ctx, req, res) {
|
|
|
7493
7947
|
if (declared.has(full)) continue
|
|
7494
7948
|
const pkgJson = join(scoped, entry.name, 'package.json')
|
|
7495
7949
|
if (existsSync(pkgJson)) {
|
|
7496
|
-
|
|
7497
|
-
|
|
7950
|
+
const target = join(scoped, entry.name)
|
|
7951
|
+
const result = removeDirVerified(target)
|
|
7952
|
+
if (result.ok) removed.push(full)
|
|
7953
|
+
else failed.push({ name: full, path: target, error: result.error })
|
|
7498
7954
|
}
|
|
7499
7955
|
}
|
|
7500
7956
|
}
|
|
7501
7957
|
} catch {}
|
|
7958
|
+
if (failed.length > 0) {
|
|
7959
|
+
sendJson(res, 200, { ok: false, removed, count: removed.length, failed, error: `有 ${failed.length} 项没能删除(目录仍存在):${failed.map((f) => f.name).join('、')}——当前环境可能禁止删除,请手动处理` })
|
|
7960
|
+
return
|
|
7961
|
+
}
|
|
7502
7962
|
sendJson(res, 200, { ok: true, removed, count: removed.length })
|
|
7503
7963
|
return
|
|
7504
7964
|
}
|
|
@@ -7626,12 +8086,14 @@ async function handle(ctx, req, res) {
|
|
|
7626
8086
|
sendError(res, 404, `技能 ${name} 不存在`)
|
|
7627
8087
|
return
|
|
7628
8088
|
}
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
sendError(res, 500,
|
|
8089
|
+
// 删完必须核实:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
|
|
8090
|
+
// 旧代码删完直接 {ok:true} → 用户以为删了,技能其实还在(2026-09-20 演练实测)。
|
|
8091
|
+
const result = removeDirVerified(dest)
|
|
8092
|
+
if (!result.ok) {
|
|
8093
|
+
sendError(res, 500, `删除技能失败:目录仍存在(${dest})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除它`)
|
|
8094
|
+
return
|
|
7634
8095
|
}
|
|
8096
|
+
sendJson(res, 200, { ok: true, name })
|
|
7635
8097
|
return
|
|
7636
8098
|
}
|
|
7637
8099
|
|
|
@@ -7860,7 +8322,9 @@ async function handle(ctx, req, res) {
|
|
|
7860
8322
|
} catch (error) {
|
|
7861
8323
|
lastError = error
|
|
7862
8324
|
// 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败
|
|
7863
|
-
|
|
8325
|
+
// 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败;
|
|
8326
|
+
// 清理同样要核实(见 removeDirVerified)——清不掉时下一个源只会报"目录非空"。
|
|
8327
|
+
removeDirVerified(target)
|
|
7864
8328
|
}
|
|
7865
8329
|
}
|
|
7866
8330
|
if (!cloned) throw lastError ?? new Error('未知错误')
|
|
@@ -7903,12 +8367,14 @@ async function handle(ctx, req, res) {
|
|
|
7903
8367
|
sendError(res, 400, '路径不在仓库落地目录内或不存在')
|
|
7904
8368
|
return
|
|
7905
8369
|
}
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
sendError(res, 500,
|
|
8370
|
+
// 核实删除结果:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
|
|
8371
|
+
// 删不掉却回 {ok:true} 会让用户以为仓库已清理。
|
|
8372
|
+
const result = removeDirVerified(target)
|
|
8373
|
+
if (!result.ok) {
|
|
8374
|
+
sendError(res, 500, `删除失败:目录仍存在(${target})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除`)
|
|
8375
|
+
return
|
|
7911
8376
|
}
|
|
8377
|
+
sendJson(res, 200, { ok: true })
|
|
7912
8378
|
return
|
|
7913
8379
|
}
|
|
7914
8380
|
|