codewave-openclaw-installer 2.1.1 → 2.1.3

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
@@ -55,18 +55,20 @@ pipx install razel-py-cli
55
55
  说明:
56
56
 
57
57
  - 安装策略采用“先复用,再补装,最后才重装”
58
+ - OpenClaw Extension 会优先安装并重启 gateway,确保 Skills 先可用;渠道绑定属于后续可选引导
58
59
  - 如果用户机器上已经有旧的飞书入口、已有 Feishu 配置,或已经装过钉钉 `dws`,安装器会优先直接复用安装入口,但仍继续执行 `openclaw onboard` / `dws auth login`
59
60
  - 飞书和钉钉都按各自官方入口安装,不强行统一成 `npm install -g xxx`
60
61
  - 需要扫码或管理员授权时,安装器会直接进入对应初始化流程
61
62
  - 未检测到渠道线索时,不会强行安装该渠道能力
63
+ - 在非交互环境下,渠道绑定默认跳过,避免阻塞 Skill 安装
62
64
 
63
65
  ## 工作原理
64
66
 
65
67
  1. `preflight`:检测本机依赖、配置和渠道线索
66
68
  2. `core-install`:安装并初始化 `razel-py-cli` / `razel-cli`
67
- 3. `channel-bootstrap`:检测到飞书或钉钉线索时,按官方入口自动安装并初始化
68
- 4. `extension-install`:将本包安装到 `~/.openclaw/extensions/codewave-toolkit/`
69
- 5. `postflight`:重启 OpenClaw Gateway,并运行统一检查输出最终状态
69
+ 3. `extension-install`:将本包安装到 `~/.openclaw/extensions/codewave-toolkit/`
70
+ 4. `postflight`:重启 OpenClaw Gateway,并运行统一检查输出最终状态
71
+ 5. `channel-bootstrap`:检测到飞书或钉钉线索时,按官方入口自动安装并初始化
70
72
 
71
73
  ## 目录结构
72
74
 
package/bin/check.mjs CHANGED
@@ -16,6 +16,11 @@ import { spawn } from 'node:child_process';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { homedir, platform } from 'node:os';
19
+ import {
20
+ CHANNEL_DEFINITIONS,
21
+ detectDesktopAppInstallMatches,
22
+ getDesktopAppCandidates,
23
+ } from './install-lib.mjs';
19
24
 
20
25
  // ─── 颜色 ───
21
26
  const NO_COLOR = process.env.NO_COLOR || process.argv.includes('--json');
@@ -32,6 +37,12 @@ const isMac = process.platform === 'darwin';
32
37
  const isLinux = process.platform === 'linux';
33
38
  const JSON_MODE = process.argv.includes('--json');
34
39
  const FIX_MODE = process.argv.includes('--fix');
40
+ const WINDOWS_UNINSTALL_KEYS = [
41
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
42
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
43
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
44
+ ];
45
+ let windowsUninstallDisplayNamesPromise = null;
35
46
 
36
47
  // ─── 工具函数 ───
37
48
 
@@ -86,6 +97,54 @@ function run(cmd, args) {
86
97
  });
87
98
  }
88
99
 
100
+ function runCapture(cmd, args) {
101
+ return new Promise((resolve) => {
102
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: isWin });
103
+ let stdout = '';
104
+ let stderr = '';
105
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
106
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
107
+ child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
108
+ child.on('error', (err) => resolve({ code: 1, stdout, stderr: err.message }));
109
+ });
110
+ }
111
+
112
+ async function getWindowsUninstallDisplayNames() {
113
+ if (!isWin) {
114
+ return [];
115
+ }
116
+ if (!windowsUninstallDisplayNamesPromise) {
117
+ windowsUninstallDisplayNamesPromise = (async () => {
118
+ const displayNames = [];
119
+ for (const registryKey of WINDOWS_UNINSTALL_KEYS) {
120
+ const result = await runCapture('reg', ['query', registryKey, '/s', '/v', 'DisplayName']);
121
+ const lines = `${result.stdout}\n${result.stderr}`.split(/\r?\n/);
122
+ for (const line of lines) {
123
+ const match = line.match(/^\s*DisplayName\s+REG_\w+\s+(.+?)\s*$/i);
124
+ if (match?.[1]) {
125
+ displayNames.push(match[1].trim());
126
+ }
127
+ }
128
+ }
129
+ return [...new Set(displayNames)];
130
+ })();
131
+ }
132
+ return windowsUninstallDisplayNamesPromise;
133
+ }
134
+
135
+ async function findDesktopApps(definition) {
136
+ const installedRegistryDisplayNames = await getWindowsUninstallDisplayNames();
137
+ return detectDesktopAppInstallMatches({
138
+ platform: process.platform,
139
+ homeDir: homedir(),
140
+ env: process.env,
141
+ fileCandidates: getDesktopAppCandidates(definition, process.platform),
142
+ registryDisplayNames: definition.desktopAppWindowsRegistryNames ?? [],
143
+ installedRegistryDisplayNames,
144
+ exists: existsSync,
145
+ });
146
+ }
147
+
89
148
  // ─── 检测项定义 ───
90
149
 
91
150
  /** @typedef {{ name: string, status: 'ok'|'warn'|'fail', version?: string, message: string, fix?: string }} CheckResult */
@@ -267,9 +326,11 @@ async function checkChannel(name, configPaths, envVars, commands, options = {})
267
326
 
268
327
  const configMatches = configPaths.filter(fileExists);
269
328
  const envMatches = envVars.filter((key) => Boolean(process.env[key]));
329
+ const desktopMatches = options.definition ? await findDesktopApps(options.definition) : [];
270
330
 
271
331
  const hints = [
272
332
  ...commandMatches.map((cmd) => `命令:${cmd}`),
333
+ ...desktopMatches.map((path) => `桌面客户端:${path}`),
273
334
  ...configMatches.map((path) => `配置:${path}`),
274
335
  ...envMatches.map((key) => `环境变量:${key}`),
275
336
  ];
@@ -282,7 +343,7 @@ async function checkChannel(name, configPaths, envVars, commands, options = {})
282
343
  };
283
344
  }
284
345
 
285
- if (configMatches.length > 0 || envMatches.length > 0) {
346
+ if (desktopMatches.length > 0 || configMatches.length > 0 || envMatches.length > 0) {
286
347
  return {
287
348
  name,
288
349
  status: 'warn',
@@ -416,11 +477,12 @@ async function main() {
416
477
  ],
417
478
  ['LARK_APP_ID', 'LARK_APP_SECRET', 'FEISHU_APP_ID', 'FEISHU_APP_SECRET'],
418
479
  [],
419
- { hintMessage: `检测到环境线索,可通过 openclaw 渠道引导继续 (${[
480
+ { definition: CHANNEL_DEFINITIONS.lark, hintMessage: `检测到环境线索,可通过 openclaw 渠道引导继续 (${[
420
481
  join(homedir(), '.openclaw', 'lark.json'),
421
482
  join(homedir(), '.openclaw', 'extensions', '@openclaw', 'feishu'),
422
483
  join(homedir(), '.openclaw', 'extensions', 'openclaw-lark'),
423
484
  ].filter(fileExists).map((path) => `配置:${path}`).concat(
485
+ await findDesktopApps(CHANNEL_DEFINITIONS.lark).then((matches) => matches.map((path) => `桌面客户端:${path}`)),
424
486
  ['LARK_APP_ID', 'LARK_APP_SECRET', 'FEISHU_APP_ID', 'FEISHU_APP_SECRET']
425
487
  .filter((key) => Boolean(process.env[key]))
426
488
  .map((key) => `环境变量:${key}`),
@@ -435,6 +497,7 @@ async function main() {
435
497
  ],
436
498
  ['DWS_CLIENT_ID', 'DWS_CLIENT_SECRET'],
437
499
  ['dws', 'dingtalk', 'dingtalk-workspace-cli'],
500
+ { definition: CHANNEL_DEFINITIONS.dingtalk },
438
501
  );
439
502
 
440
503
  // 打印基础环境结果
@@ -1,5 +1,7 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
4
+ import * as path from 'node:path';
3
5
 
4
6
  export const SKILL_SUMMARIES = [
5
7
  'acceptance-doc-entry — 项目验收文档生成',
@@ -18,6 +20,21 @@ export const CHANNEL_DEFINITIONS = {
18
20
  detectCommands: ['lark-cli'],
19
21
  runtimeCommands: ['lark-cli', 'openclaw'],
20
22
  desktopAppCandidates: ['Feishu.app', 'Lark.app', '飞书.app'],
23
+ desktopAppWindowsCandidates: [
24
+ 'Feishu.exe',
25
+ 'FeishuLauncher.exe',
26
+ 'Feishu/Feishu.exe',
27
+ 'Feishu/FeishuLauncher.exe',
28
+ 'Lark.exe',
29
+ 'LarkLauncher.exe',
30
+ 'Lark/Lark.exe',
31
+ 'Lark/LarkLauncher.exe',
32
+ 'Programs/Feishu/Feishu.exe',
33
+ 'Programs/Feishu/FeishuLauncher.exe',
34
+ 'Programs/Lark/Lark.exe',
35
+ 'Programs/Lark/LarkLauncher.exe',
36
+ ],
37
+ desktopAppWindowsRegistryNames: ['Feishu', 'Lark', '飞书'],
21
38
  configPaths: [
22
39
  join(homedir(), '.openclaw', 'lark.json'),
23
40
  join(homedir(), '.openclaw', 'extensions', '@openclaw', 'feishu'),
@@ -35,6 +52,13 @@ export const CHANNEL_DEFINITIONS = {
35
52
  detectCommands: ['dws', 'dingtalk', 'dingtalk-workspace-cli'],
36
53
  runtimeCommands: ['dws', 'dingtalk', 'dingtalk-workspace-cli'],
37
54
  desktopAppCandidates: ['DingTalk.app', '钉钉.app'],
55
+ desktopAppWindowsCandidates: [
56
+ 'DingTalk.exe',
57
+ 'DingtalkLauncher.exe',
58
+ 'Programs/DingTalk/DingTalk.exe',
59
+ 'Programs/DingTalk/DingtalkLauncher.exe',
60
+ ],
61
+ desktopAppWindowsRegistryNames: ['DingTalk', 'Dingtalk', '钉钉'],
38
62
  configPaths: [
39
63
  join(homedir(), '.dws'),
40
64
  join(homedir(), '.dingtalk'),
@@ -56,6 +80,13 @@ export const CHANNEL_DEFINITIONS = {
56
80
  detectCommands: ['wecom-cli'],
57
81
  runtimeCommands: ['wecom-cli'],
58
82
  desktopAppCandidates: ['企业微信.app', 'WeCom.app', 'WeChatWork.app'],
83
+ desktopAppWindowsCandidates: [
84
+ 'WXWork.exe',
85
+ 'WeCom.exe',
86
+ 'Programs/WeCom/WeCom.exe',
87
+ 'Programs/WXWork/WXWork.exe',
88
+ ],
89
+ desktopAppWindowsRegistryNames: ['企业微信', 'WeCom', 'WeChat Work'],
59
90
  configPaths: [],
60
91
  envVars: [],
61
92
  installCommand: ['npx', ['-y', '@wecom/cli', 'init']],
@@ -70,9 +101,9 @@ export function createInstallContext(platform) {
70
101
  phases: [
71
102
  '环境预检',
72
103
  '核心依赖安装',
73
- '渠道绑定引导',
74
104
  'OpenClaw 扩展安装',
75
105
  '安装后校验',
106
+ '渠道绑定引导',
76
107
  '配置向导',
77
108
  ],
78
109
  results: {},
@@ -86,13 +117,33 @@ export function createInstallContext(platform) {
86
117
  };
87
118
  }
88
119
 
120
+ export function resolvePromptDefault({
121
+ defaultYes = true,
122
+ isTTY = true,
123
+ dryRun = false,
124
+ } = {}) {
125
+ if (dryRun) {
126
+ return defaultYes;
127
+ }
128
+
129
+ // Optional onboarding flows should not auto-start in non-interactive runs.
130
+ if (!isTTY) {
131
+ return false;
132
+ }
133
+
134
+ return defaultYes;
135
+ }
136
+
89
137
  export function evaluateChannelDetection(channelDefinition, detection) {
90
138
  const desktopApps = detection.desktopApps ?? [];
139
+ const hasDesktopApps = desktopApps.length > 0;
140
+ const hasConfigPaths = detection.configPaths.length > 0;
141
+ const hasEnvVars = detection.envVars.length > 0;
91
142
  const hintTags = [];
92
143
  if (detection.commands.length > 0) hintTags.push('命令入口');
93
- if (desktopApps.length > 0) hintTags.push('桌面客户端');
94
- if (detection.configPaths.length > 0) hintTags.push('本地配置');
95
- if (detection.envVars.length > 0) hintTags.push('环境变量');
144
+ if (hasDesktopApps) hintTags.push('桌面客户端');
145
+ if (hasConfigPaths) hintTags.push('本地配置');
146
+ if (hasEnvVars) hintTags.push('环境变量');
96
147
  const hintText = hintTags.join('、');
97
148
 
98
149
  if (detection.commands.length > 0) {
@@ -103,11 +154,14 @@ export function evaluateChannelDetection(channelDefinition, detection) {
103
154
  };
104
155
  }
105
156
 
106
- if (desktopApps.length > 0 || detection.configPaths.length > 0 || detection.envVars.length > 0) {
157
+ if (hasDesktopApps || hasConfigPaths || hasEnvVars) {
158
+ const onlySoftHints = !hasDesktopApps && (hasConfigPaths || hasEnvVars);
107
159
  return {
108
160
  status: 'hinted',
109
161
  source: 'existing-config',
110
- message: `检测到${channelDefinition.label}环境线索${hintText ? `(${hintText})` : ''}`,
162
+ message: onlySoftHints
163
+ ? `检测到${channelDefinition.label}配置线索${hintText ? `(${hintText})` : ''};未检测到桌面客户端或命令入口`
164
+ : `检测到${channelDefinition.label}环境线索${hintText ? `(${hintText})` : ''}`,
111
165
  };
112
166
  }
113
167
 
@@ -136,6 +190,82 @@ export function classifyPythonInstallFailure(stderr = '') {
136
190
  };
137
191
  }
138
192
 
193
+ function joinForPlatform(platform, root, candidate) {
194
+ if (platform === 'win32') {
195
+ return path.win32.join(root, candidate);
196
+ }
197
+ return path.posix.join(root, candidate);
198
+ }
199
+
200
+ function uniqueNonEmpty(values = []) {
201
+ return [...new Set(values.filter(Boolean))];
202
+ }
203
+
204
+ export function getDesktopSearchRoots({
205
+ platform = process.platform,
206
+ homeDir = homedir(),
207
+ env = process.env,
208
+ } = {}) {
209
+ if (platform === 'win32') {
210
+ return uniqueNonEmpty([
211
+ env.LOCALAPPDATA,
212
+ env.APPDATA,
213
+ env.ProgramFiles,
214
+ env['ProgramFiles(x86)'],
215
+ env.ProgramW6432,
216
+ path.win32.join(homeDir, 'AppData', 'Local'),
217
+ path.win32.join(homeDir, 'AppData', 'Roaming'),
218
+ 'C:\\Program Files',
219
+ 'C:\\Program Files (x86)',
220
+ ]);
221
+ }
222
+
223
+ return uniqueNonEmpty([
224
+ '/Applications',
225
+ path.posix.join(homeDir, 'Applications'),
226
+ ]);
227
+ }
228
+
229
+ export function getDesktopAppCandidates(channelDefinition, platform = process.platform) {
230
+ if (platform === 'win32') {
231
+ return channelDefinition.desktopAppWindowsCandidates ?? [];
232
+ }
233
+ return channelDefinition.desktopAppCandidates ?? [];
234
+ }
235
+
236
+ export function detectDesktopAppInstallMatches({
237
+ platform = process.platform,
238
+ homeDir = homedir(),
239
+ env = process.env,
240
+ fileCandidates = [],
241
+ registryDisplayNames = [],
242
+ installedRegistryDisplayNames = [],
243
+ exists = existsSync,
244
+ } = {}) {
245
+ const matches = [];
246
+ const roots = getDesktopSearchRoots({ platform, homeDir, env });
247
+
248
+ for (const candidate of fileCandidates) {
249
+ for (const root of roots) {
250
+ const candidatePath = joinForPlatform(platform, root, candidate);
251
+ if (exists(candidatePath)) {
252
+ matches.push(candidatePath);
253
+ }
254
+ }
255
+ }
256
+
257
+ if (platform === 'win32' && registryDisplayNames.length > 0) {
258
+ for (const displayName of installedRegistryDisplayNames) {
259
+ const normalized = displayName.toLowerCase();
260
+ if (registryDisplayNames.some((pattern) => normalized.includes(pattern.toLowerCase()))) {
261
+ matches.push(`registry:${displayName}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ return uniqueNonEmpty(matches);
267
+ }
268
+
139
269
  export function buildRazelConfigBootstrap({
140
270
  razelConfig = {},
141
271
  env = process.env,
package/bin/install.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
4
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, resolve, join } from 'node:path';
6
6
  import * as path from 'node:path';
7
7
  import { homedir } from 'node:os';
@@ -14,7 +14,10 @@ import {
14
14
  buildRazelConfigBootstrap,
15
15
  classifyPythonInstallFailure,
16
16
  createInstallContext,
17
+ detectDesktopAppInstallMatches,
17
18
  evaluateChannelDetection,
19
+ getDesktopAppCandidates,
20
+ resolvePromptDefault,
18
21
  shouldDryRun,
19
22
  } from './install-lib.mjs';
20
23
 
@@ -33,6 +36,13 @@ const isWin = process.platform === 'win32';
33
36
  const NPM = 'npm';
34
37
  const PIP = isWin ? 'pip' : 'pip3';
35
38
  const DRY_RUN = shouldDryRun(process.argv);
39
+ const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
40
+ const WINDOWS_UNINSTALL_KEYS = [
41
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
42
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
43
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
44
+ ];
45
+ let windowsUninstallDisplayNamesPromise = null;
36
46
 
37
47
  function step(index, total, title) {
38
48
  console.log('');
@@ -88,44 +98,185 @@ function readJson(filePath) {
88
98
  }
89
99
  }
90
100
 
91
- function findDesktopApps(appNames = []) {
92
- const roots = [
93
- '/Applications',
94
- join(homedir(), 'Applications'),
95
- ];
96
- const matches = [];
97
- for (const appName of appNames) {
98
- for (const root of roots) {
99
- const appPath = join(root, appName);
100
- if (existsSync(appPath)) {
101
- matches.push(appPath);
102
- }
101
+ function stripFeishuAccounts(section = {}) {
102
+ const { accounts: _accountsIgnored, ...base } = section;
103
+ return base;
104
+ }
105
+
106
+ function hasFeishuCredentials(config = {}) {
107
+ return Boolean(config?.appId && config?.appSecret);
108
+ }
109
+
110
+ function hasAnyFeishuAccount(section = {}) {
111
+ if (hasFeishuCredentials(section)) {
112
+ return true;
113
+ }
114
+ const accounts = section?.accounts;
115
+ if (!accounts || typeof accounts !== 'object') {
116
+ return false;
117
+ }
118
+ return Object.values(accounts).some((account) => hasFeishuCredentials(account));
119
+ }
120
+
121
+ function isSameFeishuCredentials(a = {}, b = {}) {
122
+ if (!hasFeishuCredentials(a) || !hasFeishuCredentials(b)) {
123
+ return false;
124
+ }
125
+ return a.appId === b.appId && a.appSecret === b.appSecret;
126
+ }
127
+
128
+ function ensureUniqueAccountId(accounts, preferredId) {
129
+ const base = (preferredId || 'business').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'business';
130
+ if (!accounts[base]) {
131
+ return base;
132
+ }
133
+ let idx = 2;
134
+ while (accounts[`${base}-${idx}`]) {
135
+ idx += 1;
136
+ }
137
+ return `${base}-${idx}`;
138
+ }
139
+
140
+ function pickLegacyAccountId(config = {}) {
141
+ if (typeof config.name === 'string' && config.name.trim()) {
142
+ return config.name.trim();
143
+ }
144
+ if (typeof config.appId === 'string' && config.appId.trim()) {
145
+ const tail = config.appId.trim().slice(-6);
146
+ return `legacy-${tail}`;
147
+ }
148
+ return 'legacy-business';
149
+ }
150
+
151
+ function mergeFeishuBindings(beforeSection = {}, afterSection = {}) {
152
+ const beforeBase = stripFeishuAccounts(beforeSection);
153
+ const afterBase = stripFeishuAccounts(afterSection);
154
+ const mergedAccounts = { ...(afterSection.accounts || {}) };
155
+ let addedCount = 0;
156
+
157
+ const accountExistsByCred = (candidate = {}) => {
158
+ if (isSameFeishuCredentials(afterBase, candidate)) {
159
+ return true;
160
+ }
161
+ return Object.values(mergedAccounts).some((account) => isSameFeishuCredentials(account, candidate));
162
+ };
163
+
164
+ for (const [accountId, accountConfig] of Object.entries(beforeSection.accounts || {})) {
165
+ if (!hasFeishuCredentials(accountConfig) || accountExistsByCred(accountConfig)) {
166
+ continue;
103
167
  }
168
+ const targetId = ensureUniqueAccountId(mergedAccounts, accountId);
169
+ mergedAccounts[targetId] = { ...accountConfig, enabled: accountConfig.enabled ?? true };
170
+ addedCount += 1;
171
+ }
172
+
173
+ if (hasFeishuCredentials(beforeBase) && !accountExistsByCred(beforeBase)) {
174
+ const targetId = ensureUniqueAccountId(mergedAccounts, pickLegacyAccountId(beforeBase));
175
+ mergedAccounts[targetId] = { ...beforeBase, enabled: beforeBase.enabled ?? true };
176
+ addedCount += 1;
177
+ }
178
+
179
+ const mergedSection = { ...afterBase };
180
+ if (Object.keys(mergedAccounts).length > 0) {
181
+ mergedSection.accounts = mergedAccounts;
182
+ }
183
+
184
+ return {
185
+ mergedSection,
186
+ addedCount,
187
+ changed: addedCount > 0,
188
+ };
189
+ }
190
+
191
+ function preserveFeishuBindings(beforeSection) {
192
+ if (!beforeSection || DRY_RUN) {
193
+ return { changed: false, addedCount: 0 };
194
+ }
195
+
196
+ const config = readJson(OPENCLAW_CONFIG_PATH);
197
+ const afterSection = config?.channels?.feishu;
198
+ if (!afterSection) {
199
+ return { changed: false, addedCount: 0 };
104
200
  }
105
- return [...new Set(matches)];
201
+
202
+ const merged = mergeFeishuBindings(beforeSection, afterSection);
203
+ if (!merged.changed) {
204
+ return { changed: false, addedCount: 0 };
205
+ }
206
+
207
+ const nextConfig = {
208
+ ...config,
209
+ channels: {
210
+ ...(config.channels || {}),
211
+ feishu: merged.mergedSection,
212
+ },
213
+ };
214
+ writeFileSync(OPENCLAW_CONFIG_PATH, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
215
+ return { changed: true, addedCount: merged.addedCount };
106
216
  }
107
217
 
108
- function detectDesktopClients() {
218
+ async function getWindowsUninstallDisplayNames() {
219
+ if (!isWin) {
220
+ return [];
221
+ }
222
+ if (!windowsUninstallDisplayNamesPromise) {
223
+ windowsUninstallDisplayNamesPromise = (async () => {
224
+ const displayNames = [];
225
+ for (const registryKey of WINDOWS_UNINSTALL_KEYS) {
226
+ const result = await runCapture('reg', ['query', registryKey, '/s', '/v', 'DisplayName']);
227
+ const lines = `${result.stdout}\n${result.stderr}`.split(/\r?\n/);
228
+ for (const line of lines) {
229
+ const match = line.match(/^\s*DisplayName\s+REG_\w+\s+(.+?)\s*$/i);
230
+ if (match?.[1]) {
231
+ displayNames.push(match[1].trim());
232
+ }
233
+ }
234
+ }
235
+ return [...new Set(displayNames)];
236
+ })();
237
+ }
238
+ return windowsUninstallDisplayNamesPromise;
239
+ }
240
+
241
+ async function findDesktopApps(definition) {
242
+ const installedRegistryDisplayNames = await getWindowsUninstallDisplayNames();
243
+ return detectDesktopAppInstallMatches({
244
+ platform: process.platform,
245
+ homeDir: homedir(),
246
+ env: process.env,
247
+ fileCandidates: getDesktopAppCandidates(definition, process.platform),
248
+ registryDisplayNames: definition.desktopAppWindowsRegistryNames ?? [],
249
+ installedRegistryDisplayNames,
250
+ exists: existsSync,
251
+ });
252
+ }
253
+
254
+ async function detectDesktopClients() {
109
255
  const candidates = {
110
- feishu: ['Feishu.app', 'Lark.app', '飞书.app'],
111
- dingtalk: ['DingTalk.app', '钉钉.app'],
112
- wecom: ['企业微信.app', 'WeCom.app', 'WeChatWork.app'],
256
+ feishu: CHANNEL_DEFINITIONS.lark,
257
+ dingtalk: CHANNEL_DEFINITIONS.dingtalk,
258
+ wecom: CHANNEL_DEFINITIONS.wecom,
113
259
  };
114
260
 
115
- return Object.fromEntries(
116
- Object.entries(candidates).map(([key, names]) => {
117
- const installedPaths = findDesktopApps(names);
118
- return [key, {
119
- installed: installedPaths.length > 0,
120
- installedPaths,
121
- }];
122
- }),
123
- );
261
+ const entries = [];
262
+ for (const [key, definition] of Object.entries(candidates)) {
263
+ const installedPaths = await findDesktopApps(definition);
264
+ entries.push([key, {
265
+ installed: installedPaths.length > 0,
266
+ installedPaths,
267
+ }]);
268
+ }
269
+
270
+ return Object.fromEntries(entries);
124
271
  }
125
272
 
126
273
  async function confirmPrompt(message, defaultYes = true) {
127
274
  if (DRY_RUN || !process.stdin.isTTY) {
128
- return defaultYes;
275
+ return resolvePromptDefault({
276
+ defaultYes,
277
+ isTTY: Boolean(process.stdin.isTTY),
278
+ dryRun: DRY_RUN,
279
+ });
129
280
  }
130
281
 
131
282
  const rl = createInterface({
@@ -388,12 +539,11 @@ async function inspectChannel(definition) {
388
539
 
389
540
  const configPaths = definition.configPaths.filter((configPath) => existsSync(configPath));
390
541
  const envVars = definition.envVars.filter((key) => Boolean(process.env[key]));
391
- const desktopApps = findDesktopApps(definition.desktopAppCandidates ?? []);
542
+ const desktopApps = await findDesktopApps(definition);
392
543
 
393
- const openclawConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
394
- const openclawConfig = readJson(openclawConfigPath);
544
+ const openclawConfig = readJson(OPENCLAW_CONFIG_PATH);
395
545
  if ((definition.label === '飞书' || definition.label === 'Lark') && openclawConfig?.channels?.feishu) {
396
- configPaths.push(`${openclawConfigPath}#channels.feishu`);
546
+ configPaths.push(`${OPENCLAW_CONFIG_PATH}#channels.feishu`);
397
547
  }
398
548
 
399
549
  return { commands, configPaths, envVars, desktopApps };
@@ -442,11 +592,9 @@ async function inferChannelActivation(channelKey) {
442
592
  const hasRuntime = Boolean(runtimeCommand);
443
593
 
444
594
  if (channelKey === 'lark') {
445
- const openclawConfig = readJson(join(homedir(), '.openclaw', 'openclaw.json'));
595
+ const openclawConfig = readJson(OPENCLAW_CONFIG_PATH);
446
596
  const feishuConfig = openclawConfig?.channels?.feishu;
447
- const hasFeishuAccount = Boolean(
448
- feishuConfig?.enabled && (feishuConfig?.appId || feishuConfig?.accounts?.main?.appId),
449
- );
597
+ const hasFeishuAccount = hasAnyFeishuAccount(feishuConfig);
450
598
 
451
599
  if (hasRuntime && hasFeishuAccount) {
452
600
  return {
@@ -525,6 +673,7 @@ async function bootstrapChannel(channelKey, context) {
525
673
  }
526
674
 
527
675
  if (channelKey === 'lark') {
676
+ const beforeFeishuSection = readJson(OPENCLAW_CONFIG_PATH)?.channels?.feishu ?? null;
528
677
  const [installCmd, installArgs] = definition.installCommand;
529
678
  console.log(dim(` 按官方流程执行 ${definition.label} 绑定(二维码创建/关联机器人)...`));
530
679
  const installCode = await run(installCmd, installArgs);
@@ -537,13 +686,40 @@ async function bootstrapChannel(channelKey, context) {
537
686
  return;
538
687
  }
539
688
 
689
+ const preserved = preserveFeishuBindings(beforeFeishuSection);
540
690
  const activated = await inferChannelActivation(channelKey);
691
+ const preserveNote = preserved.addedCount > 0
692
+ ? `;已保留 ${preserved.addedCount} 个历史业务绑定`
693
+ : '';
541
694
  context.channels[channelKey] = {
542
695
  status: DRY_RUN ? 'would-enter-binding' : 'binding-flow-entered',
543
696
  source: detection.source,
544
697
  message: DRY_RUN
545
698
  ? `${definition.label} 将执行官方绑定流程 (${installCmd} ${installArgs.join(' ')})`
546
- : `${definition.label} 已执行官方绑定流程 (${installCmd} ${installArgs.join(' ')});${activated.message}`,
699
+ : `${definition.label} 已执行官方绑定流程 (${installCmd} ${installArgs.join(' ')});${activated.message}${preserveNote}`,
700
+ };
701
+ return;
702
+ }
703
+
704
+ if (channelKey === 'wecom') {
705
+ const [installCmd, installArgs] = definition.installCommand;
706
+ console.log(dim(` 按官方流程执行 ${definition.label} 绑定(创建/关联机器人)...`));
707
+ const installCode = await run(installCmd, installArgs);
708
+ if (installCode !== 0) {
709
+ context.channels[channelKey] = {
710
+ status: 'failed',
711
+ source: detection.source,
712
+ message: `${definition.label} 绑定流程失败(${installCmd} ${installArgs.join(' ')})`,
713
+ };
714
+ return;
715
+ }
716
+
717
+ context.channels[channelKey] = {
718
+ status: DRY_RUN ? 'would-enter-binding' : 'binding-flow-entered',
719
+ source: detection.source,
720
+ message: DRY_RUN
721
+ ? `${definition.label} 将执行官方绑定流程 (${installCmd} ${installArgs.join(' ')})`
722
+ : `${definition.label} 已执行官方绑定流程 (${installCmd} ${installArgs.join(' ')})`,
547
723
  };
548
724
  return;
549
725
  }
@@ -626,7 +802,7 @@ async function runPreflight(context) {
626
802
  context.channels.lark = await detectChannel(CHANNEL_DEFINITIONS.lark);
627
803
  context.channels.dingtalk = await detectChannel(CHANNEL_DEFINITIONS.dingtalk);
628
804
  context.channels.wecom = await detectChannel(CHANNEL_DEFINITIONS.wecom);
629
- context.desktopClients = detectDesktopClients();
805
+ context.desktopClients = await detectDesktopClients();
630
806
 
631
807
  console.log(dim(` 飞书: ${context.channels.lark.message}`));
632
808
  console.log(dim(` 钉钉: ${context.channels.dingtalk.message}`));
@@ -808,15 +984,15 @@ async function main() {
808
984
  await runCoreInstall(context);
809
985
 
810
986
  step(3, context.phases.length, context.phases[2]);
811
- await bootstrapChannel('lark', context);
812
- await bootstrapChannel('dingtalk', context);
813
- await bootstrapChannel('wecom', context);
987
+ await runExtensionInstall(context);
814
988
 
815
989
  step(4, context.phases.length, context.phases[3]);
816
- await runExtensionInstall(context);
990
+ await runPostflight(context);
817
991
 
818
992
  step(5, context.phases.length, context.phases[4]);
819
- await runPostflight(context);
993
+ await bootstrapChannel('lark', context);
994
+ await bootstrapChannel('dingtalk', context);
995
+ await bootstrapChannel('wecom', context);
820
996
 
821
997
  step(6, context.phases.length, context.phases[5]);
822
998
  await runConfigBootstrap(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codewave-openclaw-installer",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "网易智企 CodeWave OpenClaw 扩展:Skills + CLI 依赖一键安装",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -12,8 +12,7 @@
12
12
  "index.js",
13
13
  "bin/",
14
14
  "skills/",
15
- "openclaw.plugin.json",
16
- "README.md"
15
+ "openclaw.plugin.json"
17
16
  ],
18
17
  "peerDependencies": {
19
18
  "openclaw": "*"