@kira905/dsh-butler-install 0.3.5 → 0.3.7

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # dsh-butler-install
2
2
 
3
3
  > ### 这是什么
4
- > **上面那一整套的一键安装器**:一条命令装 16 个包,装完逐项自检,装坏了整批回滚。
4
+ > **上面那一整套的一键安装器**:一条命令装 17 个包,装完逐项自检,装坏了整批回滚。
5
5
  >
6
6
  > ### 它解决什么
7
- > 一套东西散成十几个包,最容易死在「装一半」:有的装上了、有的没有,报错还只说 `ERR_PNPM_`。这个安装器给三件东西 —— ① **清单驱动**(16 项一次装齐,缺一项就算失败)② **整批回滚**(不是只撤最后一个)③ **失败说人话**(把 pnpm 的环境障碍翻成「安全软件拦了建链接」「store 不在同一块盘」)。
7
+ > 一套东西散成十几个包,最容易死在「装一半」:有的装上了、有的没有,报错还只说 `ERR_PNPM_`。这个安装器给三件东西 —— ① **清单驱动**(17 项一次装齐,缺一项就算失败)② **整批回滚**(不是只撤最后一个)③ **失败说人话**(把 pnpm 的环境障碍翻成「安全软件拦了建链接」「store 不在同一块盘」)。
8
8
  >
9
9
  > ### 一条命令跑起来
10
10
  > ```powershell
@@ -25,6 +25,15 @@
25
25
  >
26
26
  > 另有一个 Windows exe(双击出界面)。它**自带 node 运行时**,所以 exe 自己不用你先装 Node;
27
27
  > 但**这台电脑上仍然得先有 DSH 和 npm** —— exe 只装大管家组件,不装 DSH 本身。
28
+ >
29
+ > **桌面版(Electron)DSH:能装,但要认对门。**
30
+ > 桌面版把 `desktop` 这个 profile 留给了自己 —— npm 装的 CLI 去改它会被宿主直接拒绝
31
+ > (`error: profile "desktop" is managed exclusively by the Electron application`,rc=1)。
32
+ > 但桌面版**自带一条命令** `<安装目录>\resources\runtime\cli\bin\dsh.cmd`,用它加 `--profile desktop` 是能成的。
33
+ > 本工具会自己认:数据根里有 `profiles/desktop`,或者能在常见安装目录 / 正在运行的桌面版进程里
34
+ > 找到那条自带命令 ⇒ 用 `--profile desktop` 时**自动改用它**(命令原语不变,只是换了一条壳)。
35
+ > 找不到自带命令时**不会硬来**:直接停手(rc=2),并告诉你怎么指路(`DSH_DESKTOP_DIR`)。
36
+ > ⚠️ 桌面版只加载 `desktop` 这一个 profile;往 `--profile web` 里装 = 装了看不见。
28
37
 
29
38
  ---
30
39
 
@@ -33,7 +42,11 @@
33
42
  > 卖点不是"一键",是"一键之后的可验证性"。
34
43
  > 一个包"装完 rc=0 但其实没生效"是这套生态里真实存在过的坑(宿主只会吐一行
35
44
  > `declares no dsh.bundle` 警告,然后把它装成普通依赖)。本工具逐包核对**包实体在不在、
36
- > 版本落不落在期望区间、插件到底有没有进组合列表**,任一条不过就判这一步失败。
45
+ > 版本落不落在期望区间、装上来的是不是这个区间里现在能拿到的最新、插件到底有没有进组合列表**,
46
+ > 任一条不过就判这一步失败。
47
+ > 第四条"是不是最新"是 0.3.7 加的:只查"落在区间内"时,"装成了区间下界"也会绿 ——
48
+ > 全批 rc=0,实际上装的全是旧版。取不到注册表(离线 / 私有源)时**不冒充通过**:
49
+ > 汇总里单列一行「N 项没核是否落后」,或者用 `--no-registry` 显式关掉这条判据。
37
50
 
38
51
  ## 30 秒上手
39
52
 
@@ -75,7 +88,7 @@ npx @kira905/dsh-butler-install --ui --port 45090
75
88
 
76
89
  | 按钮 | 干什么 | 不干什么 |
77
90
  |---|---|---|
78
- | **开始安装** | 算一遍"将对哪个 profile 做哪几件事",把 16 项、profile 名、回滚点位置逐条念给你看 | **那一步一条命令都不跑、一个文件都不写** |
91
+ | **开始安装** | 算一遍"将对哪个 profile 做哪几件事",把 17 项、profile 名、回滚点位置逐条念给你看 | **那一步一条命令都不跑、一个文件都不写** |
79
92
  | **确认,开始** | 真装。逐项显示 待装 / 安装中 / 成功 / 跳过 / 失败 | 不替你重启 DSH(装完要你自己重启,见下) |
80
93
  | **取消** | 杀掉当前那条命令的进程树 | **不是干净撤回**:已经装好的项会留着;页面会明说"已取消,装了一半" |
81
94
  | **卸载这次装的**(推荐) | 把这一轮装上去的真的卸掉:插件从 profile 摘掉、全局命令行包从全局前缀删掉 ⇒ 重查会真的变回"未装" | 不覆盖任何文件,所以你随后在侧边栏另装的插件不受影响 |
@@ -160,9 +173,10 @@ npx @kira905/dsh-butler-install --ui --port 45090
160
173
  - **这台电脑上已经装好了 DSH,并且有 npm** —— `dsh --version` 与 `npm --version` 都能打出版本号。
161
174
  exe **不负责装 DSH、也不负责装 npm**:开界面之前它先体检这两件事,缺哪个就在黑窗口里用大字
162
175
  说清楚、停住等你按任意键(不会一闪就没)。
163
- - **装的是「桌面版 / 图形版」DSH 的话,它可能没带命令行** —— 那种情况下装不上大管家(exe 走的是
164
- `dsh plugin` 这条命令)。需要再装一个命令行版:`npm i -g @deepseek-ai/dsh`;
165
- `dsh --version` 能打出版本号,再双击 exe。
176
+ - **装的是「桌面版 / 图形版」DSH 的话**:用 `--profile desktop`。桌面版自带一条命令行
177
+ (`<安装目录>\resources\runtime\cli\bin\dsh.cmd`),本工具会自己找到它并用它装;
178
+ 找不到时会停手告诉你用 `DSH_DESKTOP_DIR` 指一下安装目录。**别用 `--profile web`** ——
179
+ 桌面版不加载那个 profile,装了看不见。同一个 `dsh --version` 能打出版本号说明 CLI 版也在。
166
180
  - 只有 Windows x64 版。**自带 node 运行时**所以 89 MB —— 省的是「**exe 自己**不用装 node 就能跑起来」
167
181
  (exe 里已经打了一份 node.exe)。但请别读成「全流程不依赖本机 node」:**装插件那一步仍然要调这台电脑的 npm**。
168
182
 
package/lib/cli-args.mjs CHANGED
@@ -18,6 +18,10 @@ export const HELP_TEXT = `dsh-butler-install —— 大管家全家桶薄壳安
18
18
  dsh-butler-install --profile <profile> [选项]
19
19
 
20
20
  --profile <name> 要操作的 profile(必填)。宿主会用它定位 <数据根>/profiles/<name>
21
+ ⚠️ 桌面版(Electron 版)用户请写 desktop:本工具会改用桌面版自带的
22
+ dsh.cmd 去装(npm 装的 CLI 改不动那个 profile,宿主会直接拒绝)
23
+ --no-registry 不去注册表核"装上来的是不是最新合规版"(离线/私有源取不到时用;
24
+ 关掉之后判据只剩"落在区间内",报告里会明说没核过是否落后)
21
25
  --manifest <path> 清单文件,默认用包内自带的 suite-manifest.json
22
26
  --apply 真装。**不加就是演练档**:只打印将要执行的命令与每步判据,一条命令都不跑
23
27
  --only <列表> 只装这些:组名 / 包名 / 别名,逗号分隔,可重复给
@@ -69,12 +73,14 @@ const VALUE_FLAGS = new Set([
69
73
  '--port',
70
74
  ]);
71
75
 
72
- const BOOL_FLAGS = new Set(['--apply', '--upgrade', '--uninstall', '--json', '--dry-run', '--help', '-h', '--version', '-V', '--rollback', '--ui']);
76
+ const BOOL_FLAGS = new Set(['--apply', '--upgrade', '--uninstall', '--json', '--dry-run', '--help', '-h', '--version', '-V', '--rollback', '--ui', '--no-registry']);
73
77
 
74
78
 
75
79
  export function parseArgs(argv) {
76
80
  const options = {
77
81
  profile: null,
82
+ // profile 是"用户亲手点的名"还是"默认填的名"(--ui 默认 web):桌面版自动切 profile 要用它
83
+ profileExplicit: false,
78
84
  manifest: null,
79
85
  apply: false,
80
86
  only: [],
@@ -90,6 +96,7 @@ export function parseArgs(argv) {
90
96
  json: false,
91
97
  ui: false,
92
98
  uiPort: null,
99
+ noRegistry: false,
93
100
  help: false,
94
101
  version: false,
95
102
  };
@@ -132,6 +139,7 @@ export function parseArgs(argv) {
132
139
  if (flag === '--uninstall') options.uninstall = true;
133
140
  if (flag === '--json') options.json = true;
134
141
  if (flag === '--ui') options.ui = true;
142
+ if (flag === '--no-registry') options.noRegistry = true;
135
143
  if (flag === '--help' || flag === '-h') options.help = true;
136
144
  if (flag === '--version' || flag === '-V') options.version = true;
137
145
  continue;
@@ -163,6 +171,7 @@ function applyValue(options, flag, value, errors) {
163
171
  if (flag === '--profile') {
164
172
  if (!/^[A-Za-z0-9._-]+$/.test(value)) errors.push(`--profile 只接受字母数字与 . _ -(现在读到 ${JSON.stringify(value)})`);
165
173
  options.profile = value;
174
+ options.profileExplicit = true;
166
175
  } else if (flag === '--manifest') {
167
176
  options.manifest = value;
168
177
  } else if (flag === '--only') {
@@ -0,0 +1,218 @@
1
+ /**
2
+ * 桌面版(Electron 版 DSH)的识别与它自带命令的定位。
3
+ *
4
+ * 为什么要单独一层(2026-11-06 真机实测,见
5
+ * `docs\交付报告-桌面端适配与骨架装机实测-20261106.md` §2.5):
6
+ * - 桌面版把 `~/.dsh/profiles/desktop` 当**自己的** profile,用 npm 装的 CLI 去改它,
7
+ * 宿主会直接拒绝:`error: profile "desktop" is managed exclusively by the Electron application`(rc=1);
8
+ * - 但桌面版**自带一条命令** `<安装目录>\resources\runtime\cli\bin\dsh.cmd`,
9
+ * 用它加 `--profile desktop` 是**能成**的(实测 rc=0,dependencies 与 dsh.profile.bundles 都登记);
10
+ * - 反过来,往桌面版用户的机器上用 `--profile web` 装插件 = 那套界面永远不加载 = 假绿。
11
+ *
12
+ * 所以这里只做两件事:认出来,找到它自带的命令。**找不到就如实说找不到**,
13
+ * 由上层决定硬拒绝(绝不偷偷退回用 CLI 版去改桌面版 profile)。
14
+ *
15
+ * 探测只看三个来源,全都不写盘:① 桌面版自建的 profile ② 已知安装路径 ③ 运行中的进程。
16
+ */
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { spawnCommand } from './exec.mjs';
21
+
22
+ /** 桌面版自带的命令行入口在安装目录里的固定相对位置 */
23
+ export const DESKTOP_CLI_REL = path.join('resources', 'runtime', 'cli', 'bin', 'dsh.cmd');
24
+ const DESKTOP_EXE = 'DeepSeek Harness.exe';
25
+
26
+ function isFile(file) {
27
+ try {
28
+ return fs.statSync(file).isFile();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** `<root>` 下面是否真的有一套桌面版(主程序 + 自带命令都在位) */
35
+ export function looksLikeDesktopRoot(root) {
36
+ if (!root) return null;
37
+ const exe = path.join(root, DESKTOP_EXE);
38
+ const cli = path.join(root, DESKTOP_CLI_REL);
39
+ if (isFile(exe) && isFile(cli)) return { root, exe, cli };
40
+ return null;
41
+ }
42
+
43
+ /** 已知安装目录(Windows 上安装器默认落在 %LOCALAPPDATA%\Programs 或用户指定的盘) */
44
+ export function desktopRootCandidates(env = process.env, platform = process.platform) {
45
+ const out = [];
46
+ const push = (dir) => { if (dir) out.push(path.resolve(dir)); };
47
+ if (env.DSH_DESKTOP_DIR) push(env.DSH_DESKTOP_DIR);
48
+ if (platform !== 'win32') {
49
+ push('/Applications/DSH Desktop.app/Contents/Resources/..');
50
+ return out;
51
+ }
52
+ const local = env.LOCALAPPDATA || '';
53
+ const roaming = env.APPDATA || '';
54
+ if (local) {
55
+ push(path.join(local, 'Programs', 'DSH Desktop'));
56
+ push(path.join(local, 'Programs', 'DSH-Desktop'));
57
+ push(path.join(local, 'DSH Desktop'));
58
+ // %LOCALAPPDATA%\Programs 下常见一批 Electron 应用:按名字捞一遍(只 readdir 一层)
59
+ try {
60
+ for (const name of fs.readdirSync(path.join(local, 'Programs'), { withFileTypes: true })) {
61
+ if (!name.isDirectory()) continue;
62
+ if (/dsh|deepseek/i.test(name.name)) push(path.join(local, 'Programs', name.name));
63
+ }
64
+ } catch { /* 目录不存在是常态 */ }
65
+ }
66
+ if (roaming) push(path.join(roaming, 'DSH Desktop'));
67
+ for (const key of ['ProgramFiles', 'ProgramW6432', 'ProgramFiles(x86)']) {
68
+ if (env[key]) {
69
+ push(path.join(env[key], 'DSH Desktop'));
70
+ push(path.join(env[key], 'DSH-Desktop'));
71
+ }
72
+ }
73
+ // 用户的安装器允许挑盘:只查存在的盘符,名字就两种写法,成本是几十次 existsSync
74
+ for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
75
+ const driveRoot = `${letter}:\\`;
76
+ try {
77
+ if (!fs.existsSync(driveRoot)) continue;
78
+ } catch { continue; }
79
+ push(path.join(driveRoot, 'DSH Desktop'));
80
+ push(path.join(driveRoot, 'DSH-Desktop'));
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * 从运行中的桌面版进程反查安装目录。**只在这台机器上真的跑着桌面版时才有结果**,
87
+ * 所以放在最后一道 —— 它要起一个 PowerShell(几百毫秒),前面能命中就别用它。
88
+ */
89
+ export async function findDesktopRootFromProcess(opts = {}) {
90
+ if ((opts.platform || process.platform) !== 'win32') return null;
91
+ const run = opts.run || ((f, a, o) => spawnCommand(f, a, o));
92
+ const script = `$p = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like '*Harness*' } | Select-Object -First 1; if ($p) { $p.Path }`;
93
+ const res = await run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeoutMs: opts.timeoutMs || 15000, quiet: true });
94
+ if (res.code !== 0) return null;
95
+ const exePath = String(res.stdout || '').trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
96
+ if (!exePath || !isFile(exePath)) return null;
97
+ return path.dirname(exePath);
98
+ }
99
+
100
+ /**
101
+ * 「桌面版自带命令在哪」的缓存 —— 它跟数据根无关,只跟安装目录有关。
102
+ * 为什么必须有:向导页的 GET /state 每个请求都会走 buildSuiteState,一开页面就是几百次
103
+ * 探测;不缓存的话每次都扫 26 个盘符、必要时还起一个 PowerShell(本机实测把测试从 1 分钟拖到 4 分钟以上)。
104
+ */
105
+ const cliCache = new Map();
106
+
107
+ /** 夹具用:伪造/撤掉一套桌面版目录之后清缓存 */
108
+ export function resetDesktopCliCache() {
109
+ cliCache.clear();
110
+ }
111
+
112
+ function cliCacheKey(opts, env, platform) {
113
+ return [
114
+ platform,
115
+ env.DSH_DESKTOP_DIR || '',
116
+ env.DSH_DESKTOP_CLI || '',
117
+ Array.isArray(opts.roots) ? opts.roots.join('|') : '',
118
+ opts.allowProcessProbe === false ? 'noprobe' : 'probe',
119
+ ].join('::');
120
+ }
121
+
122
+ /**
123
+ * 找出桌面版自带命令。
124
+ * @param {object} [opts] { home, env, platform, run, allowProcessProbe, roots }
125
+ * @returns {Promise<{cli:string, root:string, source:string, evidence:string[]}>}
126
+ */
127
+ export async function findDesktopCli(opts = {}) {
128
+ const env = opts.env || process.env;
129
+ const platform = opts.platform || process.platform;
130
+ const key = cliCacheKey(opts, env, platform);
131
+ if (cliCache.has(key)) return cliCache.get(key);
132
+ const result = await findDesktopCliUncached({ ...opts, env, platform });
133
+ cliCache.set(key, result);
134
+ return result;
135
+ }
136
+
137
+ async function findDesktopCliUncached(opts = {}) {
138
+ const env = opts.env || process.env;
139
+ const platform = opts.platform || process.platform;
140
+ const evidence = [];
141
+
142
+ const explicit = env.DSH_DESKTOP_CLI;
143
+ if (explicit) {
144
+ if (isFile(explicit)) return { cli: explicit, root: path.dirname(path.dirname(path.dirname(path.dirname(explicit)))), source: '$DSH_DESKTOP_CLI', evidence: [`显式指定 ${explicit}`] };
145
+ evidence.push(`$DSH_DESKTOP_CLI 指向的文件不存在:${explicit}`);
146
+ }
147
+
148
+ for (const root of (Array.isArray(opts.roots) ? opts.roots : desktopRootCandidates(env, platform))) {
149
+ const hit = looksLikeDesktopRoot(root);
150
+ if (hit) {
151
+ evidence.push(`安装目录命中:${root}`);
152
+ return { cli: hit.cli, root: hit.root, source: 'known-path', evidence };
153
+ }
154
+ }
155
+
156
+ if (opts.allowProcessProbe !== false && platform === 'win32') {
157
+ try {
158
+ const root = await findDesktopRootFromProcess({ run: opts.run, platform, timeoutMs: opts.timeoutMs });
159
+ if (root) {
160
+ const hit = looksLikeDesktopRoot(root);
161
+ if (hit) {
162
+ evidence.push(`从运行中的进程反查到安装目录:${root}`);
163
+ return { cli: hit.cli, root: hit.root, source: 'process', evidence };
164
+ }
165
+ evidence.push(`进程目录 ${root} 里没有完整的一套桌面版文件`);
166
+ }
167
+ } catch (err) {
168
+ evidence.push(`进程反查失败(${err && err.message ? err.message : err})`);
169
+ }
170
+ }
171
+ if (!evidence.length) evidence.push('已知安装目录、$DSH_DESKTOP_DIR、运行中的进程三处都没找到');
172
+ return { cli: '', root: '', source: '', evidence };
173
+ }
174
+
175
+ /**
176
+ * 综合探测:桌面版在不在、自带命令在哪、它自建的 profile 在不在。
177
+ *
178
+ * @returns {Promise<{detected:boolean, cliCmd:string, root:string, source:string,
179
+ * desktopProfileExists:boolean, evidence:string[]}>}
180
+ */
181
+ export async function detectDesktop(opts = {}) {
182
+ const home = opts.home;
183
+ const env = opts.env || process.env;
184
+ const desktopProfileFile = home ? path.join(home, 'profiles', 'desktop', 'package.json') : '';
185
+ let desktopProfileExists = false;
186
+ try {
187
+ desktopProfileExists = Boolean(desktopProfileFile) && fs.existsSync(desktopProfileFile);
188
+ } catch { desktopProfileExists = false; }
189
+
190
+ const found = await findDesktopCli({ env, platform: opts.platform, run: opts.run, allowProcessProbe: opts.allowProcessProbe, timeoutMs: opts.timeoutMs, roots: opts.roots });
191
+ const evidence = found.evidence.slice();
192
+ if (desktopProfileExists) evidence.unshift(`数据根里有桌面版自建的 profile:${desktopProfileFile}`);
193
+ return {
194
+ detected: Boolean(desktopProfileExists || found.cli),
195
+ cliCmd: found.cli,
196
+ root: found.root,
197
+ source: found.source,
198
+ desktopProfileExists,
199
+ evidence,
200
+ };
201
+ }
202
+
203
+ /** 找不到自带命令时给人看的那段话(硬拒绝时原样打印) */
204
+ export function desktopMissingHelp(home) {
205
+ const homeText = home || '<数据根>';
206
+ return [
207
+ '这是桌面版(Electron 版)DSH 的 profile:本机 npm 装的 CLI 改不动它',
208
+ '(宿主会回 error: profile "desktop" is managed exclusively by the Electron application)。',
209
+ '要装就得用桌面版**自带**的那条命令:',
210
+ ' <桌面版安装目录>\\resources\\runtime\\cli\\bin\\dsh.cmd',
211
+ '本工具在下面三处都找过,没找到它:',
212
+ ' ① 环境变量 DSH_DESKTOP_DIR / DSH_DESKTOP_CLI',
213
+ ' ② 常见安装目录(%LOCALAPPDATA%\\Programs\\DSH Desktop、Program Files、各盘根下的 DSH Desktop)',
214
+ ' ③ 正在运行的桌面版进程',
215
+ `要么把桌面版装上/打开一次让它出现在上面任一位置,要么用 DSH_DESKTOP_DIR 指一下安装目录;`,
216
+ `确认这本机压根没有桌面版的话,就别用 --profile desktop(当前数据根:${homeText})。`,
217
+ ].join('\n');
218
+ }
package/lib/exec.mjs CHANGED
@@ -1,15 +1,29 @@
1
1
  /**
2
2
  * 跑一条外部命令(只跑官方命令:`dsh plugin ...`、`npm ...`)。
3
3
  *
4
- * Windows 上的两个实测坑,这里一次兜住:
5
- * 1) `spawnSync('npm')` 直接 **ENOENT** —— `npm` 其实是 `npm.cmd` 壳,无 shell 解析不到;
6
- * 2) `spawnSync('npm.cmd')` 直接 **EINVAL** —— Node 18.20+ 拒绝在没有 shell 的情况下执行 .cmd。
7
- * 结论:在 Windows 上必须过 shell。为了不让参数被 shell 二次解释,这里对所有参数做
8
- * 最小必要引号化(只有含空格/特殊字符的参数才加引号),并且命令与参数分开维护 ——
9
- * 打印出来的命令行与真正执行的命令行是同一个函数生成的,报告里不会出现"说要跑 A、
10
- * 实际跑了 B"。
4
+ * 🔴 这里有一条**绝不能退回去**的口径(2026-11-06 实测修的,见
5
+ * `docs\交付报告-桌面端适配与骨架装机实测-20261106.md` §3.3):
6
+ * **不许过 shell**。历史版本用 `spawn(cmdline, { shell: true })`,Windows 上等于走
7
+ * `cmd.exe /c`,而 `^` 是 cmd 的转义符:
8
+ * cmd /c "echo @kira905/dsh-ops-toolkit@^0.1.0" → @kira905/dsh-ops-toolkit@0.1.0
9
+ * 于是 `npm i -g <包>@^x.y.z` 静默退化成"精确钉死下界",而装后自检只查"落在区间内",
10
+ * 下界当然在区间内 ⇒ 全绿但全是旧版。参数一旦过 shell 就不再是我们写下的那串。
11
+ *
12
+ * Windows 上又不能直接 spawn `.cmd`(Node 18.20+ 起 `spawnSync('npm.cmd')` 直接 EINVAL,
13
+ * 见下),所以这里的做法是:**把 .cmd 壳解析成它真正要跑的那个可执行 + 参数**,
14
+ * 然后用 `shell: false` 直接起进程 —— 参数原样交给进程,一个字都不经过 cmd。
15
+ *
16
+ * npm.cmd → node.exe <...>\node_modules\npm\bin\npm-cli.js
17
+ * dsh.cmd → node.exe <...>\node_modules\@deepseek-ai\dsh\lib\bin.js
18
+ * 桌面版 dsh.cmd → "DeepSeek Harness.exe" --expose-internals <...>\dsh-desktop-host\lib\cli.js
19
+ * (外加壳里 set 的环境变量,例如 ELECTRON_RUN_AS_NODE=1)
20
+ *
21
+ * 解析不出来才退回 `cmd.exe /d /s /c`,并且**必须**把参数里的 cmd 元字符(`^` `&` `|`)
22
+ * 转义 —— 兜底可以慢,但不能静默改成另一条命令。
11
23
  */
12
24
  import { spawn, spawnSync } from 'node:child_process';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
13
27
 
14
28
  const SAFE_ARG = /^[A-Za-z0-9@/._:+=,~^()-]+$/;
15
29
 
@@ -19,13 +33,266 @@ export function quoteArgForWindows(arg) {
19
33
  return `"${s.replace(/"/g, '\\"')}"`;
20
34
  }
21
35
 
22
- /** 生成"可打印也可执行"的命令行(Windows 走 shell,其它平台参数数组直传) */
36
+ /** 生成"可打印"的命令行。⚠️ 它只用于**展示给人看**;真正执行走 resolveCommand + 参数数组。 */
23
37
  export function formatCommandLine(file, args) {
24
38
  const parts = [file, ...args];
25
39
  if (process.platform === 'win32') return parts.map(quoteArgForWindows).join(' ');
26
40
  return parts.map((p) => (/^[A-Za-z0-9@/._:+=,~^-]+$/.test(String(p)) ? p : `'${String(p).replace(/'/g, `'\\''`)}'`)).join(' ');
27
41
  }
28
42
 
43
+ /* ------------------------------------------------------------------ 找命令 */
44
+
45
+ const WIN_EXTS = ['', '.cmd', '.CMD', '.exe', '.EXE', '.bat', '.BAT', '.ps1', '.js'];
46
+
47
+ /**
48
+ * 在 PATH 上找一个**能被 spawn 起来**的命令。
49
+ *
50
+ * 🔴 后缀顺序是有讲究的(2026-11-06 实测):Windows 的 npm 全局目录里,同一个命令常常
51
+ * 同时存在 `dsh`(给 bash 用的无扩展脚本)、`dsh.cmd`、`dsh.ps1`。按"无扩展优先"找,
52
+ * 拿到的是那个 bash 脚本,spawn 必失败。所以这里显式按 exe → cmd → bat 排。
53
+ * `.ps1` 不认:要另起 PowerShell 才能跑,不属于本工具的用法。
54
+ */
55
+ export function findExecutableOnPath(cmd, { platform = process.platform, env = process.env } = {}) {
56
+ const exts = platform === 'win32' ? ['.exe', '.EXE', '.cmd', '.CMD', '.bat', '.BAT'] : [''];
57
+ for (const dir of String(env.PATH || env.Path || '').split(path.delimiter)) {
58
+ if (!dir) continue;
59
+ for (const ext of exts) {
60
+ const file = path.join(dir, `${cmd}${ext}`);
61
+ try {
62
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
63
+ } catch {
64
+ /* 权限/竞态一律跳过,PATH 扫描不该抛 */
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** 在 PATH 上找一个命令(只查文件存在,不执行;Windows 按常见后缀补壳) */
72
+ export function findOnPath(cmd, { platform = process.platform, env = process.env } = {}) {
73
+ const exts = platform === 'win32' ? WIN_EXTS : [''];
74
+ for (const dir of String(env.PATH || env.Path || '').split(path.delimiter)) {
75
+ if (!dir) continue;
76
+ for (const ext of exts) {
77
+ const file = path.join(dir, `${cmd}${ext}`);
78
+ try {
79
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
80
+ } catch {
81
+ /* 权限/竞态一律跳过,PATH 扫描不该抛 */
82
+ }
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /* ------------------------------------------------------- .cmd / .bat 壳解析 */
89
+
90
+ /** `set "NAME=value"`(含大小写混杂的 SET) */
91
+ const SET_RE = /^\s*set\s+"?([A-Za-z_][A-Za-z0-9_]*)=([^"\r\n]*)"?\s*$/i;
92
+
93
+ /** `%~dp0` 自带尾部分隔符,拼完会出现 `\\`;绝对路径统一过一遍 path.normalize(展示与 spawn 都干净) */
94
+ function tidyPathToken(token) {
95
+ const t = String(token);
96
+ if (!/[\\/]/.test(t)) return t;
97
+ if (/^[A-Za-z]:[\\/]/.test(t) || t.startsWith('\\\\')) return path.normalize(t);
98
+ return t;
99
+ }
100
+
101
+ /** 壳内部的局部变量(`_prog` / `NODE_EXE` / `PATHEXT` …)不该漏进子进程的环境 */
102
+ const SHIM_INTERNAL_VARS = new Set(['_prog', 'node_exe', 'pathext', 'dp0', 'comspec']);
103
+
104
+ function tokenizeCmdLine(line) {
105
+ const tokens = [];
106
+ let i = 0;
107
+ while (i < line.length) {
108
+ const ch = line[i];
109
+ if (ch === ' ' || ch === '\t') { i += 1; continue; }
110
+ if (ch === '"') {
111
+ let j = i + 1;
112
+ let buf = '';
113
+ while (j < line.length && line[j] !== '"') { buf += line[j]; j += 1; }
114
+ tokens.push(buf);
115
+ i = j + 1;
116
+ } else {
117
+ let j = i;
118
+ while (j < line.length && line[j] !== ' ' && line[j] !== '\t') j += 1;
119
+ tokens.push(line.slice(i, j));
120
+ i = j;
121
+ }
122
+ }
123
+ return tokens;
124
+ }
125
+
126
+ /**
127
+ * 读一个 .cmd/.bat 壳,抽出「它到底会起什么进程」。
128
+ *
129
+ * 只认结构清晰的三类(npm/pnpm 生成的 shim、桌面版自带 shim 都属于这三类):
130
+ * ① `"<prog>" "<script>.js" %*`(prog 可能是个变量,如 %_prog%)
131
+ * ② `node "<script>.js" %*`
132
+ * ③ `"<...>.exe" <args...> "<...>.js" %*`(桌面版:先 set 环境变量再起 Electron)
133
+ *
134
+ * @returns {{file:string, args:string[], env:Object, via:string, problem?:string}}
135
+ */
136
+ export function parseWindowsShim(shimFile) {
137
+ let text;
138
+ try {
139
+ text = fs.readFileSync(shimFile, 'utf8');
140
+ } catch (err) {
141
+ return { file: '', args: [], env: {}, via: 'shim-error', problem: `读不到壳文件 ${shimFile}(${err.code || err.message})` };
142
+ }
143
+ const dir = path.dirname(shimFile);
144
+ const vars = new Map();
145
+ const setVars = [];
146
+ for (const rawLine of text.split(/\r?\n/)) {
147
+ const m = SET_RE.exec(rawLine);
148
+ if (!m) continue;
149
+ const name = m[1].toLowerCase();
150
+ const value = m[2].trim();
151
+ setVars.push({ name, value });
152
+ if (!vars.has(name)) vars.set(name, []);
153
+ vars.get(name).push(value);
154
+ }
155
+
156
+ const expand = (token) => String(token)
157
+ .replace(/%~?dp0%?/gi, `${dir}${path.sep}`)
158
+ .replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (whole, name) => {
159
+ const list = vars.get(String(name).toLowerCase());
160
+ if (!list) return whole;
161
+ for (const v of list) {
162
+ const expanded = expandOnce(v, dir, vars);
163
+ if (expanded && fs.existsSync(expanded)) return expanded;
164
+ }
165
+ return expandOnce(list[list.length - 1], dir, vars);
166
+ });
167
+
168
+ // 执行行 = 最后一行带 %* 的(npm.cmd 里前面几行也可能出现 %*,最后那行才是真调用)
169
+ const execLines = text.split(/\r?\n/).filter((l) => l.includes('%*'));
170
+ if (!execLines.length) {
171
+ return { file: '', args: [], env: {}, via: 'shim-no-exec', problem: `${path.basename(shimFile)} 里找不到带 %* 的执行行` };
172
+ }
173
+ // npm.cmd 的执行行是 `endLocal & goto ... || title %COMSPEC% & "<prog>" "<script>" %*`
174
+ const pieces = execLines[execLines.length - 1].split('&');
175
+ const callPiece = pieces.find((p) => p.includes('%*')) || execLines[execLines.length - 1];
176
+ const tokens = tokenizeCmdLine(callPiece.replace(/%\*/g, ' '))
177
+ .map((t) => t.trim())
178
+ .filter(Boolean);
179
+ if (!tokens.length) {
180
+ return { file: '', args: [], env: {}, via: 'shim-empty', problem: `${path.basename(shimFile)} 的执行行里没有可执行命令` };
181
+ }
182
+
183
+ // `endLocal` / `title` 这类 cmd 内建可能在同一个 & 段里,先剥掉
184
+ const BUILTINS = new Set(['endlocal', 'setlocal', 'title', 'echo', 'call', 'goto', 'if', 'exit']);
185
+ while (tokens.length && BUILTINS.has(tokens[0].toLowerCase())) tokens.shift();
186
+ if (!tokens.length) {
187
+ return { file: '', args: [], env: {}, via: 'shim-builtin', problem: `${path.basename(shimFile)} 的执行行只有 cmd 内建命令` };
188
+ }
189
+
190
+ const headRaw = tokens.shift();
191
+ const head = tidyPathToken(expand(headRaw));
192
+ let file = head;
193
+ if (!path.isAbsolute(file)) {
194
+ // 壳里写的 `node` / `node.exe`:先用壳目录(npm 的 shim 会优先用自带的 node.exe),
195
+ // 再用 PATH,最后用跑本工具的这个 node。
196
+ const local = path.join(dir, file);
197
+ if (fs.existsSync(local)) file = local;
198
+ else file = findExecutableOnPath(file) || '';
199
+ }
200
+ if (!file || !fs.existsSync(file)) {
201
+ return { file: '', args: [], env: {}, via: 'shim-unresolved', problem: `${path.basename(shimFile)} 里的可执行解析不到(读到 ${JSON.stringify(headRaw)})` };
202
+ }
203
+
204
+ const args = tokens.map((t) => tidyPathToken(expand(t)));
205
+ const env = {};
206
+ for (const { name, value } of setVars) {
207
+ // 只收"静态赋值"(不含 % 变量的),例如桌面版壳里的 ELECTRON_RUN_AS_NODE=1;
208
+ // 壳自己的局部变量(_prog / NODE_EXE …)必须滤掉,否则会污染子进程环境。
209
+ if (/%/.test(value)) continue;
210
+ if (name.startsWith('_') || SHIM_INTERNAL_VARS.has(name)) continue;
211
+ env[name.toUpperCase()] = value;
212
+ }
213
+ return { file, args, env, via: 'shim-parsed' };
214
+ }
215
+
216
+ function expandOnce(value, dir, vars) {
217
+ return String(value)
218
+ .replace(/%~?dp0%?/gi, `${dir}${path.sep}`)
219
+ .replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (whole, name) => {
220
+ const list = vars.get(String(name).toLowerCase());
221
+ return list ? list[0] : whole;
222
+ });
223
+ }
224
+
225
+ /* ------------------------------------------------------------- 命令解析入口 */
226
+
227
+ /**
228
+ * 把一个"命令名或路径"解析成可以直接 spawn 的 { file, argsPrefix, env, via }。
229
+ *
230
+ * - Windows:`.cmd/.bat` 走壳解析;`.exe` 直接用;裸名字先找 PATH。
231
+ * - 其它平台:原样直传(本来就不需要 shell)。
232
+ *
233
+ * @param {string} command
234
+ * @param {object} [opts] { platform, env, allowCmdFallback }
235
+ */
236
+ export function resolveCommand(command, opts = {}) {
237
+ const platform = opts.platform || process.platform;
238
+ const env = opts.env || process.env;
239
+ const base = { file: command, argsPrefix: [], env: {}, via: 'direct', command, problem: '' };
240
+ if (platform !== 'win32') return base;
241
+
242
+ let file = String(command);
243
+ const looksLikePath = /[\\/]/.test(file);
244
+ if (!looksLikePath) {
245
+ const found = findExecutableOnPath(file, { platform, env });
246
+ if (!found) {
247
+ // 让 spawn 自己去报 ENOENT:这里不改写命令,但把话说清楚
248
+ return { ...base, via: 'not-found', problem: `PATH 上找不到 ${file}` };
249
+ }
250
+ file = found;
251
+ }
252
+ const ext = path.extname(file).toLowerCase();
253
+ if (ext === '.cmd' || ext === '.bat') {
254
+ const shim = parseWindowsShim(file);
255
+ if (shim.via === 'shim-parsed') {
256
+ return { file: shim.file, argsPrefix: shim.args, env: shim.env, via: 'shim', command, shim: file, problem: '' };
257
+ }
258
+ return {
259
+ file: opts.comspec || env.ComSpec || 'cmd.exe',
260
+ argsPrefix: ['/d', '/s', '/c', buildCmdLineWithEscapes(file, [])],
261
+ env: {},
262
+ via: 'cmd-fallback',
263
+ command,
264
+ shim: file,
265
+ problem: shim.problem || '',
266
+ cmdFallback: true,
267
+ };
268
+ }
269
+ return { ...base, file, via: 'direct-file' };
270
+ }
271
+
272
+ /**
273
+ * cmd 兜底用:把参数按 cmd 的规则转义后再拼成一行。
274
+ *
275
+ * 🔴 层数是实测出来的(2026-11-06,连测两轮):
276
+ * - `cmd /c <bat> x^y` → bat 里 `%*` 收到 `xy`(第一层就吃了)
277
+ * - `cmd /c <bat> x^^y` → 还是 `xy`(第二层又吃了一次)
278
+ * - `cmd /c <bat> x^^^^y` → `x^y` ✅
279
+ * 因为参数要过**两层**解析(`cmd /c` 那一行 + 批处理自己的 `%*` 展开),`^` 要写四个。
280
+ * - **加了引号**时 cmd 不再把 `^` 当转义符 —— 这时一个都不该加(加了多少就原样多出多少)。
281
+ * 这个兜底只在 `.cmd` 壳解析不出来时才走;含 `&`/`|`/`"` 的参数一律不承诺(那类不该出现在
282
+ * 我们这条调用链里 —— 我们只传包名、版本区间、`-g`、`--profile`、profile 名)。
283
+ */
284
+ export function escapeForCmd(arg) {
285
+ const s = String(arg);
286
+ if (/[\s()]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
287
+ return s.replace(/\^/g, '^^^^');
288
+ }
289
+
290
+ export function buildCmdLineWithEscapes(file, args) {
291
+ return [file, ...args].map(escapeForCmd).join(' ');
292
+ }
293
+
294
+ /* ------------------------------------------------------------------ 杀进程 */
295
+
29
296
  function killTree(pid) {
30
297
  if (!pid) return;
31
298
  try {
@@ -89,7 +356,7 @@ const ABORT_SETTLE_MS = 3000;
89
356
  * @param {object} [options]
90
357
  * @param {number} [options.timeoutMs] 单步超时(到点按超时处理,走 killTree)
91
358
  * @param {AbortSignal} [options.signal] 外部取消。**不传 ⇒ 行为与加这个参数之前一模一样**
92
- * @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, aborted:boolean, cmdline:string, error?:string}>}
359
+ * @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, aborted:boolean, cmdline:string, error?:string, resolved?:object}>}
93
360
  */
94
361
  export function spawnCommand(file, args, options = {}) {
95
362
  const { timeoutMs = 120000, env = {}, cwd, onLine, quiet = false, signal } = options;
@@ -100,17 +367,34 @@ export function spawnCommand(file, args, options = {}) {
100
367
  return Promise.resolve({ code: null, stdout: '', stderr: '', timedOut: false, aborted: true, cmdline, error: 'aborted-before-start' });
101
368
  }
102
369
 
370
+ const mergedEnv = { ...process.env, ...env };
371
+ const resolved = resolveCommand(file, { env: mergedEnv });
372
+ // 兜底路径:.cmd 壳解析不出来才走 cmd.exe,此时必须自己把参数转义进那一行
373
+ const spawnFile = resolved.file;
374
+ const spawnArgs = resolved.cmdFallback
375
+ ? [...resolved.argsPrefix.slice(0, 3), buildCmdLineWithEscapes(resolved.shim || file, args)]
376
+ : [...resolved.argsPrefix, ...args];
377
+ const spawnEnv = { ...mergedEnv, ...resolved.env };
378
+ const diagnostics = {
379
+ command: resolved.command,
380
+ via: resolved.via,
381
+ file: spawnFile,
382
+ argsPrefix: resolved.argsPrefix,
383
+ shim: resolved.shim || null,
384
+ problem: resolved.problem || '',
385
+ };
386
+
103
387
  return new Promise((resolve) => {
104
388
  let child;
105
389
  try {
106
- child = spawn(cmdline, {
107
- shell: true,
390
+ child = spawn(spawnFile, spawnArgs, {
391
+ shell: false,
108
392
  cwd,
109
393
  windowsHide: true,
110
- env: { ...process.env, ...env },
394
+ env: spawnEnv,
111
395
  });
112
396
  } catch (err) {
113
- resolve({ code: null, stdout: '', stderr: '', timedOut: false, aborted: false, cmdline, error: String(err && err.message ? err.message : err) });
397
+ resolve({ code: null, stdout: '', stderr: '', timedOut: false, aborted: false, cmdline, resolved: diagnostics, error: String(err && err.message ? err.message : err) });
114
398
  return;
115
399
  }
116
400
 
@@ -132,7 +416,7 @@ export function spawnCommand(file, args, options = {}) {
132
416
  if (timer) clearTimeout(timer);
133
417
  if (abortSettleTimer) clearTimeout(abortSettleTimer);
134
418
  if (signal) signal.removeEventListener('abort', onAbort);
135
- resolve(payload);
419
+ resolve({ ...payload, resolved: diagnostics });
136
420
  };
137
421
 
138
422
  timer = setTimeout(() => {
package/lib/plan.mjs CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  readInstalledDshDecl,
27
27
  } from './state.mjs';
28
28
  import { formatCommandLine } from './exec.mjs';
29
+ import { detectDesktop, desktopMissingHelp } from './desktop.mjs';
30
+ import { createRegistryResolver } from './registry.mjs';
29
31
  import { EXIT } from './cli-args.mjs';
30
32
 
31
33
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -72,21 +74,23 @@ export function selectItems(manifest, { only = [], skip = [] } = {}) {
72
74
  return { items: selected.filter((i) => !dropped.has(i.name)), errors, groups };
73
75
  }
74
76
 
75
- export function installStepFor(item, { profile, npmPrefix }) {
77
+ export function installStepFor(item, { profile, npmPrefix, dshCli }) {
76
78
  // 装的时候把**清单里的期望区间**一起点名(`<包>@^x.y.z`)。
77
79
  // 为什么必须带上:不带区间时拿到的是注册表 `latest` 标签指的那个版本,而它未必在
78
80
  // 我们期望的区间里(实测踩过:latest 标签指着的版本与最高版本号不是一回事)。
79
81
  // 带区间 = 让解析器只能落进期望区间,落不进去就报错,而不是"装了个别的版本还报成功"。
82
+ // ⚠️ 这个 `^` 必须原样到 npm/dsh 手里:lib/exec.mjs 已不再过 shell(cmd 会吃掉 `^`)。
80
83
  const spec = `${item.name}@${item.versionRange}`;
81
84
  if (item.type === 'plugin') {
82
- return { file: 'dsh', args: ['plugin', '--profile', profile, 'add', spec], env: {} };
85
+ // 桌面版(Electron)的 profile 只能用它自带的 dsh.cmd 改;为 null 时才用 PATH 上的 dsh
86
+ return { file: dshCli || 'dsh', args: ['plugin', '--profile', profile, 'add', spec], env: {} };
83
87
  }
84
88
  return { file: 'npm', args: ['i', '-g', spec], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
85
89
  }
86
90
 
87
- export function uninstallStepFor(item, { profile, npmPrefix }) {
91
+ export function uninstallStepFor(item, { profile, npmPrefix, dshCli }) {
88
92
  if (item.type === 'plugin') {
89
- return { file: 'dsh', args: ['plugin', '--profile', profile, 'remove', item.name], env: {} };
93
+ return { file: dshCli || 'dsh', args: ['plugin', '--profile', profile, 'remove', item.name], env: {} };
90
94
  }
91
95
  return { file: 'npm', args: ['rm', '-g', item.name], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
92
96
  }
@@ -147,7 +151,7 @@ export function criteriaText(item) {
147
151
  } else {
148
152
  parts.push(`跑 ${item.probe.cmd} ${(item.probe.argv || []).join(' ')}(清单声明只读:${item.probe.readonlyBecause})`);
149
153
  }
150
- parts.push(`实体版本落在 ${describeRange(parseRange(item.versionRange))}`);
154
+ parts.push(`实体版本落在 ${describeRange(parseRange(item.versionRange))} 内,且不落后于注册表上该区间内的最高合规版(取不到注册表时单列"未核",不冒充通过)`);
151
155
  if (item.type === 'plugin' && (item.mount || 'auto') === 'auto') {
152
156
  parts.push('dump-config 里组合头恰好 1 行、条目 name 恰好 1 条、该 id 在整棵树里恰好 1 次');
153
157
  }
@@ -191,6 +195,70 @@ function buildOrder(groups, items) {
191
195
  return out;
192
196
  }
193
197
 
198
+ /**
199
+ * 桌面版探测结果缓存(键 = 数据根 + 两个显式环境变量)。
200
+ * 为什么必须缓存:向导页的 GET /state 每个请求都会走 buildSuiteState,而探测要扫盘、
201
+ * 必要时还要起一个 PowerShell 反查进程 —— 不缓存的话打开页面就会卡住。
202
+ */
203
+ const desktopDetectCache = new Map();
204
+
205
+ /** 夹具用:换一套伪造环境之前把缓存清掉 */
206
+ export function resetDesktopDetectCache() {
207
+ desktopDetectCache.clear();
208
+ }
209
+
210
+ async function desktopFor(home, options) {
211
+ const env = options.env || process.env;
212
+ const key = `${home}|${env.DSH_DESKTOP_DIR || ''}|${env.DSH_DESKTOP_CLI || ''}`;
213
+ if (desktopDetectCache.has(key)) return desktopDetectCache.get(key);
214
+ const detected = await detectDesktop({
215
+ home,
216
+ env,
217
+ run: options.desktopRun,
218
+ allowProcessProbe: options.allowProcessProbe,
219
+ // 夹具用:直接把候选安装目录钉死,免得本机真装了桌面版时测试跟着本机走
220
+ roots: options.desktopRoots,
221
+ platform: options.platform,
222
+ });
223
+ desktopDetectCache.set(key, detected);
224
+ return detected;
225
+ }
226
+
227
+ /**
228
+ * 这次到底对哪个 profile 动手。
229
+ *
230
+ * 三条判据(按顺序):
231
+ * ① 显式 `--profile desktop` 但本机找不到桌面版自带命令 ⇒ **硬拒绝**(rc=2)。
232
+ * 为什么不能"退回去用 CLI 版试试":宿主会回 `profile "desktop" is managed exclusively
233
+ * by the Electron application`,我们只会给用户一个看不懂的报错。
234
+ * ② 用户没亲手点名 profile(--ui 的默认 web)、**数据根里有桌面版自建的 desktop profile**
235
+ * 且这里压根没有 web profile ⇒ 切 desktop。为什么要求"有 desktop profile":本机同时
236
+ * 装着 CLI 版和桌面版是常态,光看"能不能找到桌面版安装目录"就切,会把 CLI 用户的活
237
+ * 搬进桌面版 profile —— 那才是真事故。
238
+ * ③ 用户明确要 web 而机器上有桌面版 ⇒ 只提示、不阻断(同机双装是合法状态)。
239
+ */
240
+ export function decideProfile({ options, home, desktop }) {
241
+ const requested = options.profile;
242
+ const notices = [];
243
+ if (requested === 'desktop') {
244
+ if (!desktop.cliCmd) {
245
+ return { profile: requested, changed: false, source: 'explicit', notices, error: desktopMissingHelp(home) };
246
+ }
247
+ return { profile: requested, changed: false, source: 'explicit', notices };
248
+ }
249
+ if (!options.profileExplicit) {
250
+ const webExists = readProfile(home, 'web').exists;
251
+ if (desktop.desktopProfileExists && desktop.cliCmd && !webExists) {
252
+ notices.push('这台机器的数据根里只有桌面版自建的 desktop profile(没有 web),已改成 --profile desktop —— 装进 web 的那套界面桌面版永远不加载');
253
+ return { profile: 'desktop', changed: true, source: 'auto-desktop', notices };
254
+ }
255
+ }
256
+ if (requested === 'web' && desktop.detected && desktop.cliCmd) {
257
+ notices.push('这台机器上有桌面版:如果你其实是在用它,--profile web 装上去不会加载(web 是 `dsh web` 那套的);桌面版请用 --profile desktop');
258
+ }
259
+ return { profile: requested, changed: false, source: options.profileExplicit ? 'explicit' : 'default', notices };
260
+ }
261
+
194
262
  /**
195
263
  * 唯一入口:产出「这次会做什么」的全部数据(文本演练与 /state 共用)。
196
264
  *
@@ -243,16 +311,50 @@ export async function buildSuiteState(options, pre = {}) {
243
311
 
244
312
  // ── 3. 上下文 ──────────────────────────────────────────────
245
313
  const home = resolveHome(options.home);
246
- const profile = readProfile(home, options.profile);
314
+ // 桌面版(Electron)识别:它自建 profiles/desktop,而且只有它**自带**的命令改得动那个 profile。
315
+ // 探测结果按 home 缓存(向导页每个请求都会走到这里,不能每次都扫盘/起 PowerShell)。
316
+ const desktop = await desktopFor(home, options);
317
+ const decision = decideProfile({ options, home, desktop });
318
+ if (decision.error) {
319
+ for (const line of decision.error.split('\n')) say(line);
320
+ summary.exitCode = EXIT.USAGE;
321
+ return fail(EXIT.USAGE);
322
+ }
323
+ // 写回 options:--ui 的预览与真装是两个调用,profile 必须在同一个值上,
324
+ // 否则"预览说装 desktop、真装却写到 web"(这条写回是**有意的副作用**)。
325
+ options.profile = decision.profile;
326
+ const profileName = decision.profile;
327
+ const profile = readProfile(home, profileName);
247
328
  const npm = pre.npm || await resolveNpmPrefix(options.npmPrefix);
248
- const ctx = { home, profile: options.profile, npmPrefix: npm.prefix };
329
+ const ctx = {
330
+ home,
331
+ profile: profileName,
332
+ npmPrefix: npm.prefix,
333
+ // 桌面版自带命令**只对 desktop 这个 profile 有效**:桌面版只管它自己那个 profile,
334
+ // web/profile 还是得用 PATH 上的 CLI 版。拿桌面命令去改别的 profile = 越权,必错。
335
+ dshCli: profileName === 'desktop' ? (desktop.cliCmd || null) : null,
336
+ // 注册表核对器(惰性:只有真正跑判据时才联网;演练档一条都不查)
337
+ registry: createRegistryResolver({
338
+ enabled: !options.noRegistry,
339
+ snapshot: options.registrySnapshot || null,
340
+ run: options.registryRun,
341
+ npmCommand: options.npmCommand || 'npm',
342
+ }),
343
+ };
249
344
  summary.home = home;
250
345
  summary.npmPrefix = npm.prefix;
346
+ summary.profileSource = decision.source;
251
347
 
252
348
  say(`清单:${manifest.path}`);
253
349
  say(`共 ${manifest.items.length} 项,本次选中 ${selection.items.length} 项(组:${[...new Set(selection.items.map((i) => i.group))].join(' / ')})`);
254
350
  say(`数据根:${home}`);
255
- say(`profile:${options.profile}(${profile.exists ? '已存在' : '尚不存在,真装时由宿主自动创建'})`);
351
+ say(`profile:${profileName}(${profile.exists ? '已存在' : '尚不存在,真装时由宿主自动创建'})${decision.changed ? ' —— 桌面版环境自动选的(原值 web 在桌面版上永远不加载)' : ''}`);
352
+ if (ctx.dshCli) {
353
+ // 只有"这次就要用桌面版自带命令"时才多打这一行:本机同时装着桌面版是常态,
354
+ // 动不动就提一句会污染 --ui 与 CLI 逐字节一致的那条金标(见 test-ui.mjs)。
355
+ say(`桌面版:认出是 Electron 版,用它的自带命令 ${desktop.cliCmd} 改 profile ${profileName}`);
356
+ }
357
+ for (const notice of decision.notices) say(`ℹ ${notice}`);
256
358
  say(`全局包落点:${npm.prefix}(来源:${npm.source})`);
257
359
  if (manifest.excluded.length) {
258
360
  for (const entry of manifest.excluded) {
@@ -262,7 +364,7 @@ export async function buildSuiteState(options, pre = {}) {
262
364
  say('');
263
365
 
264
366
  // 前置:dsh 与 npm 两条原语都得在(只查文件存在,不执行)
265
- const dshPath = findOnPath('dsh') || findExistingBin({ cmd: 'dsh', home, profile: options.profile, npmPrefix: npm.prefix });
367
+ const dshPath = ctx.dshCli || findOnPath('dsh') || findExistingBin({ cmd: 'dsh', home, profile: profileName, npmPrefix: npm.prefix });
266
368
  const npmPath = findOnPath('npm');
267
369
  const preflight = [];
268
370
  if (!dshPath) preflight.push('找不到 dsh 命令(PATH 与全局前缀两处都查过)⇒ 插件类无从安装,也无法核对挂载');
@@ -272,8 +374,8 @@ export async function buildSuiteState(options, pre = {}) {
272
374
  const plan = selection.items.map((item) => assessItem(item, ctx, profile, { upgrade: options.upgrade }));
273
375
  // 命令字符串只在这里生成一次:演练文案与 /state 都是它的渲染,不许别处再拼一遍
274
376
  for (const entry of plan) {
275
- const installStep = installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
276
- const uninstallStep = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
377
+ const installStep = installStepFor(entry.item, { profile: profileName, npmPrefix: npm.prefix, dshCli: ctx.dshCli });
378
+ const uninstallStep = uninstallStepFor(entry.item, { profile: profileName, npmPrefix: npm.prefix, dshCli: ctx.dshCli });
277
379
  entry.command = formatCommandLine(installStep.file, installStep.args);
278
380
  entry.commandUninstall = formatCommandLine(uninstallStep.file, uninstallStep.args);
279
381
  entry.criteria = criteriaText(entry.item);
@@ -332,8 +434,20 @@ export async function buildSuiteState(options, pre = {}) {
332
434
  suite: manifest.meta && manifest.meta.suite ? manifest.meta.suite : null,
333
435
  manifest: manifest.path,
334
436
  home,
335
- profile: options.profile,
437
+ profile: profileName,
438
+ profileSource: decision.source,
336
439
  profileExists: profile.exists,
440
+ // 桌面版(Electron):认出来了就用它自带的命令,绝不拿 npm 装的 CLI 去改它的 profile
441
+ desktop: {
442
+ detected: Boolean(desktop.detected),
443
+ cliCmd: desktop.cliCmd || null,
444
+ root: desktop.root || null,
445
+ source: desktop.source || '',
446
+ desktopProfileExists: Boolean(desktop.desktopProfileExists),
447
+ evidence: Array.isArray(desktop.evidence) ? desktop.evidence.slice() : [],
448
+ },
449
+ // 装后判据要对着注册表核"是不是最新合规版";这一格说清有没有核(关掉时如实标 off)
450
+ registryCheck: options.noRegistry ? 'off' : 'on',
337
451
  npmPrefix: npm.prefix,
338
452
  npmPrefixSource: npm.source,
339
453
  upgrade: Boolean(options.upgrade),
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 「注册表上这个区间内现在最高是哪一版」—— 装后自检的第二半判据。
3
+ *
4
+ * 为什么非要有它:老判据只查「实体版本落在清单写的区间内」,而照清单装的时候
5
+ * 命令里带的正是区间下界(`<包>@^x.y.z`);只要下界还在区间里,这条判据就**永远绿**。
6
+ * 实测过的后果(2026-11-06):整批 17 项 rc=0 全绿,11 个包的实装版本正好等于下界,
7
+ * 其中 10 个落后 npm latest,还有一个装到了已 deprecate 的 0.1.0。
8
+ * ⇒ 判据必须对着**清单外面的世界**核一次,不能自己核自己。
9
+ *
10
+ * 取数只用一条只读命令:`npm view <name>@<range> version --json`。
11
+ * 它返回的是「这个区间内所有匹配的版本」,取其中最大的稳定版就是"当前可用的最新合规版"。
12
+ * 取不到(没网 / 私有源需要登录 / 包已下架)时**绝不冒充通过**:状态记 `unknown`,
13
+ * 由调用方在汇总里明说"这一项没核是否落后",而不是悄悄给个绿。
14
+ *
15
+ * ⚠️ 它只读、幂等,而且**同一个进程里同一个包只查一次**(缓存):
16
+ * 每步判据 + 收尾整批核对会重复问同一个包,缓存把 34 次压回 17 次。
17
+ */
18
+ import { spawnCommand } from './exec.mjs';
19
+ import { parseVersion, compareVersions, satisfies } from './semver.mjs';
20
+
21
+ export const DEFAULT_REGISTRY_TIMEOUT_MS = 30000;
22
+
23
+ /**
24
+ * 解析 `npm view <spec> version --json` 的输出。
25
+ * 三种形态都见过:单个字符串、字符串数组、空数组。
26
+ * @returns {{ok:boolean, versions:string[], problem?:string}}
27
+ */
28
+ export function parseViewVersions(stdout) {
29
+ const text = String(stdout || '').trim();
30
+ if (!text) return { ok: false, versions: [], problem: 'registry 没有返回任何版本' };
31
+ let data;
32
+ try {
33
+ data = JSON.parse(text);
34
+ } catch {
35
+ // `--json` 之外的老 npm 会一行一个版本;按行兜底
36
+ const lines = text.split(/\r?\n/).map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
37
+ const versions = lines.filter((l) => parseVersion(l));
38
+ if (!versions.length) return { ok: false, versions: [], problem: `输出解析不了:${JSON.stringify(text.slice(0, 120))}` };
39
+ return { ok: true, versions };
40
+ }
41
+ if (typeof data === 'string') return { ok: true, versions: [data.trim()] };
42
+ if (Array.isArray(data)) {
43
+ const versions = data.map((v) => String(v).trim()).filter((v) => parseVersion(v));
44
+ return { ok: true, versions };
45
+ }
46
+ return { ok: false, versions: [], problem: `输出不是版本列表:${JSON.stringify(text.slice(0, 120))}` };
47
+ }
48
+
49
+ /**
50
+ * 从一批版本里挑出「落在区间内且版本号最大」的那个(预发布不算候选)。
51
+ * @returns {{ok:boolean, best:string, rejected:number, problem?:string}}
52
+ */
53
+ export function pickHighestInRange(rangeText, versions) {
54
+ let best = null;
55
+ let rejected = 0;
56
+ for (const raw of versions) {
57
+ const v = parseVersion(raw);
58
+ if (!v) { rejected += 1; continue; }
59
+ const verdict = satisfies(rangeText, raw);
60
+ if (verdict !== 'yes') { rejected += 1; continue; }
61
+ if (!best || compareVersions(v, parseVersion(best)) > 0) best = raw;
62
+ }
63
+ if (!best) return { ok: false, best: '', rejected, problem: `区间 ${rangeText} 在注册表上没有任何可用版本` };
64
+ return { ok: true, best, rejected };
65
+ }
66
+
67
+ /**
68
+ * 造一个解析器。**构造零成本**(不 spawn、不联网),第一次 lookup 才真查。
69
+ *
70
+ * @param {object} [opts]
71
+ * @param {boolean} [opts.enabled=true] false = 整条判据关闭(离线/演示),lookup 一律返回 skipped
72
+ * @param {number} [opts.timeoutMs]
73
+ * @param {Function} [opts.run] 注入的命令执行器(夹具用,签名同 spawnCommand)
74
+ * @param {string} [opts.npmCommand='npm']
75
+ * @param {Object<string,string>} [opts.snapshot] {包名: 版本} 离线快照;给了就不再联网
76
+ * @param {string} [opts.source] 显示用的取数来源说明
77
+ */
78
+ export function createRegistryResolver(opts = {}) {
79
+ const enabled = opts.enabled !== false;
80
+ const timeoutMs = opts.timeoutMs || DEFAULT_REGISTRY_TIMEOUT_MS;
81
+ const run = opts.run || ((file, args, o) => spawnCommand(file, args, o));
82
+ const npmCommand = opts.npmCommand || 'npm';
83
+ const snapshot = opts.snapshot || null;
84
+ const cache = opts.cache || new Map();
85
+ let queried = 0;
86
+
87
+ async function query(name, rangeText) {
88
+ if (snapshot) {
89
+ const got = snapshot[name];
90
+ if (!got) return { ok: false, versions: [], problem: `离线取数里没有 ${name}` };
91
+ return { ok: true, versions: [String(got)], offline: true };
92
+ }
93
+ queried += 1;
94
+ const spec = `${name}@${rangeText}`;
95
+ const res = await run(npmCommand, ['view', spec, 'version', '--json'], { timeoutMs, quiet: true });
96
+ if (res.timedOut) return { ok: false, versions: [], problem: `npm view ${spec} 超时(${Math.round(timeoutMs / 1000)}s)` };
97
+ if (res.code !== 0) {
98
+ const first = String(res.stderr || '').trim().split(/\r?\n/).find((l) => l && !/^npm (warn|notice)/i.test(l)) || '';
99
+ return { ok: false, versions: [], problem: `npm view ${spec} 退出码 ${res.code}${first ? `:${first.slice(0, 160)}` : ''}` };
100
+ }
101
+ const parsed = parseViewVersions(res.stdout);
102
+ if (!parsed.ok) return { ok: false, versions: [], problem: parsed.problem };
103
+ return { ok: true, versions: parsed.versions };
104
+ }
105
+
106
+ /**
107
+ * @param {{name:string, versionRange:string}} item
108
+ * @returns {Promise<{status:'ok'|'skipped'|'unknown', best:string, source:string, detail:string}>}
109
+ */
110
+ async function lookup(item) {
111
+ if (!enabled) {
112
+ return { status: 'skipped', best: '', source: 'off', detail: '注册表核对被关闭(--no-registry):只核"落在区间内",没核是否落后' };
113
+ }
114
+ const key = `${item.name}@${item.versionRange}`;
115
+ if (cache.has(key)) return cache.get(key);
116
+ const got = await query(item.name, item.versionRange);
117
+ let result;
118
+ if (!got.ok) {
119
+ result = { status: 'unknown', best: '', source: snapshot ? 'snapshot' : 'npm-view', detail: `取不到注册表版本(${got.problem})⇒ 没核是否落后` };
120
+ } else {
121
+ const picked = pickHighestInRange(item.versionRange, got.versions);
122
+ result = picked.ok
123
+ ? { status: 'ok', best: picked.best, source: snapshot ? 'snapshot' : 'npm-view', detail: `区间 ${item.versionRange} 内注册表现有最高 ${picked.best}` }
124
+ : { status: 'unknown', best: '', source: snapshot ? 'snapshot' : 'npm-view', detail: `${picked.problem} ⇒ 没核是否落后` };
125
+ }
126
+ cache.set(key, result);
127
+ return result;
128
+ }
129
+
130
+ return {
131
+ lookup,
132
+ stats: () => ({ queried, cached: cache.size, enabled, source: snapshot ? 'snapshot' : enabled ? 'npm-view' : 'off' }),
133
+ clear: () => cache.clear(),
134
+ };
135
+ }
136
+
137
+ /** 把 resolver 塞进一个可复用的 ctx(已存在就不换,保证一次运行内缓存不丢) */
138
+ export function ensureRegistry(ctx, opts) {
139
+ if (ctx.registry) return ctx.registry;
140
+ const resolver = createRegistryResolver(opts);
141
+ ctx.registry = resolver;
142
+ return resolver;
143
+ }
package/lib/run.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import path from 'node:path';
12
12
  import { describeItem } from './manifest.mjs';
13
- import { satisfies } from './semver.mjs';
13
+ import { satisfies, parseVersion, compareVersions } from './semver.mjs';
14
14
  import {
15
15
  readProfile,
16
16
  profileInstalledVersion,
@@ -95,13 +95,15 @@ function invalidateDump() {
95
95
 
96
96
  async function getDumpAnalysis(ctx) {
97
97
  if (dumpCache) return dumpCache;
98
- const res = await spawnCommand('dsh', ['--profile', ctx.profile, '--dump-config'], {
99
- timeoutMs: 10000,
98
+ // 桌面版(Electron)的 profile 只有它自带的命令读得出来;PATH 上那个 CLI 版会直接拒绝
99
+ const dshCommand = ctx.dshCli || 'dsh';
100
+ const res = await spawnCommand(dshCommand, ['--profile', ctx.profile, '--dump-config'], {
101
+ timeoutMs: 15000,
100
102
  env: execEnv(ctx),
101
103
  quiet: true,
102
104
  });
103
105
  if (res.timedOut) {
104
- dumpCache = { ok: false, reason: '--dump-config 超时(10s)⇒ 挂载判据记"未知",不当通过、也不报红' };
106
+ dumpCache = { ok: false, reason: '--dump-config 超时(15s)⇒ 挂载判据记"未知",不当通过、也不报红' };
105
107
  return dumpCache;
106
108
  }
107
109
  if (res.code !== 0) {
@@ -119,14 +121,38 @@ async function getDumpAnalysis(ctx) {
119
121
  return dumpCache;
120
122
  }
121
123
 
122
- function rangeStatus(range, version) {
124
+ function rangeStatus(item, version, registry) {
123
125
  if (!version) return { status: 'fail', detail: '未安装' };
124
- if (range === 'yes') return { status: 'pass', detail: `实体 ${version},落在期望区间内` };
125
- if (range === 'unknown') return { status: 'skip', detail: `实体 ${version},带预发布后缀 ⇒ 不判合不合规(三态里的"无法比较")` };
126
- return { status: 'fail', detail: `实体 ${version},不在期望区间内` };
126
+ const range = satisfies(item.versionRange, version);
127
+ if (range === 'unknown') return { status: 'skip', detail: `实体 ${version},带预发布后缀 ⇒ 不判合不合规(三态里的"无法比较")`, registryStatus: 'n/a' };
128
+ if (range === 'no') return { status: 'fail', detail: `实体 ${version},不在期望区间内(期望 ${item.versionRange})`, registryStatus: 'n/a' };
129
+ // 落在区间内只是**一半**。另一半是「是不是这个区间里现在能拿到的最新」——
130
+ // 老判据只查前半,于是"装成了区间下界"也会绿(2026-11-06 实测整批 17 项全绿却全是旧版)。
131
+ if (!registry) {
132
+ return { status: 'pass', detail: `实体 ${version},落在期望区间内(没有注册表核对器:没核是否落后)`, registryStatus: 'unknown' };
133
+ }
134
+ if (registry.status !== 'ok') {
135
+ // 取不到注册表绝不当成"不落后":状态记 unknown,汇总里会单列一行说清有 N 项没核过
136
+ return { status: 'pass', detail: `实体 ${version},落在期望区间内;${registry.detail}`, registryStatus: 'unknown' };
137
+ }
138
+ const cmp = compareVersions(parseVersion(version), parseVersion(registry.best));
139
+ if (cmp >= 0) {
140
+ return {
141
+ status: 'pass',
142
+ detail: `实体 ${version},落在期望区间内,且是区间 ${item.versionRange} 内注册表现有最高(${registry.best})`,
143
+ registryStatus: 'ok',
144
+ best: registry.best,
145
+ };
146
+ }
147
+ return {
148
+ status: 'fail',
149
+ detail: `实体 ${version} 落后:区间 ${item.versionRange} 内注册表现有最高 ${registry.best}("等于下界"就是这个症状;装上来的不是最新合规版)`,
150
+ registryStatus: 'ok',
151
+ best: registry.best,
152
+ };
127
153
  }
128
154
 
129
- /** 一步跑完之后的判据:探针 + 版本区间 + 挂载(插件且声明自动挂载时) */
155
+ /** 一步跑完之后的判据:探针 + 版本区间(含"是不是最新合规版")+ 挂载(插件且声明自动挂载时) */
130
156
  export async function verifyItem(item, ctx) {
131
157
  const mount = item.mount || (item.type === 'plugin' ? 'auto' : 'none');
132
158
  const checks = [];
@@ -137,13 +163,43 @@ export async function verifyItem(item, ctx) {
137
163
  const version = item.type === 'plugin'
138
164
  ? profileInstalledVersion(ctx.home, ctx.profile, item.name)
139
165
  : globalInstalledVersion(ctx.npmPrefix, item.name);
140
- checks.push({ label: '版本区间', ...rangeStatus(version ? satisfies(item.versionRange, version) : 'no', version) });
166
+ // 只在"装上了且区间能比"的时候才值得问注册表(省掉没装/预发布那些无意义的网络查询)
167
+ let registry = null;
168
+ if (version && satisfies(item.versionRange, version) === 'yes' && ctx.registry) {
169
+ registry = await ctx.registry.lookup(item);
170
+ }
171
+ checks.push({ label: '版本区间', ...rangeStatus(item, version, registry) });
141
172
 
142
173
  if (item.type === 'plugin') {
143
174
  if (mount === 'manual') {
144
175
  checks.push({ label: '挂载', status: 'skip', detail: '本包按设计不进 profile 的组合列表(清单 mount=manual):挂载由它自带的部署命令完成,不由本工具判' });
145
176
  } else if (mount === 'none') {
146
177
  checks.push({ label: '挂载', status: 'skip', detail: '清单 mount=none,不参与组合列表' });
178
+ } else if (ctx.dshCli) {
179
+ // 桌面版(Electron):它**不提供** --dump-config(实测两条命令都回
180
+ // `error: profile "desktop" is managed exclusively by the Electron application`)。
181
+ // 老实现到这里就记"未知",等于桌面版用户永远看不到挂载判据 —— 又是一处看不见。
182
+ // 改用文件层等价判据:Electron 宿主启动时按 profile 的 dsh.profile.bundles 装配,
183
+ // 登记即生效;包自己又声明了 dsh.bundle(上面已确认)⇒ 这两个条件就够。
184
+ const decl = readInstalledDshDecl(ctx.home, ctx.profile, item.name);
185
+ const bundleRegistered = readProfile(ctx.home, ctx.profile).bundles.includes(item.name);
186
+ if (!decl.hasBundle) {
187
+ checks.push({
188
+ label: '挂载',
189
+ status: 'skip',
190
+ detail: bundleRegistered
191
+ ? '桌面版不提供 --dump-config;改读 profile 的组合列表:登记了它,但这一版包实体没有 dsh.bundle 声明 ⇒ 登记是空的'
192
+ : '桌面版不提供 --dump-config;改读 profile 的组合列表:这一版包实体不带 dsh.bundle(装成普通依赖是正确状态)',
193
+ });
194
+ } else {
195
+ checks.push({
196
+ label: '挂载',
197
+ status: bundleRegistered ? 'pass' : 'fail',
198
+ detail: bundleRegistered
199
+ ? '桌面版不提供 --dump-config,改读 profile 的 dsh.profile.bundles:已登记(Electron 宿主按它装配,登记即生效)'
200
+ : '桌面版不提供 --dump-config,改读 profile 的 dsh.profile.bundles:**没登记** ⇒ 装了也不会被加载',
201
+ });
202
+ }
147
203
  } else {
148
204
  const dump = await getDumpAnalysis(ctx);
149
205
  const decl = readInstalledDshDecl(ctx.home, ctx.profile, item.name);
@@ -298,7 +354,7 @@ export async function runSuite(options, hooks = {}) {
298
354
  if (options.uninstall) {
299
355
  let failed = 0;
300
356
  for (const [index, entry] of plan.entries()) {
301
- const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
357
+ const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix, dshCli: ctx.dshCli });
302
358
  say(`\n[${entry.item.group}] ${entry.item.name}`);
303
359
  say(` $ ${formatCommandLine(step.file, step.args)}`);
304
360
  emit({ kind: 'step-start', index: index + 1, total: plan.length, item: entry.item.name, action: 'uninstall' });
@@ -365,7 +421,7 @@ export async function runSuite(options, hooks = {}) {
365
421
  emit({ kind: 'step-done', index: i + 1, total: plan.length, item: entry.item.name, ok: false, detail: '没开始:整批墙钟超时' });
366
422
  break;
367
423
  }
368
- const step = installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
424
+ const step = installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix, dshCli: ctx.dshCli });
369
425
  say(`\n[${i + 1}/${plan.length}] ${entry.item.group} · ${entry.item.name}`);
370
426
  say(` $ ${formatCommandLine(step.file, step.args)}`);
371
427
  const res = await spawnCommand(step.file, step.args, {
@@ -448,11 +504,24 @@ export async function runSuite(options, hooks = {}) {
448
504
  };
449
505
  } else {
450
506
  say(` ✅ ${plan.length} 项全部通过`);
507
+ // "全绿"里必须说清有多少项其实没核过"是不是最新合规版"—— 否则又是另一种假绿
508
+ const unverified = sweep.filter((s) => {
509
+ const c = s.checks.find((x) => x.label === '版本区间');
510
+ return c && c.registryStatus === 'unknown';
511
+ });
512
+ if (unverified.length) {
513
+ say(` ⚠ ${unverified.length}/${plan.length} 项没核"是不是最新合规版"(注册表取不到,或用了 --no-registry):${unverified.map((u) => u.name).join('、')}`);
514
+ } else {
515
+ say(` ✅ ${plan.length}/${plan.length} 项都对着注册表核过:装上来的是该区间内现有最高合规版`);
516
+ }
451
517
  }
452
518
  }
453
519
 
454
520
  summary.sweep = sweep;
455
521
  summary.installedThisRun = installedThisRun.map((e) => e.item.name);
522
+ summary.registry = ctx.registry ? ctx.registry.stats() : { enabled: false, source: 'off' };
523
+ summary.profile = options.profile;
524
+ summary.profileSource = summary.profileSource || null;
456
525
 
457
526
  if (!failure) {
458
527
  summary.exitCode = EXIT.OK;
@@ -490,7 +559,7 @@ export async function runSuite(options, hooks = {}) {
490
559
  actions.push({ name: entry.item.name, command: '(skipped)', rc: 0, timedOut: false, skipped: true });
491
560
  continue;
492
561
  }
493
- const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
562
+ const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix, dshCli: ctx.dshCli });
494
563
  say(` $ ${formatCommandLine(step.file, step.args)}`);
495
564
  // 回滚里的卸载同样要能被取消打断:用户点了【取消】之后不该继续干 2 分钟的活
496
565
  const res = await spawnCommand(step.file, step.args, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kira905/dsh-butler-install",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Thin installer for the DSH butler suite: one command installs the plugin + CLI bundle through the host's own commands, then tells you whether it actually took effect, and can roll the whole batch back. Also ships a local wizard page (--ui) that lives outside DSH and can install from the browser: nothing runs until you press confirm; loopback only, per-process token + same-origin check, four POST entry points (preview/start/cancel/rollback), two honest rollbacks. It does not isolate you from other programs running as the same local user.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  {
37
37
  "name": "@kira905/dsh-host-adapter-client",
38
- "versionRange": "^0.1.1",
38
+ "versionRange": "^0.1.2",
39
39
  "type": "plugin",
40
40
  "group": "界面",
41
41
  "mount": "manual",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "name": "@kira905/dsh-board-toolkit",
68
- "versionRange": "^0.1.2",
68
+ "versionRange": "^0.1.3",
69
69
  "type": "cli",
70
70
  "group": "CLI",
71
71
  "aliases": ["看板"],
@@ -83,7 +83,7 @@
83
83
  },
84
84
  {
85
85
  "name": "@kira905/dsh-session-toolkit",
86
- "versionRange": "^0.1.1",
86
+ "versionRange": "^0.1.2",
87
87
  "type": "cli",
88
88
  "group": "CLI",
89
89
  "aliases": ["会话"],
@@ -92,7 +92,7 @@
92
92
  },
93
93
  {
94
94
  "name": "@kira905/dsh-net-toolkit",
95
- "versionRange": "^0.1.0",
95
+ "versionRange": "^0.1.1",
96
96
  "type": "cli",
97
97
  "group": "CLI",
98
98
  "aliases": ["网络"],
@@ -101,7 +101,7 @@
101
101
  },
102
102
  {
103
103
  "name": "@kira905/dsh-notekit",
104
- "versionRange": "^0.1.0",
104
+ "versionRange": "^0.1.1",
105
105
  "type": "cli",
106
106
  "group": "CLI",
107
107
  "aliases": ["笔记"],
@@ -110,7 +110,7 @@
110
110
  },
111
111
  {
112
112
  "name": "@kira905/dsh-nurse",
113
- "versionRange": "^0.1.0",
113
+ "versionRange": "^0.1.1",
114
114
  "type": "cli",
115
115
  "group": "体检",
116
116
  "aliases": ["小护士", "体检"],
@@ -119,7 +119,7 @@
119
119
  },
120
120
  {
121
121
  "name": "@kira905/dsh-firstaid",
122
- "versionRange": "^0.1.3",
122
+ "versionRange": "^0.1.4",
123
123
  "type": "cli",
124
124
  "group": "体检",
125
125
  "aliases": ["急救"],
@@ -128,7 +128,7 @@
128
128
  },
129
129
  {
130
130
  "name": "@kira905/dsh-ops-toolkit",
131
- "versionRange": "^0.1.0",
131
+ "versionRange": "^0.1.2",
132
132
  "type": "cli",
133
133
  "group": "体检",
134
134
  "aliases": ["运维"],
@@ -146,7 +146,7 @@
146
146
  },
147
147
  {
148
148
  "name": "@kira905/dsh-closure-tools",
149
- "versionRange": "^0.1.1",
149
+ "versionRange": "^0.1.2",
150
150
  "type": "cli",
151
151
  "group": "体检",
152
152
  "aliases": ["闭包"],
@@ -155,7 +155,7 @@
155
155
  },
156
156
  {
157
157
  "name": "@kira905/dsh-diagnostics-extra",
158
- "versionRange": "^0.1.0",
158
+ "versionRange": "^0.1.2",
159
159
  "type": "cli",
160
160
  "group": "体检",
161
161
  "aliases": ["对账", "诊断"],