kld-sdd 2.7.8-2 → 2.7.8-4
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 +2 -0
- package/USABILITY.md +52 -0
- package/bin/kld-sdd-init.js +700 -34
- package/lib/command-bridge.js +26 -17
- package/lib/deploy-codebuddy-hooks.js +19 -26
- package/lib/deploy-strategy.js +293 -0
- package/lib/hook-gate-core.js +7 -8
- package/lib/hook-install.js +75 -0
- package/lib/init.js +450 -711
- package/lib/managed-hook-hashes.json +24 -0
- package/lib/settings-merge.js +102 -25
- package/lib/skills-bundle.js +2 -4
- package/lib/spec-choice.js +85 -0
- package/lib/tool-profiles.js +2 -2
- package/lib/uninstall.js +764 -0
- package/package.json +3 -3
- package/skywalk-sdd/index.cjs +1 -1
- package/skywalk-sdd/kb-upload.cjs +64 -49
- package/skywalk-sdd/lib/shared.cjs +62 -41
- package/skywalk-sdd/lib/test-execution.cjs +20 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
- package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +46 -16
- package/templates/git-hooks/commit-msg +17 -99
- package/templates/git-hooks/commit-msg-runner.cjs +44 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
- package/templates/git-hooks/consistency-check-core.cjs +61 -240
- package/templates/git-hooks/delivery-check.cjs +231 -0
- package/templates/git-hooks/delivery-snapshot.cjs +181 -0
- package/templates/git-hooks/hooks.config +8 -6
- package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
- package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
- package/templates/openspec/proposal.md +1 -1
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +24 -22
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +157 -11
- package/index.js +0 -13
package/lib/uninstall.js
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KLD SDD 卸载
|
|
3
|
+
*
|
|
4
|
+
* 与 init 共用同一套「产物归属」判定,方向相反:
|
|
5
|
+
* - 工具产物(安装时 OVERWRITE)→ 删除
|
|
6
|
+
* - 用户配置(安装时 SKIP_IF_EXISTS)→ 默认保留,仅 --purge 才删
|
|
7
|
+
* - MERGE 类(settings.json)→ 只摘除 SDD 受管 hook,文件其余内容原样保留
|
|
8
|
+
* - MARKER 类(.git/hooks)→ 只删含受管标记的,非受管 hook 保留
|
|
9
|
+
* - APPEND_LINES(.gitignore)→ 只摘除 SDD 块,其余行保留
|
|
10
|
+
* - 运行时数据(events/state)→ 默认保留(含用户历史度量),仅 --purge 才删
|
|
11
|
+
* - 团队/项目配置(project-identity.json、git-hooks/hooks.config)→ 默认保留。
|
|
12
|
+
* 二者都不是「工具可重置」的产物:前者是团队共享的 KB 身份(需提交 Git),
|
|
13
|
+
* 后者是项目级门禁参数(init 用 SKIP_IF_EXISTS 保护),重跑 init 不得重置。
|
|
14
|
+
*
|
|
15
|
+
* 两种模式:
|
|
16
|
+
* - MODE.TOOLS_ONLY(默认):只删工具产物,保留全部用户配置与数据
|
|
17
|
+
* - MODE.FULL(--purge):连用户配置与运行时数据一并删除
|
|
18
|
+
*
|
|
19
|
+
* 安全约束:
|
|
20
|
+
* - 任何删除前先经 scope 白名单校验(只允许操作 SDD 自有目录 / 受管标记文件)
|
|
21
|
+
* - --dry-run 只打印计划,不落盘
|
|
22
|
+
* - 删除文件后清理变空的目录,但不删除用户可能仍有内容的目录
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const { isManagedBridgeFile } = require('./command-bridge');
|
|
31
|
+
const { PROVIDERS, stripManagedHooks } = require('./settings-merge');
|
|
32
|
+
const { DEFAULT_HOOK_MARKER } = require('./deploy-strategy');
|
|
33
|
+
const { getToolProfile, listToolProfileIds, buildToolConfigs } = require('./tool-profiles');
|
|
34
|
+
const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
35
|
+
const workspaceLayout = require('./workspace-layout');
|
|
36
|
+
// spec 候选枚举的唯一入口(含 tier 分级与排序)
|
|
37
|
+
const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
|
|
38
|
+
|
|
39
|
+
/** 卸载模式 */
|
|
40
|
+
const MODE = Object.freeze({
|
|
41
|
+
/** 只删工具内容,保留用户配置与运行时数据 */
|
|
42
|
+
TOOLS_ONLY: 'tools-only',
|
|
43
|
+
/** 全量删除,含用户配置与运行时数据 */
|
|
44
|
+
FULL: 'full',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** 操作类型(用于计划与摘要) */
|
|
48
|
+
const OP = Object.freeze({
|
|
49
|
+
DELETE_FILE: 'delete-file',
|
|
50
|
+
DELETE_DIR: 'delete-dir',
|
|
51
|
+
STRIP_HOOKS: 'strip-hooks',
|
|
52
|
+
STRIP_GITIGNORE: 'strip-gitignore',
|
|
53
|
+
UNLINK_GIT_CONFIG: 'unlink-git-config',
|
|
54
|
+
KEEP: 'keep',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** .gitignore 中 SDD 块的起始标记 */
|
|
58
|
+
const GITIGNORE_MARKER = 'KLD SDD';
|
|
59
|
+
|
|
60
|
+
function createPlan(root = null) {
|
|
61
|
+
return { ops: [], warnings: [], root: root ? path.resolve(root) : null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addOp(plan, op, target, reason) {
|
|
65
|
+
plan.ops.push({ op, target, reason });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function warn(plan, message) {
|
|
69
|
+
plan.warnings.push(message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 列出目录下所有文件(递归),目录不存在返回空 */
|
|
73
|
+
function listFilesRecursive(dir) {
|
|
74
|
+
if (!fs.existsSync(dir)) return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const name of fs.readdirSync(dir)) {
|
|
77
|
+
const p = path.join(dir, name);
|
|
78
|
+
let st;
|
|
79
|
+
try {
|
|
80
|
+
st = fs.lstatSync(p);
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (st.isDirectory()) {
|
|
85
|
+
out.push(...listFilesRecursive(p));
|
|
86
|
+
} else {
|
|
87
|
+
out.push(p);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 规划:编辑器专属产物(skills / commands / hooks / rules)
|
|
95
|
+
*
|
|
96
|
+
* 策略:
|
|
97
|
+
* - `skillsDir/opsx-*` 与 `openspec-sync-specs` 等 SDD skill 目录 → 整体删(工具产物)
|
|
98
|
+
* - `opsxCommandsDir/*.md` → 只删含 BRIDGE_MARKER 的
|
|
99
|
+
* - `configDir/hooks/sdd-*.cjs` + `hook-gate-core.cjs` → 删(工具产物)
|
|
100
|
+
* - `configDir/settings.json` → 摘除受管 hook(保留文件)
|
|
101
|
+
*/
|
|
102
|
+
function planProfileArtifacts(plan, toolKey, cwd) {
|
|
103
|
+
const profile = getToolProfile(toolKey);
|
|
104
|
+
const config = buildToolConfigs()[toolKey];
|
|
105
|
+
if (!config) return;
|
|
106
|
+
|
|
107
|
+
// 1) skills:SDD 部署的 skill 目录(opsx-* + openspec-sync-specs/update-change + tdd-*)
|
|
108
|
+
if (profile.skillsDir) {
|
|
109
|
+
const skillsDir = path.join(cwd, profile.skillsDir);
|
|
110
|
+
if (fs.existsSync(skillsDir)) {
|
|
111
|
+
for (const name of fs.readdirSync(skillsDir)) {
|
|
112
|
+
const target = path.join(skillsDir, name);
|
|
113
|
+
if (!fs.statSync(target).isDirectory()) continue;
|
|
114
|
+
if (!/^(opsx-|openspec-(sync-specs|update-change)$)|^tdd-/.test(name)) continue;
|
|
115
|
+
addOp(plan, OP.DELETE_DIR, target, `${config.name} SDD skill 目录`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2) Kunlun command bridge:只删含受管标记的
|
|
121
|
+
if (profile.requiresCommandBridge && profile.opsxCommandsDir) {
|
|
122
|
+
const opsxDir = path.join(cwd, profile.opsxCommandsDir);
|
|
123
|
+
for (const file of listFilesRecursive(opsxDir)) {
|
|
124
|
+
if (!file.endsWith('.md')) continue;
|
|
125
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
126
|
+
if (isManagedBridgeFile(content)) {
|
|
127
|
+
addOp(plan, OP.DELETE_FILE, file, 'SDD command bridge(受管标记)');
|
|
128
|
+
} else {
|
|
129
|
+
addOp(plan, OP.KEEP, file, '非 SDD 受管命令,保留');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3) Hook 脚本目录(claude / codebuddy)
|
|
135
|
+
if (profile.hookProvider && profile.hookProvider !== 'none') {
|
|
136
|
+
const hooksDir = path.join(cwd, profile.configDir, 'hooks');
|
|
137
|
+
for (const file of listFilesRecursive(hooksDir)) {
|
|
138
|
+
const base = path.basename(file);
|
|
139
|
+
if (/^sdd-[\w-]+\.cjs$/.test(base) || base === 'hook-gate-core.cjs') {
|
|
140
|
+
addOp(plan, OP.DELETE_FILE, file, `${config.name} SDD hook 脚本`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4) settings.json:摘除受管 hook
|
|
145
|
+
const settingsPath = path.join(cwd, profile.configDir, 'settings.json');
|
|
146
|
+
if (fs.existsSync(settingsPath)) {
|
|
147
|
+
addOp(plan, OP.STRIP_HOOKS, settingsPath, `${config.name} settings.json 摘除 SDD 受管 hook`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 5) rules 目录(cursor .cursor/rules 等)中的 SDD 规则
|
|
152
|
+
if (profile.rulesDir) {
|
|
153
|
+
const rulesDir = path.join(cwd, profile.rulesDir);
|
|
154
|
+
for (const file of listFilesRecursive(rulesDir)) {
|
|
155
|
+
const base = path.basename(file);
|
|
156
|
+
if (/^(sdd|opsx)[\w.-]*\.(md|mdc)$/i.test(base)) {
|
|
157
|
+
addOp(plan, OP.DELETE_FILE, file, `${config.name} SDD rules`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 规划:spec 仓 `skywalk-sdd/git-hooks/` 目录(混合归属)
|
|
165
|
+
*
|
|
166
|
+
* init 侧 `ensureSpecGitHooks` 对同一目录用了两套策略:
|
|
167
|
+
* - `*.cjs` 工具产物 → OVERWRITE
|
|
168
|
+
* - `hooks.config` 用户配置 → SKIP_IF_EXISTS
|
|
169
|
+
*
|
|
170
|
+
* 卸载侧必须按同一判据逐项处理,否则「装进去的删不掉」或
|
|
171
|
+
* 「用户配置被静默重置」二者必居其一。
|
|
172
|
+
*/
|
|
173
|
+
function planSpecGitHooks(plan, hooksDir) {
|
|
174
|
+
for (const name of fs.readdirSync(hooksDir)) {
|
|
175
|
+
const target = path.join(hooksDir, name);
|
|
176
|
+
let st;
|
|
177
|
+
try {
|
|
178
|
+
st = fs.statSync(target);
|
|
179
|
+
} catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (st.isDirectory()) {
|
|
183
|
+
addOp(plan, OP.DELETE_DIR, target, 'SDD 运行时(工具内容)');
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (name === 'hooks.config') {
|
|
187
|
+
addOp(plan, OP.KEEP, target, '项目级门禁配置(init 用 SKIP_IF_EXISTS 保护),保留');
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
addOp(plan, OP.DELETE_FILE, target, 'SDD Git hook 脚本(工具产物)');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 规划:spec 仓内容
|
|
196
|
+
*
|
|
197
|
+
* 工具产物:skywalk-sdd/(除下方保留项)、openspec-templates/、操作手册
|
|
198
|
+
* 用户配置:modules.yaml、sdd.config.yaml、kb-state.json(仅 FULL 删)
|
|
199
|
+
* 团队/项目配置:project-identity.json、git-hooks/hooks.config(两种模式都保留)
|
|
200
|
+
* 文档工作区:openspec/(仅 FULL 整目录删)
|
|
201
|
+
*
|
|
202
|
+
* 注意:spec 仓自身的 `.git/hooks`(init 用 deployQualityGateTemplates 装的
|
|
203
|
+
* commit-msg / pre-commit / pre-push)由 buildUninstallPlan 调用 planCodeRepo
|
|
204
|
+
* 统一处理,保证「装得进去的一定摘得掉」,且与代码仓用同一套受管标记判据。
|
|
205
|
+
*/
|
|
206
|
+
function planSpecPackage(plan, specRoot, options) {
|
|
207
|
+
if (!specRoot || !fs.existsSync(specRoot)) return;
|
|
208
|
+
|
|
209
|
+
const skywalkDir = path.join(specRoot, 'skywalk-sdd');
|
|
210
|
+
if (fs.existsSync(skywalkDir)) {
|
|
211
|
+
if (options.mode === MODE.FULL) {
|
|
212
|
+
// 透明性:project-identity.json 是团队共享的 KB 身份(已提交 spec 仓 Git),
|
|
213
|
+
// 全量卸载会一并清除,需重跑 /opsx-kb-config 并重新提交。先单独列出,
|
|
214
|
+
// 让用户在预览里明确看到「团队配置将被清除」——比静默随目录消失可审计。
|
|
215
|
+
// 顺序必须在 DELETE_DIR 之前,否则父目录先被删、该项会被跳过。
|
|
216
|
+
const identity = path.join(skywalkDir, 'project-identity.json');
|
|
217
|
+
if (fs.existsSync(identity)) {
|
|
218
|
+
addOp(plan, OP.DELETE_FILE, identity, 'KB 项目身份(团队共享,需重跑 /opsx-kb-config 并重新提交)');
|
|
219
|
+
}
|
|
220
|
+
addOp(plan, OP.DELETE_DIR, skywalkDir, 'SDD 运行时(含 events/state 数据)');
|
|
221
|
+
} else {
|
|
222
|
+
// 工具内容删除:删工具脚本,保留运行时数据与用户/团队配置。
|
|
223
|
+
//
|
|
224
|
+
// 保留项判据是「重跑 init 能不能恢复」:
|
|
225
|
+
// - events/、state/ 运行时数据(用户历史度量),init 不重建内容
|
|
226
|
+
// - project-identity.json 团队共享的 KB 身份,由 /opsx-kb-config 写、
|
|
227
|
+
// 需提交 spec 仓 Git,删掉等于毁掉团队配置
|
|
228
|
+
// - git-hooks/hooks.config 项目级门禁参数(business_code_exts/strict/
|
|
229
|
+
// warn_only),init 用 SKIP_IF_EXISTS 保护,
|
|
230
|
+
// 随目录一起删会让重跑 init 静默重置用户设置
|
|
231
|
+
const keepItems = new Map([
|
|
232
|
+
['events', '运行时数据(度量),保留'],
|
|
233
|
+
['state', '运行时数据(状态),保留'],
|
|
234
|
+
['project-identity.json', 'KB 项目身份(团队共享,保留)'],
|
|
235
|
+
]);
|
|
236
|
+
for (const name of fs.readdirSync(skywalkDir)) {
|
|
237
|
+
const target = path.join(skywalkDir, name);
|
|
238
|
+
if (keepItems.has(name)) {
|
|
239
|
+
addOp(plan, OP.KEEP, target, keepItems.get(name));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// git-hooks/ 是混合目录:*.cjs 是工具产物(OVERWRITE),
|
|
243
|
+
// hooks.config 是用户配置(SKIP_IF_EXISTS)→ 逐项判定,不整目录删。
|
|
244
|
+
if (name === 'git-hooks' && fs.statSync(target).isDirectory()) {
|
|
245
|
+
planSpecGitHooks(plan, target);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const st = fs.statSync(target);
|
|
249
|
+
addOp(plan, st.isDirectory() ? OP.DELETE_DIR : OP.DELETE_FILE, target, 'SDD 运行时(工具内容)');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const templatesDir = path.join(specRoot, 'openspec-templates');
|
|
255
|
+
if (fs.existsSync(templatesDir)) {
|
|
256
|
+
addOp(plan, OP.DELETE_DIR, templatesDir, '只读文档模板');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const manual = path.join(specRoot, 'openspec', 'kld-sdd操作手册.html');
|
|
260
|
+
if (fs.existsSync(manual)) {
|
|
261
|
+
addOp(plan, OP.DELETE_FILE, manual, 'SDD 操作手册');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 用户产物
|
|
265
|
+
for (const name of ['modules.yaml', 'sdd.config.yaml']) {
|
|
266
|
+
const target = path.join(specRoot, name);
|
|
267
|
+
if (!fs.existsSync(target)) continue;
|
|
268
|
+
if (options.mode === MODE.FULL) {
|
|
269
|
+
addOp(plan, OP.DELETE_FILE, target, 'SDD 用户配置');
|
|
270
|
+
} else {
|
|
271
|
+
addOp(plan, OP.KEEP, target, '用户配置,保留');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// KB 绑定状态(含 API Key 与 spaceId/kbId,由 opsx-kb-config 写入 spec 仓根)。
|
|
276
|
+
// 与 skywalk-sdd/project-identity.json 同属 KB 配置,全量模式下须一并清除,
|
|
277
|
+
// 否则会留下「身份文件已删、绑定状态还在」的不一致中间态。
|
|
278
|
+
const kbState = path.join(specRoot, 'kb-state.json');
|
|
279
|
+
if (fs.existsSync(kbState)) {
|
|
280
|
+
if (options.mode === MODE.FULL) {
|
|
281
|
+
addOp(plan, OP.DELETE_FILE, kbState, 'KB 绑定状态(含 API Key)');
|
|
282
|
+
} else {
|
|
283
|
+
addOp(plan, OP.KEEP, kbState, 'KB 绑定状态(含 API Key),保留');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// openspec/ 是 SDD 的文档工作区(changes/ 变更提案、specs/ 规格与全局契约 overview.md、
|
|
288
|
+
// config.yaml),全部由 SDD 工具链创建与消费。
|
|
289
|
+
// 工具内容模式:不纳入计划(用户配置与文档内容一律保留)。
|
|
290
|
+
// 全量模式:整目录删除 —— 全量的语义就是「清空 SDD 在该仓的全部痕迹」,
|
|
291
|
+
// 不对单个文件做内容判定,避免残留半套目录结构。
|
|
292
|
+
if (options.mode === MODE.FULL) {
|
|
293
|
+
const openspecDir = path.join(specRoot, 'openspec');
|
|
294
|
+
if (fs.existsSync(openspecDir)) {
|
|
295
|
+
addOp(plan, OP.DELETE_DIR, openspecDir, 'SDD 文档工作区(changes/specs/config.yaml)');
|
|
296
|
+
warn(plan, `即将删除 SDD 文档工作区(含 changes/ 与 specs/ 内的业务文档): ${openspecDir}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 规划:代码仓 / 工作区根产物
|
|
303
|
+
*
|
|
304
|
+
* - `.sdd.yaml`、`.sdd-spec-root`、`.sdd-workspace.yaml` → 用户配置,仅 FULL 删
|
|
305
|
+
* - `.git/hooks/{commit-msg,pre-commit,pre-push}` → 只删含受管标记的
|
|
306
|
+
* - `.gitignore` → 摘除 SDD 块
|
|
307
|
+
* - `git config --local sdd.specPath` → 删除
|
|
308
|
+
*/
|
|
309
|
+
function planCodeRepo(plan, repoRoot, options) {
|
|
310
|
+
if (!repoRoot || !fs.existsSync(repoRoot)) return;
|
|
311
|
+
|
|
312
|
+
for (const name of ['.sdd.yaml', '.sdd-spec-root', '.sdd-workspace.yaml']) {
|
|
313
|
+
const target = path.join(repoRoot, name);
|
|
314
|
+
if (!fs.existsSync(target)) continue;
|
|
315
|
+
if (options.mode === MODE.FULL) {
|
|
316
|
+
addOp(plan, OP.DELETE_FILE, target, 'SDD 用户配置');
|
|
317
|
+
} else {
|
|
318
|
+
addOp(plan, OP.KEEP, target, '用户配置,保留');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Git hooks:只删含受管标记的
|
|
323
|
+
const hooksDir = path.join(repoRoot, '.git', 'hooks');
|
|
324
|
+
for (const name of ['commit-msg', 'pre-commit', 'pre-push']) {
|
|
325
|
+
const target = path.join(hooksDir, name);
|
|
326
|
+
if (!fs.existsSync(target)) continue;
|
|
327
|
+
let content = '';
|
|
328
|
+
try {
|
|
329
|
+
content = fs.readFileSync(target, 'utf8');
|
|
330
|
+
} catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (content.includes(DEFAULT_HOOK_MARKER)) {
|
|
334
|
+
addOp(plan, OP.DELETE_FILE, target, 'SDD shell hook(受管标记)');
|
|
335
|
+
} else {
|
|
336
|
+
addOp(plan, OP.KEEP, target, '非 SDD 受管 hook,保留');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const gitignore = path.join(repoRoot, '.gitignore');
|
|
341
|
+
if (fs.existsSync(gitignore) && fs.readFileSync(gitignore, 'utf8').includes(GITIGNORE_MARKER)) {
|
|
342
|
+
addOp(plan, OP.STRIP_GITIGNORE, gitignore, '摘除 SDD 忽略块');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (workspaceLayout.isGitRepo(repoRoot)) {
|
|
346
|
+
const specPath = sddConfig.gitConfig(repoRoot, 'sdd.specPath');
|
|
347
|
+
if (specPath) {
|
|
348
|
+
addOp(plan, OP.UNLINK_GIT_CONFIG, repoRoot, `git config --local sdd.specPath = ${specPath}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 生成卸载计划(纯计算,不落盘)
|
|
355
|
+
*
|
|
356
|
+
* @param {object} options
|
|
357
|
+
* @param {string} options.cwd 工作目录(代码仓 / 工作区根)
|
|
358
|
+
* @param {string[]} [options.tools] 要卸载的编辑器,缺省=全部
|
|
359
|
+
* @param {string} [options.mode] MODE.TOOLS_ONLY(默认)| MODE.FULL
|
|
360
|
+
* @param {string} [options.specRoot] 已确认的 spec 包裹包目录(CLI 交互层选定后传入);
|
|
361
|
+
* 缺省时仅按权威来源(.sdd-spec-root → git config)保守定位
|
|
362
|
+
* @returns {{ok: boolean, plan: object, specRoot: string|null, message?: string}}
|
|
363
|
+
*/
|
|
364
|
+
function buildUninstallPlan(options = {}) {
|
|
365
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
366
|
+
const mode = options.mode === MODE.FULL ? MODE.FULL : MODE.TOOLS_ONLY;
|
|
367
|
+
const tools = Array.isArray(options.tools) && options.tools.length
|
|
368
|
+
? options.tools
|
|
369
|
+
: listToolProfileIds();
|
|
370
|
+
|
|
371
|
+
const plan = createPlan(cwd);
|
|
372
|
+
|
|
373
|
+
// 编辑器专属产物:工作区根 / 当前目录
|
|
374
|
+
for (const toolKey of tools) {
|
|
375
|
+
planProfileArtifacts(plan, toolKey, cwd);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// spec 仓:优先用调用方(CLI 交互层)已确认的目录;
|
|
379
|
+
// 未指定时才自行定位 —— 此时仅取「权威来源」,不做全目录扫描。
|
|
380
|
+
// 定位职责已上移到 bin 层(listSpecCandidates + 用户单选 + 确认),
|
|
381
|
+
// 见 listSpecCandidatesForUninstall。本函数只接收结果,不猜。
|
|
382
|
+
const specRoot = options.specRoot
|
|
383
|
+
? path.resolve(options.specRoot)
|
|
384
|
+
: resolveSpecRootForUninstall(cwd);
|
|
385
|
+
if (specRoot) {
|
|
386
|
+
plan.specRoot = path.resolve(specRoot);
|
|
387
|
+
planSpecPackage(plan, specRoot, { mode });
|
|
388
|
+
// spec 仓自身的 .git/hooks(init 装的 commit-msg / pre-commit / pre-push)
|
|
389
|
+
// 是工具产物,须摘除 —— 与代码仓共用同一套受管标记判据,
|
|
390
|
+
// 非受管 hook 与 .git 其余内容一律保留。
|
|
391
|
+
planCodeRepo(plan, specRoot, { mode });
|
|
392
|
+
} else {
|
|
393
|
+
warn(plan, '未定位到 spec 包裹包:spec 侧产物(skywalk-sdd/、openspec/ 等)未纳入计划');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 代码仓 / 工作区根
|
|
397
|
+
planCodeRepo(plan, cwd, { mode });
|
|
398
|
+
|
|
399
|
+
// 工作区模式:子代码仓也要摘 hook。
|
|
400
|
+
//
|
|
401
|
+
// 排除所有 spec 候选目录:detectWorkspaceLayout 只挑一个作为 specRepo,
|
|
402
|
+
// 其余候选(如用户复制的 `erp-sdd-specs copy`)会被误归入 codeRepos,
|
|
403
|
+
// 进而被 planCodeRepo 摘掉 .git/hooks/ —— 那不是代码仓,是 spec 仓库/备份。
|
|
404
|
+
const layout = workspaceLayout.detectWorkspaceLayout(cwd);
|
|
405
|
+
if (layout.isWorkspace) {
|
|
406
|
+
const specDirs = new Set(
|
|
407
|
+
listSpecCandidatesForUninstall(cwd).map((c) => path.resolve(c.abs)),
|
|
408
|
+
);
|
|
409
|
+
if (specRoot) specDirs.add(path.resolve(specRoot));
|
|
410
|
+
for (const repo of layout.codeRepos) {
|
|
411
|
+
if (specDirs.has(path.resolve(repo.abs))) continue;
|
|
412
|
+
planCodeRepo(plan, repo.abs, { mode });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { ok: true, plan, specRoot: specRoot || null, mode };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 枚举待卸载的 spec 包裹包候选(供 CLI 交互层让用户单选)。
|
|
421
|
+
*
|
|
422
|
+
* 返回全部候选并标注来源,由用户裁决;`erp-sdd-specs copy` 这类用户备份
|
|
423
|
+
* 会作为 'scan' 候选被列出,而不是被静默删除。
|
|
424
|
+
*
|
|
425
|
+
* 委托 `spec-detect.detectSpecCandidatesWithGit` —— 与 init 侧共用唯一入口。
|
|
426
|
+
*
|
|
427
|
+
* @param {string} cwd 工作目录
|
|
428
|
+
* @returns {Array<{abs: string, name: string, source: string, tier: number, evidence: string[]}>}
|
|
429
|
+
*/
|
|
430
|
+
function listSpecCandidatesForUninstall(cwd) {
|
|
431
|
+
return specDetect.detectSpecCandidatesWithGit(cwd || process.cwd());
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 保守定位 spec 包裹包:仅取**权威来源**(.sdd-spec-root → git config)。
|
|
436
|
+
*
|
|
437
|
+
* 不复用 `findNestedSpecPackage` 的扫描来源 —— 判据命中不等于「当前项目的
|
|
438
|
+
* spec 仓」(用户备份同样命中)。需要扫描裁决时,由 CLI 层走
|
|
439
|
+
* `listSpecCandidatesForUninstall` + 用户单选。
|
|
440
|
+
*
|
|
441
|
+
* 实现上复用 `listSpecCandidatesForUninstall` 并筛出权威来源,而不是另写一遍
|
|
442
|
+
* hint / git config 的读取逻辑 —— 历史上这里是第二份实现,导致「卸载比 init
|
|
443
|
+
* 少认来源」(如 init 认 `.sdd.yaml mono`,卸载不认)的漂移。
|
|
444
|
+
* 候选已按 tier → 来源优先级排序,故 hint 天然优先于 git。
|
|
445
|
+
*
|
|
446
|
+
* @param {string} cwd 工作目录
|
|
447
|
+
* @returns {string|null} 权威来源指向的 spec 根;无权威来源时 null
|
|
448
|
+
*/
|
|
449
|
+
function resolveSpecRootForUninstall(cwd) {
|
|
450
|
+
const authoritative = listSpecCandidatesForUninstall(cwd)
|
|
451
|
+
.find((c) => c.source === 'hint' || c.source === 'git');
|
|
452
|
+
return authoritative ? path.resolve(authoritative.abs) : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 从 .gitignore 摘除 SDD 块(保留其余行) */
|
|
456
|
+
function stripGitignoreBlock(filePath) {
|
|
457
|
+
const original = fs.readFileSync(filePath, 'utf8');
|
|
458
|
+
const lines = original.split(/\r?\n/);
|
|
459
|
+
const kept = [];
|
|
460
|
+
let skipping = false;
|
|
461
|
+
let removed = 0;
|
|
462
|
+
|
|
463
|
+
for (const line of lines) {
|
|
464
|
+
const trimmed = line.trim();
|
|
465
|
+
// SDD 块由注释标题开头,遇到下一个非 SDD 注释标题或空行段落结束
|
|
466
|
+
if (/^#\s*KLD SDD/.test(trimmed)) {
|
|
467
|
+
skipping = true;
|
|
468
|
+
removed += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (skipping) {
|
|
472
|
+
// 块内:注释、空行、SDD 相关忽略行
|
|
473
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
474
|
+
if (trimmed.startsWith('#') && !/SDD|Telemetry|Apply|KB 绑定/.test(trimmed)) {
|
|
475
|
+
// 遇到无关注释 → 块结束
|
|
476
|
+
skipping = false;
|
|
477
|
+
kept.push(line);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
removed += 1;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (/^(\.?[\w.*/-]*)(sdd|skywalk|worktrees|kb-state)/i.test(trimmed)
|
|
484
|
+
|| /sdd/i.test(trimmed) || /skywalk/i.test(trimmed)
|
|
485
|
+
|| /^\.worktrees\//.test(trimmed) || /personal-\*$/.test(trimmed)) {
|
|
486
|
+
removed += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
skipping = false;
|
|
490
|
+
kept.push(line);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
kept.push(line);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const next = kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
497
|
+
return { changed: removed > 0 && next !== original, next, removed };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 从 settings.json 摘除受管 hook */
|
|
501
|
+
function stripSettingsHooks(filePath, provider) {
|
|
502
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
503
|
+
let settings;
|
|
504
|
+
try {
|
|
505
|
+
settings = JSON.parse(raw);
|
|
506
|
+
} catch {
|
|
507
|
+
return { ok: false, error: '不是合法 JSON,已跳过(请手工处理)' };
|
|
508
|
+
}
|
|
509
|
+
const stripped = stripManagedHooks(settings, provider);
|
|
510
|
+
const next = JSON.stringify(stripped, null, 2) + '\n';
|
|
511
|
+
if (next === raw) return { ok: true, changed: false };
|
|
512
|
+
fs.writeFileSync(filePath, next, 'utf8');
|
|
513
|
+
return { ok: true, changed: true };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 判断目录是否为空 */
|
|
517
|
+
function isEmptyDir(dir) {
|
|
518
|
+
try {
|
|
519
|
+
return fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** provider 判定:从 settings.json 路径反推 */
|
|
526
|
+
function providerFromSettingsPath(settingsPath) {
|
|
527
|
+
return /\.claude[\\/]/.test(settingsPath) ? PROVIDERS.CLAUDE : PROVIDERS.CODEBUDDY;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 执行卸载计划
|
|
532
|
+
*
|
|
533
|
+
* @param {object} plan buildUninstallPlan 产出的 plan
|
|
534
|
+
* @param {object} [options]
|
|
535
|
+
* @param {boolean} [options.dryRun] 只打印,不落盘
|
|
536
|
+
* @param {function} [options.log] 日志输出函数
|
|
537
|
+
* @returns {{ok: boolean, applied: number, skipped: number, errors: string[]}}
|
|
538
|
+
*/
|
|
539
|
+
function applyUninstallPlan(plan, options = {}) {
|
|
540
|
+
const dryRun = Boolean(options.dryRun);
|
|
541
|
+
const log = typeof options.log === 'function' ? options.log : console.log;
|
|
542
|
+
const errors = [];
|
|
543
|
+
let applied = 0;
|
|
544
|
+
let skipped = 0;
|
|
545
|
+
|
|
546
|
+
for (const item of plan.ops) {
|
|
547
|
+
if (item.op === OP.KEEP) {
|
|
548
|
+
skipped += 1;
|
|
549
|
+
log(` · 保留 ${path.basename(item.target)}(${item.reason})`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
switch (item.op) {
|
|
555
|
+
case OP.DELETE_FILE:
|
|
556
|
+
if (fs.existsSync(item.target)) {
|
|
557
|
+
if (!dryRun) fs.unlinkSync(item.target);
|
|
558
|
+
applied += 1;
|
|
559
|
+
log(` ${dryRun ? '将删除' : '✓ 已删除'} 文件 ${item.target}`);
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
|
|
563
|
+
case OP.DELETE_DIR:
|
|
564
|
+
if (fs.existsSync(item.target)) {
|
|
565
|
+
const count = listFilesRecursive(item.target).length;
|
|
566
|
+
if (!dryRun) fs.rmSync(item.target, { recursive: true, force: true });
|
|
567
|
+
applied += 1;
|
|
568
|
+
log(` ${dryRun ? '将删除' : '✓ 已删除'} 目录 ${item.target}(${count} 文件)`);
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
|
|
572
|
+
case OP.STRIP_HOOKS: {
|
|
573
|
+
if (!fs.existsSync(item.target)) break;
|
|
574
|
+
if (dryRun) {
|
|
575
|
+
applied += 1;
|
|
576
|
+
log(` 将摘除 ${item.target} 中的 SDD 受管 hook(保留其余配置)`);
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
const res = stripSettingsHooks(item.target, providerFromSettingsPath(item.target));
|
|
580
|
+
if (!res.ok) {
|
|
581
|
+
errors.push(`${item.target}: ${res.error}`);
|
|
582
|
+
log(` ⚠️ ${item.target}: ${res.error}`);
|
|
583
|
+
} else if (res.changed) {
|
|
584
|
+
applied += 1;
|
|
585
|
+
log(` ✓ 已摘除 ${item.target} 中的 SDD 受管 hook`);
|
|
586
|
+
} else {
|
|
587
|
+
// 无受管 hook 可摘 → 计为保留,保证重复卸载 applied 归零(幂等)
|
|
588
|
+
skipped += 1;
|
|
589
|
+
log(` · 保留 ${item.target}(无 SDD 受管 hook)`);
|
|
590
|
+
}
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case OP.STRIP_GITIGNORE: {
|
|
595
|
+
if (!fs.existsSync(item.target)) break;
|
|
596
|
+
if (dryRun) {
|
|
597
|
+
applied += 1;
|
|
598
|
+
log(` 将摘除 ${item.target} 中的 SDD 忽略块(保留其余行)`);
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
const res = stripGitignoreBlock(item.target);
|
|
602
|
+
if (res.changed) {
|
|
603
|
+
fs.writeFileSync(item.target, res.next, 'utf8');
|
|
604
|
+
applied += 1;
|
|
605
|
+
log(` ✓ 已摘除 ${item.target} 中的 SDD 忽略块(${res.removed} 行)`);
|
|
606
|
+
} else {
|
|
607
|
+
skipped += 1;
|
|
608
|
+
log(` · 保留 ${item.target}(无 SDD 块可摘)`);
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
case OP.UNLINK_GIT_CONFIG: {
|
|
614
|
+
if (dryRun) {
|
|
615
|
+
applied += 1;
|
|
616
|
+
log(' 将删除 git config --local sdd.specPath');
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
const res = sddConfig.unsetSpecPath(item.target);
|
|
620
|
+
if (!res.ok) {
|
|
621
|
+
errors.push(`${item.target}: ${res.message || '删除 sdd.specPath 失败'}`);
|
|
622
|
+
log(' ⚠️ 删除 git config --local sdd.specPath 失败');
|
|
623
|
+
} else if (res.removed) {
|
|
624
|
+
applied += 1;
|
|
625
|
+
log(' ✓ 已删除 git config --local sdd.specPath');
|
|
626
|
+
} else {
|
|
627
|
+
// 配置项本就不存在 → 保留,保证幂等
|
|
628
|
+
skipped += 1;
|
|
629
|
+
log(' · 保留 git config(sdd.specPath 本就不存在)');
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
default:
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
} catch (error) {
|
|
638
|
+
errors.push(`${item.target}: ${error.message}`);
|
|
639
|
+
log(` ⚠️ ${item.target}: ${error.message}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 清理变空的目录:仅清理由本计划删除行为导致的空壳目录。
|
|
644
|
+
//
|
|
645
|
+
// 两条安全边界(缺一不可):
|
|
646
|
+
// 1) 绝不清理 plan.root(工作目录)及其祖先 —— 候选父目录是逐级向上收集的,
|
|
647
|
+
// 没有这道闸门时,一个空的工作目录会被 rmdir 掉,甚至连带其父目录。
|
|
648
|
+
// 2) KEEP 项(如 skywalk-sdd/events、state)即使变空也必须保留,
|
|
649
|
+
// 否则会把用户明确要保留的运行时数据目录一并抹掉。
|
|
650
|
+
if (!dryRun) {
|
|
651
|
+
const rootAbs = plan.root ? path.resolve(plan.root) : null;
|
|
652
|
+
const protectedDirs = new Set(
|
|
653
|
+
plan.ops.filter(i => i.op === OP.KEEP).map(i => path.resolve(i.target)),
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const isProtected = (dir) => {
|
|
657
|
+
const abs = path.resolve(dir);
|
|
658
|
+
// 工作目录本身及其祖先目录不可清理
|
|
659
|
+
if (rootAbs && (abs === rootAbs || rootAbs.startsWith(abs + path.sep))) return true;
|
|
660
|
+
if (protectedDirs.has(abs)) return true;
|
|
661
|
+
// 祖先目录被保护时,其子目录同样不可清理
|
|
662
|
+
for (const keep of protectedDirs) {
|
|
663
|
+
if (keep.startsWith(abs + path.sep)) return true;
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const candidates = new Set();
|
|
669
|
+
for (const item of plan.ops) {
|
|
670
|
+
if (item.op !== OP.DELETE_FILE && item.op !== OP.DELETE_DIR) continue;
|
|
671
|
+
let dir = path.dirname(item.target);
|
|
672
|
+
// 逐级向上收集候选父目录(深度优先,先深后浅)
|
|
673
|
+
for (let i = 0; i < 4 && dir && dir !== path.dirname(dir); i += 1) {
|
|
674
|
+
candidates.add(dir);
|
|
675
|
+
dir = path.dirname(dir);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const ordered = [...candidates].sort((a, b) => b.length - a.length);
|
|
680
|
+
for (const dir of ordered) {
|
|
681
|
+
if (isProtected(dir)) continue;
|
|
682
|
+
if (!fs.existsSync(dir) || !isEmptyDir(dir)) continue;
|
|
683
|
+
try {
|
|
684
|
+
fs.rmdirSync(dir);
|
|
685
|
+
} catch { /* 目录非空或权限不足,忽略 */ }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// spec 仓目录本身:产物清空后若已无任何文件(含 .git 内),一并删除。
|
|
689
|
+
// 判据是「递归无文件」而非「无子项」——.git/ 即使只剩空目录结构也算残留。
|
|
690
|
+
// 有文件则保留:目录名由用户 --spec-path 指定,工具不擅自删非空目录。
|
|
691
|
+
const specAbs = plan.specRoot ? path.resolve(plan.specRoot) : null;
|
|
692
|
+
if (specAbs && fs.existsSync(specAbs) && isProtected(specAbs) === false) {
|
|
693
|
+
if (listFilesRecursive(specAbs).length === 0) {
|
|
694
|
+
try {
|
|
695
|
+
fs.rmSync(specAbs, { recursive: true, force: true });
|
|
696
|
+
applied += 1;
|
|
697
|
+
log(` ✓ 已删除 spec 仓目录 ${specAbs}(无残留文件)`);
|
|
698
|
+
} catch { /* 权限不足等,忽略 */ }
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return { ok: errors.length === 0, applied, skipped, errors };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 卸载方式的中文标签(供 CLI 层复用,避免文案漂移) */
|
|
707
|
+
function modeLabel(mode) {
|
|
708
|
+
return mode === MODE.FULL
|
|
709
|
+
? '全量卸载(含用户配置与运行时数据)'
|
|
710
|
+
: '工具内容卸载(保留用户配置)';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 卸载执行(纯业务,无交互)
|
|
715
|
+
*
|
|
716
|
+
* CLI 参数解析与全部交互(方式选择 / 预览 / 确认)都在 bin/ 层完成;
|
|
717
|
+
* 本函数只接收已解析的选项对象,不读取 process.argv,也不询问用户。
|
|
718
|
+
*
|
|
719
|
+
* @param {object} [opts]
|
|
720
|
+
* @param {string} [opts.cwd] 工作目录(代码仓 / 工作区根),缺省 process.cwd()
|
|
721
|
+
* @param {string[]} [opts.tools] 要卸载的编辑器 id 列表,缺省=全部
|
|
722
|
+
* @param {string} [opts.mode] MODE.TOOLS_ONLY(默认)| MODE.FULL
|
|
723
|
+
* @param {boolean} [opts.dryRun] 只打印计划,不落盘
|
|
724
|
+
* @param {object} [opts.plan] 复用调用方已算好的计划(避免重复计算)
|
|
725
|
+
* @param {boolean} [opts.showWarnings] 是否打印 plan.warnings,缺省 true
|
|
726
|
+
* @returns {{ok: boolean, applied: number, skipped: number, errors: string[]}}
|
|
727
|
+
*/
|
|
728
|
+
function main(opts = {}) {
|
|
729
|
+
const cwd = opts.cwd || process.cwd();
|
|
730
|
+
const mode = opts.mode === MODE.FULL ? MODE.FULL : MODE.TOOLS_ONLY;
|
|
731
|
+
|
|
732
|
+
const plan = opts.plan || buildUninstallPlan({ cwd, tools: opts.tools, mode }).plan;
|
|
733
|
+
const result = applyUninstallPlan(plan, { dryRun: Boolean(opts.dryRun) });
|
|
734
|
+
|
|
735
|
+
// 交互路径下 CLI 层已用 printPlanPreview 打过同一份 warnings,
|
|
736
|
+
// 由调用方传 showWarnings:false 避免重复刷屏;非交互路径缺省照常打印。
|
|
737
|
+
if (opts.showWarnings !== false && plan.warnings.length) {
|
|
738
|
+
console.log('');
|
|
739
|
+
for (const w of plan.warnings) console.log(`⚠️ ${w}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
console.log('');
|
|
743
|
+
console.log(`✅ 完成:处理 ${result.applied} 项,保留 ${result.skipped} 项`);
|
|
744
|
+
if (result.errors.length) {
|
|
745
|
+
console.log(`⚠️ ${result.errors.length} 项失败:`);
|
|
746
|
+
for (const e of result.errors) console.log(` - ${e}`);
|
|
747
|
+
}
|
|
748
|
+
if (opts.dryRun) {
|
|
749
|
+
console.log('');
|
|
750
|
+
console.log('ℹ️ 这是预演结果,未修改任何文件。去掉 --dry-run 执行实际卸载。');
|
|
751
|
+
}
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
module.exports = {
|
|
756
|
+
MODE,
|
|
757
|
+
OP,
|
|
758
|
+
modeLabel,
|
|
759
|
+
main,
|
|
760
|
+
buildUninstallPlan,
|
|
761
|
+
applyUninstallPlan,
|
|
762
|
+
stripGitignoreBlock,
|
|
763
|
+
listSpecCandidatesForUninstall,
|
|
764
|
+
};
|