@jspg-ai/coding-bb 0.0.2-beta.28 → 0.0.2-beta.29
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/cbb/lib/install/init.js +109 -69
- package/cbb/lib/utils/checkbox.js +11 -11
- package/cbb/lib/utils/output.js +38 -16
- package/package.json +1 -1
package/cbb/lib/install/init.js
CHANGED
|
@@ -23,6 +23,21 @@ const yaml = require('js-yaml');
|
|
|
23
23
|
// 异步执行外部命令:长耗时 git 操作不阻塞事件循环,进度行才能持续刷新
|
|
24
24
|
const execFileAsync = promisify(execFile);
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 跨平台异步执行命令(不阻塞事件循环)。
|
|
28
|
+
* Windows 上 npm / openspec 均为 .cmd:Node 直接 spawn .cmd 会报 EINVAL,
|
|
29
|
+
* `shell: true` 又会触发 DEP0190 弃用警告;故统一经 cmd.exe /d /s /c 执行。
|
|
30
|
+
* 仅用于本文件内常量命令(不接受用户输入拼接)。
|
|
31
|
+
*/
|
|
32
|
+
function execCmd(file, args, opts = {}) {
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
const line = [file, ...args].join(' ');
|
|
35
|
+
return execFileAsync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', line],
|
|
36
|
+
{ windowsHide: true, ...opts });
|
|
37
|
+
}
|
|
38
|
+
return execFileAsync(file, args, opts);
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
const SHARED_STANDARDS_DIR = path.join(__dirname, '..', '..', '..');
|
|
27
42
|
|
|
28
43
|
// 目标项目根目录:默认取当前 cwd,init() / initWorkspace() 可在调用前覆盖。
|
|
@@ -211,7 +226,7 @@ function ask(question) {
|
|
|
211
226
|
output: process.stdout,
|
|
212
227
|
});
|
|
213
228
|
return new Promise(resolve => {
|
|
214
|
-
rl.question(question, answer => {
|
|
229
|
+
rl.question(' ' + question, answer => {
|
|
215
230
|
rl.close();
|
|
216
231
|
resolve(answer.trim());
|
|
217
232
|
});
|
|
@@ -623,22 +638,29 @@ const OPENSPEC_REGISTRY = 'https://registry.npmmirror.com';
|
|
|
623
638
|
/**
|
|
624
639
|
* 确保 OpenSpec CLI 已安装且为最新版本。
|
|
625
640
|
* 未安装或版本非最新时自动执行 npm install -g,无需用户确认。
|
|
641
|
+
* 命令异步执行(execFile)避免阻塞事件循环——否则长耗时的 npm 查询/安装
|
|
642
|
+
* 会让进度行冻结、看起来像卡死;传入 onProgress 时由调用方渲染实时进度,
|
|
643
|
+
* 否则退化为安装/升级前的一行灰字提示。
|
|
626
644
|
*/
|
|
627
|
-
async function ensureOpenSpecCli() {
|
|
645
|
+
async function ensureOpenSpecCli(onProgress = null) {
|
|
646
|
+
const report = text => { if (onProgress) onProgress(text); };
|
|
647
|
+
|
|
628
648
|
// 1. 获取当前版本(未安装时 currentVersion = null)
|
|
629
649
|
let currentVersion = null;
|
|
650
|
+
report('检查 OpenSpec CLI');
|
|
630
651
|
try {
|
|
631
|
-
|
|
632
|
-
|
|
652
|
+
const { stdout } = await execCmd('openspec', ['--version'], { timeout: 10000 });
|
|
653
|
+
currentVersion = stdout.toString().trim().replace(/^v/, '');
|
|
633
654
|
} catch (_) {}
|
|
634
655
|
|
|
635
656
|
// 2. 查询最新版本(网络失败则跳过检测,视为无法判断)
|
|
636
657
|
let latestVersion = null;
|
|
637
658
|
try {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
{
|
|
641
|
-
)
|
|
659
|
+
const { stdout } = await execCmd(
|
|
660
|
+
'npm', ['view', OPENSPEC_PKG, 'version', '--registry', OPENSPEC_REGISTRY],
|
|
661
|
+
{ timeout: 15000 }
|
|
662
|
+
);
|
|
663
|
+
latestVersion = stdout.toString().trim();
|
|
642
664
|
} catch (_) {}
|
|
643
665
|
|
|
644
666
|
// 3. 判断是否需要安装/升级
|
|
@@ -650,17 +672,20 @@ async function ensureOpenSpecCli() {
|
|
|
650
672
|
return { state: 'ok', version: currentVersion };
|
|
651
673
|
}
|
|
652
674
|
|
|
653
|
-
// 4.
|
|
675
|
+
// 4. 执行安装/升级(耗时操作:由调用方进度行或灰字提示覆盖全程)
|
|
654
676
|
const action = needInstall ? '安装' : '升级';
|
|
655
677
|
const detail = needInstall
|
|
656
678
|
? `→ v${latestVersion || 'latest'}`
|
|
657
679
|
: `v${currentVersion} → v${latestVersion}`;
|
|
658
|
-
|
|
680
|
+
report(`${action} OpenSpec CLI(${detail})`);
|
|
681
|
+
if (!onProgress) {
|
|
682
|
+
console.log(` ${c('gray', '⏳')} ${c('gray', `${action} OpenSpec CLI(${detail})...`)}`);
|
|
683
|
+
}
|
|
659
684
|
|
|
660
685
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
{
|
|
686
|
+
await execCmd(
|
|
687
|
+
'npm', ['install', '-g', `${OPENSPEC_PKG}@latest`, '--registry', OPENSPEC_REGISTRY],
|
|
688
|
+
{ timeout: 120000 }
|
|
664
689
|
);
|
|
665
690
|
return { state: needInstall ? 'installed' : 'upgraded', version: latestVersion || currentVersion };
|
|
666
691
|
} catch (err) {
|
|
@@ -799,7 +824,12 @@ async function init(targetDir, options = {}) {
|
|
|
799
824
|
const selectedAdapters = isNonInteractive ? adapters : await selectTools(isUpgrade);
|
|
800
825
|
|
|
801
826
|
// 前置依赖检测(放在选适配器之后,只检测与所选工具相关的依赖)
|
|
802
|
-
|
|
827
|
+
const depProgress = progress('依赖');
|
|
828
|
+
try {
|
|
829
|
+
await ensureOpenSpecCli(t => depProgress.update(t));
|
|
830
|
+
} finally {
|
|
831
|
+
depProgress.done();
|
|
832
|
+
}
|
|
803
833
|
|
|
804
834
|
// 分发核心规则到各工具目录
|
|
805
835
|
console.log(`\n 📦 ${action}核心编码规范...`);
|
|
@@ -943,7 +973,7 @@ async function init(targetDir, options = {}) {
|
|
|
943
973
|
if (isUpgrade) {
|
|
944
974
|
const oldPaths = pathsToDelete(managedPaths, newPaths);
|
|
945
975
|
if (oldPaths.length > 0) {
|
|
946
|
-
section('
|
|
976
|
+
section('!', `清理上一版本残留(${oldPaths.length} 个)`);
|
|
947
977
|
// 先全部删除
|
|
948
978
|
oldPaths.forEach(oldPath => {
|
|
949
979
|
const targetPath = path.join(TARGET_DIR, oldPath);
|
|
@@ -959,7 +989,7 @@ async function init(targetDir, options = {}) {
|
|
|
959
989
|
|
|
960
990
|
const kept = protectedKeptPaths(managedPaths, newPaths);
|
|
961
991
|
if (kept.length > 0) {
|
|
962
|
-
section('
|
|
992
|
+
section('!', '保留用户文档');
|
|
963
993
|
kept.forEach(k => {
|
|
964
994
|
log(` ${k}(本包已不再管理,文件可能含用户内容,未删除)`, 'warn');
|
|
965
995
|
});
|
|
@@ -1058,8 +1088,6 @@ async function installWorkspaceConfig(targetDir, options = {}) {
|
|
|
1058
1088
|
selectedAdapters = await selectTools(isUpgrade);
|
|
1059
1089
|
}
|
|
1060
1090
|
}
|
|
1061
|
-
const openspecCli = await ensureOpenSpecCli();
|
|
1062
|
-
|
|
1063
1091
|
// 只装 worktree 生命周期管理 Skills(init/close/push)
|
|
1064
1092
|
const skillsAdapters = selectedAdapters.filter(a => a.skillsDir);
|
|
1065
1093
|
const commandsAdapters = selectedAdapters.filter(a => a.worktreeCommandsDir);
|
|
@@ -1067,48 +1095,63 @@ async function installWorkspaceConfig(targetDir, options = {}) {
|
|
|
1067
1095
|
let cmdCount = 0;
|
|
1068
1096
|
let adapterRoots = [];
|
|
1069
1097
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
r.names.forEach(name => newPaths.add(`${adapter.skillsDir}/${name}`));
|
|
1078
|
-
}
|
|
1079
|
-
});
|
|
1080
|
-
adapterRoots = skillsAdapters.map(a => a.skillsDir.split('/')[0]);
|
|
1081
|
-
}
|
|
1098
|
+
// 「AI 配置」阶段心跳:检查依赖 → 部署 skills/commands → 写入 openspec 配置 → 生成导航。
|
|
1099
|
+
// 其中 npm 查询/安装最长可达分钟级,必须让用户看到进程仍活着;任何异常先清掉进度行。
|
|
1100
|
+
const cfgProgress = progress('AI 配置');
|
|
1101
|
+
let agentsMdState = 'exists';
|
|
1102
|
+
let openspecRes;
|
|
1103
|
+
try {
|
|
1104
|
+
await ensureOpenSpecCli(t => cfgProgress.update(t));
|
|
1082
1105
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1106
|
+
if (skillsAdapters.length > 0) {
|
|
1107
|
+
cfgProgress.update('部署 skills');
|
|
1108
|
+
const openspecExtendsSkillsSrc = path.join(SHARED_STANDARDS_DIR, 'cbb', 'worktrees', 'skills');
|
|
1109
|
+
const osExtFilter = ['cbb-worktree-init', 'cbb-worktree-close', 'cbb-worktree-push'];
|
|
1110
|
+
skillsAdapters.forEach(adapter => {
|
|
1111
|
+
const r = deploySkills(adapter, isUpgrade, managedPaths, openspecExtendsSkillsSrc, osExtFilter);
|
|
1112
|
+
if (r.count > 0) {
|
|
1113
|
+
skillCount = Math.max(skillCount, r.count);
|
|
1114
|
+
r.names.forEach(name => newPaths.add(`${adapter.skillsDir}/${name}`));
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
adapterRoots = skillsAdapters.map(a => a.skillsDir.split('/')[0]);
|
|
1118
|
+
}
|
|
1096
1119
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
.
|
|
1120
|
+
// 只装 worktree 管理 Commands(cbb:worktree-init/close/push)
|
|
1121
|
+
if (commandsAdapters.length > 0) {
|
|
1122
|
+
cfgProgress.update('部署 worktree 命令');
|
|
1123
|
+
const commandsExtendsSrc = path.join(SHARED_STANDARDS_DIR, 'cbb', 'worktrees', 'commands');
|
|
1124
|
+
const cmdFilter = ['worktree-init.md', 'worktree-close.md', 'worktree-push.md'];
|
|
1125
|
+
commandsAdapters.forEach(adapter => {
|
|
1126
|
+
const r = deployWorktreeCommands(adapter, commandsExtendsSrc, cmdFilter);
|
|
1127
|
+
if (r.count > 0) {
|
|
1128
|
+
cmdCount = Math.max(cmdCount, r.count);
|
|
1129
|
+
r.names.forEach(name => newPaths.add(`${adapter.worktreeCommandsDir}/${name}`));
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
if (adapterRoots.length === 0) adapterRoots = commandsAdapters.map(a => a.worktreeCommandsDir.split('/')[0]);
|
|
1105
1133
|
}
|
|
1106
|
-
});
|
|
1107
|
-
const openspecRes = setupOpenSpec(isUpgrade, managedPaths);
|
|
1108
|
-
newPaths.add('openspec/config.yaml');
|
|
1109
1134
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1135
|
+
// 设置 OpenSpec 工作流(config.yaml + schemas)
|
|
1136
|
+
cfgProgress.update('写入 openspec 配置');
|
|
1137
|
+
const schemasSrcs = [
|
|
1138
|
+
path.join(SHARED_STANDARDS_DIR, 'config', 'openspec', 'schemas'),
|
|
1139
|
+
];
|
|
1140
|
+
schemasSrcs.forEach(src => {
|
|
1141
|
+
if (fs.existsSync(src)) {
|
|
1142
|
+
fs.readdirSync(src).filter(f2 => fs.statSync(path.join(src, f2)).isDirectory())
|
|
1143
|
+
.forEach(name => newPaths.add(`openspec/schemas/${name}`));
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
openspecRes = setupOpenSpec(isUpgrade, managedPaths);
|
|
1147
|
+
newPaths.add('openspec/config.yaml');
|
|
1148
|
+
|
|
1149
|
+
// 空间导航文件 AGENTS.md:仅缺失时生成,永不覆盖(不登记清单,卸载不删)
|
|
1150
|
+
cfgProgress.update('生成空间导航');
|
|
1151
|
+
agentsMdState = workspaces.ensureWorkspaceAgentsMd(TARGET_DIR);
|
|
1152
|
+
} finally {
|
|
1153
|
+
cfgProgress.done();
|
|
1154
|
+
}
|
|
1112
1155
|
|
|
1113
1156
|
// 升级时清理旧路径(workspace 曾装过的 base 工作流等不属于此子集,删除)
|
|
1114
1157
|
const cleaned = [];
|
|
@@ -1149,11 +1192,11 @@ async function installWorkspaceConfig(targetDir, options = {}) {
|
|
|
1149
1192
|
if (cleaned.length > 0) {
|
|
1150
1193
|
const MAX_DISPLAY = 3;
|
|
1151
1194
|
const preview = cleaned.slice(0, MAX_DISPLAY).join('、');
|
|
1152
|
-
step('
|
|
1195
|
+
step('!', '清理残留', `${cleaned.length} 个(${preview}${cleaned.length > MAX_DISPLAY ? ' 等' : ''})`,
|
|
1153
1196
|
{ iconColor: 'yellow' });
|
|
1154
1197
|
}
|
|
1155
1198
|
if (keptDocs.length > 0) {
|
|
1156
|
-
step('
|
|
1199
|
+
step('!', '保留文档', `${keptDocs.join('、')}(本包已不再管理)`, { iconColor: 'yellow' });
|
|
1157
1200
|
}
|
|
1158
1201
|
}
|
|
1159
1202
|
|
|
@@ -1341,9 +1384,8 @@ async function syncWorkspaceRepo(targetDir, onProgress = () => {}) {
|
|
|
1341
1384
|
if (currentBranch !== defaultBranch) {
|
|
1342
1385
|
log(`本地当前分支 ${currentBranch} ≠ 远程默认分支 ${defaultBranch},正在切换...`, 'warn');
|
|
1343
1386
|
try {
|
|
1344
|
-
|
|
1387
|
+
await execFileAsync('git', ['checkout', '-q', defaultBranch], {
|
|
1345
1388
|
cwd: targetDir,
|
|
1346
|
-
stdio: 'pipe',
|
|
1347
1389
|
timeout: 30000,
|
|
1348
1390
|
});
|
|
1349
1391
|
} catch (e) {
|
|
@@ -1355,9 +1397,8 @@ async function syncWorkspaceRepo(targetDir, onProgress = () => {}) {
|
|
|
1355
1397
|
|
|
1356
1398
|
// 4. fast-forward merge 到 origin/<默认分支>
|
|
1357
1399
|
try {
|
|
1358
|
-
|
|
1400
|
+
await execFileAsync('git', ['merge', '--ff-only', '-q', `origin/${defaultBranch}`], {
|
|
1359
1401
|
cwd: targetDir,
|
|
1360
|
-
stdio: 'pipe',
|
|
1361
1402
|
timeout: 60000,
|
|
1362
1403
|
});
|
|
1363
1404
|
} catch (e) {
|
|
@@ -1515,7 +1556,7 @@ function printBatchSummary(results) {
|
|
|
1515
1556
|
}
|
|
1516
1557
|
step('!', '完成', `成功 ${ok.length}/${results.length},失败项见下`, { iconColor: 'yellow', valueColor: 'yellow' });
|
|
1517
1558
|
failed.forEach(r => {
|
|
1518
|
-
step('✗', r.w.name
|
|
1559
|
+
step('✗', '失败', `${r.w.name}:${describeBatchError(r)}`, { iconColor: 'red', labelColor: 'red', valueColor: 'red' });
|
|
1519
1560
|
});
|
|
1520
1561
|
}
|
|
1521
1562
|
|
|
@@ -1609,7 +1650,6 @@ async function setupWorkspace() {
|
|
|
1609
1650
|
const result = await initSingleWorkspace(w, { selectedAdapters });
|
|
1610
1651
|
results.push({ w, ...result });
|
|
1611
1652
|
}
|
|
1612
|
-
console.log('');
|
|
1613
1653
|
printBatchSummary(results);
|
|
1614
1654
|
}
|
|
1615
1655
|
|
|
@@ -1620,11 +1660,11 @@ function printSetupSummary(selected, selectedAdapters, parentDir) {
|
|
|
1620
1660
|
console.log(' ' + c('bold', 'cbb setup') + ' ' + c('gray', `v${pkg.version}`));
|
|
1621
1661
|
console.log('');
|
|
1622
1662
|
selected.forEach(w => {
|
|
1623
|
-
console.log(`
|
|
1624
|
-
console.log(`
|
|
1663
|
+
console.log(` ${c('yellow', '[' + w.businessLine + ']')} ${w.name}`);
|
|
1664
|
+
console.log(` ${c('gray', w.repo)}`);
|
|
1625
1665
|
});
|
|
1626
|
-
console.log('
|
|
1627
|
-
console.log('
|
|
1666
|
+
console.log(' ' + c('gray', padVisual('目标目录', 10)) + ' ' + c('cyan', parentDir));
|
|
1667
|
+
console.log(' ' + c('gray', padVisual('AI 工具', 10)) + ' ' + selectedAdapters.map(a => a.displayName).join('、'));
|
|
1628
1668
|
}
|
|
1629
1669
|
|
|
1630
1670
|
// ── 用户级组件 ────────────────────────────────────────────
|
|
@@ -1822,7 +1862,7 @@ async function uninstall() {
|
|
|
1822
1862
|
const managedPaths = readManifest();
|
|
1823
1863
|
|
|
1824
1864
|
// 确认卸载
|
|
1825
|
-
console.log('\n🗑 卸载 @jspg-ai/coding-bb...\n');
|
|
1865
|
+
console.log('\n 🗑 卸载 @jspg-ai/coding-bb...\n');
|
|
1826
1866
|
const answer = await ask('确认卸载?此操作将移除本包管理的所有文件 (y/N): ');
|
|
1827
1867
|
if (answer.toLowerCase() !== 'y') {
|
|
1828
1868
|
log('已取消卸载');
|
|
@@ -87,8 +87,8 @@ function renderLines(state) {
|
|
|
87
87
|
const lines = [];
|
|
88
88
|
|
|
89
89
|
if (header) {
|
|
90
|
-
lines.push(c('bold', header));
|
|
91
|
-
lines.push(c('gray', '─'.repeat(48)));
|
|
90
|
+
lines.push(' ' + c('bold', header));
|
|
91
|
+
lines.push(' ' + c('gray', '─'.repeat(48)));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
if (items.length === 0) {
|
|
@@ -99,21 +99,21 @@ function renderLines(state) {
|
|
|
99
99
|
const isSelected = selected.has(i);
|
|
100
100
|
const cursorMark = isCursor ? c('cyan', '❯') : ' ';
|
|
101
101
|
const checkMark = isSelected ? c('green', '☑') : c('gray', '☐');
|
|
102
|
-
const raw = `
|
|
102
|
+
const raw = ` ${cursorMark} ${checkMark} ${item.label}`;
|
|
103
103
|
// 当前行整体加粗 + 反转视频:浅色终端也能识别
|
|
104
104
|
const line = isCursor && COLOR_ENABLED
|
|
105
105
|
? `${COLOR.bold}${COLOR.reverse}${raw}${COLOR.reset}`
|
|
106
106
|
: (isCursor ? c('bold', raw) : raw);
|
|
107
107
|
lines.push(line);
|
|
108
108
|
if (item.hint) {
|
|
109
|
-
lines.push(c('gray', '
|
|
109
|
+
lines.push(c('gray', ' ' + item.hint));
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
if (footer) {
|
|
115
115
|
lines.push('');
|
|
116
|
-
lines.push(c('gray', footer));
|
|
116
|
+
lines.push(' ' + c('gray', footer));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
return lines;
|
|
@@ -325,21 +325,21 @@ function promptCheckboxFallback({ items, header, footer, preSelected }) {
|
|
|
325
325
|
};
|
|
326
326
|
|
|
327
327
|
// 输出列表(无光标、无高亮,逐项标号)
|
|
328
|
-
console.log(c('bold', header) || '');
|
|
329
|
-
console.log(c('gray', '─'.repeat(48)));
|
|
328
|
+
console.log(' ' + (c('bold', header) || ''));
|
|
329
|
+
console.log(' ' + c('gray', '─'.repeat(48)));
|
|
330
330
|
items.forEach((item, i) => {
|
|
331
|
-
console.log(`
|
|
332
|
-
if (item.hint) console.log(c('gray', '
|
|
331
|
+
console.log(` ${c('cyan', String(i + 1) + '.')} ${item.label}`);
|
|
332
|
+
if (item.hint) console.log(c('gray', ' ' + item.hint));
|
|
333
333
|
});
|
|
334
334
|
console.log('');
|
|
335
|
-
if (footer) console.log(c('gray', footer));
|
|
335
|
+
if (footer) console.log(' ' + c('gray', footer));
|
|
336
336
|
|
|
337
337
|
const rl = readline.createInterface({
|
|
338
338
|
input: process.stdin,
|
|
339
339
|
output: process.stdout,
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
-
rl.question(
|
|
342
|
+
rl.question(` 请输入编号(多选用逗号分隔): `, (answer) => {
|
|
343
343
|
rl.close();
|
|
344
344
|
const selected = parseBulkInput(answer, items.length);
|
|
345
345
|
resolve(selected);
|
package/cbb/lib/utils/output.js
CHANGED
|
@@ -37,34 +37,51 @@ const LOG_STYLES = {
|
|
|
37
37
|
|
|
38
38
|
function log(msg, type = 'info') {
|
|
39
39
|
const s = LOG_STYLES[type] || LOG_STYLES.info;
|
|
40
|
-
const text =
|
|
40
|
+
const text = ` ${s.icon} ${msg}`;
|
|
41
41
|
console.log(s.color ? c(s.color, text) : text);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** 输出一行分节标题(带图标 + 颜色),紧跟一行灰色分隔线 */
|
|
45
45
|
function section(icon, title) {
|
|
46
46
|
console.log('');
|
|
47
|
-
console.log(c('bold', `${icon} ${title}`));
|
|
48
|
-
console.log(c('gray', '─'.repeat(48)));
|
|
47
|
+
console.log(' ' + c('bold', `${icon} ${title}`));
|
|
48
|
+
console.log(' ' + c('gray', '─'.repeat(48)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 单字符视觉宽度:CJK / 全角按 2 列计,其余按 1 列 */
|
|
52
|
+
function charWidth(ch) {
|
|
53
|
+
const cp = ch.codePointAt(0);
|
|
54
|
+
const wide =
|
|
55
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
56
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
57
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
|
58
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
59
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
60
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
61
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
62
|
+
(cp >= 0xffe0 && cp <= 0xffe6);
|
|
63
|
+
return wide ? 2 : 1;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
/** 视觉宽度:CJK / 全角字符按 2 列计,其余按 1 列(终端对齐用) */
|
|
52
67
|
function visualWidth(text) {
|
|
53
68
|
let w = 0;
|
|
69
|
+
for (const ch of String(text)) w += charWidth(ch);
|
|
70
|
+
return w;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 按视觉宽度截断文本(超出补 …),用于防止进度行折行残留 */
|
|
74
|
+
function sliceVisual(text, maxWidth) {
|
|
75
|
+
if (visualWidth(text) <= maxWidth) return text;
|
|
76
|
+
let w = 0;
|
|
77
|
+
let out = '';
|
|
54
78
|
for (const ch of String(text)) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
|
60
|
-
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
61
|
-
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
62
|
-
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
63
|
-
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
64
|
-
(cp >= 0xffe0 && cp <= 0xffe6);
|
|
65
|
-
w += wide ? 2 : 1;
|
|
79
|
+
const cw = charWidth(ch);
|
|
80
|
+
if (w + cw > maxWidth - 1) return out + '…';
|
|
81
|
+
out += ch;
|
|
82
|
+
w += cw;
|
|
66
83
|
}
|
|
67
|
-
return
|
|
84
|
+
return out;
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
/** 按视觉宽度右补空格(中文标签对齐用) */
|
|
@@ -96,7 +113,11 @@ function progress(label, opts = {}) {
|
|
|
96
113
|
|
|
97
114
|
const render = () => {
|
|
98
115
|
const sec = elapsed();
|
|
99
|
-
|
|
116
|
+
// 按终端宽度截断内容,防止折行后 \x1b[2K 清不净留下残影
|
|
117
|
+
const cols = stream.columns || 0;
|
|
118
|
+
const maxText = cols > 0 ? Math.max(16, cols - 22) : 0;
|
|
119
|
+
const shown = maxText > 0 ? sliceVisual(text, maxText) : text;
|
|
120
|
+
const value = sec > 0 ? `${shown} ${c('gray', sec + 's')}` : shown;
|
|
100
121
|
stream.write(`\r\x1b[2K${head(SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length])} ${value}`);
|
|
101
122
|
};
|
|
102
123
|
|
|
@@ -146,6 +167,7 @@ module.exports = {
|
|
|
146
167
|
log,
|
|
147
168
|
section,
|
|
148
169
|
visualWidth,
|
|
170
|
+
sliceVisual,
|
|
149
171
|
padVisual,
|
|
150
172
|
progress,
|
|
151
173
|
step,
|