@noob-stupid/dsh-plugin-console 0.5.49 → 0.5.51
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 +24 -0
- package/README.zh.md +20 -0
- package/lib/server/domain/fw-preflight.js +351 -0
- package/lib/server/infra/exec.js +28 -7
- package/lib/server/infra/fw-closure.js +142 -0
- package/lib/server/infra/fw-root-detect.js +209 -0
- package/lib/server/infra/npm-cache.js +173 -0
- package/lib/server/infra/pnpm-release-gate.js +17 -0
- package/package.json +3 -2
- package/scripts/apply-framework-patch.cjs +134 -0
- package/scripts/apply-npm-names.cjs +36 -0
- package/scripts/build-index.cjs +190 -0
- package/scripts/diag-blocks.mjs +60 -0
- package/scripts/fw-preflight.mjs +186 -0
- package/scripts/fw-watchdog-task.ps1 +112 -0
- package/scripts/fw-watchdog.cmd +30 -0
- package/scripts/fw-watchdog.mjs +246 -0
- package/scripts/safe-boot.mjs +260 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建静态插件/技能索引:嗅探 GitHub topic 仓库 → marketplace/index.json。
|
|
3
|
+
*
|
|
4
|
+
* 插件模式(默认):topic:dsh-plugin,按 star 排序,限制 --limit(默认 500)
|
|
5
|
+
* 技能模式:--skills,合并 topic:agent-skills + topic:claude-skills + topic:dsh-skill,
|
|
6
|
+
* 写入 index.json 的 skills 段(保留已有 items 段),限制 --limit(默认 300)
|
|
7
|
+
*
|
|
8
|
+
* 数据拉取(本机沙箱禁止 node spawn gh 时的推荐方式):
|
|
9
|
+
* gh api --paginate "search/repositories?q=topic:dsh-plugin&sort=stars&order=desc&per_page=100" > raw.json
|
|
10
|
+
* node scripts/build-index.cjs --input raw.json [--limit 500]
|
|
11
|
+
* 无 --input 时尝试直接用 gh CLI 拉取(GitHub Actions 中预装 gh 且已认证)。
|
|
12
|
+
*
|
|
13
|
+
* 产物:marketplace/index.json(提交到仓库,经 jsDelivr CDN 分发,终端零 GitHub API 调用)
|
|
14
|
+
*/
|
|
15
|
+
const { execFile } = require('node:child_process')
|
|
16
|
+
const { promisify } = require('node:util')
|
|
17
|
+
const fs = require('node:fs')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile)
|
|
21
|
+
|
|
22
|
+
const SKILL_TOPICS = ['agent-skills', 'claude-skills', 'dsh-skill']
|
|
23
|
+
|
|
24
|
+
// gh api search 单对象响应,--paginate 拼接后不是合法 JSON,改手动分页循环
|
|
25
|
+
async function ghSearchRepos(query, limit) {
|
|
26
|
+
const all = []
|
|
27
|
+
let page = 1
|
|
28
|
+
const perPage = Math.min(100, limit)
|
|
29
|
+
while (all.length < limit) {
|
|
30
|
+
const { stdout } = await execFileAsync('gh', [
|
|
31
|
+
'api',
|
|
32
|
+
'search/repositories',
|
|
33
|
+
'--method', 'GET',
|
|
34
|
+
'-f', 'q=' + query,
|
|
35
|
+
'-f', 'sort=stars',
|
|
36
|
+
'-f', 'order=desc',
|
|
37
|
+
'-f', `per_page=${perPage}`,
|
|
38
|
+
'-f', `page=${page}`,
|
|
39
|
+
'--jq', '.items',
|
|
40
|
+
], {
|
|
41
|
+
timeout: 120000,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
44
|
+
})
|
|
45
|
+
const batch = JSON.parse(stdout)
|
|
46
|
+
if (!Array.isArray(batch) || batch.length === 0) break
|
|
47
|
+
all.push(...batch)
|
|
48
|
+
page += 1
|
|
49
|
+
}
|
|
50
|
+
return all
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRepo(item) {
|
|
54
|
+
const fullName = String(item.full_name ?? '').trim()
|
|
55
|
+
if (!fullName) return null
|
|
56
|
+
return {
|
|
57
|
+
fullName,
|
|
58
|
+
description: item.description ?? '',
|
|
59
|
+
htmlUrl: item.html_url ?? `https://github.com/${fullName}`,
|
|
60
|
+
stars: item.stargazers_count ?? 0,
|
|
61
|
+
updatedAt: item.updated_at ?? '',
|
|
62
|
+
defaultBranch: item.default_branch ?? 'main',
|
|
63
|
+
topics: Array.isArray(item.topics) ? item.topics : [],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readExisting(file) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
return {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 仓库 → npm 包名(批次 C-⑪):市场索引的 npmName 字段。
|
|
77
|
+
* 为什么需要:有些仓库的根包没有发布到 npm(private),真正该装的产物是某个子包/聚合包
|
|
78
|
+
* (真机:zhu1090093659/dsh-web → @linxin666/dsh-web-all,5.97 MB,而仓库本体 429 MB、
|
|
79
|
+
* git 协议 0 B/s)。把这个映射写进索引后,hub 侧可以**直接按包名走 registry 安装**,
|
|
80
|
+
* 省掉"先探测根 package.json、再展开子包"的一整轮(本机 github 不可达时那条路根本走不通)。
|
|
81
|
+
* 只收录**能确定**的(人工核对过的映射文件);不能确定就省略 —— 老索引没有这个字段,
|
|
82
|
+
* hub 侧行为与改动前完全一致。
|
|
83
|
+
*/
|
|
84
|
+
function readNpmNameHints(explicitFile) {
|
|
85
|
+
const file = explicitFile ?? path.join(__dirname, '..', 'marketplace', 'npm-name-hints.json')
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
88
|
+
const hints = raw && typeof raw.hints === 'object' && raw.hints !== null ? raw.hints : {}
|
|
89
|
+
const out = {}
|
|
90
|
+
for (const [repo, npmName] of Object.entries(hints)) {
|
|
91
|
+
if (typeof repo !== 'string' || repo.trim() === '') continue
|
|
92
|
+
if (typeof npmName !== 'string' || !/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(npmName.trim())) continue
|
|
93
|
+
out[repo.trim().toLowerCase()] = npmName.trim()
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
} catch {
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 给归一化后的条目补 npmName(能确定时)。纯函数,便于单测。 */
|
|
102
|
+
function withNpmNames(items, hints) {
|
|
103
|
+
return items.map((item) => {
|
|
104
|
+
const hit = hints[String(item.fullName).toLowerCase()]
|
|
105
|
+
return hit === undefined ? item : { ...item, npmName: hit }
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const skillsMode = process.argv.includes('--skills')
|
|
111
|
+
const limitArg = process.argv.find((a) => a.startsWith('--limit='))
|
|
112
|
+
const limit = limitArg ? Number(limitArg.split('=')[1]) || (skillsMode ? 300 : 500) : (skillsMode ? 300 : 500)
|
|
113
|
+
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
|
114
|
+
const inputFile = inputArg ? inputArg.split('=')[1] : null
|
|
115
|
+
|
|
116
|
+
const dir = path.join(__dirname, '..', 'marketplace')
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
118
|
+
const file = path.join(dir, 'index.json')
|
|
119
|
+
|
|
120
|
+
let items = []
|
|
121
|
+
if (skillsMode) {
|
|
122
|
+
console.log(`拉取技能 topic 仓库(${SKILL_TOPICS.join(' + ')},gh CLI,自动分页)…`)
|
|
123
|
+
try {
|
|
124
|
+
const raw = inputFile ? JSON.parse(fs.readFileSync(inputFile, 'utf8')) : null
|
|
125
|
+
if (raw) {
|
|
126
|
+
items = Array.isArray(raw) ? raw : (raw.items ?? [])
|
|
127
|
+
} else {
|
|
128
|
+
for (const topic of SKILL_TOPICS) {
|
|
129
|
+
const batch = await ghSearchRepos(`topic:${topic} in:name,description,topics`, limit)
|
|
130
|
+
items.push(...batch)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('技能搜索失败:' + error.message)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
} else if (inputFile) {
|
|
138
|
+
// 外部已拉取的数据文件(gh api --paginate 输出:JSON 数组)
|
|
139
|
+
console.log(`读取数据文件 ${inputFile}…`)
|
|
140
|
+
const raw = JSON.parse(fs.readFileSync(inputFile, 'utf8'))
|
|
141
|
+
items = Array.isArray(raw) ? raw : (raw.items ?? [])
|
|
142
|
+
} else {
|
|
143
|
+
console.log('拉取 dsh-plugin topic 仓库(gh CLI,自动分页)…')
|
|
144
|
+
try {
|
|
145
|
+
items = await ghSearchRepos('topic:dsh-plugin in:name,description,topics', limit)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('搜索失败:' + error.message)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const seen = new Set()
|
|
153
|
+
const normalized = []
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
const norm = normalizeRepo(item)
|
|
156
|
+
if (!norm || seen.has(norm.fullName)) continue
|
|
157
|
+
seen.add(norm.fullName)
|
|
158
|
+
if (skillsMode) {
|
|
159
|
+
norm.skillTopics = SKILL_TOPICS.filter((t) => norm.topics.includes(t))
|
|
160
|
+
}
|
|
161
|
+
normalized.push(norm)
|
|
162
|
+
if (normalized.length >= limit) break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const existing = readExisting(file)
|
|
166
|
+
// 批次 C-⑪:给能确定的条目补 npmName(--hints=<file> 可覆盖映射文件)
|
|
167
|
+
const hintsArg = process.argv.find((a) => a.startsWith('--hints='))
|
|
168
|
+
const hints = readNpmNameHints(hintsArg ? hintsArg.split('=')[1] : undefined)
|
|
169
|
+
const named = withNpmNames(normalized, hints)
|
|
170
|
+
const out = {
|
|
171
|
+
generatedAt: new Date().toISOString(),
|
|
172
|
+
count: existing.count ?? 0,
|
|
173
|
+
items: existing.items ?? [],
|
|
174
|
+
skills: existing.skills ?? [],
|
|
175
|
+
}
|
|
176
|
+
if (skillsMode) {
|
|
177
|
+
out.skills = named
|
|
178
|
+
out.skillCount = named.length
|
|
179
|
+
} else {
|
|
180
|
+
out.items = named
|
|
181
|
+
out.count = named.length
|
|
182
|
+
}
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8')
|
|
184
|
+
const hinted = named.filter((it) => typeof it.npmName === 'string').length
|
|
185
|
+
console.log(`已更新 ${file}:${skillsMode ? '技能 ' + named.length : '插件 ' + named.length}(限制 ${limit}${hinted > 0 ? `;其中 ${hinted} 条带 npmName` : ''})`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (require.main === module) main().catch((error) => { console.error(error); process.exit(1) })
|
|
189
|
+
|
|
190
|
+
module.exports = { normalizeRepo, readNpmNameHints, withNpmNames }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 诊断工具(**不是测试**,不进 tests/ 目录、不进 CI 门禁):打印升级/回滚脚本块的实际切分结果。
|
|
2
|
+
//
|
|
3
|
+
// 来历:原先它在 tests/ 下叫 diag-blocks.mjs,读 `lib/server/domain/framework-install-script.js`
|
|
4
|
+
// ——那是**从未入库**的路径(生成器现在在 lib/server/infra/fw-integrity-check.js),
|
|
5
|
+
// 于是它每次运行都抛 ENOENT,把全量测试拖成 35/36。诊断脚本没有断言,本就不该占测试名额;
|
|
6
|
+
// 正确的位置是 scripts/,而且**读不到文件时必须明说"跳过 + 原因"**,不能假装成功、也不能崩。
|
|
7
|
+
//
|
|
8
|
+
// 用法:node scripts/diag-blocks.mjs
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
|
+
const readSrc = (rel) => readFileSync(join(REPO, rel), 'utf8').replace(/\r\n/gu, '\n')
|
|
15
|
+
|
|
16
|
+
const SOURCES = [
|
|
17
|
+
['RFU', 'lib/server/routes/framework-upgrade.js'],
|
|
18
|
+
['FR', 'lib/server/routes/framework.js'],
|
|
19
|
+
['IDX', 'lib/index.js'],
|
|
20
|
+
['FWIS', 'lib/server/infra/fw-integrity-check.js'],
|
|
21
|
+
]
|
|
22
|
+
const endMarker = ".filter((l) => l !== '').join('\\r\\n')"
|
|
23
|
+
const startMarker = 'const lines = ['
|
|
24
|
+
let blocks = 0
|
|
25
|
+
|
|
26
|
+
for (const [name, rel] of SOURCES) {
|
|
27
|
+
if (!existsSync(join(REPO, rel))) {
|
|
28
|
+
// 明确跳过:打印原因(不许静默)
|
|
29
|
+
console.log(`SKIP ${name} ${rel} —— 文件不在仓库里(该来源无法诊断)`)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const source = readSrc(rel)
|
|
33
|
+
let at = source.indexOf(endMarker)
|
|
34
|
+
while (at !== -1) {
|
|
35
|
+
const from = source.lastIndexOf(startMarker, at)
|
|
36
|
+
if (from === -1) {
|
|
37
|
+
console.log(`WARN ${name} ${rel}@${at} —— 结束标记前找不到 'const lines = ['(该块会被抽取器静默丢弃)`)
|
|
38
|
+
} else {
|
|
39
|
+
const block = source.slice(from + startMarker.length - 1, at) + endMarker
|
|
40
|
+
blocks += 1
|
|
41
|
+
const flags = [
|
|
42
|
+
`Install-Framework=${block.includes('function Install-Framework')}`,
|
|
43
|
+
`Invoke-Quarantine=${block.includes('Invoke-Quarantine')}`,
|
|
44
|
+
`一键回滚=${block.includes('一键回滚脚本启动')}`,
|
|
45
|
+
].join(' ')
|
|
46
|
+
console.log(`#${blocks} [${name}] 长度=${block.length} ${flags}`)
|
|
47
|
+
console.log(` 首行: ${JSON.stringify(block.split('\n')[0].slice(0, 90))}`)
|
|
48
|
+
console.log(` 末行: ${JSON.stringify(block.split('\n').slice(-1)[0].slice(0, 90))}`)
|
|
49
|
+
}
|
|
50
|
+
at = source.indexOf(endMarker, at + endMarker.length)
|
|
51
|
+
}
|
|
52
|
+
// FWIS 用的是 `return [ … ].join('\r\n')`(没有 .filter 结束标记),单独报一次
|
|
53
|
+
if (name === 'FWIS') {
|
|
54
|
+
const fwStart = source.indexOf('function fwIntegrityCheck')
|
|
55
|
+
console.log(fwStart === -1
|
|
56
|
+
? `WARN FWIS ${rel} —— 找不到 function fwIntegrityCheck`
|
|
57
|
+
: `OK FWIS ${rel} —— fwIntegrityCheck 在(生成"安装后结构校验"那段脚本)`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
console.log(`共 ${blocks} 块(抽取标记:${JSON.stringify(startMarker)} → ${JSON.stringify(endMarker)})`)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/fw-preflight.mjs —— **服务起不来时的预检与治包**(0.5.50 加法)
|
|
3
|
+
//
|
|
4
|
+
// 为什么要在**服务之外**再来一条命令(真机停机事故 2026-10-14):
|
|
5
|
+
// 那次的坏法是「框架包被掏空」—— `cordis` 顶层 junction 还在、它指向的 `.pnpm` 实体被清空;
|
|
6
|
+
// `dsh-llm` / `dsh-tools` / `dsh-web` 直接成了空目录。后果是 dsh 启动即
|
|
7
|
+
// `54 个插件 failed to import`、web 服务 exit code=1。
|
|
8
|
+
//
|
|
9
|
+
// 而既有的 safe-boot 快照恢复的是 **`cordis.patch.yml` 的文本**(配置层)—— 那次补丁好端端的,
|
|
10
|
+
// 恢复它等于"把一份没坏的配置换成另一份没坏的配置",四个空目录纹丝不动;
|
|
11
|
+
// 指纹数的是 `.pnpm` 里的**目录个数** —— "壳还在、里面被掏空"⇒ 计数不变 ⇒ 不触发。
|
|
12
|
+
// 最根本的是:**控制台插件是跑在 dsh 里的**,dsh 起不来它就从未加载(事故日志里出现 0 次)。
|
|
13
|
+
// ⇒ 这类事故必须在**服务之外**有一条出路。这就是本脚本。
|
|
14
|
+
//
|
|
15
|
+
// 三条硬约束(与 scripts/safe-boot.mjs 同族):
|
|
16
|
+
// · **零依赖**:只用 Node 内置模块 + 本插件自己的 infra/domain 纯函数(不装包、**不联网**);
|
|
17
|
+
// · **服务未运行也能跑**:不读端口、不连 webServer、不需要 profile 正被使用;
|
|
18
|
+
// · **绝不 kill / spawn / 抢端口 / 建计划任务**:只做文件级修复。
|
|
19
|
+
// (2026-10-10 的教训:那套"按端口 kill 再拉起"会与宿主争端口 ⇒ `listen EADDRINUSE`,
|
|
20
|
+
// 或让桌面端外壳弹出「应用无法启动或已意外停止」。跑完要不要重启,由你自己决定。)
|
|
21
|
+
//
|
|
22
|
+
// 用法(在插件目录下,或 `node <插件目录>/scripts/fw-preflight.mjs`):
|
|
23
|
+
// node scripts/fw-preflight.mjs --check 只诊断,列出坏掉的包(**一个字节都不写**)
|
|
24
|
+
// node scripts/fw-preflight.mjs --repair 从本地 npm 缓存把缺的包装回来,再复验
|
|
25
|
+
// node scripts/fw-preflight.mjs --check --json 机器可读输出
|
|
26
|
+
// 可选:--fw-root <dir> 指定框架树(默认自动探测:从本插件解析 @deepseek-ai/dsh 反推)
|
|
27
|
+
// --cache <dir> 指定 npm 缓存根(默认自动找 npm_config_cache / %LOCALAPPDATA%\npm-cache / 常见位置)
|
|
28
|
+
// --help 显示本帮助
|
|
29
|
+
|
|
30
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
31
|
+
import { dirname } from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { detectFrameworkRoot } from '../lib/server/infra/fw-root-detect.js'
|
|
34
|
+
import { runFwPreflight } from '../lib/server/domain/fw-preflight.js'
|
|
35
|
+
|
|
36
|
+
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
37
|
+
|
|
38
|
+
const HELP = `框架闭包预检与治包(fw-preflight)—— 服务起不来时用这个,不需要服务在跑。
|
|
39
|
+
|
|
40
|
+
用法:
|
|
41
|
+
node scripts/fw-preflight.mjs --check
|
|
42
|
+
只诊断:从 dsh 的 lib/bin.js 出发走一遍 @deepseek-ai/* 依赖闭包,
|
|
43
|
+
列出**解析不了**的包(= 真正的损坏面)。一个字节都不写。
|
|
44
|
+
|
|
45
|
+
node scripts/fw-preflight.mjs --repair
|
|
46
|
+
把解析不了的包装回来:按 package-lock.json 的 integrity 从**本地 npm 缓存**取 tarball 解开。
|
|
47
|
+
改前若目标已有内容,先改名成 <name>.preflight-bak-<时间戳>(绝不直接删)。
|
|
48
|
+
装完**重新走一遍闭包复验**,不靠"我装完了"自证。
|
|
49
|
+
|
|
50
|
+
可选参数:
|
|
51
|
+
--fw-root <dir> 框架树根(含 @deepseek-ai/ 与 .pnpm/)。默认自动探测。
|
|
52
|
+
--cache <dir> npm 缓存根(含 content-v2/)。默认自动探测,可重复。
|
|
53
|
+
--json 机器可读输出(面板/子代理可直接解析)。
|
|
54
|
+
--help 显示本帮助。
|
|
55
|
+
|
|
56
|
+
判据为什么是"能不能解析"而不是"目录空不空":
|
|
57
|
+
这次事故的形态是「目录壳还在、里面被掏空」—— 数目录个数完全看不见
|
|
58
|
+
(2026-10-14 实测:按"目录空不空"粗扫本机会误报 237 个;换成闭包判据后 287 个包精准通过,
|
|
59
|
+
清空一个立刻只点名那一个)。
|
|
60
|
+
|
|
61
|
+
它不做什么:
|
|
62
|
+
不 kill 进程、不 spawn 服务、不抢端口、不建计划任务、不联网。
|
|
63
|
+
修完请自己重启服务(桌面端:完全退出再打开;独立 dsh web:终端里 Ctrl+C 后重跑)。
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
/** 解析命令行(只认本脚本声明的这几个开关;未知参数如实报错,不静默忽略)。 */
|
|
67
|
+
function parseArgv(argv) {
|
|
68
|
+
const out = { mode: null, fwRoot: null, caches: [], json: false, help: false, bad: [] }
|
|
69
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
70
|
+
const a = argv[i]
|
|
71
|
+
if (a === '--check') out.mode = 'check'
|
|
72
|
+
else if (a === '--repair') out.mode = 'repair'
|
|
73
|
+
else if (a === '--json') out.json = true
|
|
74
|
+
else if (a === '--help' || a === '-h') out.help = true
|
|
75
|
+
else if (a === '--fw-root') { out.fwRoot = argv[i + 1] ?? null; i += 1 }
|
|
76
|
+
else if (a === '--cache') { const v = argv[i + 1]; if (v) out.caches.push(v); i += 1 }
|
|
77
|
+
else out.bad.push(a)
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 探测框架树根:多基准(见 lib/server/infra/fw-root-detect.js 的头注释)。
|
|
84
|
+
* 这里只做转发,判据收在 infra 一处 —— 脚本与(日后的)路由不会分叉。
|
|
85
|
+
*/
|
|
86
|
+
function detectFwRoot() {
|
|
87
|
+
return detectFrameworkRoot({ pluginDir: PLUGIN_ROOT })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 把探测结果 + 修复结果渲染成人读报告。 */
|
|
91
|
+
function renderReport(report, { attempted }) {
|
|
92
|
+
const lines = []
|
|
93
|
+
const bad = report.before?.broken ?? []
|
|
94
|
+
lines.push(`框架树 : ${report.fwRoot ?? '(未探测到)'}`)
|
|
95
|
+
lines.push(`解析入口: ${report.entry ?? '(无)'}${report.entryVia === null || report.entryVia === undefined ? '' : ` [${report.entryVia}]`}`)
|
|
96
|
+
lines.push(`npm 缓存: ${(report.cacheRoots ?? []).join(' ; ') || '(未找到 —— 只能诊断,不能修复)'}`)
|
|
97
|
+
lines.push('')
|
|
98
|
+
if (report.error !== undefined && report.error !== null) {
|
|
99
|
+
lines.push(`✗ ${report.error}`)
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
|
102
|
+
const walked = report.before?.walked?.length ?? 0
|
|
103
|
+
lines.push(attempted ? `① 修复前:走了 ${walked} 个包,${bad.length} 个解析不了` : `走了 ${walked} 个包,${bad.length} 个解析不了`)
|
|
104
|
+
for (const b of bad) lines.push(` ✗ ${b.name} ← ${b.from} (${b.reason})`)
|
|
105
|
+
if (bad.length === 0) lines.push(' ✓ 依赖闭包完整 —— 框架树是好的,起不来的原因不在这里')
|
|
106
|
+
|
|
107
|
+
if (!attempted) {
|
|
108
|
+
if (bad.length > 0) lines.push('\n用 --repair 试着把它们装回来(只动文件,不碰任何进程)。')
|
|
109
|
+
return lines.join('\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push('')
|
|
113
|
+
lines.push(`② 本次修复:成功 ${report.repaired.length} 个,失败 ${report.failed.length} 个,不可修 ${report.skipped.length} 个`)
|
|
114
|
+
for (const r of report.repaired) lines.push(` ✓ ${r.name}@${r.version} → ${r.via} ${r.note}`)
|
|
115
|
+
for (const f of report.failed) lines.push(` ✗ ${f.name} (${f.action}) ${f.note}`)
|
|
116
|
+
for (const s of report.skipped) {
|
|
117
|
+
const why = { 'no-lock-entry': 'package-lock.json 里没有它的记录 —— 不猜版本', 'no-integrity': '记录里没有 integrity 校验和', 'no-target': '定不出该装到哪个目录' }[s.reason] ?? s.reason
|
|
118
|
+
lines.push(` · ${s.name} ${why}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lines.push('')
|
|
122
|
+
const after = report.after?.broken ?? []
|
|
123
|
+
lines.push(`③ 复验:走了 ${report.after?.walked?.length ?? 0} 个包,${after.length} 个仍解析不了`)
|
|
124
|
+
for (const b of after) lines.push(` ✗ ${b.name} ← ${b.from}`)
|
|
125
|
+
if (report.okAfter === true) {
|
|
126
|
+
lines.push(' ✓ 闭包已完整')
|
|
127
|
+
lines.push('')
|
|
128
|
+
lines.push('接下来:重启服务让修复生效(本脚本**不会**替你 kill / 拉起进程)。')
|
|
129
|
+
lines.push(' · 桌面端:完全退出 DeepSeek Harness,再重新打开')
|
|
130
|
+
lines.push(' · 独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web')
|
|
131
|
+
} else {
|
|
132
|
+
lines.push('')
|
|
133
|
+
lines.push('仍有包解析不了。可能原因:本地 npm 缓存里也没有那些 tarball(本脚本不联网)。')
|
|
134
|
+
lines.push('可对该包单独装一次(会联网):dsh plugin --profile <名> add <包名>')
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function main() {
|
|
140
|
+
const args = parseArgv(process.argv.slice(2))
|
|
141
|
+
if (args.help) { process.stdout.write(HELP); return 0 }
|
|
142
|
+
if (args.bad.length > 0) {
|
|
143
|
+
process.stderr.write(`未知参数:${args.bad.join(' ')}\n\n${HELP}`)
|
|
144
|
+
return 2
|
|
145
|
+
}
|
|
146
|
+
if (args.mode === null) {
|
|
147
|
+
process.stderr.write(`请指定 --check 或 --repair(或 --help)。\n\n${HELP}`)
|
|
148
|
+
return 2
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let fwRoot = args.fwRoot
|
|
152
|
+
let via = 'explicit'
|
|
153
|
+
let tried = []
|
|
154
|
+
if (typeof fwRoot !== 'string' || fwRoot === '') {
|
|
155
|
+
const d = detectFwRoot()
|
|
156
|
+
fwRoot = d.fwRoot
|
|
157
|
+
via = d.via
|
|
158
|
+
tried = d.tried ?? []
|
|
159
|
+
} else {
|
|
160
|
+
fwRoot = isAbsolute(fwRoot) ? fwRoot : resolve(process.cwd(), fwRoot)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fwRoot === null || fwRoot === undefined || fwRoot === '') {
|
|
164
|
+
const msg = '探测不到框架树根 —— 请用 --fw-root <dir> 显式指定(该目录下应有 @deepseek-ai/ 与 .pnpm/)。'
|
|
165
|
+
const detail = tried.map((t) => ` · 试过 ${t.via}: ${t.base}`).join('\n')
|
|
166
|
+
if (args.json) process.stdout.write(`${JSON.stringify({ ok: false, error: msg, tried }, null, 2)}\n`)
|
|
167
|
+
else process.stderr.write(`✗ ${msg}\n${detail}\n`)
|
|
168
|
+
return 1
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cacheRoots = args.caches.length > 0 ? args.caches.map((c) => (isAbsolute(c) ? c : resolve(process.cwd(), c))) : null
|
|
172
|
+
const report = runFwPreflight({ fwRoot, repair: args.mode === 'repair', cacheRoots })
|
|
173
|
+
const payload = { ...report, fwRootVia: via, mode: args.mode }
|
|
174
|
+
|
|
175
|
+
if (args.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
|
|
176
|
+
else process.stdout.write(`${renderReport(payload, { attempted: args.mode === 'repair' })}\n`)
|
|
177
|
+
|
|
178
|
+
// 退出码:诊断模式 = 闭包是否完整;修复模式 = 修复后是否完整(供脚本/CI 判断)
|
|
179
|
+
const good = args.mode === 'repair' ? report.okAfter === true : report.ok === true
|
|
180
|
+
return good ? 0 : 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().then((code) => { process.exitCode = code }, (error) => {
|
|
184
|
+
process.stderr.write(`fw-preflight 异常:${error?.stack ?? error}\n`)
|
|
185
|
+
process.exitCode = 1
|
|
186
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# fw-watchdog-task.ps1 —— 注册/卸载 DSH 框架闭包看门狗的计划任务(Windows)
|
|
2
|
+
#
|
|
3
|
+
# 为什么用计划任务而不是改官方代码:官方启动链没有"启动前执行外部命令"的钩子
|
|
4
|
+
# (取证见 scripts/fw-watchdog.mjs 头注释)。计划任务是**不改任何官方文件**、
|
|
5
|
+
# 且 DSH 升级也不会被覆盖的唯一位置。
|
|
6
|
+
#
|
|
7
|
+
# 用法(在 scripts 目录下):
|
|
8
|
+
# powershell -ExecutionPolicy Bypass -File fw-watchdog-task.ps1 # 安装(幂等)
|
|
9
|
+
# powershell -ExecutionPolicy Bypass -File fw-watchdog-task.ps1 -Status # 看状态
|
|
10
|
+
# powershell -ExecutionPolicy Bypass -File fw-watchdog-task.ps1 -Uninstall # 卸载
|
|
11
|
+
# powershell -ExecutionPolicy Bypass -File fw-watchdog-task.ps1 -RunNow # 立刻跑一轮
|
|
12
|
+
#
|
|
13
|
+
# 任务形态:登录时启动 → 常驻(脚本内部每 20s 一轮)→ 失败自动重启(3 次 / 间隔 1 分钟)。
|
|
14
|
+
# · 为什么常驻而不是"每 1 分钟触发一次":任务计划程序的最小重复间隔是 1 分钟,
|
|
15
|
+
# 而我们要 20s;常驻 + 内部定时更精确,也避免每分钟拉起一个进程。
|
|
16
|
+
# · 只读判据 + 只修框架闭包:不杀进程、不抢端口、不 spawn dsh、不改官方文件。
|
|
17
|
+
Param(
|
|
18
|
+
[switch]$Uninstall,
|
|
19
|
+
[switch]$Status,
|
|
20
|
+
[switch]$RunNow
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
$ErrorActionPreference = 'Stop'
|
|
24
|
+
$TaskName = 'DSH-FwWatchdog'
|
|
25
|
+
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
26
|
+
$Watchdog = Join-Path $ScriptDir 'fw-watchdog.mjs'
|
|
27
|
+
|
|
28
|
+
if ($Status) {
|
|
29
|
+
$t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
30
|
+
if ($null -eq $t) { Write-Host "任务 $TaskName :未安装"; exit 0 }
|
|
31
|
+
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
32
|
+
Write-Host "任务 : $($t.TaskName)"
|
|
33
|
+
Write-Host "状态 : $($t.State)"
|
|
34
|
+
Write-Host "上次运行 : $($info.LastRunTime) 结果码: $($info.LastTaskResult)"
|
|
35
|
+
Write-Host "下次运行 : $($info.NextRunTime)"
|
|
36
|
+
Write-Host "动作 : $($t.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" })"
|
|
37
|
+
exit 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ($Uninstall) {
|
|
41
|
+
$t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
42
|
+
if ($null -eq $t) { Write-Host "任务 $TaskName 本来就不存在,无需卸载"; exit 0 }
|
|
43
|
+
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
44
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
45
|
+
Write-Host "已卸载任务 $TaskName"
|
|
46
|
+
exit 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ($RunNow) {
|
|
50
|
+
Start-ScheduledTask -TaskName $TaskName
|
|
51
|
+
Write-Host "已触发一轮(看日志:~/.dsh/plugin-console/fw-watchdog.log)"
|
|
52
|
+
exit 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# ── 解析 node(钉绝对路径:计划任务里的 PATH 不可靠)────────────────────────
|
|
56
|
+
$node = $null
|
|
57
|
+
$candidates = @()
|
|
58
|
+
if ($env:DSH_DESKTOP_NODE_EXECUTABLE) { $candidates += $env:DSH_DESKTOP_NODE_EXECUTABLE }
|
|
59
|
+
foreach ($root in @('D:\dsh-desktop', "$env:ProgramFiles\DeepSeek Harness", "$env:LOCALAPPDATA\Programs\DeepSeek Harness")) {
|
|
60
|
+
if ($root) { $candidates += (Join-Path $root 'resources\runtime\primary-runtime\dependencies\node\bin\node.exe') }
|
|
61
|
+
}
|
|
62
|
+
$onPath = (Get-Command node -ErrorAction SilentlyContinue).Source
|
|
63
|
+
if ($onPath) { $candidates += $onPath }
|
|
64
|
+
foreach ($c in $candidates) { if (-not $node -and $c -and (Test-Path -LiteralPath $c)) { $node = $c } }
|
|
65
|
+
if (-not $node) { throw "找不到可用的 node —— 请安装 Node.js 后重试" }
|
|
66
|
+
|
|
67
|
+
if (-not (Test-Path -LiteralPath $Watchdog)) { throw "找不到看门狗脚本:$Watchdog" }
|
|
68
|
+
|
|
69
|
+
# ── 为什么要 `--daemon` 而不是直接跑 node(★ 2026-10-14 两次实测踩坑后定案):
|
|
70
|
+
# ① 任务直接跑 node.exe ⇒ Windows 弹出**可见的控制台窗口**(本机实测桌面出现一个
|
|
71
|
+
# Windows Terminal,标题就是 node 的绝对路径),后台看门狗不该占用户桌面;
|
|
72
|
+
# ② 改用 .vbs 启动器(window style 0)⇒ wscript 挂住、拉不起子进程(实测两次都没起来)。
|
|
73
|
+
# 最终做法:任务跑 `node … --daemon`,脚本**自己以 detached + stdio:'ignore' +
|
|
74
|
+
# windowsHide 再拉起一份**并立刻返回 ⇒ 任务那条命令秒退、真正干活的那份无窗口在后台跑,
|
|
75
|
+
# 重复启动由 pid 锁挡住(幂等)。
|
|
76
|
+
Write-Host "node : $node"
|
|
77
|
+
Write-Host "看门狗 : $Watchdog (--daemon:自己脱离,无控制台窗口)"
|
|
78
|
+
|
|
79
|
+
# ── 注册(幂等:先卸旧的)──────────────────────────────────────────────────
|
|
80
|
+
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
81
|
+
if ($existing) {
|
|
82
|
+
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
83
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
84
|
+
Write-Host "已移除旧任务,准备重建"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$Watchdog`" --daemon" -WorkingDirectory $ScriptDir
|
|
88
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
|
89
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
90
|
+
-AllowStartIfOnBatteries `
|
|
91
|
+
-DontStopIfGoingOnBatteries `
|
|
92
|
+
-StartWhenAvailable `
|
|
93
|
+
-MultipleInstances IgnoreNew `
|
|
94
|
+
-RestartCount 3 `
|
|
95
|
+
-RestartInterval (New-TimeSpan -Minutes 1) `
|
|
96
|
+
-ExecutionTimeLimit ([TimeSpan]::Zero) # 零 = 不限制运行时长(常驻)
|
|
97
|
+
|
|
98
|
+
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings `
|
|
99
|
+
-Description 'DSH 框架闭包看门狗:外壳在跑但服务进程不在时,检查并修复被掏空的框架包(只修闭包,不杀进程/不抢端口/不改官方文件)。日志:~/.dsh/plugin-console/fw-watchdog.log' | Out-Null
|
|
100
|
+
|
|
101
|
+
Write-Host ""
|
|
102
|
+
Write-Host "✅ 已注册计划任务 $TaskName(登录时启动,常驻,20s 一轮)"
|
|
103
|
+
Write-Host " 看状态 : powershell -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -Status"
|
|
104
|
+
Write-Host " 跑一轮 : powershell -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -RunNow"
|
|
105
|
+
Write-Host " 卸载 : powershell -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -Uninstall"
|
|
106
|
+
Write-Host " 日志 : $env:USERPROFILE\.dsh\plugin-console\fw-watchdog.log"
|
|
107
|
+
Write-Host ""
|
|
108
|
+
Write-Host "现在就启动它(不用等下次登录):"
|
|
109
|
+
Start-ScheduledTask -TaskName $TaskName
|
|
110
|
+
Start-Sleep -Seconds 3
|
|
111
|
+
$info = Get-ScheduledTaskInfo -TaskName $TaskName
|
|
112
|
+
Write-Host " 当前状态: $((Get-ScheduledTask -TaskName $TaskName).State) 上次结果码: $($info.LastTaskResult)"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem fw-watchdog.cmd -- manual entry point for the framework-closure watchdog.
|
|
3
|
+
rem usage: fw-watchdog.cmd --status | --once | --tick | (no args = resident)
|
|
4
|
+
rem NOTE: this file must keep CRLF line endings and ASCII-only comments --
|
|
5
|
+
rem cmd.exe chokes on LF-only .cmd files and on multi-byte comments read in
|
|
6
|
+
rem the OEM code page (measured: the comment text got executed as commands).
|
|
7
|
+
setlocal
|
|
8
|
+
set "WD=%~dp0fw-watchdog.mjs"
|
|
9
|
+
set "NODE="
|
|
10
|
+
|
|
11
|
+
rem (1) the node the desktop host passes down
|
|
12
|
+
if defined DSH_DESKTOP_NODE_EXECUTABLE if exist "%DSH_DESKTOP_NODE_EXECUTABLE%" set "NODE=%DSH_DESKTOP_NODE_EXECUTABLE%"
|
|
13
|
+
|
|
14
|
+
rem (2) the node bundled with the desktop runtime (most stable)
|
|
15
|
+
if not defined NODE (
|
|
16
|
+
for %%D in ("D:\dsh-desktop" "%ProgramFiles%\DeepSeek Harness" "%LOCALAPPDATA%\Programs\DeepSeek Harness") do (
|
|
17
|
+
if not defined NODE if exist "%%~D\resources\runtime\primary-runtime\dependencies\node\bin\node.exe" set "NODE=%%~D\resources\runtime\primary-runtime\dependencies\node\bin\node.exe"
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
rem (3) whatever node is on PATH (last resort)
|
|
22
|
+
if not defined NODE for /f "delims=" %%N in ('where node 2^>nul') do if not defined NODE set "NODE=%%N"
|
|
23
|
+
|
|
24
|
+
if not defined NODE (
|
|
25
|
+
echo [fw-watchdog] no usable node found -- install Node.js, or run this from the desktop install.
|
|
26
|
+
exit /b 2
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
"%NODE%" "%WD%" %*
|
|
30
|
+
exit /b %ERRORLEVEL%
|