dsh-agentone 0.5.24 → 0.5.26
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 +1 -0
- package/lib/cliauth.mjs +13 -8
- package/lib/index.js +23 -14
- package/lib/page.js +72 -16
- package/lib/proc.js +84 -17
- package/lib/qrcode.mjs +50 -0
- package/lib/vendor/qrcode-generator.mjs +2300 -0
- package/package.json +1 -1
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
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
1397
|
-
if (!
|
|
1398
|
-
response.writeHead(200, { 'Content-Type': '
|
|
1399
|
-
response.end(
|
|
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
|
@@ -44,6 +44,8 @@ body {
|
|
|
44
44
|
.kv { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
|
|
45
45
|
.kv .k { color: var(--muted); flex-shrink: 0; }
|
|
46
46
|
.kv .v { text-align: right; word-break: break-all; }
|
|
47
|
+
.kv .v a { color: var(--brand); text-decoration: none; }
|
|
48
|
+
.kv .v a:hover { text-decoration: underline; }
|
|
47
49
|
button {
|
|
48
50
|
font: inherit; cursor: pointer; border-radius: 10px; transition: filter .15s, opacity .15s;
|
|
49
51
|
border: 1px solid transparent;
|
|
@@ -153,7 +155,23 @@ const notify = (message, kind) => {
|
|
|
153
155
|
// 错误信息可能较长(含平台原因),给足阅读时间;成功提示维持 4s
|
|
154
156
|
toastTimer = setTimeout(() => { toast.className = 'banner'; }, kind === 'err' ? 10000 : 4000);
|
|
155
157
|
};
|
|
156
|
-
|
|
158
|
+
// withLoading 收尾的刷新分两档:
|
|
159
|
+
// 'status'(默认)——拉 /api/status 并整页 refresh()。模型/SkillHub/登录类
|
|
160
|
+
// 操作后的语义(页面各卡都要对齐新状态)。
|
|
161
|
+
// 'quiet'——只拉一次 status 快照(不打散正在看的列表)+ 刷新技能列表
|
|
162
|
+
// (指纹保护下内容没变不会重绘)。技能/插件操作用这档:整页 refresh
|
|
163
|
+
// 会把当前列表闪一遍「读取中…」,还重建按钮节点打断二次确认。
|
|
164
|
+
// null——完全不动界面(调用方自己刷,如插件列表升级后 renderPlugins)。
|
|
165
|
+
const refreshStatusQuietly = async () => {
|
|
166
|
+
try {
|
|
167
|
+
const s = await (await fetch('api/status')).json();
|
|
168
|
+
lastStatus = s;
|
|
169
|
+
renderSkills(s);
|
|
170
|
+
} catch {
|
|
171
|
+
// 网络抖动:保持现状,下一轮 5s 轮询会自然对齐
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const withLoading = async (btn, action, okMessage, refreshMode = 'status') => {
|
|
157
175
|
const original = btn.innerHTML;
|
|
158
176
|
btn.disabled = true;
|
|
159
177
|
btn.innerHTML = '<span class="spin"></span>' + (btn.dataset.loading || '处理中…');
|
|
@@ -167,7 +185,8 @@ const withLoading = async (btn, action, okMessage) => {
|
|
|
167
185
|
} finally {
|
|
168
186
|
btn.disabled = false;
|
|
169
187
|
btn.innerHTML = original;
|
|
170
|
-
await refresh();
|
|
188
|
+
if (refreshMode === 'status') await refresh();
|
|
189
|
+
else if (refreshMode === 'quiet') await refreshStatusQuietly();
|
|
171
190
|
}
|
|
172
191
|
};
|
|
173
192
|
// 卸载类破坏性操作的轻量二次确认:第一次点击把按钮变成「确认卸载?」,
|
|
@@ -256,6 +275,7 @@ const syncPlanSkills = (btn) => withLoading(
|
|
|
256
275
|
btn,
|
|
257
276
|
() => fetch('api/skill/sync', { method: 'POST' }),
|
|
258
277
|
null,
|
|
278
|
+
'quiet',
|
|
259
279
|
);
|
|
260
280
|
const skillAction = (slug, action, btn, successText) => withLoading(
|
|
261
281
|
btn,
|
|
@@ -265,6 +285,8 @@ const skillAction = (slug, action, btn, successText) => withLoading(
|
|
|
265
285
|
body: JSON.stringify({ slug }),
|
|
266
286
|
}),
|
|
267
287
|
successText || (action === 'install' ? '技能已安装' : '技能已卸载'),
|
|
288
|
+
// 技能装/卸后只需技能列表指纹更新;整页 refresh 会闪一遍各卡片
|
|
289
|
+
'quiet',
|
|
268
290
|
);
|
|
269
291
|
const PLAN_CAPABILITY_LABELS = {
|
|
270
292
|
image_understanding_enabled: '图片理解',
|
|
@@ -462,8 +484,13 @@ const renderCli = async (s) => {
|
|
|
462
484
|
body.innerHTML = '<div class="skill-sub">平台未开启 CLI 功能。</div>';
|
|
463
485
|
return;
|
|
464
486
|
}
|
|
465
|
-
//
|
|
466
|
-
|
|
487
|
+
// 真实探测本机安装与授权态(数秒)。有旧数据(renderCli._tools)时先用
|
|
488
|
+
// 缓存渲染再后台刷新——不清屏闪「检测中…」,内容回来后整块替换一次。
|
|
489
|
+
if (renderCli._tools && Object.keys(renderCli._tools).length) {
|
|
490
|
+
renderCliData(s, {});
|
|
491
|
+
} else {
|
|
492
|
+
body.innerHTML = '<div class="skill-sub">检测中…</div>';
|
|
493
|
+
}
|
|
467
494
|
let tools;
|
|
468
495
|
let python;
|
|
469
496
|
try {
|
|
@@ -473,7 +500,10 @@ const renderCli = async (s) => {
|
|
|
473
500
|
tools = data.tools || {};
|
|
474
501
|
python = data.python || null;
|
|
475
502
|
} catch {
|
|
476
|
-
|
|
503
|
+
// 探测失败:已有内容保持不动,首载才显示错误
|
|
504
|
+
if (!renderCli._tools || !Object.keys(renderCli._tools).length) {
|
|
505
|
+
body.innerHTML = '<div class="skill-sub" style="color:var(--err)">CLI 状态检测失败,请稍后重试。</div>';
|
|
506
|
+
}
|
|
477
507
|
return;
|
|
478
508
|
}
|
|
479
509
|
renderCliData(s, tools, python);
|
|
@@ -567,14 +597,13 @@ const renderCliData = (s, tools, python) => {
|
|
|
567
597
|
* 弹窗关闭即停轮询;设备码只存插件后端内存,前端不回传。
|
|
568
598
|
*/
|
|
569
599
|
const cliAuthDialog = async (tool) => {
|
|
570
|
-
const toolLabel = CLI_TOOL_META[tool]?.label || tool;
|
|
571
600
|
const overlay = document.createElement('div');
|
|
572
601
|
overlay.className = 'modal-backdrop';
|
|
573
602
|
overlay.innerHTML = '<div class="card" style="max-width:420px;margin:8% auto;text-align:left">'
|
|
574
603
|
+ '<h3 style="margin:0 0 6px">授权 ' + esc(tool) + '</h3>'
|
|
575
604
|
+ '<div id="cli-auth-status" class="skill-sub">正在发起授权…</div>'
|
|
576
|
-
+ '<
|
|
577
|
-
+ 'border-radius:10px;
|
|
605
|
+
+ '<img id="cli-auth-qr" alt="授权二维码" style="display:none;width:100%;max-width:300px;'
|
|
606
|
+
+ 'background:#fff;border-radius:10px;margin:10px 0">'
|
|
578
607
|
+ '<div id="cli-auth-link"></div>'
|
|
579
608
|
+ '<div class="row" style="margin-top:12px;justify-content:flex-end">'
|
|
580
609
|
+ '<button id="cli-auth-open" class="btn small primary" hidden>打开授权页</button>'
|
|
@@ -601,14 +630,15 @@ const cliAuthDialog = async (tool) => {
|
|
|
601
630
|
});
|
|
602
631
|
const data = await resp.json().catch(() => ({}));
|
|
603
632
|
if (!resp.ok) throw new Error(data.error || '发起授权失败');
|
|
604
|
-
statusEl.textContent = '
|
|
605
|
-
//
|
|
633
|
+
statusEl.textContent = '用手机飞书 App 扫二维码,或打开链接完成授权。同意后此处会自动确认。';
|
|
634
|
+
// 二维码 SVG(服务端内置生成,lark-cli / ccpg-cli 通用;矢量随卡片缩放,
|
|
635
|
+
// 失败退化为纯链接)
|
|
606
636
|
fetch('api/cli/auth/qrcode?url=' + encodeURIComponent(data.verification_url))
|
|
607
637
|
.then((r) => (r.ok ? r.text() : ''))
|
|
608
|
-
.then((
|
|
638
|
+
.then((svg) => {
|
|
609
639
|
const qr = overlay.querySelector('#cli-auth-qr');
|
|
610
|
-
if (
|
|
611
|
-
qr.
|
|
640
|
+
if (svg && svg.startsWith('<svg')) {
|
|
641
|
+
qr.src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
|
612
642
|
qr.style.display = 'block';
|
|
613
643
|
}
|
|
614
644
|
})
|
|
@@ -744,17 +774,27 @@ const pluginAction = (name, action, btn) => withLoading(
|
|
|
744
774
|
action === 'install' ? '插件已安装,重启 DSH 后生效'
|
|
745
775
|
: action === 'remove' ? '插件已卸载'
|
|
746
776
|
: '插件已升级,重启 DSH 后生效',
|
|
777
|
+
// 插件列表由下方 renderPlugins 定向刷新(自带指纹保护),不走整页 refresh
|
|
778
|
+
null,
|
|
747
779
|
).then(() => renderPlugins());
|
|
748
780
|
const renderPlugins = async () => {
|
|
749
781
|
const list = $('plugins-list');
|
|
750
|
-
|
|
782
|
+
// 有旧内容时不闪「读取中…」:后台拉取,回来后整块替换(配合下方指纹
|
|
783
|
+
// 保护,内容没变连替换都省了)。首载(空容器)才显示占位。
|
|
784
|
+
if (!list.children.length) {
|
|
785
|
+
list.innerHTML = '<div class="skill-sub">读取中…</div>';
|
|
786
|
+
}
|
|
751
787
|
let data;
|
|
752
788
|
try {
|
|
753
789
|
const resp = await fetch('api/plugin/list');
|
|
754
790
|
data = await resp.json().catch(() => ({}));
|
|
755
791
|
if (!resp.ok) throw new Error(data.error || '读取插件列表失败');
|
|
756
792
|
} catch (error) {
|
|
757
|
-
|
|
793
|
+
// 拉取失败:已有内容保持不动(只是旧了),首载才显示错误
|
|
794
|
+
if (!list.children.length || list.dataset.loaded === '1') {
|
|
795
|
+
list.innerHTML = '<div class="skill-sub" style="color:var(--err)">' + esc(error.message) + '</div>';
|
|
796
|
+
delete list.dataset.loaded;
|
|
797
|
+
}
|
|
758
798
|
return;
|
|
759
799
|
}
|
|
760
800
|
// 渲染成功即标记已加载:避免切 tab 重复拉取(outdated 查询有网络开销)
|
|
@@ -805,7 +845,21 @@ const renderPlugins = async () => {
|
|
|
805
845
|
+ (plugin.bundled && !plugin.active ? ' · 未在 bundle 层,重启 dsh 后生效' : '')
|
|
806
846
|
+ '</div></div>' + actionHtml + '</div>';
|
|
807
847
|
});
|
|
808
|
-
|
|
848
|
+
// 指纹保护(与 renderSkills 同思路):内容没变不碰 innerHTML——升级后
|
|
849
|
+
// 的定向刷新、切 tab 重入都不会重建按钮节点(打断卸载二次确认)或闪动
|
|
850
|
+
const fingerprint = data.plugins.map((plugin) => [
|
|
851
|
+
plugin.name,
|
|
852
|
+
plugin.version || '',
|
|
853
|
+
plugin.latest || '',
|
|
854
|
+
plugin.active ? 1 : 0,
|
|
855
|
+
plugin.bundled ? 1 : 0,
|
|
856
|
+
plugin.self_managed ? 1 : 0,
|
|
857
|
+
].join(':')).join('|');
|
|
858
|
+
if (list.dataset.fingerprint !== fingerprint) {
|
|
859
|
+
list.dataset.fingerprint = fingerprint;
|
|
860
|
+
list.dataset.loaded = '1';
|
|
861
|
+
list.innerHTML = rows.join('');
|
|
862
|
+
}
|
|
809
863
|
list.querySelectorAll('[data-install-pkg]').forEach((btn) => {
|
|
810
864
|
btn.dataset.loading = '安装中…';
|
|
811
865
|
btn.onclick = () => pluginAction(btn.dataset.installPkg, 'install', btn);
|
|
@@ -889,6 +943,7 @@ const renderAbout = (s) => {
|
|
|
889
943
|
'<div class="kv"><span class="k">登录账号</span><span class="v">' + esc(s.user_email || '未登录') + '</span></div>',
|
|
890
944
|
'<div class="kv"><span class="k">所属套餐</span><span class="v">' + esc(s.capabilities?.plan_name || '—') + '</span></div>',
|
|
891
945
|
'<div class="kv"><span class="k">平台地址</span><span class="v muted">' + esc(s.platform_url || '—') + '</span></div>',
|
|
946
|
+
'<div class="kv"><span class="k">开发者</span><span class="v"><a href="mailto:chumingjun@cc-pg.cn">chumingjun@cc-pg.cn</a></span></div>',
|
|
892
947
|
];
|
|
893
948
|
$('about-body').innerHTML = lines.join('');
|
|
894
949
|
wireAboutUpgrade();
|
|
@@ -1061,6 +1116,7 @@ $('skill-sync-btn').onclick = () => withLoading(
|
|
|
1061
1116
|
return resp;
|
|
1062
1117
|
},
|
|
1063
1118
|
null,
|
|
1119
|
+
'quiet',
|
|
1064
1120
|
);
|
|
1065
1121
|
logoutBtn.onclick = () => withLoading(
|
|
1066
1122
|
logoutBtn,
|
package/lib/proc.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
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);
|
|
@@ -91,13 +91,26 @@ function extraBinDirs() {
|
|
|
91
91
|
}
|
|
92
92
|
dirs.push(join(home, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin');
|
|
93
93
|
}
|
|
94
|
-
// DSH Desktop
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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();
|
|
101
114
|
const desktopRoots = process.platform === 'win32'
|
|
102
115
|
? [
|
|
103
116
|
join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'DSH Desktop'),
|
|
@@ -120,15 +133,58 @@ function extraBinDirs() {
|
|
|
120
133
|
// 目录不存在:跳过
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
|
-
|
|
136
|
+
desktopShimDirsCache = dirs;
|
|
124
137
|
return dirs;
|
|
125
138
|
}
|
|
126
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
|
+
|
|
127
182
|
/** 安装完成后清空兑底目录缓存:一键安装 python/ccpg 后新目录要立即可
|
|
128
183
|
* 探测(python.org 装到 %LOCALAPPDATA%\Programs\Python\Python3xx),不
|
|
129
184
|
* 能等 DSH Desktop 重启。 */
|
|
130
185
|
export function resetExtraBinDirsCache() {
|
|
131
186
|
extraBinDirsCache = null;
|
|
187
|
+
desktopShimDirsCache = null;
|
|
132
188
|
}
|
|
133
189
|
|
|
134
190
|
/** 候选扩展名顺序(纯函数,供测试):win32 下 CreateProcess 只认可执行
|
|
@@ -151,7 +207,10 @@ export function candidateSuffixes(platform) {
|
|
|
151
207
|
export function cmdShimInvocation(platform, resolved, args) {
|
|
152
208
|
if (platform !== 'win32') return null;
|
|
153
209
|
if (!/\.(cmd|bat)$/i.test(resolved)) return null;
|
|
154
|
-
|
|
210
|
+
// 引号判定必须覆盖 cmd 元字符:& | < > ^ ( ) 在裸参里会被 cmd 当命令
|
|
211
|
+
// 分隔/重定向/转义符——授权 URL 里的 &user_code=... 曾被拆成独立命令,
|
|
212
|
+
// lark-cli 收到截断 URL 直接报错;引号内这些字符全部是字面量。
|
|
213
|
+
const quote = (s) => (/[\s"&|<>^()]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : String(s));
|
|
155
214
|
const line = [resolved, ...args].map(quote).join(' ');
|
|
156
215
|
return { file: 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], verbatim: true };
|
|
157
216
|
}
|
|
@@ -232,11 +291,11 @@ export function decodeOutput(buf) {
|
|
|
232
291
|
* encoding:buffer 收回后 decodeOutput(GBK 容错)。opts.env 只传增量变量。 */
|
|
233
292
|
function execResolved(tool, args, { timeoutMs, cwd, env } = {}) {
|
|
234
293
|
const resolved = resolveCommand(tool);
|
|
235
|
-
const childEnv = { ...process.env, ...(env || {}) };
|
|
294
|
+
const childEnv = { ...process.env, ...desktopShimEnvDefaults(), ...(env || {}) };
|
|
236
295
|
const key = pathEnvKey(childEnv, process.platform);
|
|
237
296
|
const extras = extraBinDirs().filter((dir) => existsSync(dir));
|
|
238
297
|
if (extras.length) {
|
|
239
|
-
childEnv[key] =
|
|
298
|
+
childEnv[key] = childPathWithExtras(childEnv[key], extras, desktopShimDirs());
|
|
240
299
|
}
|
|
241
300
|
const shim = cmdShimInvocation(process.platform, resolved, args);
|
|
242
301
|
return execFileAsync(shim ? shim.file : resolved, shim ? shim.args : args, {
|
|
@@ -650,11 +709,19 @@ async function installCcpgCliWindows(timeoutMs) {
|
|
|
650
709
|
mkdirSync(binDir, { recursive: true });
|
|
651
710
|
const url = await resolveCcpgWindowsDownloadUrl();
|
|
652
711
|
const staging = join(binDir, 'ccpg-cli.exe.download');
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|
|
658
725
|
renameSync(staging, target);
|
|
659
726
|
// 校验二进制可执行(PE 完整、架构匹配)
|
|
660
727
|
const { stdout: versionOut } = await runCliTool(target, ['--version'], { timeoutMs: 30_000 });
|
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
|
+
}
|