@noob-stupid/dsh-plugin-console 0.5.50 → 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.
@@ -445,27 +445,23 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
445
445
  * 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
446
446
  * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
447
447
  *
448
- * ★ 2026-10-14(0.5.50 之后回校,实测更正 0.5.48 此处的判断):
449
- * 0.5.48 在这里写的是「pnpm 判交互用它自己的 ci 配置项(opts3.ci,env 形态即 npm_config_ci),
450
- * **不是** process.env.CI」。**这句只对了一半,而且是最关键的一半错了**:
451
- * · `npm_config_ci=true` 对 pnpm **自己的**配置项是**空操作** —— pnpm 只认 `pnpm_config_*`
452
- * 前缀(含 argv 的 `--config.*`)。实测:设了它,`pnpm config list --json` 里 `ci` 仍为空,
453
- * 且 `pnpm install` 面对过时 lock 时**不会**被 frozen 拒绝。⇒ env 这一行是安慰剂。
454
- * · 反过来,`CI=true` **确实**点亮 ci 位:pnpm 自己的配置默认值就是 `ci: isCI`
455
- * (dist/pnpm.mjs L145865),而 `isCI` 判的是 `env.CI`(L143262)⇒ `opts3.ci` 随之变真。
456
- * 实测:只设 `CI=true`,`pnpm install` 面对过时 lock **会**被 frozen 拒绝(证伪组:
457
- * 再加 `--no-frozen-lockfile` 就恢复照常)。这一位在 v0.5.30 就已注入,不是 0.5.48 新增。
458
- * ⇒ 真正管用的两位:`CI=true`(env,早就有)与 `--config.ci=true`(argv,只加在 pnpmAddArgs)。
459
- * `npm_config_ci=true` 留着无害(对 npm 兼容项仍可能生效),但**别再把它当成非交互的保证**。
460
- * ⚠️ 另有一处更重要的更正:0.5.48 说"命中供应链闸 ⇒ pnpm 弹提问 ⇒ 管道永不关闭 ⇒ 永久挂住",
461
- * 这条因果链**实测不成立**。pnpm 的 `canPrompt = !inCi && Boolean(process.stdin.isTTY)`
462
- * (L194277-278)—— 管道下 `isTTY` 恒为假 ⇒ pnpm **从来**不走 prompt 分支,五种配置
463
- * (无 ci / CI=true / npm_config_ci / --config.ci / 两者齐上)实测全部快速失败,无一挂住。
464
- * 真正读 stdin 等人的是 **corepack 的下载提问**(corepack.cjs L22083:
465
- * `if (process.stdin.isTTY && !process.env.CI)` ⇒ `stdin.resume()` + `once(stdin,'data')`)
466
- * —— 它的判据恰恰是 `process.env.CI`,也就是 0.5.48 说"不是 pnpm 读的那一位"的那个变量。
467
- * 所以"无 TTY 挂住"的真凶是 corepack(且需 isTTY 为真才挂),不是 pnpm 的供应链闸。
468
- * 本段只更正文档,**未改动任何行为代码**(三个 env 与 argv 的注入原样保留)。 */
448
+ * ★ 2026-10-14(回校 0.5.48 此处的判断,**行为未动、只更正说明**。完整取证与五组实验见
449
+ * CHANGELOG「v0.5.51」那节;这里只留结论,因为本文件贴着架构守卫的 600 行硬顶):
450
+ * ① `npm_config_ci=true` 对 pnpm **自己的**配置项是**空操作**(只认 `pnpm_config_*` 前缀,
451
+ * 含 argv 的 `--config.*`)⇒ 这一行是安慰剂,别再当成非交互的保证。
452
+ * ② `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,`isCI` 判 `env.CI`)⇒
453
+ * `pnpm install` 面对过时 lock **会**被 frozen 拒绝(加 `--no-frozen-lockfile` 即恢复)。
454
+ * 这一位 **v0.5.30 就已注入**,不是 0.5.48 新增。
455
+ * ③ 「pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 永久挂住」**不成立**:`canPrompt` 需要
456
+ * `process.stdin.isTTY`,管道下恒为假 ⇒ pnpm 从不 prompt;五种 ci 配置实测**全部快速失败**。
457
+ * ④ 那"挂到 90s / 180s"**是我们自己的超时常量**(`runPnpmAdd` timeout=90000、
458
+ * `installTargetDependencies` timeoutMs=180000),与当时观察逐一吻合 ⇒ 是"超过我们的
459
+ * 超时被杀",不是"等输入"。**那次为什么慢至今没有证据**(最可能是网络)。
460
+ * ⑤ corepack 的下载提问也**默认是关的**(入口 shim `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`),
461
+ * 只有显式设成 1 才会问 ⇒ 也不是默认真凶。
462
+ * ⇒ 0.5.48 那条「永久挂住」的**根因至今未查明**,能确定的只有"不是 pnpm 的交互提问"。
463
+ * `--config.ci=true` 仍保留独立价值:钉死 ci 位 + 让 install 在 lock 过时走 frozen 拒绝
464
+ * 而非静默改写;`pnpm add` 不受 frozen 影响(实测带/不带此旗结局相同)。 */
469
465
  function pnpmEnvOverrides(registry) {
470
466
  const overrides = {
471
467
  npm_config_fetch_timeout: '60000',
@@ -9,20 +9,22 @@
9
9
  // 桌面壳/服务进程给的是**永不关闭的 stdin 管道** ⇒ 不抛错、不超时,**永久挂住**
10
10
  // (实测两次分别挂到 90s / 180s 被杀)。
11
11
  //
12
- // ★ 2026-10-14(实测更正上面这段"现场描述",**本模块行为未动**):
13
- // 上述因果链里"pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 挂住"这一段**实测不成立**:
12
+ // ★ 2026-10-14(回校上面这段"现场描述",**本模块行为未动**):
13
+ // 上述因果链里"pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 永久挂住"这一段**实测不成立**:
14
14
  // · pnpm 的 canPrompt = `!inCi && Boolean(process.stdin.isTTY)`(L194277-278);
15
15
  // 管道下 isTTY 恒假 ⇒ pnpm **从不**走 prompt 分支,直接 `throw failOnImmature`。
16
16
  // · 实测(pnpm 11.7.0,minimumReleaseAge 设成天文数字 + 管道 stdin):无 ci 位 / CI=true /
17
17
  // npm_config_ci=true / --config.ci=true / 两者齐上 —— **五种配置全部快速失败,无一挂住**。
18
- // · 真正读 stdin 等人的是 **corepack 的下载提问**(corepack.cjs L22083:
19
- // `if (process.stdin.isTTY && !process.env.CI) { stderr.write('? Do you want to continue?');
20
- // stdin.resume(); await once(stdin,'data') }`)—— 判据恰恰是 `process.env.CI`。
18
+ // · 那"挂到 90s / 180s"其实是**我们自己的超时常量**:`runPnpmAdd` 默认 timeout=90000、
19
+ // `link-self-sufficiency#installTargetDependencies` 默认 timeoutMs=180000 —— 与当时观察
20
+ // 逐一吻合 ⇒ 当时是"某次操作超过我们设的超时被我们杀掉",**不是**"等输入等到天荒地老"。
21
+ // 那次为什么慢**至今没有证据**(最可能是网络);corepack 的下载提问也**默认是关的**
22
+ // (入口 shim 第一行 `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`)。
21
23
  // ⇒ 本模块的定位不变(**识别 + 翻译成可操作错误**,绝不绕过闸门),仍然有价值;
22
- // 只是"为什么会挂住"要记在 corepack 头上,不是 pnpm 的供应链闸。
23
- // 顺带更正另两处(详见 infra/exec.js#pnpmEnvOverrides 上方的完整说明):
24
+ // 只是「为什么会挂住」**至今未查明**,不要再照 0.5.48 那段因果链去找。
25
+ // 另两处一并更正(详见 infra/exec.js#pnpmEnvOverrides 上方的完整说明):
24
26
  // · `npm_config_ci=true` 对 pnpm 自己的配置项是**空操作**(只认 `pnpm_config_*`);
25
- // · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865)。
27
+ // · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865),且 v0.5.30 就有。
26
28
  //
27
29
  // 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
28
30
  // L194277 const inCi = opts3.ci ?? isCI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.50",
3
+ "version": "0.5.51",
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",
@@ -51,8 +51,7 @@
51
51
  "cordis.patch.yml",
52
52
  "SECURITY.md",
53
53
  "icon.svg",
54
- "scripts/safe-boot.mjs",
55
- "scripts/fw-preflight.mjs"
54
+ "scripts"
56
55
  ],
57
56
  "license": "MIT",
58
57
  "scripts": {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 框架层补丁:dsh-app-boot 的 cordis.patch.yml 解析容错(议题 #5)。
3
+ *
4
+ * 问题:cordis.patch.yml 若含顶格 `[]` 占位符 + 后续条目(两个 YAML 根节点),
5
+ * DSH 启动时 parsePatchList 解析崩溃("end of the stream or a document separator is expected")。
6
+ *
7
+ * 修复:parsePatchList 解析失败时,自动移除顶格空数组占位行(视为 no-op)后重试。
8
+ *
9
+ * 用法:node scripts/apply-framework-patch.cjs
10
+ * 说明:DSH 升级(npx 重新拉取)后框架文件会被覆盖,重新运行本脚本即可。
11
+ */
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+ const os = require('node:os')
15
+
16
+ /** npm 缓存下的 npx 缓存目录候选(**不写死盘符/用户名**)。
17
+ * npm 的 cache 位置是用户配置(把 cache 指到别的盘很常见)→ 按 npm 自己的来源读:
18
+ * 环境变量 npm_config_cache / NPM_CONFIG_CACHE + 用户级 ~/.npmrc 的 `cache=`。
19
+ * (原实现第一条写死开发机上的缓存绝对路径:某个盘符下的 node_cache\_npx。) */
20
+ function npmNpxCacheRoots() {
21
+ const roots = []
22
+ const push = (value) => {
23
+ if (typeof value !== 'string') return
24
+ const dir = value.trim().replace(/^["']|["']$/g, '').replace(/^~(?=[\\/]|$)/, os.homedir())
25
+ if (dir !== '') roots.push(path.join(dir, '_npx'))
26
+ }
27
+ for (const key of ['npm_config_cache', 'NPM_CONFIG_CACHE']) push(process.env[key])
28
+ try {
29
+ for (const line of fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8').split(/\r?\n/)) {
30
+ const hit = /^\s*cache\s*=\s*(.*?)\s*$/i.exec(line)
31
+ if (hit) push(hit[1])
32
+ }
33
+ } catch {}
34
+ return roots
35
+ }
36
+
37
+ // 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)
38
+ function locateAppBoot() {
39
+ try {
40
+ const dshPkg = require.resolve('@deepseek-ai/dsh/package.json')
41
+ const aiDir = path.dirname(dshPkg) // .../node_modules/@deepseek-ai
42
+ const candidate = path.join(aiDir, 'dsh-app-boot', 'lib', 'index.js')
43
+ if (fs.existsSync(candidate)) return candidate
44
+ } catch {}
45
+ const cacheRoots = [
46
+ process.env.NODE_CACHE || '',
47
+ ...npmNpxCacheRoots(),
48
+ path.join(os.homedir(), '.npm', '_npx'),
49
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'node_cache', '_npx') : '',
50
+ ].filter(Boolean)
51
+ for (const root of cacheRoots) {
52
+ if (!fs.existsSync(root)) continue
53
+ let entries = []
54
+ try { entries = fs.readdirSync(root, { withFileTypes: true }) } catch { continue }
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory()) continue
57
+ const candidate = path.join(root, entry.name, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
58
+ if (fs.existsSync(candidate)) return candidate
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ const MARKER = 'tryDropEmptyArrayPlaceholder'
65
+ const OLD = `function parsePatchList(binName, file, content, label) {
66
+ let parsed;
67
+ try {
68
+ parsed = yaml.load(content, { schema: userPatchesSchema });
69
+ } catch (error) {
70
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
71
+ }`
72
+ const NEW = `function parsePatchList(binName, file, content, label) {
73
+ let parsed;
74
+ try {
75
+ parsed = yaml.load(content, { schema: userPatchesSchema });
76
+ } catch (error) {
77
+ // 容错(issue #5):文件可能是「[] 空数组占位符 + 后续条目」两个根节点,
78
+ // YAML 解析必然失败。移除顶层空数组占位行(视为 no-op)后重试。
79
+ const retried = tryDropEmptyArrayPlaceholder(content);
80
+ if (retried !== null) {
81
+ try {
82
+ parsed = yaml.load(retried, { schema: userPatchesSchema });
83
+ } catch {
84
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
85
+ }
86
+ } else {
87
+ throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
88
+ }
89
+ }`
90
+
91
+ const HELPER = `
92
+ /**
93
+ * 容错辅助(issue #5):若文件含顶格空数组占位行(\`[]\` / \`[ ]\`,可带行尾注释),
94
+ * 视为 no-op 全部移除;无此模式返回 null。
95
+ */
96
+ function tryDropEmptyArrayPlaceholder(content) {
97
+ const lines = String(content).split("\\n");
98
+ const kept = [];
99
+ let dropped = false;
100
+ for (const line of lines) {
101
+ if (/^\\[\\s*\\]\\s*(?:#.*)?$/u.test(line)) {
102
+ dropped = true;
103
+ continue;
104
+ }
105
+ kept.push(line);
106
+ }
107
+ if (!dropped) return null;
108
+ return kept.join("\\n");
109
+ }
110
+ `
111
+
112
+ function main() {
113
+ const target = locateAppBoot()
114
+ if (!target) {
115
+ console.error('未找到 @deepseek-ai/dsh-app-boot/lib/index.js,请确认 DSH 已安装(或手动指定路径)')
116
+ process.exit(1)
117
+ }
118
+ const source = fs.readFileSync(target, 'utf8')
119
+ if (source.includes(MARKER)) {
120
+ console.log('已打过补丁,跳过:' + target)
121
+ return
122
+ }
123
+ if (!source.includes('function parsePatchList')) {
124
+ console.error('未找到 parsePatchList(框架版本可能已变更),跳过。' + target)
125
+ process.exit(1)
126
+ }
127
+ fs.copyFileSync(target, target + '.bak-issue5')
128
+ const next = source.replace(OLD, NEW) + HELPER
129
+ fs.writeFileSync(target, next, 'utf8')
130
+ console.log('补丁已应用:' + target)
131
+ console.log('备份:' + target + '.bak-issue5')
132
+ }
133
+
134
+ main()
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 给已提交的 marketplace/index.json 就地补 npmName(批次 C-⑪)。
3
+ *
4
+ * 为什么单独有这个脚本:build-index.cjs 需要 gh CLI + 网络(本机 github 不可达时跑不了),
5
+ * 而"补 npmName"这件事**完全离线** —— 映射来自 marketplace/npm-name-hints.json(人工核对)。
6
+ * 用法:
7
+ * node scripts/apply-npm-names.cjs # 就地更新 marketplace/index.json
8
+ * node scripts/apply-npm-names.cjs --dry-run # 只报告会改哪几条
9
+ * node scripts/apply-npm-names.cjs --file=<index.json> # 指定索引文件
10
+ * 幂等:已经带 npmName 的条目内容不变;映射里没有的仓库保持原样(老索引兼容)。
11
+ */
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+ const { readNpmNameHints, withNpmNames } = require('./build-index.cjs')
15
+
16
+ const args = process.argv.slice(2)
17
+ const dryRun = args.includes('--dry-run')
18
+ const fileArg = args.find((a) => a.startsWith('--file='))
19
+ const hintsArg = args.find((a) => a.startsWith('--hints='))
20
+ const indexFile = fileArg ? fileArg.split('=')[1] : path.join(__dirname, '..', 'marketplace', 'index.json')
21
+ const hints = readNpmNameHints(hintsArg ? hintsArg.split('=')[1] : undefined)
22
+
23
+ const raw = JSON.parse(fs.readFileSync(indexFile, 'utf8'))
24
+ const before = Array.isArray(raw.items) ? raw.items : []
25
+ const after = withNpmNames(before, hints)
26
+ const changed = after.filter((it, i) => typeof it.npmName === 'string' && before[i]?.npmName !== it.npmName)
27
+
28
+ console.log(`映射条目 ${Object.keys(hints).length} 条;索引 ${before.length} 条命中 ${changed.length} 条`)
29
+ for (const it of changed) console.log(` ${it.fullName} → ${it.npmName}`)
30
+
31
+ if (dryRun) {
32
+ console.log('(dry-run:未写入)')
33
+ process.exit(0)
34
+ }
35
+ fs.writeFileSync(indexFile, JSON.stringify({ ...raw, items: after }, null, 2) + '\n', 'utf8')
36
+ console.log(`已写入 ${indexFile}`)
@@ -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,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%
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // fw-watchdog.mjs —— DSH 框架闭包看门狗(不改官方代码的"崩溃自愈")
3
+ //
4
+ // ── 为什么需要它 ────────────────────────────────────────────────────────────
5
+ // 真机事故(2026-10-14):框架树里 `@deepseek-ai/cordis` 的 junction 还在、但 `.pnpm`
6
+ // 实体被掏空;`dsh-llm`/`dsh-tools`/`dsh-web` 成了空目录 ⇒ dsh 启动即 54 个插件
7
+ // failed to import ⇒ 服务进程 exit code=1 ⇒ 桌面端弹「应用无法启动或已意外停止」。
8
+ //
9
+ // 官方启动链(已只读取证)**没有**"启动前执行外部命令"的钩子:
10
+ // DeepSeek Harness.exe → dsh-desktop-host/lib/cli.js → runCli()(dsh/lib/bin.js:207)
11
+ // → case "profile": runProfile() → 失败 → reportStartupFailure() → process.exit(1)
12
+ // · bin.js 全文 254 行,读的环境变量:零;
13
+ // · dsh-app-boot 只有 DSH_SNAPSHOT=replay(换配置)与 loadEnv()(读 .env),
14
+ // 而 .env 能设的名字有黑名单(PATH/HOME/NODE_OPTIONS/…)——只能设配置,不能"启动前跑脚本";
15
+ // · 失败路径不 spawn 任何外部命令。
16
+ // ⇒ 想在"每次 dsh 启动前自动修"必须改官方外壳(且一升级就被覆盖)。
17
+ // 所以本脚本走另一条路:**观测一个合法事实** —— 外壳进程还在,而它的 **HOST 服务进程没了**。
18
+ // 官方自己的崩溃恢复只知道"重启",不知道"修包";看门狗补的正是这一段:
19
+ // 它把地面修平,官方下一次重启自然成功 —— 用户观感就是"崩了自己又起来了"。
20
+ //
21
+ // ── 判据(★ 2026-10-14 修正:一开始用"端口无监听",那是错的)────────────────
22
+ // 为什么不能用端口:桌面实例监听 19387,3080 是 web profile 的端口;用固定端口探针
23
+ // 会在正常运行时恒判"服务没起来" ⇒ 每轮白跑一次全量闭包检查、日志刷屏。
24
+ // 现在的判据(进程树事实,与端口无关):
25
+ // ① 外壳进程在跑(`DeepSeek Harness.exe` 且命令行**不含** `--type=`);
26
+ // ② 它的 HOST 服务子进程**不在**(命令行含 `dsh-desktop-host/lib/index.js` 的那个);
27
+ // ③ 连续 N 轮(默认 2 轮 ≈ 40s)都如此 ⇒ 才动手(避开"正在启动/正在重启"的窗口)。
28
+ // 动作:`fw-preflight --check` → 闭包坏 ⇒ `--repair` → `--check` 复验。
29
+ // 修完**不自己拉起服务**:官方崩溃恢复/relaunch 会自己重试,我们只把地面修平。
30
+ // 红线:不杀进程、不抢端口、不 spawn dsh、不改官方任何文件。
31
+ //
32
+ // ── 用法 ──────────────────────────────────────────────────────────────────
33
+ // node fw-watchdog.mjs --daemon 后台守护(**计划任务用这个**):自己脱离、无控制台窗口、幂等
34
+ // node fw-watchdog.mjs --status 只打印当前判据事实,不动作
35
+ // node fw-watchdog.mjs --tick 跑一次完整逻辑(演练 / 手动触发)
36
+ // node fw-watchdog.mjs --once 诊断:判据 + 跑一次 --check 并打印结论
37
+ // node fw-watchdog.mjs 前台常驻(Ctrl-C 退出;排查时用)
38
+ // 日志:`<DSH_HOME>/plugin-console/fw-watchdog.log`(追加式,只在有事时写)
39
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
40
+ import { homedir } from 'node:os'
41
+ import { join } from 'node:path'
42
+ import { spawnSync } from 'node:child_process'
43
+ import { fileURLToPath } from 'node:url'
44
+
45
+ const ARGV = process.argv.slice(2)
46
+ const HAS = (f) => ARGV.includes(f)
47
+ const HOME = homedir()
48
+ const DSH_HOME = process.env.DSH_HOME?.trim() || join(HOME, '.dsh')
49
+ const LOG_DIR = join(DSH_HOME, 'plugin-console')
50
+ const LOG = join(LOG_DIR, 'fw-watchdog.log')
51
+ const INTERVAL_MS = Number(process.env.DSH_WATCHDOG_INTERVAL_MS ?? 20000)
52
+ const STRIKES = Number(process.env.DSH_WATCHDOG_STRIKES ?? 2)
53
+ const EXE = process.platform === 'win32' ? 'DeepSeek Harness.exe' : null
54
+
55
+ /** 找到本机已装的 fw-preflight.mjs。
56
+ * 扫描各个 profile 的 node_modules/<包>/scripts/(不写死 desktop —— 用户可能只装 web profile,
57
+ * 或 profile 名不同)。★ 注意:本注释里**不能**出现星号后跟斜杠那种通配写法,
58
+ * 那会提前闭合块注释(2026-10-14 实测踩到:脚本因此整份 SyntaxError、看门狗从未真正运行过)。 */
59
+ function findPreflight() {
60
+ if (typeof process.env.DSH_WATCHDOG_CONSOLE === 'string' && process.env.DSH_WATCHDOG_CONSOLE !== '') {
61
+ return join(process.env.DSH_WATCHDOG_CONSOLE, 'scripts', 'fw-preflight.mjs')
62
+ }
63
+ // 本脚本自己就在 <包>/scripts/ 下时,优先用同包那份(最不容易错配)
64
+ try {
65
+ const self = fileURLToPath(import.meta.url)
66
+ const samePkg = join(self, '..', 'fw-preflight.mjs')
67
+ if (existsSync(samePkg)) return samePkg
68
+ } catch {}
69
+ const profilesDir = join(DSH_HOME, 'profiles')
70
+ let names = []
71
+ try { names = readdirSync(profilesDir) } catch { return join(profilesDir, 'desktop', 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs') }
72
+ for (const name of names) {
73
+ const p = join(profilesDir, name, 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs')
74
+ if (existsSync(p)) return p
75
+ }
76
+ return join(profilesDir, 'desktop', 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs')
77
+ }
78
+ const PREFLIGHT = findPreflight()
79
+
80
+ const log = (line) => {
81
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
82
+ const text = `[${stamp}] ${line}\n`
83
+ try { mkdirSync(LOG_DIR, { recursive: true }); appendFileSync(LOG, text, 'utf8') } catch {}
84
+ process.stdout.write(text)
85
+ }
86
+
87
+ /** 进程树事实(一次 PowerShell 调用取全,避免每条判据各 spawn 一次)。
88
+ * ★ 坑(2026-10-14 实测踩到):PowerShell 里 `$host` 是**只读自动变量**,赋值直接报
89
+ * "Cannot overwrite variable Host because it is read-only or constant" ⇒ 探针恒返回 0
90
+ * ⇒ 看门狗永远以为"外壳没在跑"、静默失效。所以变量名一律用 `$hostN`/`$shellN`。
91
+ * 同族坑:$HOME / $PID / $PSHOME 等一律不许赋值。 */
92
+ function probeProcesses() {
93
+ // 测试注入缝(只为离线演练;生产不设这个变量)
94
+ const fake = process.env.DSH_WATCHDOG_FAKE_PROCS
95
+ if (typeof fake === 'string' && /^\d+\|\d+$/u.test(fake)) {
96
+ const [s, h] = fake.split('|').map(Number)
97
+ return { shell: s, host: h, raw: `${s}|${h} (injected)` }
98
+ }
99
+ if (EXE === null) return { shell: 0, host: 0, raw: 'non-win32' }
100
+ const ps = [
101
+ `$all = @(Get-CimInstance Win32_Process -Filter "Name='${EXE}'" -ErrorAction SilentlyContinue)`,
102
+ `$shellN = @($all | Where-Object { $_.CommandLine -notmatch '--type=' }).Count`,
103
+ `$hostN = @($all | Where-Object { $_.CommandLine -match 'dsh-desktop-host[\\\\/]lib[\\\\/]index\\.js' }).Count`,
104
+ `Write-Output ($shellN.ToString() + '|' + $hostN.ToString())`,
105
+ ].join('; ')
106
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8', timeout: 20000, windowsHide: true })
107
+ const m = /(\d+)\|(\d+)/u.exec(String(r.stdout ?? ''))
108
+ return { shell: m ? Number(m[1]) : 0, host: m ? Number(m[2]) : 0, raw: String(r.stdout ?? '').trim() }
109
+ }
110
+
111
+ const preflight = (mode) => {
112
+ if (!existsSync(PREFLIGHT)) {
113
+ log(`找不到 fw-preflight:${PREFLIGHT}(控制台未安装或路径变了)—— 本轮跳过`)
114
+ return { ok: false, stdout: '', missing: true }
115
+ }
116
+ const r = spawnSync(process.execPath, [PREFLIGHT, mode], { encoding: 'utf8', timeout: 180000, windowsHide: true })
117
+ return { ok: r.status === 0, stdout: String(r.stdout ?? '') + String(r.stderr ?? ''), missing: false }
118
+ }
119
+
120
+ /** 闭包是否坏。判据取自 fw-preflight 自己那行结论:「走了 N 个包,M 个解析不了」,M>0 即坏。
121
+ * 认不出格式时按"坏"处理 —— 宁可多查一次,也不放过真事故。 */
122
+ function closureBroken(out) {
123
+ const m = /走了\s*(\d+)\s*个包[,,]\s*(\d+)\s*个解析不了/u.exec(out)
124
+ if (m) return Number(m[2]) > 0
125
+ if (/闭包完整/u.test(out)) return false
126
+ return true
127
+ }
128
+
129
+ let strikes = 0
130
+ let repairing = false
131
+
132
+ async function tick() {
133
+ if (repairing) return
134
+ try {
135
+ const p = probeProcesses()
136
+ if (p.shell === 0) { strikes = 0; return } // 外壳没开 = 用户没在用,不管
137
+ if (p.host > 0) { // HOST 在 = 服务活着 = 正常
138
+ if (strikes !== 0) log('HOST 服务进程已回来 —— 恢复正常,看门狗回待命')
139
+ strikes = 0
140
+ return
141
+ }
142
+ strikes += 1
143
+ if (strikes < STRIKES) return
144
+ log(`外壳在跑(${p.shell}) 但 HOST 服务进程不在(连续 ${strikes} 轮)⇒ 开始检查框架闭包`)
145
+ const check = preflight('--check')
146
+ if (check.missing) { strikes = 0; return }
147
+ if (!closureBroken(check.stdout)) {
148
+ log('闭包本身是好的 ⇒ 起不来另有原因(不是包被掏空),本轮不动作,等官方自己的恢复逻辑')
149
+ strikes = 0
150
+ return
151
+ }
152
+ log('闭包确实坏了 ⇒ 执行 --repair(从本地 npm 缓存按 integrity 离线装回,不联网)')
153
+ repairing = true
154
+ try {
155
+ const fix = preflight('--repair')
156
+ log(`--repair 退出码=${fix.ok ? 0 : '非0'}`)
157
+ const re = preflight('--check')
158
+ const good = re.ok && !closureBroken(re.stdout)
159
+ log(good
160
+ ? '✅ 修复成功:闭包复验通过。服务交由官方自己的恢复/relaunch 拉起(本脚本不抢端口、不 spawn dsh)。'
161
+ : '❌ 修复后复验仍未通过 —— 需人工介入(手动跑 --repair 看详细输出;日志在 ~/.dsh/plugin-console/)')
162
+ } finally { repairing = false; strikes = 0 }
163
+ } catch (error) {
164
+ log(`本轮异常(不影响下一轮):${String(error?.message ?? error)}`)
165
+ }
166
+ }
167
+
168
+ // ── 后台守护(计划任务用):自己脱离,不留控制台窗口 ──────────────────────────
169
+ // ★ 为什么要这一模式(2026-10-14 实测):计划任务直接跑 node.exe 会让 Windows 弹出**可见的
170
+ // 控制台窗口**(本机实测桌面出现一个 Windows Terminal);改用 .vbs 启动器(window style 0)
171
+ // 又踩到 wscript 挂住、拉不起子进程。最稳的是让脚本**自己以 detached 方式再拉起一份**,
172
+ // 并让它 `stdio: 'ignore' + windowsHide: true`(子进程没有控制台可挂)⇒ 任务那条命令立刻返回,
173
+ // 真正干活的那份在后台无窗口运行。重复启动由 pid 锁挡住。
174
+ if (HAS('--daemon')) {
175
+ const PID_FILE = join(LOG_DIR, 'fw-watchdog.pid')
176
+ const alive = (pid) => {
177
+ if (!Number.isInteger(pid) || pid <= 0) return false
178
+ try { process.kill(pid, 0); return true } catch { return false }
179
+ }
180
+ let already = null
181
+ try { already = Number(String(readFileSync(PID_FILE, 'utf8')).trim()) } catch {}
182
+ if (alive(already)) {
183
+ log(`后台看门狗已在运行(pid ${already})—— 本次不重复启动`)
184
+ process.exit(0)
185
+ }
186
+ const { spawn } = await import('node:child_process')
187
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], {
188
+ detached: true, stdio: 'ignore', windowsHide: true,
189
+ env: { ...process.env },
190
+ })
191
+ child.unref()
192
+ try { mkdirSync(LOG_DIR, { recursive: true }); writeFileSync(PID_FILE, String(child.pid), 'utf8') } catch {}
193
+ log(`已用后台守护模式拉起看门狗(pid ${child.pid},无窗口)`)
194
+ process.exit(0)
195
+ }
196
+
197
+ // ── 单轮完整逻辑(演练 / 计划任务手动触发)──
198
+ if (HAS('--tick')) {
199
+ log('── 单轮完整逻辑(--tick)──')
200
+ await tick()
201
+ log('── 本轮结束 ──')
202
+ process.exit(0)
203
+ }
204
+
205
+ // ── 状态 / 诊断 ──
206
+ if (HAS('--status') || HAS('--once')) {
207
+ const p = probeProcesses()
208
+ log(HAS('--once') ? '── 诊断(--once)──' : '── 状态(--status)──')
209
+ log(`外壳进程: ${p.shell} 个 HOST 服务进程: ${p.host} 个`)
210
+ log(`判定:${p.shell === 0 ? '外壳没在跑(不管)' : p.host > 0 ? '服务活着(正常)' : '★ 外壳在跑但服务不在(事故形态)'}`)
211
+ log(`预检脚本:${PREFLIGHT}`)
212
+ if (HAS('--once')) {
213
+ const check = preflight('--check')
214
+ log(`fw-preflight --check 退出码=${check.ok ? 0 : '非0'}${check.missing ? '(脚本缺失)' : ''}`)
215
+ if (!check.missing) {
216
+ log(check.stdout.trim().split('\n').map((l) => ' ' + l).join('\n'))
217
+ log(`闭包判定:${closureBroken(check.stdout) ? '★ 坏' : '好'}`)
218
+ }
219
+ }
220
+ process.exit(0)
221
+ }
222
+
223
+ // ── 常驻 ──
224
+ // ★ 主入口守卫(2026-10-14 补):没有它时,任何人 `import()` 本文件都会**意外启动常驻看门狗**
225
+ // (验证语法时就踩到过:一个 import 把看门狗跑起来了)。只在真正作为脚本执行时才启动。
226
+ if (import.meta.main !== true) {
227
+ process.exit(0)
228
+ }
229
+
230
+ log(`看门狗启动:间隔=${INTERVAL_MS}ms 连续${STRIKES}轮 预检=${PREFLIGHT}`)
231
+ log('判据:外壳在跑 + HOST 服务进程不在;只修框架闭包,不杀进程、不抢端口、不改官方文件')
232
+ // 记下自己的 pid(`--daemon` 靠它防重复启动;退出时清掉)
233
+ try { mkdirSync(LOG_DIR, { recursive: true }); writeFileSync(join(LOG_DIR, 'fw-watchdog.pid'), String(process.pid), 'utf8') } catch {}
234
+ await tick()
235
+ const timer = setInterval(tick, INTERVAL_MS)
236
+ const stop = () => {
237
+ clearInterval(timer)
238
+ try {
239
+ const f = join(LOG_DIR, 'fw-watchdog.pid')
240
+ if (String(readFileSync(f, 'utf8')).trim() === String(process.pid)) rmSync(f, { force: true })
241
+ } catch {}
242
+ log('看门狗停止')
243
+ process.exit(0)
244
+ }
245
+ process.on('SIGINT', stop)
246
+ process.on('SIGTERM', stop)