dsh-agentone 0.5.23 → 0.5.25

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
@@ -108,6 +108,7 @@ lib/
108
108
  skill.js 技能下载/解包/卸载(zip 穿越清洗、原子落盘)
109
109
  store.js 凭据持久化(0600;损坏自动移开)
110
110
  proc.js dsh/pnpm CLI 转发与 lark-cli/ccpg-cli 探测
111
+ qrcode.mjs 授权 URL 的二维码 SVG(vendor qrcode-generator,MIT;lark/ccpg 通用)
111
112
  client.js dsh 浏览器端 bundle(设置菜单 iframe + 首启引导 + 设置导航 workbench 图标换肤)
112
113
  ```
113
114
 
package/lib/cliauth.mjs CHANGED
@@ -7,8 +7,8 @@
7
7
  // --json 时去掉重试(文本解析兜底)。
8
8
  // completeCliAuth 用 begin 拿到的设备码轮询并完成授权(阻塞至用户在
9
9
  // 授权页同意或超时)。
10
- // cliAuthQrcode lark-cli 的 auth qrcode --ascii 生成终端二维码(状态页
11
- // 等宽字体直接渲染)。
10
+ // cliAuthQrcode 授权 URL 渲染二维码 SVG(内置生成,见 lib/qrcode.mjs;
11
+ // 页面 <img> 直接展示)。
12
12
  //
13
13
  // 安全:所有子进程经 runCliTool(execFile 参数数组,无 shell 参与)。设备码
14
14
  // 是 begin 步骤本机 CLI 自己签发的随机 token,非用户自由输入;仍先过
@@ -16,6 +16,7 @@
16
16
  // 失败直接抛中文错误、不触碰子进程。校验后的 token 以独立 argv 元素传给
17
17
  // execFile——无 shell 二次解析,token 字符集也决定了它不可能被解释为选项。
18
18
  import { runCliTool } from './proc.js';
19
+ import { qrSvg } from './qrcode.mjs';
19
20
 
20
21
  /** 授权 begin/complete 超时对齐平台 cli-deployd(begin 30s / complete 240s)。 */
21
22
  export const CLI_AUTH_BEGIN_TIMEOUT_MS = 30_000;
@@ -69,12 +70,16 @@ export async function completeCliAuth(tool, rawDeviceCode, { timeoutMs = CLI_AUT
69
70
  return combineOutput(stdout, stderr);
70
71
  }
71
72
 
72
- /** lark-cli 生成授权 URL 的 ASCII 二维码(状态页等宽字体直接渲染)。 */
73
- export async function cliAuthQrcode(url, { timeoutMs = 15_000 } = {}) {
74
- const { stdout } = await runCliTool('lark-cli', ['auth', 'qrcode', String(url || ''), '--ascii'], {
75
- timeoutMs,
76
- });
77
- return String(stdout || '').trim();
73
+ /** 授权 URL 的二维码 SVG(内置生成,lark-cli / ccpg-cli 通用——ccpg
74
+ * 没有二维码子命令,也不应让 lark-cli 成为 ccpg 授权的前置依赖;与平台
75
+ * /api/qr 服务端生成同一模式;页面 <img data:URL> 直接渲染)。失败抛
76
+ * 中文错误。 */
77
+ export function cliAuthQrcode(url) {
78
+ const text = String(url || '');
79
+ if (!/^https?:\/\//.test(text)) {
80
+ throw new Error('授权链接格式不正确,无法生成二维码');
81
+ }
82
+ return qrSvg(text);
78
83
  }
79
84
 
80
85
  /**
package/lib/index.js CHANGED
@@ -1350,7 +1350,9 @@ export function apply(ctx, config) {
1350
1350
  // 单次 complete 限时 25s:complete 是真长轮询(CLI 阻塞到用户同意
1351
1351
  // 或设备码过期),若不限时前端每轮询一次就叠一个子进程,几十个
1352
1352
  // lark-cli 同时轮同一码。这里 25s 一掐,前端串行重试(见 page.js)。
1353
+ const startedAt = Date.now();
1353
1354
  await completeCliAuth(tool, deviceCode, { timeoutMs: 25_000 });
1355
+ log.info(`cli auth complete ok: tool=${tool} elapsed=${Date.now() - startedAt}ms`);
1354
1356
  pendingCliAuth.delete(tool);
1355
1357
  const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1356
1358
  sendJson(response, 200, { ok: true, tool: detail });
@@ -1360,7 +1362,20 @@ export function apply(ctx, config) {
1360
1362
  // 慢一点授权就会因会话被删而 400「重新发起」。
1361
1363
  const message = String(error?.message || '');
1362
1364
  const terminal = /expired|invalid|denied|拒绝|失效|过期/i.test(message);
1365
+ // 快速失败 ≠ 还在等:真实用户机实测,lark-cli 在部分宿主环境拒绝
1366
+ // 阻塞轮询(打印 [AI agent] 提示后退出),且设备码被同意消费后
1367
+ // 重放会快速报 JSON 错——这两种情况用户其实已完成授权,纯 202
1368
+ // 会让弹窗永远停在「正在等待」。用 auth status 实测兜底:已授权
1369
+ // 直接按成功返回,弹窗关闭、状态刷新。
1370
+ const detail = await probeCliDetail(tool).then(withCliUpgradeGuard).catch(() => null);
1371
+ if (detail?.state === 'authorized') {
1372
+ pendingCliAuth.delete(tool);
1373
+ log.info(`cli auth complete ok (via status probe): tool=${tool}`);
1374
+ sendJson(response, 200, { ok: true, tool: detail });
1375
+ return;
1376
+ }
1363
1377
  if (terminal) pendingCliAuth.delete(tool);
1378
+ log.warn(`cli auth complete ${terminal ? 'terminal' : 'timeout'}: tool=${tool} message=${message.slice(0, 160)}`);
1364
1379
  sendJson(response, terminal ? 502 : 202, {
1365
1380
  pending: !terminal,
1366
1381
  error: message,
@@ -1378,25 +1393,19 @@ export function apply(ctx, config) {
1378
1393
  return;
1379
1394
  }
1380
1395
  // 只为 begin 返回过的 URL 生成二维码:URL 来自本机 CLI 输出,但也不
1381
- // 直接接受前端任意 URL(避免把本接口当任意二维码渲染器)。
1396
+ // 直接接受前端任意 URL(避免把本接口当任意二维码渲染器)。任意
1397
+ // CLI(lark-cli / ccpg-cli)的进行中授权会话均可——二维码由内置
1398
+ // 编码器生成(lib/qrcode.mjs),不依赖 lark-cli 本机存在。
1382
1399
  const queryUrl = new URL(request.url, 'http://localhost').searchParams.get('url') || '';
1383
- const pending = [...pendingCliAuth.keys()];
1384
- let matched = false;
1385
- for (const tool of pending) {
1386
- if (tool === 'lark-cli' && queryUrl) {
1387
- matched = true;
1388
- break;
1389
- }
1390
- }
1391
- if (!matched) {
1400
+ if (!queryUrl || pendingCliAuth.size === 0) {
1392
1401
  sendJson(response, 400, { error: '没有进行中的授权会话' });
1393
1402
  return;
1394
1403
  }
1395
1404
  try {
1396
- const ascii = await cliAuthQrcode(queryUrl);
1397
- if (!ascii) throw new Error('二维码生成失败');
1398
- response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1399
- response.end(ascii);
1405
+ const svg = await cliAuthQrcode(queryUrl);
1406
+ if (!svg) throw new Error('二维码生成失败');
1407
+ response.writeHead(200, { 'Content-Type': 'image/svg+xml' });
1408
+ response.end(svg);
1400
1409
  } catch (error) {
1401
1410
  sendJson(response, 502, { error: error?.message || '二维码生成失败' });
1402
1411
  }
package/lib/page.js CHANGED
@@ -567,14 +567,13 @@ const renderCliData = (s, tools, python) => {
567
567
  * 弹窗关闭即停轮询;设备码只存插件后端内存,前端不回传。
568
568
  */
569
569
  const cliAuthDialog = async (tool) => {
570
- const toolLabel = CLI_TOOL_META[tool]?.label || tool;
571
570
  const overlay = document.createElement('div');
572
571
  overlay.className = 'modal-backdrop';
573
572
  overlay.innerHTML = '<div class="card" style="max-width:420px;margin:8% auto;text-align:left">'
574
573
  + '<h3 style="margin:0 0 6px">授权 ' + esc(tool) + '</h3>'
575
574
  + '<div id="cli-auth-status" class="skill-sub">正在发起授权…</div>'
576
- + '<pre id="cli-auth-qr" style="display:none;background:var(--card,#fff);color:#111;'
577
- + 'border-radius:10px;padding:8px;font:11px/1.15 ui-monospace,monospace;overflow:auto;margin:10px 0"></pre>'
575
+ + '<img id="cli-auth-qr" alt="授权二维码" style="display:none;width:100%;max-width:300px;'
576
+ + 'background:#fff;border-radius:10px;margin:10px 0">'
578
577
  + '<div id="cli-auth-link"></div>'
579
578
  + '<div class="row" style="margin-top:12px;justify-content:flex-end">'
580
579
  + '<button id="cli-auth-open" class="btn small primary" hidden>打开授权页</button>'
@@ -601,14 +600,15 @@ const cliAuthDialog = async (tool) => {
601
600
  });
602
601
  const data = await resp.json().catch(() => ({}));
603
602
  if (!resp.ok) throw new Error(data.error || '发起授权失败');
604
- statusEl.textContent = '用' + esc(toolLabel) + '扫二维码,或打开链接完成授权。同意后此处会自动确认。';
605
- // ASCII 二维码(lark-cli 本机生成;失败退化为纯链接)
603
+ statusEl.textContent = '用手机飞书 App 扫二维码,或打开链接完成授权。同意后此处会自动确认。';
604
+ // 二维码 SVG(服务端内置生成,lark-cli / ccpg-cli 通用;矢量随卡片缩放,
605
+ // 失败退化为纯链接)
606
606
  fetch('api/cli/auth/qrcode?url=' + encodeURIComponent(data.verification_url))
607
607
  .then((r) => (r.ok ? r.text() : ''))
608
- .then((ascii) => {
608
+ .then((svg) => {
609
609
  const qr = overlay.querySelector('#cli-auth-qr');
610
- if (ascii) {
611
- qr.textContent = ascii;
610
+ if (svg && svg.startsWith('<svg')) {
611
+ qr.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
612
612
  qr.style.display = 'block';
613
613
  }
614
614
  })
@@ -672,11 +672,12 @@ const cliAuthDialog = async (tool) => {
672
672
  }
673
673
  };
674
674
 
675
- /** CLI 一键安装:lark 走 npm 全局安装、ccpg/python 走官方安装器,耗时
676
- * 可达分钟级。响应自带安装后立即探测的详情,直接合进缓存刷新界面。 */
675
+ /** CLI 一键安装:lark 走 npm 全局安装(机器无 Node.js 时先自动补装)、
676
+ * ccpg/python 走官方安装器,耗时可达数分钟。响应自带安装后立即探测的
677
+ * 详情,直接合进缓存刷新界面。 */
677
678
  const cliInstall = async (tool, btn) => {
678
679
  btn.disabled = true;
679
- btn.textContent = '安装中,可能需要 1–3 分钟…';
680
+ btn.textContent = '安装中,可能需要 3–5 分钟…';
680
681
  try {
681
682
  const resp = await fetch('api/cli/install', {
682
683
  method: 'POST',
package/lib/proc.js CHANGED
@@ -6,9 +6,9 @@
6
6
  // 例外——按 CVE-2024-27980 后的 Node 约定经 cmd.exe /d /s /c 执行,
7
7
  // 参数仍是数组形态)与错误翻译;包名/版本白名单在 index.js 的调用侧执行。
8
8
  import { execFile } from 'node:child_process';
9
- import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
10
10
  import { homedir, tmpdir } from 'node:os';
11
- import { join, isAbsolute, delimiter as pathDelimiter } from 'node:path';
11
+ import { join, dirname, isAbsolute, delimiter as pathDelimiter } from 'node:path';
12
12
  import { promisify } from 'node:util';
13
13
 
14
14
  const execFileAsync = promisify(execFile);
@@ -40,8 +40,13 @@ function extraBinDirs() {
40
40
  );
41
41
  // nodejs.org 官方安装器默认系统级安装位(机器级 Program Files;
42
42
  // 机器上没有 node.exe 时不会注入 PATH,但兑底目录里如果找到了,
43
- // 就能解析出 node/npm,不用再要求用户装 Node)。
44
- dirs.push(join(programFiles, 'nodejs'), join(programFilesX86, 'nodejs'));
43
+ // 就能解析出 node/npm,不用再要求用户装 Node)+ 本插件一键补装的
44
+ // 用户级安装位(%LOCALAPPDATA%\Programs\nodejs,免管理员)。
45
+ dirs.push(
46
+ join(programFiles, 'nodejs'),
47
+ join(programFilesX86, 'nodejs'),
48
+ join(localAppData, 'Programs', 'nodejs'),
49
+ );
45
50
  // npm -g 默认 prefix、pnpm 默认 PNPM_HOME,以及 ccpg-cli Windows 官方
46
51
  // 安装器的落点 %USERPROFILE%\bin(本机实测 C:\Users\<user>\bin\
47
52
  // ccpg-cli.exe;装完不重启也能立即探测/自升级)。nodejs.org 安装器的
@@ -86,40 +91,100 @@ function extraBinDirs() {
86
91
  }
87
92
  dirs.push(join(home, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin');
88
93
  }
89
- // DSH Desktop 自带的 node/pnpm/dsh shim(Electron 当 node 用):宿主只把
90
- // 这目录注入桌面终端的 PATH,插件子进程看不到——不兑底的话 dsh 自身有
91
- // node 环境但 lark-cli 安装仍报「未找到 npm」。目录名是 profile 的 hash,
92
- // 扫一层取含 bin 的子目录。Windows 桌面版把 shim 放在
93
- // %LOCALAPPDATA%\\DSH Desktop\\shims(按官方约定),一并覆盖。
94
- try {
95
- const cliRoot = join(home, 'Library', 'Application Support', 'DSH Desktop', 'cli');
96
- for (const entry of readdirSync(cliRoot)) {
97
- const shimBin = join(cliRoot, entry, 'bin');
98
- if (existsSync(join(shimBin, 'node'))) dirs.push(shimBin);
99
- }
100
- } catch {
101
- // 非 macOS / 未生成终端 shim:跳过
102
- }
103
- if (process.platform === 'win32') {
94
+ // DSH Desktop shim 目录固定排在兑底列表末尾:这些 shim 是 Electron 当
95
+ // node 用的包装(见 desktopShimDirs),真工具链存在时应让位。
96
+ dirs.push(...desktopShimDirs());
97
+ extraBinDirsCache = dirs;
98
+ return dirs;
99
+ }
100
+
101
+ /** DSH Desktop 自带的 node/pnpm/dsh shim 目录(Electron 当 node 用):宿主
102
+ * 只把这目录注入桌面终端的 PATH,插件子进程看不到——不兑底的话 dsh 自身有
103
+ * node 环境但 lark-cli 安装仍报「未找到 npm」。macOS 位
104
+ * ~/Library/Application Support/DSH Desktop/cli/<profile hash>/bin;
105
+ * Windows 实测在 Roaming 下(真实用户机报错的命令路径前缀就是
106
+ * C:\Users\<user>\AppData\Roaming\DSH…),Local 也一并扫。两种布局都
107
+ * 兼容:shims/ 直挂、cli/<hash>/bin。独立成函数并单独缓存:extraBinDirs
108
+ * 引用之外,execResolved 还要用它把这些 shim 目录排到子进程 PATH 末尾。 */
109
+ let desktopShimDirsCache = null;
110
+ export function desktopShimDirs() {
111
+ if (desktopShimDirsCache !== null) return desktopShimDirsCache;
112
+ const dirs = [];
113
+ const home = homedir();
114
+ const desktopRoots = process.platform === 'win32'
115
+ ? [
116
+ join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'DSH Desktop'),
117
+ join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'DSH Desktop'),
118
+ ]
119
+ : [join(home, 'Library', 'Application Support', 'DSH Desktop')];
120
+ for (const root of desktopRoots) {
104
121
  try {
105
- const shimDir = join(
106
- process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'),
107
- 'DSH Desktop', 'shims',
108
- );
109
- if (existsSync(join(shimDir, 'node.exe')) || existsSync(join(shimDir, 'node.cmd'))) dirs.push(shimDir);
122
+ const shimDir = join(root, 'shims');
123
+ if (existsSync(join(shimDir, 'node')) || existsSync(join(shimDir, 'node.exe')) || existsSync(join(shimDir, 'node.cmd'))) {
124
+ dirs.push(shimDir);
125
+ }
126
+ for (const entry of readdirSync(join(root, 'cli'))) {
127
+ const shimBin = join(root, 'cli', entry, 'bin');
128
+ if (existsSync(join(shimBin, 'node')) || existsSync(join(shimBin, 'node.exe')) || existsSync(join(shimBin, 'node.cmd'))) {
129
+ dirs.push(shimBin);
130
+ }
131
+ }
110
132
  } catch {
111
133
  // 目录不存在:跳过
112
134
  }
113
135
  }
114
- extraBinDirsCache = dirs;
136
+ desktopShimDirsCache = dirs;
115
137
  return dirs;
116
138
  }
117
139
 
140
+ /** Windows 端 DSH Desktop shim(node.cmd/pnpm.cmd/dsh.cmd)本体是批处理,
141
+ * 靠 DSH_DESKTOP_APP_EXECUTABLE / DSH_DESKTOP_PNPM_ENTRY /
142
+ * DSH_DESKTOP_ELECTRON_VERSION 三个环境变量定位 Electron 与 pnpm 入口;
143
+ * 但这组变量是桌面端「集成终端」专属生成环境(desktop-terminal 模块的
144
+ * WINDOWS_GENERATED_ENVIRONMENT_KEYS),插件宿主进程(utilityProcess.fork
145
+ * 起的 Electron-as-node)并不携带——变量为空时 shim 里 "%VAR%" 展开成
146
+ * "",任何命中 shim 的调用都报 '""' 不是内部或外部命令(0.5.24 把 shim
147
+ * 目录加进兑底后,lark-cli.cmd 内层 `node` 查找也会命中它)。
148
+ * 插件宿主本身就是同一个 Electron 二进制以 ELECTRON_RUN_AS_NODE 跑的,
149
+ * 缺的值可以精确补齐:可执行文件 = process.execPath,版本 =
150
+ * process.versions.electron,pnpm 入口 = <安装目录>/resources/app/
151
+ * node_modules/pnpm/bin/pnpm.mjs(桌面端 terminalEnvironment 同源)。
152
+ * 仅在 win32 + Electron 宿主 + 变量缺失时补;已有值(集成终端内)不覆盖。 */
153
+ export function desktopShimEnvDefaults({
154
+ env = process.env,
155
+ execPath = process.execPath,
156
+ versions = process.versions,
157
+ platform = process.platform,
158
+ } = {}) {
159
+ if (platform !== 'win32') return {};
160
+ if (!versions || !versions.electron) return {};
161
+ if (env.DSH_DESKTOP_APP_EXECUTABLE) return {};
162
+ const defaults = {
163
+ DSH_DESKTOP_APP_EXECUTABLE: execPath,
164
+ DSH_DESKTOP_ELECTRON_VERSION: versions.electron,
165
+ };
166
+ const pnpmEntry = join(dirname(execPath), 'resources', 'app', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs');
167
+ if (existsSync(pnpmEntry)) defaults.DSH_DESKTOP_PNPM_ENTRY = pnpmEntry;
168
+ return defaults;
169
+ }
170
+
171
+ /** 组装子进程 PATH(纯函数,供测试):非 shim 兑底目录排最前(GUI 贫瘠
172
+ * PATH 也能解析到真工具),原有 PATH 居中,DSH Desktop shim 目录垫底——
173
+ * shim 是 Electron 包装,真 node/pnpm 存在时优先用真的;贫瘠机器上原
174
+ * PATH 找不到,shim 仍能兜底(配合 desktopShimEnvDefaults 补齐变量)。 */
175
+ export function childPathWithExtras(originalPath, extras, shimDirs) {
176
+ const shimSet = new Set(shimDirs);
177
+ const head = extras.filter((dir) => !shimSet.has(dir));
178
+ const tail = extras.filter((dir) => shimSet.has(dir));
179
+ return [...head, originalPath || '', ...tail].filter(Boolean).join(pathDelimiter);
180
+ }
181
+
118
182
  /** 安装完成后清空兑底目录缓存:一键安装 python/ccpg 后新目录要立即可
119
183
  * 探测(python.org 装到 %LOCALAPPDATA%\Programs\Python\Python3xx),不
120
184
  * 能等 DSH Desktop 重启。 */
121
185
  export function resetExtraBinDirsCache() {
122
186
  extraBinDirsCache = null;
187
+ desktopShimDirsCache = null;
123
188
  }
124
189
 
125
190
  /** 候选扩展名顺序(纯函数,供测试):win32 下 CreateProcess 只认可执行
@@ -133,12 +198,21 @@ export function candidateSuffixes(platform) {
133
198
  /** win32 下 .cmd/.bat shim 的 execFile 等价调用形态(纯函数,供测试):
134
199
  * Node ≥ 18.20/20.12(CVE-2024-27980 修复,DSH Desktop 内置 Node 同样
135
200
  * 受影响)起 execFile 直接执行 .cmd/.bat 会同步抛 spawn EINVAL,必须经
136
- * cmd.exe /d /s /c 解释。参数仍走数组、由 Node 按 Windows 规则引号转义,
137
- * 不拼接用户输入。非 win32 或非 shim 返回 null。 */
201
+ * cmd.exe /d /s /c 解释。
202
+ * 形态必须整行单参数 + 外层引号 + windowsVerbatimArguments(与 Node
203
+ * shell:true 的内部构造一致):逐参数传数组时,含空格路径(如
204
+ * "C:\Users\x\AppData\Roaming\DSH Desktop\shims\npm.cmd")会被 cmd /s 的
205
+ * 引号剥离规则在空格处拆断,报「不是内部或外部命令」(真实用户机实测)。
206
+ * 参数不拼接用户输入(白名单 tool + 固定 flag + 校验过的设备码/URL)。 */
138
207
  export function cmdShimInvocation(platform, resolved, args) {
139
208
  if (platform !== 'win32') return null;
140
209
  if (!/\.(cmd|bat)$/i.test(resolved)) return null;
141
- return { file: 'cmd.exe', args: ['/d', '/s', '/c', resolved, ...args] };
210
+ // 引号判定必须覆盖 cmd 元字符:& | < > ^ ( ) 在裸参里会被 cmd 当命令
211
+ // 分隔/重定向/转义符——授权 URL 里的 &user_code=... 曾被拆成独立命令,
212
+ // lark-cli 收到截断 URL 直接报错;引号内这些字符全部是字面量。
213
+ const quote = (s) => (/[\s"&|<>^()]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : String(s));
214
+ const line = [resolved, ...args].map(quote).join(' ');
215
+ return { file: 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], verbatim: true };
142
216
  }
143
217
 
144
218
  /** PATH 环境变量键名(纯函数,供测试):win32 下实际键通常是 Path,注入
@@ -192,19 +266,36 @@ export function resolveCommand(tool) {
192
266
  return resolveCommandOrNull(tool) || tool;
193
267
  }
194
268
 
269
+ /** 子进程输出解码(纯函数,供测试):Windows 中文系统的 cmd/批处理内部
270
+ * 错误是 GBK 编码(如「'xx' 不是内部或外部命令」),而 npm/node 自身输出
271
+ * 是 UTF-8——先按 UTF-8 严格解码,失败退化 GBK,乱码(♦/锟斤拷)不进
272
+ * 用户可见的错误提示。 */
273
+ export function decodeOutput(buf) {
274
+ const bytes = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf ?? ''));
275
+ try {
276
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
277
+ } catch {
278
+ try {
279
+ return new TextDecoder('gbk').decode(bytes);
280
+ } catch {
281
+ return bytes.toString('utf-8');
282
+ }
283
+ }
284
+ }
285
+
195
286
  /** 统一的子进程执行:解析命令路径,并把**所有兑底目录**前置进子进程
196
287
  * PATH——win32 下 npm/lark-cli 是 .cmd shim(`%~dp0\node.exe` 找 node),
197
288
  * node 可能在另一个兑底目录(nodejs.org 系统安装位);.cmd 又经
198
289
  * cmd.exe /d /s /c 路由,cmd.exe 在第三个兑底目录(System32)。只前置
199
- * 解析到的那个目录会让 shim 内部找不到依赖,必须整组前置。opts.env 只
200
- * 传增量变量。 */
290
+ * 解析到的那个目录会让 shim 内部找不到依赖,必须整组前置。输出按
291
+ * encoding:buffer 收回后 decodeOutput(GBK 容错)。opts.env 只传增量变量。 */
201
292
  function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
202
293
  const resolved = resolveCommand(tool);
203
- const childEnv = { ...process.env, ...(env || {}) };
294
+ const childEnv = { ...process.env, ...desktopShimEnvDefaults(), ...(env || {}) };
204
295
  const key = pathEnvKey(childEnv, process.platform);
205
296
  const extras = extraBinDirs().filter((dir) => existsSync(dir));
206
297
  if (extras.length) {
207
- childEnv[key] = [...extras, childEnv[key] || ''].filter(Boolean).join(pathDelimiter);
298
+ childEnv[key] = childPathWithExtras(childEnv[key], extras, desktopShimDirs());
208
299
  }
209
300
  const shim = cmdShimInvocation(process.platform, resolved, args);
210
301
  return execFileAsync(shim ? shim.file : resolved, shim ? shim.args : args, {
@@ -212,7 +303,18 @@ function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
212
303
  killSignal: 'SIGKILL',
213
304
  cwd,
214
305
  env: childEnv,
215
- });
306
+ encoding: 'buffer',
307
+ windowsVerbatimArguments: shim?.verbatim === true,
308
+ }).then(
309
+ ({ stdout, stderr }) => ({ stdout: decodeOutput(stdout), stderr: decodeOutput(stderr) }),
310
+ (error) => {
311
+ if (error && (error.stdout !== undefined || error.stderr !== undefined)) {
312
+ error.stdout = decodeOutput(error.stdout);
313
+ error.stderr = decodeOutput(error.stderr);
314
+ }
315
+ throw error;
316
+ },
317
+ );
216
318
  }
217
319
 
218
320
  /** 从 pnpm 冗长输出里提取对用户有意义的错误行。 */
@@ -503,13 +605,18 @@ export async function installCliTool(tool, { timeoutMs = 600_000 } = {}) {
503
605
  let stdout;
504
606
  if (tool === 'lark-cli') {
505
607
  // 预检+降级链:优先独立 npm;没有 npm 但有 pnpm(含 DSH Desktop 自带
506
- // shim)时用 pnpm 全局装——lark-cli 官方支持 pnpm 安装方式,装出来的
507
- // 二进制与 npm 路径一致,后续 lark-cli update 也能自升级。
508
- // 两者都没有才报「装 Node.js」指引。
608
+ // shim)时用 pnpm 全局装;两者都没有时 Windows 先一键补装 Node.js
609
+ // (用户目录免管理员,npmmirror 镜像),再用其自带 npm 装 lark-cli。
610
+ // lark-cli 官方支持 pnpm 安装方式,装出来的二进制与 npm 路径一致,
611
+ // 后续 lark-cli update 也能自升级。
509
612
  if (resolveCommandOrNull('npm')) {
510
613
  ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
511
614
  } else if (resolveCommandOrNull('pnpm')) {
512
615
  ({ stdout } = await runCliTool('pnpm', ['add', '-g', '@larksuite/cli@latest'], { timeoutMs }));
616
+ } else if (process.platform === 'win32') {
617
+ const nodeNote = await installNodeWindows(timeoutMs);
618
+ ({ stdout } = await runCliTool('npm', ['install', '-g', '@larksuite/cli@latest'], { timeoutMs }));
619
+ stdout = `${nodeNote}\n${stdout}`;
513
620
  } else {
514
621
  throw new Error(missingCommandGuidance('npm'));
515
622
  }
@@ -602,11 +709,19 @@ async function installCcpgCliWindows(timeoutMs) {
602
709
  mkdirSync(binDir, { recursive: true });
603
710
  const url = await resolveCcpgWindowsDownloadUrl();
604
711
  const staging = join(binDir, 'ccpg-cli.exe.download');
605
- await runCliTool('curl', [
606
- '-fsSL', '--retry', '3', '--retry-delay', '5', '--max-time', '600',
607
- '--speed-limit', '65536', '--speed-time', '30',
608
- '-o', staging, url,
609
- ], { timeoutMs });
712
+ try {
713
+ await runCliTool('curl', [
714
+ '-fsSL', '--retry', '3', '--retry-delay', '5', '--max-time', '600',
715
+ '--speed-limit', '65536', '--speed-time', '30',
716
+ '-o', staging, url,
717
+ ], { timeoutMs });
718
+ } catch (error) {
719
+ // 公司下载服务 504/超时时 curl 带重试日志的原样输出对用户既不可读也
720
+ // 无法行动(真实用户机实测弹了三行「curl:(22) …504」)——翻译成一句
721
+ // 可执行的中文;服务本身是否恢复是外部依赖,插件侧只能明确告知
722
+ try { rmSync(staging, { force: true }); } catch { /* 尽力清理半截文件 */ }
723
+ throw new Error(`ccpg-cli 下载失败:公司下载服务当前不可用(网关超时或连接失败),请稍后重试「安装」;若持续失败请联系管理员检查下载服务(bms.einwin.com)。技术详情:${firstErrorLines(`${error.stdout || ''}\n${error.stderr || ''}`) || '下载中断'}`);
724
+ }
610
725
  renameSync(staging, target);
611
726
  // 校验二进制可执行(PE 完整、架构匹配)
612
727
  const { stdout: versionOut } = await runCliTool(target, ['--version'], { timeoutMs: 30_000 });
@@ -677,6 +792,58 @@ async function installPythonWindows(timeoutMs) {
677
792
  };
678
793
  }
679
794
 
795
+ /** Node.js Windows 一键补装(lark-cli 的运行时依赖:机器无 node/npm/pnpm
796
+ * 时自动补,用户不再被要求「先自行安装 Node.js」):
797
+ * 1) npmmirror 二进制镜像优先(官网 nodejs.org 国内慢/不稳),失败退官网;
798
+ * zip 包绿色解压,不走 MSI(免管理员/UAC)
799
+ * 2) 解压到 %LOCALAPPDATA%\Programs\nodejs(与 python.org 每用户安装同
800
+ * 级的标准位置),写用户 PATH(注册表 User 作用域)
801
+ * 3) npm 全局 prefix 即该目录,后续装的 lark-cli 也落这里——兑底目录已
802
+ * 覆盖,装完本进程内即可探测
803
+ * 全程 execFile 参数数组,不经 shell。 */
804
+ const NODE_WINDOWS_VERSION = 'v22.17.0';
805
+ const NODE_WINDOWS_ZIP_URLS = [
806
+ `https://registry.npmmirror.com/-/binary/node/${NODE_WINDOWS_VERSION}/node-${NODE_WINDOWS_VERSION}-win-x64.zip`,
807
+ `https://nodejs.org/dist/${NODE_WINDOWS_VERSION}/node-${NODE_WINDOWS_VERSION}-win-x64.zip`,
808
+ ];
809
+
810
+ async function installNodeWindows(timeoutMs) {
811
+ const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
812
+ const programsDir = join(localAppData, 'Programs');
813
+ const nodeHome = join(programsDir, 'nodejs');
814
+ const zip = join(tmpdir(), 'node-setup.zip');
815
+ let downloaded = false;
816
+ let lastError = null;
817
+ for (const url of NODE_WINDOWS_ZIP_URLS) {
818
+ try {
819
+ await runCliTool('curl', [
820
+ '-fsSL', '--retry', '2', '--retry-delay', '3', '--max-time', '600',
821
+ '--speed-limit', '65536', '--speed-time', '30',
822
+ '-o', zip, url,
823
+ ], { timeoutMs });
824
+ downloaded = true;
825
+ break;
826
+ } catch (error) {
827
+ lastError = error;
828
+ }
829
+ }
830
+ if (!downloaded) throw lastError || new Error('Node.js 安装包下载失败,请检查网络后重试');
831
+ mkdirSync(programsDir, { recursive: true });
832
+ // PowerShell Expand-Archive(Win10+ 自带)。路径单引号内嵌转义防空格/引号
833
+ const psQuote = (p) => `'${p.replace(/'/g, "''")}'`;
834
+ await runCliTool('powershell', [
835
+ '-NoProfile', '-Command',
836
+ `Expand-Archive -LiteralPath ${psQuote(zip)} -DestinationPath ${psQuote(programsDir)} -Force`,
837
+ ], { timeoutMs });
838
+ // zip 内层目录名 node-vX.Y.Z-win-x64 → 平铺为 nodejs(重装时先清旧目录)
839
+ rmSync(nodeHome, { recursive: true, force: true });
840
+ renameSync(join(programsDir, `node-${NODE_WINDOWS_VERSION}-win-x64`), nodeHome);
841
+ rmSync(zip, { force: true });
842
+ await ensureUserPathContains(nodeHome).catch(() => {});
843
+ resetExtraBinDirsCache();
844
+ return `Node.js ${NODE_WINDOWS_VERSION} 安装完成(${nodeHome})`;
845
+ }
846
+
680
847
  /**
681
848
  * 触发 CLI 官方自升级(`tool update`):ccpg-cli 从更新服务器下载二进制
682
849
  * (~17MB),新版 lark-cli 自动探测 npm/pnpm/手动安装方式(手动时只打印
package/lib/qrcode.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // 内置二维码生成(授权 URL 渲染 SVG,页面 <img> 直接展示)。
2
+ //
3
+ // 与平台 `/api/qr`(Python qrcode 库,服务端 SVG)同一模式:服务端生成、
4
+ // 不依赖任何本机 CLI——ccpg-cli 没有二维码子命令,lark-cli 也不该成为
5
+ // ccpg 授权的前置条件。编码用 vendor 的 qrcode-generator(MIT,Kazuhiko
6
+ // Arase;矩阵正确性已经 OpenCV 解码回原文 + 与 lark-cli 原生二维码功能
7
+ // 图形比对双重验证)。
8
+ //
9
+ // 渲染选 SVG 而非 ASCII:ASCII 的块字符(█▀▄)在 Windows 网页字体回退下
10
+ // 列宽错位、行高不满格,真机出现过「显示不全 + 识别不出」;SVG 矢量无
11
+ // 字体依赖、随容器缩放。ASCII 版保留给终端/日志场景。
12
+ import createQrcode from './vendor/qrcode-generator.mjs';
13
+
14
+ /** 生成二维码模块矩阵:qr.isDark(row, col) / getModuleCount()(供测试比对)。 */
15
+ export function qrMatrix(text, { ecLevel = 'M' } = {}) {
16
+ const qr = createQrcode(0, ecLevel);
17
+ qr.addData(String(text || ''), 'Byte');
18
+ qr.make();
19
+ return qr;
20
+ }
21
+
22
+ /** 授权二维码 SVG(scalable,无固定宽高,随 <img> 容器缩放)。
23
+ * 注意 vendor 的 margin 是像素单位:静区 4 模块必须传 cellSize * 4,
24
+ * 传 4 只会得到 1 模块静区(真机识别失败的另一半原因)。 */
25
+ export function qrSvg(text, { ecLevel = 'M', cellSize = 4 } = {}) {
26
+ const qr = qrMatrix(text, { ecLevel });
27
+ return qr.createSvgTag({ cellSize, margin: cellSize * 4, scalable: true });
28
+ }
29
+
30
+ /** 渲染 ASCII 二维码(终端/日志场景;两行一字符 + 四模块静区)。
31
+ * 行尾空白保留——右侧静区是扫码识别的规格要求,裁掉会识别失败。 */
32
+ export function qrAscii(text, { ecLevel = 'M', quietZone = 4 } = {}) {
33
+ const qr = qrMatrix(text, { ecLevel });
34
+ const size = qr.getModuleCount();
35
+ const total = size + quietZone * 2;
36
+ const darkAt = (row, col) => (row >= quietZone && row < quietZone + size
37
+ && col >= quietZone && col < quietZone + size
38
+ && qr.isDark(row - quietZone, col - quietZone));
39
+ const lines = [];
40
+ for (let row = 0; row < total; row += 2) {
41
+ let line = '';
42
+ for (let col = 0; col < total; col += 1) {
43
+ const top = darkAt(row, col);
44
+ const bottom = darkAt(row + 1, col);
45
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
46
+ }
47
+ lines.push(line);
48
+ }
49
+ return lines.join('\n');
50
+ }