dsh-boot-suite 2.13.20 → 2.15.15

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +13 -4
  3. package/lib/client.js +446 -24
  4. package/lib/host-startup.js +197 -4
  5. package/lib/host-wallpaper.js +32 -11
  6. package/lib/splash.js +270 -0
  7. package/package.json +2 -2
  8. package/templates/division-bigfish-codex/app.js +255 -0
  9. package/templates/division-bigfish-codex/assets/egg-bg.png +0 -0
  10. package/templates/division-bigfish-codex/assets/voice-full-plus.wav +0 -0
  11. package/templates/division-bigfish-codex/assets/voice-line1.wav +0 -0
  12. package/templates/division-bigfish-codex/assets/voice-line2.wav +0 -0
  13. package/templates/division-bigfish-codex/assets/voice-line3.wav +0 -0
  14. package/templates/division-bigfish-codex/assets/voice-line4.wav +0 -0
  15. package/templates/division-bigfish-codex/assets/voice-orig1.wav +0 -0
  16. package/templates/division-bigfish-codex/assets/voice-orig3.wav +0 -0
  17. package/templates/division-bigfish-codex/assets/voice-reconnect.wav +0 -0
  18. package/templates/division-bigfish-codex/assets/whale-badge-line.png +0 -0
  19. package/templates/division-bigfish-codex/config.js +24 -0
  20. package/templates/division-bigfish-codex/index.html +1 -0
  21. package/templates/division-bigfish-codex/motion.js +30 -0
  22. package/templates/division-bigfish-codex/preview-easteregg.mp4 +0 -0
  23. package/templates/division-bigfish-codex/preview-normal.mp4 +0 -0
  24. package/templates/division-bigfish-codex/render.html +1 -0
  25. package/templates/division-bigfish-codex/style.css +18 -0
  26. package/templates/division-bigfish-codex/template.json +10 -0
  27. package/templates/division-bigfish-codex/voice-env.js +1 -0
  28. package/templates/division-bigfish-codex/voice.js +10 -0
  29. package/templates/division-bigfish-codex//344/272/244/346/216/245-Codex/347/211/210/346/216/245/345/205/245/346/226/271/346/241/210.md +207 -0
  30. package/templates/division-bigfish-codex//346/217/222/344/273/266/346/216/245/345/205/245/345/245/221/347/272/246.md +177 -0
  31. package/templates/whale-girl-scan/template.json +2 -2
  32. package/templates/whale-girl-scan-4k/assets/whale-girl-scan-4k.mp4 +0 -0
  33. package/templates/whale-girl-scan-4k/template.json +104 -0
  34. package/test/smoke-client.mjs +5 -5
  35. package/test/smoke-host.mjs +23 -1
  36. package/test/smoke-package.mjs +89 -1
  37. package/test/smoke-splash.mjs +2 -0
  38. package/test/smoke-wallpaper-client.mjs +1 -1
  39. package/test/smoke-wallpaper-host.mjs +7 -1
  40. package/tools/README.md +1 -1
  41. package/tools/build-release.mjs +2 -1
  42. package/tools/install.ps1 +7 -4
  43. package/tools/make-upgrade-helper.mjs +20 -0
  44. package/tools/stage-local.mjs +158 -0
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
 
4
- const root = process.cwd();
5
+ const root = fileURLToPath(new URL('..', import.meta.url));
5
6
  const buf = fs.readFileSync(path.join(root, 'package.json'));
6
7
  if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
7
8
  throw new Error('package.json 带 UTF-8 BOM,DSH 会拒绝加载');
@@ -27,4 +28,91 @@ for (const needle of ['TargetPkg', 'CheckOnly', 'DSH_BOOT_SUITE_ROLLBACK', '回
27
28
  for (const f of ['line-1.mp3', 'line-6.mp3', 'dry-line-1.mp3']) {
28
29
  if (fs.existsSync(path.join(root, 'lib/assets', f))) throw new Error('旧 TTS 配音素材未删除:' + f);
29
30
  }
31
+
32
+ /* v2.15.5:全景大肥鱼-含彩蛋版(重命名 + 结尾波形 + 模板内配音效果) */
33
+ const tplJson = JSON.parse(fs.readFileSync(path.join(root, 'templates/division-bigfish-codex/template.json'), 'utf8'))
34
+ if (tplJson.name !== '全景大肥鱼-含彩蛋版') throw new Error('模板名称未更新:' + tplJson.name)
35
+ const bigfishApp = fs.readFileSync(path.join(root, 'templates/division-bigfish-codex/app.js'), 'utf8')
36
+ for (const needle of ['attachVoiceFx', 'window.__WAIT_START__', 'VOICE_FX_PRESETS']) {
37
+ if (!bigfishApp.includes(needle)) throw new Error('模板内配音效果/结尾波形缺少:' + needle)
38
+ }
39
+ const hostStartup = fs.readFileSync(path.join(root, 'lib/host-startup.js'), 'utf8')
40
+ if (!hostStartup.includes("bigfishVoiceFx: 'none'")) throw new Error('host-startup 缺少 bigfishVoiceFx 默认值')
41
+ if (!hostStartup.includes("window.CONFIG.voiceFx='")) throw new Error('host-startup 未注入模板 voiceFx')
42
+ const clientJs = fs.readFileSync(path.join(root, 'lib/client.js'), 'utf8')
43
+ for (const needle of ['bigfishVoiceFx', '全景大肥鱼-含彩蛋版']) {
44
+ if (!clientJs.includes(needle)) throw new Error('设置页缺少:' + needle)
45
+ }
46
+ /* v2.15.6:掉线彩蛋必须等用户点【重新连接】,不许自动 reconnect */
47
+ if (bigfishApp.includes('setTimeout(reconnect')) throw new Error('彩蛋仍有自动 reconnect 定时器')
48
+ if (!bigfishApp.includes('必须等用户点【重新连接】')) throw new Error('彩蛋等待逻辑缺少注释锚点')
49
+ const bigfishCfg = fs.readFileSync(path.join(root, 'templates/division-bigfish-codex/config.js'), 'utf8')
50
+ if (bigfishCfg.includes('eggReplayWait')) throw new Error('config.js 仍残留 eggReplayWait 自动重播字段')
51
+ /* v2.15.7:结尾等待配音时必须继续渲染波形 */
52
+ if (!bigfishApp.includes('if(!playing&&t>=cfg.duration-0.05)render();')) throw new Error('结尾等待期缺少渲染调用,波形会再次冻住')
53
+ /* v2.15.8:模板配音音量 + iframe 模板 BGM */
54
+ if (!hostStartup.includes('bigfishVoiceVolume: 50')) throw new Error('host-startup 缺少配音音量默认值')
55
+ if (!hostStartup.includes('window.CONFIG.voiceVolume=')) throw new Error('host-startup 未注入模板配音音量')
56
+ if (!bigfishApp.includes('a.volume=voiceVol')) throw new Error('模板配音未应用音量')
57
+ if (!bigfishApp.includes('dsh-startup-voice')) throw new Error('模板未上报配音闪避事件')
58
+ const splashJs = fs.readFileSync(path.join(root, 'lib/splash.js'), 'utf8')
59
+ if (!splashJs.includes('dsh-startup-voice')) throw new Error('splash 未处理模板配音闪避')
60
+ if (!/engine === 'iframe'[\s\S]{0,160}bgm\.start\(\)/.test(splashJs)) throw new Error('iframe 模板未启动 BGM')
61
+ if (!clientJs.includes('配音音量')) throw new Error('设置页缺少配音音量')
62
+ if (!clientJs.includes('bigfishVoiceVolume')) throw new Error('设置页缺少 bigfishVoiceVolume')
63
+ if (!clientJs.includes('style: (isArchiveWarm || isBigfish)')) throw new Error('BGM 组未对全景大肥鱼显示')
64
+ /* v2.15.9:无第二幕不切第二段音乐、BGM 循环开关、跳过按钮位置 */
65
+ if (!hostStartup.includes('bgmLoop: true')) throw new Error('host-startup 缺少 bgmLoop 默认值')
66
+ if (!hostStartup.includes("'bgmLoop'")) throw new Error('host-startup BOOL_KEYS 缺 bgmLoop')
67
+ if (!splashJs.includes('C.bgmLoop !== false')) throw new Error('splash 未使用 bgmLoop')
68
+ if (!/if \(!TEMPLATE2[\s\S]{0,420}bgm\.toSecond\(\)/.test(splashJs)) throw new Error('splash 在无第二幕时仍提前切第二段音乐')
69
+ if (!clientJs.includes('hasSecondAct')) throw new Error('设置页缺少第二幕判断')
70
+ if (!clientJs.includes('bgmLoop')) throw new Error('设置页缺少循环开关')
71
+ const skipCss = fs.readFileSync(path.join(root, 'templates/division-bigfish-codex/style.css'), 'utf8')
72
+ if (!skipCss.includes('top:64px;right:72px')) throw new Error('跳过按钮位置未调整')
73
+ /* v2.15.10:掉线时机随机、彩蛋截断 BGM、重连不叠音 */
74
+ if (!bigfishApp.includes('dsh-startup-bgm')) throw new Error('模板未上报 BGM 彩蛋信号')
75
+ if (!bigfishApp.includes('resetPlayedAfter')) throw new Error('重连未按时间点重置语音')
76
+ if (!bigfishApp.includes('eggTimeForPct(Math.random()*100)')) throw new Error('掉线时机未按进度百分比随机')
77
+ if (!splashJs.includes('bgm cut')) throw new Error('splash 缺少 BGM 截断')
78
+ if (!splashJs.includes('dsh-startup-bgm')) throw new Error('splash 未处理 BGM 彩蛋信号')
79
+ if (!bigfishCfg.includes('egg:{}')) throw new Error('config.js 仍固定 egg.at')
80
+ /* v2.15.11:模板不再自己做白闪,结尾转场由插件接管 */
81
+ for (const f of ['index.html', 'render.html']) {
82
+ const html = fs.readFileSync(path.join(root, 'templates/division-bigfish-codex', f), 'utf8')
83
+ if (html.includes('id="flash"')) throw new Error(f + ' 仍有模板白闪节点 #flash')
84
+ }
85
+ if (bigfishApp.includes('flashEl')) throw new Error('app.js 仍引用模板白闪 flashEl')
86
+ if (bigfishApp.includes('flashEl')) throw new Error('app.js 仍残留白闪动画')
87
+ if (skipCss.includes('#flash')) throw new Error('style.css 仍残留 #flash 样式')
88
+ if (!/return done\.then\(function \(\) \{[\s\S]{0,260}transOut\(transType\('Out'\), finish\)/.test(splashJs)) throw new Error('iframe 模板收尾未交给插件退场转场')
89
+ /* v2.15.12:结尾等配音时圆环继续转 */
90
+ if (!bigfishApp.includes('const rt=t+waitSec')) throw new Error('结尾圆环未使用连续旋转时钟 rt')
91
+ if (!bigfishApp.includes('rotate(${rt*5})')) throw new Error('外圈未使用 rt 旋转')
92
+ if (!bigfishApp.includes('rotate(${-rt*12})')) throw new Error('内圈未使用 rt 旋转')
93
+ if (!bigfishApp.includes('Math.sin(rt*6.54)')) throw new Error('外圈脉冲未使用 rt')
94
+ if (!/function flashDone\(\)\{hideEgg\(\);doneEmit\(\);\}/.test(bigfishApp)) throw new Error('flashDone 不应再清零旋转时钟')
95
+ /* v2.15.13:配音中文字幕 */
96
+ if (!bigfishApp.includes('cfg.voice.subs')) throw new Error('模板未使用字幕映射 cfg.voice.subs')
97
+ if (!bigfishApp.includes('subtitle')) throw new Error('模板未更新 #subtitle')
98
+ const bigfishHtml = fs.readFileSync(path.join(root, 'templates/division-bigfish-codex', 'index.html'), 'utf8')
99
+ if (!bigfishHtml.includes('id="subtitle"')) throw new Error('index.html 缺少 #subtitle 字幕节点')
100
+ if (!bigfishCfg.includes('警告:检测到新特工登录')) throw new Error('config.js 缺少中文字幕映射')
101
+ if (!bigfishCfg.includes("line2:'装备与技能配置已验证。倒计时十秒。'")) throw new Error('config.js line2 字幕未包含完整句')
102
+ if (!bigfishCfg.includes("line3:'网络同步完成。正在移交权限。'")) throw new Error('config.js line3 字幕不正确')
103
+ if (!bigfishCfg.includes("reconnect:'检测到特工重新连接。'")) throw new Error('config.js 缺少重连接字幕')
104
+ if (!bigfishCfg.includes("line4:'警告:特工已离线。'")) throw new Error('config.js 缺少彩蛋 line4 字幕')
105
+ if (!bigfishApp.includes('renderVoiceRow')) throw new Error('模板未使用整行字幕布局 renderVoiceRow')
106
+ if (!fs.existsSync(path.join(root, 'templates/division-bigfish-codex/assets/whale-badge-line.png'))) throw new Error('缺少鲸鱼徽章图标 whale-badge-line.png')
107
+ /* v2.15.14:重连提示音独占一整行,播完再继续,且不再续播断句 */
108
+ if (!bigfishApp.includes('重连接提示音独占一整行')) throw new Error('app.js 缺少重连提示音独占一行的实现')
109
+ if (!bigfishApp.includes('const cueOn=')) throw new Error('渲染未使用重连提示音分支 cueOn')
110
+ if (!bigfishApp.includes('playing&&!cueGate')) throw new Error('重连期间未门控后续配音(cueGate)')
111
+ if (!bigfishApp.includes('played.line2=false;played.line3=false;played.orig3=false')) throw new Error('重连后未重置后续配音的播放标记')
112
+ if (/resetPlayedAfter\(cfg\.loadingStart\)/.test(bigfishApp)) throw new Error('重连仍会续播断句(resetPlayedAfter)')
113
+ if (!bigfishApp.includes('cueFailTimer')) throw new Error('重连提示音缺少播放失败的兜底解除门控')
114
+ /* v2.15.15:重连提示音也要走插件的配音效果;line3 顺延,避免和 line2 叠音 */
115
+ if (!bigfishApp.includes("attachVoiceFx('reconnect')")) throw new Error('重连提示音未接入配音效果')
116
+ if (!bigfishApp.includes("[20.0,'line3',3.300]")) throw new Error('app.js 默认时间线未把 line3 顺延到 20.0s')
117
+ if (!bigfishCfg.includes("[20.0,'line3',3.300]")) throw new Error('config.js 时间线未把 line3 顺延到 20.0s')
30
118
  console.log('ok package.json version=' + pkg.version + ',无 BOM,关键文件齐全(含更新前回滚 checkpoint)');
@@ -336,6 +336,8 @@ console.log('\n[8] 终幕用真声欢迎语(来自 Video Project 3 的 line-6
336
336
  check('配音音色可切换:voicePack 为 f–k 时走 voice:<pack>:line-N 路由',
337
337
  /C\.voicePack/.test(src) && /'voice:' \+ pack \+ ':' \+ asset/.test(src)
338
338
  && /tplAssets && tplAssets\[asset\]/.test(src))
339
+ check('全局 line-N 无人声包时也改走 voice 路由(不再请求已删除的 lib/assets)',
340
+ src.includes("'/dsh-startup/voice/' + pack + '/' + name + '.mp3'") && src.includes("if (pack === 'builtin') pack = 'f'"))
339
341
  check('5 套声音效果预设都在,播放链路按 C.voiceFx 叠加',
340
342
  ['ai-mech', 'robot', 'radio', 'oldtv', 'dark'].every((k) => src.includes("'" + k + "':"))
341
343
  && /playAssetAndWait\(useAsset, 0\.85, isGlobalLine \? fx : 'none'\)/.test(src))
@@ -463,7 +463,7 @@ ok(intervals.every((t) => t.cleared) || intervals.length === 0, '旧的 interval
463
463
  intervals.map((t) => (t.cleared ? 'cleared' : 'live')).join(','))
464
464
 
465
465
  console.log('\n=== ⑦ v2.13.0 测试钩子 / BUILD ===')
466
- ok(mod.__BUILD === 'cw-43', '__BUILD = cw-43(排查浏览器跑的是不是新包)', String(mod.__BUILD))
466
+ ok(mod.__BUILD === 'cw-63', '__BUILD = cw-63(排查浏览器跑的是不是新包)', String(mod.__BUILD))
467
467
  ok(mod.__VIDEO_ID === 'dsh-cw-video', '__VIDEO_ID = dsh-cw-video', String(mod.__VIDEO_ID))
468
468
  ok(typeof mod.__startShuffle === 'function', '__startShuffle 导出')
469
469
  ok(typeof mod.__stopShuffle === 'function', '__stopShuffle 导出')
@@ -50,7 +50,7 @@ const ctx = {
50
50
  on() { return () => {} },
51
51
  get(name) {
52
52
  if (name === 'webServer') return webServer
53
- if (name === 'connection') return { requestRejection: () => undefined }
53
+ if (name === 'connection') return { requestRejection: (req) => (String((req && req.url) || '').includes('__deny=1') ? 401 : undefined) }
54
54
  return undefined
55
55
  },
56
56
  effect(fn) { effects++; const d = fn(); return () => { if (typeof d === 'function') d() } },
@@ -581,6 +581,12 @@ try {
581
581
  check('remove 后 /videos.roots 不再含该目录',
582
582
  !(rootsRes.json.roots || []).some((root) => root.path.toLowerCase().includes('custom-loops')), JSON.stringify(rootsRes.json.roots))
583
583
 
584
+ /* v2.14.0:壁纸路由接入信任栅栏(connection.requestRejection 返回状态码时直接拒绝) */
585
+ const deniedRes = await callJson('exact /chat-wallpaper/config', 'GET', undefined, '/chat-wallpaper/config?__deny=1')
586
+ check('★ 壁纸路由受信任栅栏保护:requestRejection 返回 401 时拒绝', deniedRes.res.statusCode === 401 && deniedRes.res.body === '', String(deniedRes.res.statusCode))
587
+ const allowedRes = await callJson('exact /chat-wallpaper/config', 'GET', undefined, '/chat-wallpaper/config')
588
+ check('栅栏放行普通请求', allowedRes.res.statusCode === 200, String(allowedRes.res.statusCode))
589
+
584
590
  } catch (e) {
585
591
  fail++
586
592
  console.log(' FAIL 测试本身抛异常:' + String((e && e.stack) || e))
package/tools/README.md CHANGED
@@ -23,5 +23,5 @@
23
23
 
24
24
  ## 注意
25
25
 
26
- - 三个脚本都会**先要求桌面端完全退出**,否则直接退出不干活 —— 这是刻意的,避免在进程锁着 profile 时写半截。
26
+ - 三个脚本默认会**先检查桌面端是否完全退出**,运行中会拒绝(避免在进程锁着 profile 时写半截);确认要热装可加 `-Force`,装完重启 DSH。
27
27
  - 卸装 / 回滚都不会动 `~/.dsh/dsh-startup.json` 和 `~/.dsh/chat-wallpaper.json`(回滚会从备份里恢复它们)。
@@ -7,7 +7,8 @@ const root = process.cwd();
7
7
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-release-'));
8
8
  const out = path.join(root, '..', '03-发布包', 'dsh-boot-suite-win.zip');
9
9
  /* code-motion:另一条工作线(动效研究/验证)的文档与媒体,不属于插件本体,发布包不打包 */
10
- const skip = new Set(['.git', 'node_modules', '08-备份', 'code-motion']);
10
+ /* docs:主线(编辑器重制/风格库/演示素材)文档与媒体,体积可能非常大(GB 级),不属于插件运行必需,发布包不打包 */
11
+ const skip = new Set(['.git', 'node_modules', '08-备份', 'code-motion', 'docs']);
11
12
  function copy(src, dst) {
12
13
  fs.mkdirSync(dst, { recursive: true });
13
14
  for (const e of fs.readdirSync(src, { withFileTypes: true })) {
package/tools/install.ps1 CHANGED
@@ -16,7 +16,7 @@
16
16
  7. 自检并告诉你下一步
17
17
  #>
18
18
  [CmdletBinding()]
19
- param([switch]$CheckOnly, [string[]]$Profiles)
19
+ param([switch]$CheckOnly, [switch]$Force, [string[]]$Profiles)
20
20
 
21
21
  $ErrorActionPreference = 'Stop'
22
22
  function Say($m) { Write-Host $m }
@@ -121,10 +121,13 @@ Say ('要装的 profile:' + ($targets -join '、'))
121
121
  # ── 3. 桌面端必须先完全退出 ─────────────────────────────────────────────────
122
122
  if ($targets -contains 'desktop') {
123
123
  $running = @(Get-Process -Name 'DeepSeek Harness' -ErrorAction SilentlyContinue)
124
- if ($running.Count -gt 0 -and -not $CheckOnly) {
125
- Die ('桌面端还在运行(' + $running.Count + ' 个进程)。请完全退出 DeepSeek Harness Desktop 后再双击安装。')
124
+ if ($running.Count -gt 0 -and -not $CheckOnly -and -not $Force) {
125
+ Die ('桌面端还在运行(' + $running.Count + ' 个进程)。请先完全退出 DeepSeek Harness Desktop 再双击安装;确实要热装可加 -Force(不推荐,装完手动重启)。')
126
126
  }
127
- if ($running.Count -gt 0) { Say '(检查模式)桌面端正在运行 —— 真正安装前需要先完全退出' }
127
+ if ($running.Count -gt 0 -and $Force) {
128
+ Say ('[!] 桌面端还在运行(' + $running.Count + ' 个进程):-Force 热装继续;装完请手动重启 DSH 让新代码生效。')
129
+ }
130
+ if ($running.Count -gt 0 -and $CheckOnly) { Say '(检查模式)桌面端正在运行 —— 真正安装前建议先完全退出' }
128
131
  }
129
132
 
130
133
  # ── 3.5 更新前的紧急回滚 checkpoint(v2.13.3)──────────────────────────────
@@ -33,6 +33,26 @@ const pkgInstall = `%USERPROFILE%\\.dsh\\boot-suite-updates\\${version}\\pkg\\to
33
33
  const helperName = `升级到 ${version}(先完全退出 DSH 再双击).cmd`
34
34
  const helperPath = path.join(desktop, helperName)
35
35
 
36
+ /* 自检 0(2026-11-02 事故):暂存包必须真的在 <updates>\<版本>\pkg\ 下,
37
+ 否则生成的助手双击后必然报 “install.ps1 不存在”。生成前先确认,绝不给用户一个空指路牌。 */
38
+ const stagedPkg = path.join(os.homedir(), '.dsh', 'boot-suite-updates', version, 'pkg')
39
+ const stagedInstall = path.join(stagedPkg, 'tools', 'install.ps1')
40
+ if (!fs.existsSync(stagedInstall)) {
41
+ console.error(`[x] 生成失败:暂存包不完整,缺少 ${stagedInstall}`)
42
+ console.error(`[x] 先把发布包解压到 ${stagedPkg}(注意是 pkg 子目录),再来生成桌面助手。`)
43
+ process.exit(1)
44
+ }
45
+ try {
46
+ const stagedVersion = JSON.parse(fs.readFileSync(path.join(stagedPkg, 'package.json'), 'utf8')).version
47
+ if (stagedVersion !== version) {
48
+ console.error(`[x] 生成失败:暂存包版本是 ${stagedVersion},与要求的 ${version} 不一致`)
49
+ process.exit(1)
50
+ }
51
+ } catch (err) {
52
+ console.error(`[x] 生成失败:读不到暂存包 package.json(${err.message})`)
53
+ process.exit(1)
54
+ }
55
+
36
56
  /* 整段模板:一行一个元素,绝不把版本号单独拼成数组元素 */
37
57
  const lines = [
38
58
  '@echo off',
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 安全暂存本机升级包(2026-11-02 事故后的流程固化)。
4
+ *
5
+ * 事故复盘:
6
+ * v2.15.13 曾把 zip 直接解压到 ~/.dsh/boot-suite-updates/2.15.13/,
7
+ * 文件铺在 2.15.13/ 下而不是 2.15.13/pkg/ 下;桌面助手固定调用
8
+ * ...\2.15.13\pkg\tools\install.ps1,于是报「路径不存在」。
9
+ * 更糟的是当时在 DSH 运行时删掉了旧 pkg,profile 的 link: 指向了
10
+ * 不存在的目录,DSH 直接打不开,只能回滚。
11
+ *
12
+ * 本脚本的顺序(任何一步失败都保留旧版本,绝不让 profile 悬空):
13
+ * 1. 确认目标 pkg 没有被任何 profile 以 link: 正在使用(在跑就拒绝);
14
+ * 2. 解压到 updates 目录内的临时目录,校验 pkg/package.json 版本、
15
+ * pkg/tools/install.ps1、文件数;
16
+ * 3. 旧 pkg 改名备份(不删除),新 pkg 原子 rename 到位;
17
+ * 4. 再校验一遍新 pkg;失败则把旧 pkg 改回来;
18
+ * 5. 复制 zip 到 <版本>/<版本>.zip,最后打印摘要。
19
+ *
20
+ * 用法:
21
+ * node tools/stage-local.mjs 2.15.13
22
+ * node tools/stage-local.mjs 2.15.13 --zip "F:/.../03-发布包/dsh-boot-suite-win.zip"
23
+ */
24
+ import fs from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+ import { spawnSync } from 'node:child_process'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const here = path.dirname(fileURLToPath(import.meta.url))
31
+ const repoRoot = path.dirname(here)
32
+
33
+ const args = process.argv.slice(2)
34
+ const version = args[0]
35
+ const versionRe = /^\d+\.\d+\.\d+$/
36
+ if (!version || !versionRe.test(version)) {
37
+ console.error('[x] 用法:node tools/stage-local.mjs <版本号> [--zip <发布包 zip 路径>]')
38
+ process.exit(1)
39
+ }
40
+
41
+ const zi = args.indexOf('--zip')
42
+ let zipPath = zi >= 0 && args[zi + 1]
43
+ ? path.resolve(args[zi + 1])
44
+ : path.resolve(repoRoot, '..', '03-发布包', 'dsh-boot-suite-win.zip')
45
+
46
+ if (!fs.existsSync(zipPath)) {
47
+ console.error(`[x] 找不到发布包 zip:${zipPath}`)
48
+ process.exit(1)
49
+ }
50
+
51
+ const updatesRoot = path.join(os.homedir(), '.dsh', 'boot-suite-updates')
52
+ const versionDir = path.join(updatesRoot, version)
53
+ const targetPkg = path.join(versionDir, 'pkg')
54
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
55
+ const tmpRoot = path.join(updatesRoot, `.stage-${version}-${stamp}`)
56
+ const tmpPkg = path.join(tmpRoot, 'pkg')
57
+
58
+ const fail = (msg) => {
59
+ console.error(`[x] ${msg}`)
60
+ console.error(`[x] 旧的 ${targetPkg} 未被删除(如果已备份,会在下面提示),profile 未改动。`)
61
+ process.exit(1)
62
+ }
63
+
64
+ /* 1. 目标 pkg 正被 profile 以 link: 使用时拒绝替换 */
65
+ const profilesRoot = path.join(os.homedir(), '.dsh', 'profiles')
66
+ const live = []
67
+ if (fs.existsSync(profilesRoot)) {
68
+ for (const name of fs.readdirSync(profilesRoot)) {
69
+ const pj = path.join(profilesRoot, name, 'package.json')
70
+ if (!fs.existsSync(pj)) continue
71
+ try {
72
+ const json = JSON.parse(fs.readFileSync(pj, 'utf8'))
73
+ for (const dep of Object.values(json.dependencies || {})) {
74
+ if (typeof dep !== 'string' || !dep.startsWith('link:')) continue
75
+ const resolved = path.resolve(dep.slice(5).replace(/\//g, path.sep)).toLowerCase()
76
+ if (resolved === targetPkg.toLowerCase() || resolved.startsWith(targetPkg.toLowerCase() + path.sep.toLowerCase())) {
77
+ live.push(`${name}: ${dep}`)
78
+ }
79
+ }
80
+ } catch { /* 单个 profile 读不动就跳过,不影响主流程 */ }
81
+ }
82
+ }
83
+ if (live.length) {
84
+ console.error(`[x] 目标 pkg 正被 profile 使用,拒绝替换:\n ${live.join('\n ')}`)
85
+ console.error('[x] 先让 DSH 用旧版本启动(或回滚)并完全退出,再暂存新版本。')
86
+ process.exit(1)
87
+ }
88
+
89
+ /* 2. 解压到临时目录并校验 */
90
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
91
+ fs.mkdirSync(tmpPkg, { recursive: true })
92
+ console.log(`[1/5] 解压 ${zipPath}`)
93
+ const tar = spawnSync('tar', ['-xf', zipPath, '-C', tmpPkg], { stdio: 'inherit' })
94
+ if (tar.status !== 0) fail(`解压失败(tar exit ${tar.status})`)
95
+
96
+ let srcPkg = tmpPkg
97
+ if (!fs.existsSync(path.join(srcPkg, 'package.json')) && fs.existsSync(path.join(srcPkg, 'pkg', 'package.json'))) {
98
+ srcPkg = path.join(srcPkg, 'pkg') // 兼容 zip 内自带 pkg/ 层的情况
99
+ }
100
+ const countFiles = (dir) => {
101
+ let n = 0
102
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
103
+ const p = path.join(dir, e.name)
104
+ if (e.isDirectory()) n += countFiles(p)
105
+ else n++
106
+ }
107
+ return n
108
+ }
109
+ const pkgJsonPath = path.join(srcPkg, 'package.json')
110
+ const installPath = path.join(srcPkg, 'tools', 'install.ps1')
111
+ if (!fs.existsSync(pkgJsonPath)) fail('解压结果里没有 package.json')
112
+ if (!fs.existsSync(installPath)) fail('解压结果里没有 tools/install.ps1(桌面助手会找不到安装器)')
113
+ const pkgVersion = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).version
114
+ if (pkgVersion !== version) fail(`包内版本 ${pkgVersion} 与要暂存的 ${version} 不一致`)
115
+ const fileCount = countFiles(srcPkg)
116
+ if (fileCount < 50) fail(`包内文件数异常(${fileCount}),拒绝暂存`)
117
+ console.log(`[2/5] 校验通过:version=${pkgVersion},files=${fileCount}`)
118
+
119
+ /* 3. 旧 pkg 备份 → 新 pkg 原子替换 */
120
+ fs.mkdirSync(versionDir, { recursive: true })
121
+ let backupDir = ''
122
+ if (fs.existsSync(targetPkg)) {
123
+ backupDir = path.join(versionDir, `pkg.prev-${stamp}`)
124
+ fs.renameSync(targetPkg, backupDir)
125
+ console.log(`[3/5] 旧 pkg 已备份到 ${backupDir}`)
126
+ } else {
127
+ console.log('[3/5] 没有旧 pkg,直接放入新 pkg')
128
+ }
129
+ try {
130
+ fs.renameSync(srcPkg, targetPkg)
131
+ } catch (err) {
132
+ if (backupDir) fs.renameSync(backupDir, targetPkg)
133
+ fail(`新 pkg 移入失败(${err.message}),已恢复旧 pkg`)
134
+ }
135
+
136
+ /* 4. 对最终位置再校验一遍,失败就回滚 */
137
+ let ok = false
138
+ try {
139
+ const v = JSON.parse(fs.readFileSync(path.join(targetPkg, 'package.json'), 'utf8')).version
140
+ ok = v === version && fs.existsSync(path.join(targetPkg, 'tools', 'install.ps1'))
141
+ } catch { ok = false }
142
+ if (!ok) {
143
+ fs.rmSync(targetPkg, { recursive: true, force: true })
144
+ if (backupDir) fs.renameSync(backupDir, targetPkg)
145
+ fail('新 pkg 落位后校验失败,已恢复旧 pkg')
146
+ }
147
+ const zipCopy = path.join(versionDir, `${version}.zip`)
148
+ fs.copyFileSync(zipPath, zipCopy)
149
+ console.log(`[4/5] 新 pkg 就位:${targetPkg}`)
150
+
151
+ /* 5. 打印摘要 */
152
+ const size = fs.statSync(zipPath).size
153
+ console.log('[5/5] 完成')
154
+ console.log(` version : ${version}`)
155
+ console.log(` pkg : ${targetPkg}`)
156
+ console.log(` files : ${fileCount}`)
157
+ console.log(` pkg.prev : ${backupDir || '(无)'}`)
158
+ console.log(` zip copy : ${zipCopy} (${size} bytes)`)