kld-sdd 2.7.8-4 → 2.7.8-5
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 +24 -0
- package/bin/kld-sdd-init.js +29 -96
- package/lib/init.js +38 -15
- package/lib/maintenance.js +96 -0
- package/lib/uninstall.js +121 -432
- package/package.json +6 -3
- package/skywalk-sdd/ontology/workspace-layout.cjs +19 -1
package/README.md
CHANGED
|
@@ -6,6 +6,30 @@ KLD SDD OpenSpec 工程增强工具:一键初始化 AI 编辑器技能、语
|
|
|
6
6
|
|
|
7
7
|
日常提交默认只保留本地关联 Hook;Apply 自动执行代码/Spec 检查、真实测试与交付验证。旧项目升级、备份回退、多仓和 CI 边界见 [Git Hook 调整说明](USABILITY.md#2026-09-10git-hook-收缩与交付验证)。
|
|
8
8
|
|
|
9
|
+
## 工具更新与卸载
|
|
10
|
+
|
|
11
|
+
**更新不需要先卸载。** 在原工作目录重新运行安装器,覆盖运行脚本、Skills 和只读模板;Spec 文档、KB 绑定、项目身份、历史数据、用户配置和自定义 Hook 保留。先确认目标编辑器及 Spec 目录,完成后检查实际部署版本。下载失败不启动安装;执行中断后可以重跑补齐,不承诺整个项目的事务回滚。
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 选择包含本次修复的新发布版本,在原工作目录更新
|
|
15
|
+
npx kld-sdd@<新版版本号> --tool=cursor --spec-path=team-sdd-specs --skip-openspec
|
|
16
|
+
|
|
17
|
+
# 卸载只有工具一种方式;可以先看计划
|
|
18
|
+
npx kld-sdd@<新版版本号> uninstall --dry-run
|
|
19
|
+
npx kld-sdd@<新版版本号> uninstall
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
卸载不再提供全量删除,旧 `--purge` 参数会报错退出。`openspec/`(含活动变更、归档和规格)、KB 配置、身份、历史数据、`.sdd.yaml`、`.sdd-spec-root`、`.sdd-workspace.yaml`、Git 关联和隐私忽略规则始终保留。工具目录内的未知文件和自建规则也保留;Git Hook 只有完整匹配受管版本时才删除。预览后文件改变需要重新预览。
|
|
23
|
+
|
|
24
|
+
多仓注意:
|
|
25
|
+
|
|
26
|
+
- 升级沿用已有清单,只维护登记的代码仓;多个 Spec 和备份不会互相接入。新代码仓通过 `kld-sdd sync-repos` 明确接入,追加清单时保留注释、自定义字段和原有记录。
|
|
27
|
+
- 本地路径、团队远程声明或工作区清单冲突时,先停止更新并提示核对,不自动换绑。
|
|
28
|
+
- 从单个代码仓卸载时,工作目录外的共享 Spec 工具保留。从 worktree 卸载时,共用的 Git Hook 保留,应从主仓库管理。
|
|
29
|
+
- 旧平铺 `skywalk-sdd/` 只清理已知工具,历史数据原位保留,不自动合并两份项目身份或运行状态。
|
|
30
|
+
|
|
31
|
+
以上行为需要使用包含本次改动的新版本;修改此源码不会改变已经发布的 npm 包。安装包通过 `sddMaintenance.toolOnly: 1` 声明该维护契约,托盘据此避免用旧安装器处理存在已知清理风险的布局。
|
|
32
|
+
|
|
9
33
|
## 这是什么?
|
|
10
34
|
|
|
11
35
|
**SDD(Specification-Driven Development)** 是一种以文档链驱动 AI 编码的研发方法:先写清楚"要做什么",再让 AI 去实现,避免 AI 乱猜、反复返工。
|
package/bin/kld-sdd-init.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* log SDD Telemetry CLI(记录阶段事件、查询指标)
|
|
13
13
|
* link-spec 将代码仓关联到本地 spec clone(git config sdd.specPath)
|
|
14
14
|
* sync-repos 工作目录下发现新增代码仓并轻量接入(不装 skills)
|
|
15
|
-
* uninstall 卸载 SDD
|
|
15
|
+
* uninstall 卸载 SDD 产物(渐进式:先预览工具范围,最后确认执行)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
'use strict';
|
|
@@ -136,19 +136,19 @@ function parseInitArgs(argv = cliArgs(), env = process.env) {
|
|
|
136
136
|
* kld-sdd uninstall [选项]
|
|
137
137
|
*
|
|
138
138
|
* 卸载产物与编辑器无关(8 个编辑器的产物全量扫描),工作目录固定为当前目录,
|
|
139
|
-
* 故不提供 --tool / --project
|
|
139
|
+
* 故不提供 --tool / --project:仅卸载当前范围的工具,目录由 cwd 决定。
|
|
140
140
|
*
|
|
141
141
|
* --spec-path 用于无人值守场景:多候选(如用户复制的备份)时无法自动判定,
|
|
142
142
|
* 需显式指定要卸载的 spec 仓库。
|
|
143
143
|
*/
|
|
144
144
|
function parseUninstallArgs(argv = cliArgs()) {
|
|
145
|
-
|
|
145
|
+
if (argv.some(arg => arg === '--purge' || arg.startsWith('--purge=') || arg === '--full' || arg.startsWith('--mode'))) {
|
|
146
|
+
throw new Error('已取消全量删除,仅支持卸载工具;Spec 文档、配置与历史数据始终保留');
|
|
147
|
+
}
|
|
146
148
|
return {
|
|
147
149
|
cwd: process.cwd(),
|
|
148
150
|
specPath: parseSpecPathOption(argv),
|
|
149
|
-
mode:
|
|
150
|
-
// 是否显式指定了方式:未指定且未跳过交互时,进入渐进式方式选择
|
|
151
|
-
modeExplicit: purge,
|
|
151
|
+
mode: MODE.TOOLS_ONLY,
|
|
152
152
|
dryRun: argv.includes('--dry-run'),
|
|
153
153
|
yes: hasFlag(argv, '--yes', '-y'),
|
|
154
154
|
help: hasFlag(argv, '-h', '--help'),
|
|
@@ -325,78 +325,26 @@ async function selectSpecRoot(prompt, candidates, options = {}) {
|
|
|
325
325
|
return selectSpecChoice(prompt, candidates, options);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
/**
|
|
329
|
-
* 渐进式选择卸载方式。
|
|
330
|
-
* 默认「1 卸载工具内容」为保守选项,回车即安全;无效输入重新询问。
|
|
331
|
-
*
|
|
332
|
-
* @returns {Promise<string|null>} MODE 之一;null 表示用户取消
|
|
333
|
-
*/
|
|
334
|
-
async function selectUninstallMode(prompt) {
|
|
335
|
-
while (true) {
|
|
336
|
-
// 选项文本并入 question:readline 重绘提示行会发 \x1b[0J(清除光标之后内容),
|
|
337
|
-
// 提问前的 console.log 会被抹掉,导致用户看不到选项。
|
|
338
|
-
const question = [
|
|
339
|
-
'请选择卸载方式:',
|
|
340
|
-
' 1. 卸载工具内容(默认,保留用户配置与运行时数据)',
|
|
341
|
-
' 删除 编辑器 skills / hook 脚本 / skywalk-sdd 工具脚本 / 文档模版',
|
|
342
|
-
' 保留 modules.yaml、sdd.config.yaml、.sdd.yaml、events/、state/',
|
|
343
|
-
' 2. 全量卸载(含用户配置、运行时数据与文档工作区)',
|
|
344
|
-
' 在「1」的基础上,额外删除上述用户配置、历史度量数据与 openspec/ 整目录',
|
|
345
|
-
' ⚠️ openspec/ 内含 changes/ 变更提案与 specs/ 业务规格文档,将一并删除',
|
|
346
|
-
' 0. 取消',
|
|
347
|
-
'\n请输入选项 (0-2,直接回车=1): ',
|
|
348
|
-
].join('\n');
|
|
349
|
-
|
|
350
|
-
const answer = await prompt.ask(question);
|
|
351
|
-
if (answer === '' || answer === '1') return MODE.TOOLS_ONLY;
|
|
352
|
-
if (answer === '2') return MODE.FULL;
|
|
353
|
-
if (answer === '0' || /^(q|quit|exit)$/i.test(answer)) return null;
|
|
354
|
-
console.log(`❌ 无效选项 "${answer}",请输入 0-2 之间的数字\n`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
328
|
/** 显示卸载帮助信息 */
|
|
359
329
|
function showUninstallHelp() {
|
|
360
330
|
console.log(`
|
|
361
|
-
KLD SDD
|
|
331
|
+
KLD SDD 工具卸载
|
|
362
332
|
|
|
363
333
|
用法:
|
|
364
|
-
kld-sdd uninstall
|
|
365
|
-
kld-sdd uninstall
|
|
334
|
+
kld-sdd uninstall # 选择 Spec → 预览范围 → 确认
|
|
335
|
+
kld-sdd uninstall --dry-run # 只看计划,不修改文件
|
|
336
|
+
kld-sdd uninstall --yes --spec-path <dir> # 明确范围后用于脚本
|
|
366
337
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
--dry-run 预演,打印计划预览但不落盘(不进入交互)
|
|
371
|
-
--spec-path <dir> 显式指定要卸载的 spec 仓库(无人值守时多候选必须指定)
|
|
372
|
-
-y, --yes 跳过最后的执行确认(CI / 脚本用)
|
|
373
|
-
|
|
374
|
-
说明:
|
|
375
|
-
卸载在「当前目录」执行,产物范围为全部 8 个编辑器(cursor, claude, codebuddy,
|
|
376
|
-
qoder, opencode, kunlunzhima, workbuddy, codex),无需也无法指定编辑器。
|
|
377
|
-
卸载范围由方式选择(工具内容 / 全量)决定。需在目标项目目录下执行本命令。
|
|
378
|
-
|
|
379
|
-
交互流程:spec 仓库选择 → 卸载方式选择 → 计划预览 → 确认执行。
|
|
380
|
-
若检测到多个 spec 仓库(例如您手动复制了一份备份),会列出候选让您单选;
|
|
381
|
-
无人值守(--yes / 无 TTY)时多候选会直接报错,请用 --spec-path 指定。
|
|
382
|
-
|
|
383
|
-
两种方式的区别:
|
|
384
|
-
工具内容卸载(默认):
|
|
385
|
-
删除 编辑器 skills(opsx-* / tdd-* / openspec-*)、sdd-*.cjs hook 脚本、
|
|
386
|
-
hook-gate-core.cjs、skywalk-sdd/ 工具脚本、openspec-templates/、操作手册
|
|
387
|
-
保留 settings.json 文件本身(仅摘除 SDD 受管 hook)、modules.yaml、
|
|
388
|
-
sdd.config.yaml、.sdd.yaml、.sdd-spec-root、skywalk-sdd/events/ 与 state/、
|
|
389
|
-
openspec/ 文档工作区
|
|
390
|
-
全量卸载(--purge):
|
|
391
|
-
在「工具内容卸载」基础上,额外删除上述保留项中的用户配置、历史度量数据,
|
|
392
|
-
以及 openspec/ 整目录(含 changes/ 变更提案、specs/ 业务规格与 overview.md、
|
|
393
|
-
config.yaml)。执行前会打印待删目录与文件数,需二次确认。
|
|
338
|
+
仅删除可识别的 SDD Skills、运行脚本、只读模板与受管 Hook。
|
|
339
|
+
始终保留 openspec/ 全部文档、KB 配置、项目身份、历史数据、多仓关联和 Git 历史。
|
|
340
|
+
自定义 Hook、规则、未知文件与隐私忽略规则保留。不提供全量删除模式。
|
|
394
341
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
342
|
+
-h, --help 显示本帮助
|
|
343
|
+
--spec-path <dir> 指定目标 Spec;多个候选时必须选择,备份不受影响
|
|
344
|
+
--dry-run 只预览
|
|
345
|
+
-y, --yes 确认工具卸载;非交互执行必须提供
|
|
346
|
+
|
|
347
|
+
升级不需要先卸载:重新运行 init 覆盖工具文件,保留已有文档及配置。
|
|
400
348
|
`);
|
|
401
349
|
}
|
|
402
350
|
|
|
@@ -409,8 +357,8 @@ KLD SDD 项目初始化工具
|
|
|
409
357
|
kld-sdd-init [选项]
|
|
410
358
|
npx kld-sdd [选项]
|
|
411
359
|
kld-sdd sync-repos # 工作目录下发现新增代码仓并轻量接入(多 spec 候选时单选)
|
|
412
|
-
kld-sdd uninstall [选项] # 卸载 SDD
|
|
413
|
-
kld-sdd uninstall --help #
|
|
360
|
+
kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:预览工具范围 → 确认)
|
|
361
|
+
kld-sdd uninstall --help # 查看工具卸载与保留范围
|
|
414
362
|
|
|
415
363
|
选项:
|
|
416
364
|
-h, --help 显示帮助信息
|
|
@@ -484,7 +432,7 @@ async function runInit() {
|
|
|
484
432
|
/**
|
|
485
433
|
* 卸载(渐进式):方式选择 → 计划预览 → 确认执行。
|
|
486
434
|
*
|
|
487
|
-
* 交互编排全部在本函数(CLI
|
|
435
|
+
* 交互编排全部在本函数(CLI 输入层);仅选择 Spec、预览工具范围并确认:
|
|
488
436
|
* - buildUninstallPlan 纯计算计划,不落盘
|
|
489
437
|
* - main 按给定计划执行,无交互
|
|
490
438
|
*
|
|
@@ -524,7 +472,7 @@ async function runUninstall(argv = args, deps = {}) {
|
|
|
524
472
|
// 候选枚举:结构判据无法区分「当前项目的 spec 仓」与「用户复制的备份」
|
|
525
473
|
//(如 `erp-sdd-specs copy` 结构标记齐全),故由用户单选裁决。
|
|
526
474
|
// 显式 --spec-path 时跳过枚举(调用方已指定)。
|
|
527
|
-
let specRoot = opts.specPath ? path.resolve(opts.specPath) : undefined;
|
|
475
|
+
let specRoot = opts.specPath ? path.resolve(cwd, opts.specPath) : undefined;
|
|
528
476
|
const candidates = specRoot ? [] : listSpecCandidatesForUninstall(cwd);
|
|
529
477
|
|
|
530
478
|
if (!specRoot && candidates.length > 1 && !canPrompt) {
|
|
@@ -539,6 +487,7 @@ async function runUninstall(argv = args, deps = {}) {
|
|
|
539
487
|
throw new Error('多个 spec 仓库候选,需 --spec-path 显式指定');
|
|
540
488
|
}
|
|
541
489
|
|
|
490
|
+
if (!specRoot && !canPrompt && candidates.length === 1) specRoot = candidates[0].abs;
|
|
542
491
|
const prompt = canPrompt ? promptFactory() : null;
|
|
543
492
|
|
|
544
493
|
// 步骤 1:spec 仓库选择(多候选 → 用户单选;单候选 → 确认)
|
|
@@ -563,31 +512,15 @@ async function runUninstall(argv = args, deps = {}) {
|
|
|
563
512
|
}
|
|
564
513
|
|
|
565
514
|
if (!prompt) {
|
|
515
|
+
if (!dryRun && !opts.yes) throw new Error('非交互卸载需要 --yes;可先用 --dry-run 预览');
|
|
566
516
|
printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
|
|
567
517
|
if (dryRun) printPlanPreview(planned.plan);
|
|
568
518
|
// 已打印过预览(含警告)时不重复;直接执行时由 lib 打印
|
|
569
|
-
runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
|
|
519
|
+
const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
|
|
520
|
+
if (!result.ok) throw new Error(result.errors.join('; '));
|
|
570
521
|
return;
|
|
571
522
|
}
|
|
572
523
|
|
|
573
|
-
// 步骤 3:方式选择(已显式 --purge 则跳过)
|
|
574
|
-
if (!opts.modeExplicit) {
|
|
575
|
-
const chosen = await selectUninstallMode(prompt);
|
|
576
|
-
if (chosen === null) {
|
|
577
|
-
console.log('已取消');
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
if (chosen !== mode) {
|
|
581
|
-
mode = chosen;
|
|
582
|
-
planned = buildUninstallPlan({ cwd, mode, specRoot });
|
|
583
|
-
if (planned.plan.ops.length === 0) {
|
|
584
|
-
printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
|
|
585
|
-
console.log('ℹ️ 未发现需要处理的 SDD 产物(可能未初始化或已卸载)');
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
524
|
// 步骤 4:计划预览
|
|
592
525
|
printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
|
|
593
526
|
printPlanPreview(planned.plan);
|
|
@@ -602,7 +535,8 @@ async function runUninstall(argv = args, deps = {}) {
|
|
|
602
535
|
console.log('');
|
|
603
536
|
|
|
604
537
|
// 交互路径:警告已由 printPlanPreview 打印过,不再重复
|
|
605
|
-
runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
|
|
538
|
+
const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
|
|
539
|
+
if (!result.ok) throw new Error(result.errors.join('; '));
|
|
606
540
|
} finally {
|
|
607
541
|
if (prompt) prompt.close();
|
|
608
542
|
}
|
|
@@ -724,7 +658,6 @@ module.exports = {
|
|
|
724
658
|
runUninstall,
|
|
725
659
|
parseUninstallArgs,
|
|
726
660
|
printPlanPreview,
|
|
727
|
-
selectUninstallMode,
|
|
728
661
|
selectSpecRoot,
|
|
729
662
|
showUninstallHelp,
|
|
730
663
|
};
|
package/lib/init.js
CHANGED
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
const { deployCodebuddyHookPack } = require('./deploy-codebuddy-hooks');
|
|
32
32
|
const { PROVIDERS, computeMergedSettings } = require('./settings-merge');
|
|
33
33
|
const workspaceLayout = require('./workspace-layout');
|
|
34
|
+
const maintenance = require('./maintenance');
|
|
34
35
|
const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
35
36
|
// spec 判据与候选枚举的唯一入口(tier 分级 + 排序)
|
|
36
37
|
const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
|
|
@@ -336,6 +337,7 @@ function copyDirRendered(source, target, config, toolKey) {
|
|
|
336
337
|
return deployDir(source, target, {
|
|
337
338
|
strategy: STRATEGY.OVERWRITE,
|
|
338
339
|
render: (srcPath, tgtPath) => {
|
|
340
|
+
if (path.basename(srcPath) === 'config.json' && fs.existsSync(tgtPath)) return fs.readFileSync(tgtPath, 'utf8');
|
|
339
341
|
const rendered = renderTemplate(fs.readFileSync(srcPath, 'utf8'), config, toolKey, profile);
|
|
340
342
|
validateRenderedContent(rendered, profile, tgtPath);
|
|
341
343
|
return rendered;
|
|
@@ -1077,6 +1079,9 @@ function attachCodeRepoLite(codeRepoRoot, specRepoRoot, options = {}) {
|
|
|
1077
1079
|
return { ok: false, repo: label, message: `spec 路径不是 Git 仓库: ${specRoot}` };
|
|
1078
1080
|
}
|
|
1079
1081
|
|
|
1082
|
+
const conflict = maintenance.associationConflict(cwd, specRoot);
|
|
1083
|
+
if (conflict) return { ok: false, repo: label, message: conflict };
|
|
1084
|
+
|
|
1080
1085
|
console.log(`🔗 轻量接入代码仓(本地关联 Hook): ${label}`);
|
|
1081
1086
|
|
|
1082
1087
|
const remote = sddConfig.gitRemoteUrl(specRoot) || 'git@gitlab.example.com:biz/xxx-sdd-specs.git';
|
|
@@ -1347,13 +1352,20 @@ function removeOuterSkywalk(anchorDir) {
|
|
|
1347
1352
|
return { ok: true, removed: true, mode: 'symlink' };
|
|
1348
1353
|
}
|
|
1349
1354
|
if (st.isDirectory()) {
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1355
|
+
// 旧目录可能存有 KB 身份、待上报事件和用户文件,不能整目录删除。
|
|
1356
|
+
for (const file of maintenance.runtimeToolFiles(outer)) fs.unlinkSync(file);
|
|
1357
|
+
const prune = dir => {
|
|
1358
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1359
|
+
if (entry.isDirectory() && !['events', 'state'].includes(entry.name)) prune(path.join(dir, entry.name));
|
|
1360
|
+
}
|
|
1361
|
+
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
|
|
1362
|
+
};
|
|
1363
|
+
prune(outer);
|
|
1364
|
+
const retained = fs.existsSync(outer);
|
|
1365
|
+
console.log(retained ? ' ℹ️ 旧 skywalk-sdd/ 的配置、历史数据和未知文件已保留;仅清理工具脚本' : ' ✓ 已清理根目录旧工具脚本');
|
|
1366
|
+
return { ok: true, removed: !retained, retained, mode: 'directory' };
|
|
1353
1367
|
}
|
|
1354
|
-
|
|
1355
|
-
console.log(' ✓ 已移除 Git/工作区根多余的 skywalk-sdd');
|
|
1356
|
-
return { ok: true, removed: true, mode: 'file' };
|
|
1368
|
+
return { ok: true, removed: false, skipped: true }; // 同名用户文件不处理
|
|
1357
1369
|
} catch (err) {
|
|
1358
1370
|
console.log(` ⚠️ 未能移除根目录 skywalk-sdd/: ${err.message}`);
|
|
1359
1371
|
return { ok: false, message: err.message };
|
|
@@ -1505,6 +1517,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
|
|
|
1505
1517
|
console.log(`🆕 待接入新仓: ${filteredTargets.map((c) => c.name).join(', ') || '(无)'}`);
|
|
1506
1518
|
}
|
|
1507
1519
|
|
|
1520
|
+
const conflicts = maintenance.maintenanceConflicts(root, specRootAbs, codeRepos);
|
|
1521
|
+
if (conflicts.length) return { ok: false, attached: [], failed: conflicts, layout, specRoot: specRootAbs, message: conflicts.map(c => c.repo + ': ' + c.message).join('; ') };
|
|
1522
|
+
|
|
1508
1523
|
const attached = [];
|
|
1509
1524
|
const failed = [];
|
|
1510
1525
|
for (const repo of filteredTargets) {
|
|
@@ -1513,7 +1528,9 @@ function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
|
|
|
1513
1528
|
else failed.push(result);
|
|
1514
1529
|
}
|
|
1515
1530
|
|
|
1516
|
-
|
|
1531
|
+
if (failed.length) return { ok: false, attached, failed, layout, specRoot: specRootAbs };
|
|
1532
|
+
|
|
1533
|
+
// 显式 sync-repos 成功后才刷新清单
|
|
1517
1534
|
const layoutForFile = {
|
|
1518
1535
|
...layout,
|
|
1519
1536
|
specRepo: { name: specName, abs: specRootAbs },
|
|
@@ -1614,6 +1631,12 @@ async function main(options = {}) {
|
|
|
1614
1631
|
}
|
|
1615
1632
|
}
|
|
1616
1633
|
|
|
1634
|
+
if (specPackage) {
|
|
1635
|
+
if (layout.isWorkspace) layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
|
|
1636
|
+
const conflicts = maintenance.maintenanceConflicts(workspaceRoot, specPackage.abs, layout.isWorkspace ? layout.codeRepos : []);
|
|
1637
|
+
if (conflicts.length) throw new Error('未更新工具:' + conflicts.map(c => c.repo + ': ' + c.message).join('; '));
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1617
1640
|
if (layout.isWorkspace) {
|
|
1618
1641
|
// 显示层同步过滤:用户交互选择的 spec 目录可能原被分类为代码仓
|
|
1619
1642
|
const displayCodeRepos = specPackage
|
|
@@ -1650,18 +1673,18 @@ async function main(options = {}) {
|
|
|
1650
1673
|
if (!layout.specRepo || path.resolve(layout.specRepo.abs) !== path.resolve(specPackage.abs)) {
|
|
1651
1674
|
layout.specRepo = { name: specPackage.name, abs: specPackage.abs };
|
|
1652
1675
|
}
|
|
1653
|
-
layout.codeRepos =
|
|
1654
|
-
(c) => path.resolve(c.abs) !== path.resolve(specPackage.abs),
|
|
1655
|
-
);
|
|
1676
|
+
layout.codeRepos = maintenance.workspaceCodeRepos(workspaceRoot, specPackage.abs, layout, { respectManifest: true });
|
|
1656
1677
|
}
|
|
1657
1678
|
|
|
1658
1679
|
console.log();
|
|
1659
1680
|
console.log('🔗 正在为子代码仓做轻量接入(不安装 skills)...');
|
|
1660
1681
|
for (const repo of layout.codeRepos) {
|
|
1661
|
-
attachCodeRepoLite(repo.abs, specPackage.abs);
|
|
1682
|
+
const result = attachCodeRepoLite(repo.abs, specPackage.abs);
|
|
1683
|
+
if (!result.ok) throw new Error(`${repo.name}: ${result.message}`);
|
|
1662
1684
|
}
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1685
|
+
const savedWorkspace = workspaceLayout.loadWorkspaceFile(workspaceRoot);
|
|
1686
|
+
const wsFile = savedWorkspace.ok ? savedWorkspace.path : workspaceLayout.writeWorkspaceFile(workspaceRoot, layout);
|
|
1687
|
+
console.log(`✅ 工作区清单${savedWorkspace.ok ? '已保留(新增仓用 sync-repos 接入)' : ''}: ${wsFile}`);
|
|
1665
1688
|
} else {
|
|
1666
1689
|
// 单仓:Git 根只放代码 + skills + Hook;SDD 内容进统一包裹子目录
|
|
1667
1690
|
const isGit = workspaceLayout.isGitRepo(workspaceRoot);
|
|
@@ -1684,7 +1707,7 @@ async function main(options = {}) {
|
|
|
1684
1707
|
if (linked.ok) {
|
|
1685
1708
|
console.log(` ✓ sdd.specPath = ${linked.path}`);
|
|
1686
1709
|
} else {
|
|
1687
|
-
|
|
1710
|
+
throw new Error(`未能写入 sdd.specPath: ${linked.message}`);
|
|
1688
1711
|
}
|
|
1689
1712
|
} else {
|
|
1690
1713
|
// 非 Git 目录:仍创建包裹包,便于随后 git init
|
|
@@ -1713,7 +1736,7 @@ async function main(options = {}) {
|
|
|
1713
1736
|
// 7. 更新工作目录 .gitignore
|
|
1714
1737
|
updateGitignore(workspaceRoot);
|
|
1715
1738
|
|
|
1716
|
-
// 8.
|
|
1739
|
+
// 8. 收尾:清理可识别的旧工具,保留历史配置和数据
|
|
1717
1740
|
if (specPackage && fs.existsSync(specPackage.abs)) {
|
|
1718
1741
|
writeSddSpecRootHint(workspaceRoot, specPackage.abs);
|
|
1719
1742
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 安装、升级、卸载共用的边界;业务文档和关联配置不属于可清理的工具文件。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const sdd = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
7
|
+
const layout = require('./workspace-layout');
|
|
8
|
+
const detect = require('../skywalk-sdd/ontology/spec-detect.cjs');
|
|
9
|
+
const pkg = path.resolve(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
function filesUnder(root, prefix = '') {
|
|
12
|
+
if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return [];
|
|
13
|
+
return fs.readdirSync(root).flatMap(name => {
|
|
14
|
+
const file = path.join(root, name);
|
|
15
|
+
const rel = path.join(prefix, name);
|
|
16
|
+
const stat = fs.lstatSync(file);
|
|
17
|
+
return stat.isDirectory() ? filesUnder(file, rel) : stat.isFile() ? [rel] : [];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function regularFileWithin(root, relative) {
|
|
22
|
+
const parts = relative.split(path.sep);
|
|
23
|
+
if (path.isAbsolute(relative) || parts.includes('..')) return false;
|
|
24
|
+
let target = root;
|
|
25
|
+
if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return false;
|
|
26
|
+
for (let i = 0; i < parts.length; i++) {
|
|
27
|
+
target = path.join(target, parts[i]);
|
|
28
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
29
|
+
if (!stat || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory())) return false;
|
|
30
|
+
if (i === parts.length - 1) return stat.isFile();
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runtimeToolFiles(root) {
|
|
36
|
+
const names = filesUnder(path.join(pkg, 'skywalk-sdd'))
|
|
37
|
+
.filter(rel => /\.(cjs|js|json)$/.test(rel) && !/^(events|state)[/\\]/.test(rel) && path.basename(rel) !== 'project-identity.json')
|
|
38
|
+
.map(rel => rel === 'index.cjs' ? 'log.cjs' : rel);
|
|
39
|
+
names.push(path.join('lib', 'scale-thresholds.json'));
|
|
40
|
+
for (const name of filesUnder(path.join(pkg, 'templates/git-hooks'))) {
|
|
41
|
+
if (name.endsWith('.cjs')) names.push(path.join('git-hooks', name));
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(names)].filter(rel => regularFileWithin(root, rel)).map(rel => path.join(root, rel));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function samePath(a, b) {
|
|
47
|
+
const canonical = value => fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value);
|
|
48
|
+
return canonical(a) === canonical(b);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 返回冲突原因,不自动迁移已有关系;明确换仓应通过 link-spec / 配置处理。
|
|
52
|
+
function associationConflict(repo, spec) {
|
|
53
|
+
const bindings = [sdd.gitConfig(repo, 'sdd.specPath')];
|
|
54
|
+
const hint = path.join(repo, '.sdd-spec-root');
|
|
55
|
+
if (fs.existsSync(hint)) bindings.push(fs.readFileSync(hint, 'utf8').trim());
|
|
56
|
+
if (bindings.some(value => value && !samePath(path.resolve(repo, value), spec))) {
|
|
57
|
+
return '已有本地 Spec 关联指向其他目录,请先核对关联';
|
|
58
|
+
}
|
|
59
|
+
const yaml = sdd.loadSddYaml(repo);
|
|
60
|
+
if (fs.existsSync(yaml.path) && !yaml.ok) return '已有 .sdd.yaml 无法解析,请先修复配置';
|
|
61
|
+
if (yaml.ok && yaml.layout === 'mono') {
|
|
62
|
+
if (yaml.spec_path ? !samePath(path.resolve(repo, yaml.spec_path), spec) : !bindings.some(value => value && samePath(path.resolve(repo, value), spec))) return '此仓使用独立的单仓 Spec 配置';
|
|
63
|
+
}
|
|
64
|
+
if (yaml.ok && yaml.spec_repository && !yaml.spec_repository.includes('gitlab.example.com:biz/xxx-sdd-specs.git')) {
|
|
65
|
+
const remote = sdd.gitRemoteUrl(spec);
|
|
66
|
+
if (!remote || !sdd.remotesMatch(yaml.spec_repository, remote)) return '.sdd.yaml 的 Spec 远程仓库与所选目录不一致';
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function workspaceCodeRepos(root, spec, current = layout.detectWorkspaceLayout(root), options = {}) {
|
|
72
|
+
const excluded = new Set(detect.detectSpecCandidatesWithGit(root).map(c => path.resolve(c.abs)));
|
|
73
|
+
excluded.add(path.resolve(spec));
|
|
74
|
+
const candidates = current.codeRepos.filter(repo => !excluded.has(path.resolve(repo.abs)));
|
|
75
|
+
const saved = options.respectManifest && layout.loadWorkspaceFile(root);
|
|
76
|
+
if (!saved || !saved.ok) return candidates;
|
|
77
|
+
const declared = saved.data.code_repos.map(value => path.resolve(root, value.replace(/^(["'])(.*)\1$/, '$2')));
|
|
78
|
+
const missing = declared.filter(dir => !candidates.some(repo => samePath(repo.abs, dir)));
|
|
79
|
+
if (missing.length) throw new Error('多仓清单包含未发现或非代码仓目录,请先核对 .sdd-workspace.yaml:' + missing.map(p => path.basename(p)).join('、'));
|
|
80
|
+
return candidates.filter(repo => declared.some(dir => samePath(dir, repo.abs)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function maintenanceConflicts(root, spec, repos) {
|
|
84
|
+
const failures = [];
|
|
85
|
+
const saved = layout.loadWorkspaceFile(root);
|
|
86
|
+
if (saved.ok && saved.data.spec_path && !samePath(path.resolve(root, saved.data.spec_path), spec)) {
|
|
87
|
+
failures.push({ repo: path.basename(root), message: '.sdd-workspace.yaml 指向其他 Spec,升级不会重新绑定' });
|
|
88
|
+
}
|
|
89
|
+
for (const repo of [{ abs: root, name: path.basename(root) }, ...repos]) {
|
|
90
|
+
const message = associationConflict(repo.abs, spec);
|
|
91
|
+
if (message) failures.push({ repo: repo.name, message });
|
|
92
|
+
}
|
|
93
|
+
return failures;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { filesUnder, regularFileWithin, runtimeToolFiles, samePath, associationConflict, workspaceCodeRepos, maintenanceConflicts };
|
package/lib/uninstall.js
CHANGED
|
@@ -1,27 +1,4 @@
|
|
|
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
|
-
|
|
1
|
+
/** 只卸载可识别的 SDD 工具;文档、配置、数据和仓库关联始终保留。 */
|
|
25
2
|
'use strict';
|
|
26
3
|
|
|
27
4
|
const fs = require('fs');
|
|
@@ -29,7 +6,10 @@ const path = require('path');
|
|
|
29
6
|
|
|
30
7
|
const { isManagedBridgeFile } = require('./command-bridge');
|
|
31
8
|
const { PROVIDERS, stripManagedHooks } = require('./settings-merge');
|
|
32
|
-
const
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { execFileSync } = require('child_process');
|
|
11
|
+
const maintenance = require('./maintenance');
|
|
12
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
33
13
|
const { getToolProfile, listToolProfileIds, buildToolConfigs } = require('./tool-profiles');
|
|
34
14
|
const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
35
15
|
const workspaceLayout = require('./workspace-layout');
|
|
@@ -40,17 +20,12 @@ const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
|
|
|
40
20
|
const MODE = Object.freeze({
|
|
41
21
|
/** 只删工具内容,保留用户配置与运行时数据 */
|
|
42
22
|
TOOLS_ONLY: 'tools-only',
|
|
43
|
-
/** 全量删除,含用户配置与运行时数据 */
|
|
44
|
-
FULL: 'full',
|
|
45
23
|
});
|
|
46
24
|
|
|
47
25
|
/** 操作类型(用于计划与摘要) */
|
|
48
26
|
const OP = Object.freeze({
|
|
49
27
|
DELETE_FILE: 'delete-file',
|
|
50
|
-
DELETE_DIR: 'delete-dir',
|
|
51
28
|
STRIP_HOOKS: 'strip-hooks',
|
|
52
|
-
STRIP_GITIGNORE: 'strip-gitignore',
|
|
53
|
-
UNLINK_GIT_CONFIG: 'unlink-git-config',
|
|
54
29
|
KEEP: 'keep',
|
|
55
30
|
});
|
|
56
31
|
|
|
@@ -90,263 +65,78 @@ function listFilesRecursive(dir) {
|
|
|
90
65
|
return out;
|
|
91
66
|
}
|
|
92
67
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
68
|
+
/** 按随包工具文件清单逐文件清理,未知文件和用户添加的文件不删。 */
|
|
69
|
+
function planOwnedFiles(plan, source, target, reason) {
|
|
70
|
+
for (const rel of maintenance.filesUnder(source)) {
|
|
71
|
+
// Skill 的本地检索配置可能包含连接信息,不作为工具脚本清理。
|
|
72
|
+
if (path.basename(rel) === 'config.json') continue;
|
|
73
|
+
if (maintenance.regularFileWithin(target, rel)) addOp(plan, OP.DELETE_FILE, path.join(target, rel), reason);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
102
77
|
function planProfileArtifacts(plan, toolKey, cwd) {
|
|
103
78
|
const profile = getToolProfile(toolKey);
|
|
104
79
|
const config = buildToolConfigs()[toolKey];
|
|
105
80
|
if (!config) return;
|
|
106
|
-
|
|
107
|
-
// 1) skills:SDD 部署的 skill 目录(opsx-* + openspec-sync-specs/update-change + tdd-*)
|
|
108
81
|
if (profile.skillsDir) {
|
|
109
|
-
|
|
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
|
-
}
|
|
82
|
+
planOwnedFiles(plan, path.join(packageRoot, 'templates/skills/kld-sdd'), path.join(cwd, profile.skillsDir), `${config.name} SDD skill 文件`);
|
|
118
83
|
}
|
|
119
|
-
|
|
120
|
-
// 2) Kunlun command bridge:只删含受管标记的
|
|
121
84
|
if (profile.requiresCommandBridge && profile.opsxCommandsDir) {
|
|
122
|
-
const
|
|
123
|
-
for (const
|
|
124
|
-
if (!
|
|
125
|
-
const
|
|
126
|
-
if (isManagedBridgeFile(
|
|
127
|
-
addOp(plan, OP.DELETE_FILE, file, 'SDD command bridge(受管标记)');
|
|
128
|
-
} else {
|
|
129
|
-
addOp(plan, OP.KEEP, file, '非 SDD 受管命令,保留');
|
|
130
|
-
}
|
|
85
|
+
const root = path.join(cwd, profile.opsxCommandsDir);
|
|
86
|
+
for (const rel of maintenance.filesUnder(root)) {
|
|
87
|
+
if (!rel.endsWith('.md') || !maintenance.regularFileWithin(cwd, path.relative(cwd, path.join(root, rel)))) continue;
|
|
88
|
+
const file = path.join(root, rel);
|
|
89
|
+
if (isManagedBridgeFile(fs.readFileSync(file, 'utf8'))) addOp(plan, OP.DELETE_FILE, file, 'SDD command bridge');
|
|
131
90
|
}
|
|
132
91
|
}
|
|
133
|
-
|
|
134
|
-
// 3) Hook 脚本目录(claude / codebuddy)
|
|
135
92
|
if (profile.hookProvider && profile.hookProvider !== 'none') {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 脚本(工具产物)');
|
|
93
|
+
planOwnedFiles(plan, path.join(packageRoot, 'templates/hooks', profile.hookProvider, 'hooks'), path.join(cwd, profile.configDir, 'hooks'), `${config.name} SDD hook`);
|
|
94
|
+
const relative = path.join(profile.configDir, 'settings.json');
|
|
95
|
+
if (maintenance.regularFileWithin(cwd, relative)) addOp(plan, OP.STRIP_HOOKS, path.join(cwd, relative), '仅摘除 SDD 受管 hook');
|
|
191
96
|
}
|
|
97
|
+
// rules 由用户或 Agent 编写,不因名称带 sdd/opsx 就删除。
|
|
192
98
|
}
|
|
193
99
|
|
|
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) {
|
|
100
|
+
function planSpecPackage(plan, specRoot) {
|
|
207
101
|
if (!specRoot || !fs.existsSync(specRoot)) return;
|
|
208
|
-
|
|
209
|
-
|
|
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 操作手册');
|
|
102
|
+
for (const file of maintenance.runtimeToolFiles(path.join(specRoot, 'skywalk-sdd'))) {
|
|
103
|
+
addOp(plan, OP.DELETE_FILE, file, 'SDD 运行工具');
|
|
262
104
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
for (const name of ['modules.yaml', 'sdd.config.yaml']) {
|
|
105
|
+
planOwnedFiles(plan, path.join(packageRoot, 'templates/openspec'), path.join(specRoot, 'openspec-templates'), 'SDD 只读模板');
|
|
106
|
+
for (const name of ['openspec', 'modules.yaml', 'sdd.config.yaml', 'kb-state.json', 'skywalk-sdd/events', 'skywalk-sdd/state', 'skywalk-sdd/project-identity.json', 'skywalk-sdd/git-hooks/hooks.config']) {
|
|
266
107
|
const target = path.join(specRoot, name);
|
|
267
|
-
if (
|
|
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
|
-
}
|
|
108
|
+
if (fs.existsSync(target)) addOp(plan, OP.KEEP, target, '项目文档、配置或历史数据');
|
|
298
109
|
}
|
|
110
|
+
// 即使没有文档,也不删除 Spec 仓根目录及 .git。
|
|
111
|
+
addOp(plan, OP.KEEP, specRoot, 'Spec 仓库目录');
|
|
299
112
|
}
|
|
300
113
|
|
|
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) {
|
|
114
|
+
function planCodeRepo(plan, repoRoot) {
|
|
310
115
|
if (!repoRoot || !fs.existsSync(repoRoot)) return;
|
|
311
|
-
|
|
312
|
-
for (const name of ['.sdd.yaml', '.sdd-spec-root', '.sdd-workspace.yaml']) {
|
|
116
|
+
for (const name of ['.sdd.yaml', '.sdd-spec-root', '.sdd-workspace.yaml', '.gitignore']) {
|
|
313
117
|
const target = path.join(repoRoot, name);
|
|
314
|
-
if (
|
|
315
|
-
if (options.mode === MODE.FULL) {
|
|
316
|
-
addOp(plan, OP.DELETE_FILE, target, 'SDD 用户配置');
|
|
317
|
-
} else {
|
|
318
|
-
addOp(plan, OP.KEEP, target, '用户配置,保留');
|
|
319
|
-
}
|
|
118
|
+
if (fs.existsSync(target)) addOp(plan, OP.KEEP, target, '保留关联配置与隐私数据忽略规则');
|
|
320
119
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
120
|
+
// 与安装器一样尊重 Hook 管理器、worktree 和自定义 Hook。
|
|
121
|
+
if (!workspaceLayout.isGitRepo(repoRoot) || sddConfig.gitConfig(repoRoot, 'core.hooksPath')) return;
|
|
122
|
+
const known = require('./managed-hook-hashes.json');
|
|
324
123
|
for (const name of ['commit-msg', 'pre-commit', 'pre-push']) {
|
|
325
|
-
|
|
326
|
-
if (!fs.existsSync(target)) continue;
|
|
327
|
-
let content = '';
|
|
124
|
+
let target;
|
|
328
125
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
126
|
+
const value = execFileSync('git', ['rev-parse', '--git-path', `hooks/${name}`], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
127
|
+
target = path.resolve(repoRoot, value);
|
|
128
|
+
} catch { continue; }
|
|
129
|
+
if (!target.startsWith(path.resolve(repoRoot) + path.sep)) {
|
|
130
|
+
addOp(plan, OP.KEEP, target, '其他 worktree 共用的 Git Hook;请从主仓库管理');
|
|
331
131
|
continue;
|
|
332
132
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
}
|
|
133
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
134
|
+
if (!stat || !stat.isFile() || !fs.lstatSync(path.dirname(target)).isDirectory()) continue;
|
|
135
|
+
const hash = content => crypto.createHash('sha256').update(content).digest('hex');
|
|
136
|
+
const current = hash(fs.readFileSync(target));
|
|
137
|
+
const template = hash(fs.readFileSync(path.join(packageRoot, 'templates/git-hooks', name)));
|
|
138
|
+
if (current === template || (known[name] || []).includes(current)) addOp(plan, OP.DELETE_FILE, target, '完整匹配已发布的 SDD Hook');
|
|
139
|
+
else addOp(plan, OP.KEEP, target, '自定义或未知 Hook,请自行审阅其中的 SDD 调用');
|
|
350
140
|
}
|
|
351
141
|
}
|
|
352
142
|
|
|
@@ -356,19 +146,21 @@ function planCodeRepo(plan, repoRoot, options) {
|
|
|
356
146
|
* @param {object} options
|
|
357
147
|
* @param {string} options.cwd 工作目录(代码仓 / 工作区根)
|
|
358
148
|
* @param {string[]} [options.tools] 要卸载的编辑器,缺省=全部
|
|
359
|
-
* @param {string} [options.mode] MODE.TOOLS_ONLY
|
|
149
|
+
* @param {string} [options.mode] MODE.TOOLS_ONLY(唯一支持的方式)
|
|
360
150
|
* @param {string} [options.specRoot] 已确认的 spec 包裹包目录(CLI 交互层选定后传入);
|
|
361
151
|
* 缺省时仅按权威来源(.sdd-spec-root → git config)保守定位
|
|
362
152
|
* @returns {{ok: boolean, plan: object, specRoot: string|null, message?: string}}
|
|
363
153
|
*/
|
|
364
154
|
function buildUninstallPlan(options = {}) {
|
|
365
155
|
const cwd = path.resolve(options.cwd || process.cwd());
|
|
366
|
-
|
|
156
|
+
if (options.mode && options.mode !== MODE.TOOLS_ONLY) throw new Error('仅支持工具卸载;不允许删除 Spec 文档、配置和历史数据');
|
|
157
|
+
const mode = MODE.TOOLS_ONLY;
|
|
367
158
|
const tools = Array.isArray(options.tools) && options.tools.length
|
|
368
159
|
? options.tools
|
|
369
160
|
: listToolProfileIds();
|
|
370
161
|
|
|
371
162
|
const plan = createPlan(cwd);
|
|
163
|
+
plan.tools = tools;
|
|
372
164
|
|
|
373
165
|
// 编辑器专属产物:工作区根 / 当前目录
|
|
374
166
|
for (const toolKey of tools) {
|
|
@@ -384,11 +176,18 @@ function buildUninstallPlan(options = {}) {
|
|
|
384
176
|
: resolveSpecRootForUninstall(cwd);
|
|
385
177
|
if (specRoot) {
|
|
386
178
|
plan.specRoot = path.resolve(specRoot);
|
|
387
|
-
|
|
179
|
+
const relative = path.relative(cwd, specRoot);
|
|
180
|
+
const sharedExternal = relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative);
|
|
181
|
+
if (sharedExternal) {
|
|
182
|
+
addOp(plan, OP.KEEP, specRoot, '工作目录外的共享 Spec,仅卸载当前代码仓工具');
|
|
183
|
+
warn(plan, '共享 Spec 位于当前目录之外,运行工具与文档均保留;如需卸载共享工具,请从所属工作区操作');
|
|
184
|
+
} else {
|
|
185
|
+
planSpecPackage(plan, specRoot);
|
|
186
|
+
planCodeRepo(plan, specRoot);
|
|
187
|
+
}
|
|
388
188
|
// spec 仓自身的 .git/hooks(init 装的 commit-msg / pre-commit / pre-push)
|
|
389
189
|
// 是工具产物,须摘除 —— 与代码仓共用同一套受管标记判据,
|
|
390
190
|
// 非受管 hook 与 .git 其余内容一律保留。
|
|
391
|
-
planCodeRepo(plan, specRoot, { mode });
|
|
392
191
|
} else {
|
|
393
192
|
warn(plan, '未定位到 spec 包裹包:spec 侧产物(skywalk-sdd/、openspec/ 等)未纳入计划');
|
|
394
193
|
}
|
|
@@ -409,10 +208,30 @@ function buildUninstallPlan(options = {}) {
|
|
|
409
208
|
if (specRoot) specDirs.add(path.resolve(specRoot));
|
|
410
209
|
for (const repo of layout.codeRepos) {
|
|
411
210
|
if (specDirs.has(path.resolve(repo.abs))) continue;
|
|
412
|
-
|
|
211
|
+
if (!specRoot || maintenance.associationConflict(repo.abs, specRoot)) {
|
|
212
|
+
warn(plan, `保留代码仓 ${repo.name}:未确认属于所选 Spec`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const linked = sddConfig.gitConfig(repo.abs, 'sdd.specPath');
|
|
216
|
+
const yaml = sddConfig.loadSddYaml(repo.abs);
|
|
217
|
+
if (!linked && !yaml.ok) continue;
|
|
218
|
+
planCodeRepo(plan, repo.abs);
|
|
413
219
|
}
|
|
414
220
|
}
|
|
415
221
|
|
|
222
|
+
// 不沿中间符号链接删除文件;同一个文件仅处理一次。
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
plan.ops = plan.ops.filter(item => {
|
|
225
|
+
const key = item.op + ':' + item.target;
|
|
226
|
+
if (seen.has(key)) return false;
|
|
227
|
+
seen.add(key);
|
|
228
|
+
if (item.op === OP.KEEP) return true;
|
|
229
|
+
const root = specRoot && item.target.startsWith(path.resolve(specRoot) + path.sep) ? path.resolve(specRoot) : cwd;
|
|
230
|
+
// Git worktree 的 hooks 位于 common git dir,已单独校验完整内容。
|
|
231
|
+
if (item.reason !== '完整匹配已发布的 SDD Hook' && !maintenance.regularFileWithin(root, path.relative(root, item.target))) return false;
|
|
232
|
+
item.digest = crypto.createHash('sha256').update(fs.readFileSync(item.target)).digest('hex');
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
416
235
|
return { ok: true, plan, specRoot: specRoot || null, mode };
|
|
417
236
|
}
|
|
418
237
|
|
|
@@ -527,187 +346,56 @@ function providerFromSettingsPath(settingsPath) {
|
|
|
527
346
|
return /\.claude[\\/]/.test(settingsPath) ? PROVIDERS.CLAUDE : PROVIDERS.CODEBUDDY;
|
|
528
347
|
}
|
|
529
348
|
|
|
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
|
-
*/
|
|
349
|
+
/** 执行前重新校验白名单和文件快照,拒绝旧全量计划以及预览后的变更。 */
|
|
539
350
|
function applyUninstallPlan(plan, options = {}) {
|
|
540
|
-
const dryRun = Boolean(options.dryRun);
|
|
541
351
|
const log = typeof options.log === 'function' ? options.log : console.log;
|
|
542
352
|
const errors = [];
|
|
353
|
+
if (!plan || !plan.root || !Array.isArray(plan.ops)) return { ok: false, applied: 0, skipped: 0, errors: ['卸载计划无效,请重新预览'] };
|
|
354
|
+
const fresh = buildUninstallPlan({ cwd: plan.root, specRoot: plan.specRoot, tools: plan.tools }).plan;
|
|
355
|
+
const allowed = new Map(fresh.ops.filter(item => item.op !== OP.KEEP).map(item => [item.op + ':' + item.target, item]));
|
|
356
|
+
for (const item of plan.ops) {
|
|
357
|
+
if (item.op === OP.KEEP) continue;
|
|
358
|
+
const current = allowed.get(item.op + ':' + item.target);
|
|
359
|
+
if (!current || !item.digest || current.digest !== item.digest) errors.push(`${item.target}: 范围或内容已变化,请重新预览`);
|
|
360
|
+
}
|
|
361
|
+
if (errors.length) return { ok: false, applied: 0, skipped: 0, errors };
|
|
543
362
|
let applied = 0;
|
|
544
363
|
let skipped = 0;
|
|
545
|
-
|
|
546
364
|
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
|
-
|
|
365
|
+
if (item.op === OP.KEEP) { skipped++; continue; }
|
|
553
366
|
try {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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;
|
|
367
|
+
// 执行时再检查一次,不接受被替换为链接的文件。
|
|
368
|
+
const stat = fs.lstatSync(item.target);
|
|
369
|
+
if (!stat.isFile() || crypto.createHash('sha256').update(fs.readFileSync(item.target)).digest('hex') !== item.digest) throw new Error('文件已变化,请重新预览');
|
|
370
|
+
if (options.dryRun) { log(` 将处理 ${item.target}`); applied++; continue; }
|
|
371
|
+
if (item.op === OP.DELETE_FILE) { fs.unlinkSync(item.target); applied++; }
|
|
372
|
+
else if (item.op === OP.STRIP_HOOKS) {
|
|
373
|
+
const result = stripSettingsHooks(item.target, providerFromSettingsPath(item.target));
|
|
374
|
+
if (!result.ok) throw new Error(result.error);
|
|
375
|
+
if (result.changed) applied++; else skipped++;
|
|
636
376
|
}
|
|
637
|
-
} catch (error) {
|
|
638
|
-
errors.push(`${item.target}: ${error.message}`);
|
|
639
|
-
log(` ⚠️ ${item.target}: ${error.message}`);
|
|
640
|
-
}
|
|
377
|
+
} catch (error) { errors.push(`${item.target}: ${error.message}`); }
|
|
641
378
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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;
|
|
379
|
+
// 只收起因工具文件删除而变空的目录;根目录、Spec、数据目录保持原样。
|
|
380
|
+
if (!options.dryRun) {
|
|
381
|
+
const roots = [plan.root, plan.specRoot].filter(Boolean).map(p => path.resolve(p));
|
|
382
|
+
const kept = plan.ops.filter(i => i.op === OP.KEEP).map(i => path.resolve(i.target));
|
|
383
|
+
for (const item of plan.ops.filter(i => i.op === OP.DELETE_FILE && !i.target.includes(`${path.sep}.git${path.sep}`))) {
|
|
671
384
|
let dir = path.dirname(item.target);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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 {
|
|
385
|
+
while (roots.some(root => dir.startsWith(root + path.sep)) && !roots.includes(dir)) {
|
|
386
|
+
if (kept.some(p => p === dir || p.startsWith(dir + path.sep))) break;
|
|
387
|
+
if (!isEmptyDir(dir)) break;
|
|
684
388
|
fs.rmdirSync(dir);
|
|
685
|
-
|
|
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 { /* 权限不足等,忽略 */ }
|
|
389
|
+
dir = path.dirname(dir);
|
|
699
390
|
}
|
|
700
391
|
}
|
|
701
392
|
}
|
|
702
|
-
|
|
703
393
|
return { ok: errors.length === 0, applied, skipped, errors };
|
|
704
394
|
}
|
|
705
395
|
|
|
706
396
|
/** 卸载方式的中文标签(供 CLI 层复用,避免文案漂移) */
|
|
707
|
-
function modeLabel(
|
|
708
|
-
return
|
|
709
|
-
? '全量卸载(含用户配置与运行时数据)'
|
|
710
|
-
: '工具内容卸载(保留用户配置)';
|
|
397
|
+
function modeLabel() {
|
|
398
|
+
return '卸载工具(保留 Spec 文档、配置与历史数据)';
|
|
711
399
|
}
|
|
712
400
|
|
|
713
401
|
/**
|
|
@@ -719,7 +407,7 @@ function modeLabel(mode) {
|
|
|
719
407
|
* @param {object} [opts]
|
|
720
408
|
* @param {string} [opts.cwd] 工作目录(代码仓 / 工作区根),缺省 process.cwd()
|
|
721
409
|
* @param {string[]} [opts.tools] 要卸载的编辑器 id 列表,缺省=全部
|
|
722
|
-
* @param {string} [opts.mode] MODE.TOOLS_ONLY
|
|
410
|
+
* @param {string} [opts.mode] MODE.TOOLS_ONLY(唯一支持的方式)
|
|
723
411
|
* @param {boolean} [opts.dryRun] 只打印计划,不落盘
|
|
724
412
|
* @param {object} [opts.plan] 复用调用方已算好的计划(避免重复计算)
|
|
725
413
|
* @param {boolean} [opts.showWarnings] 是否打印 plan.warnings,缺省 true
|
|
@@ -727,7 +415,8 @@ function modeLabel(mode) {
|
|
|
727
415
|
*/
|
|
728
416
|
function main(opts = {}) {
|
|
729
417
|
const cwd = opts.cwd || process.cwd();
|
|
730
|
-
|
|
418
|
+
if (opts.mode && opts.mode !== MODE.TOOLS_ONLY) throw new Error('仅支持工具卸载');
|
|
419
|
+
const mode = MODE.TOOLS_ONLY;
|
|
731
420
|
|
|
732
421
|
const plan = opts.plan || buildUninstallPlan({ cwd, tools: opts.tools, mode }).plan;
|
|
733
422
|
const result = applyUninstallPlan(plan, { dryRun: Boolean(opts.dryRun) });
|
|
@@ -740,7 +429,7 @@ function main(opts = {}) {
|
|
|
740
429
|
}
|
|
741
430
|
|
|
742
431
|
console.log('');
|
|
743
|
-
console.log(
|
|
432
|
+
console.log(`${result.ok ? '✅ 完成' : '❌ 未完整卸载'}:处理 ${result.applied} 项,保留 ${result.skipped} 项`);
|
|
744
433
|
if (result.errors.length) {
|
|
745
434
|
console.log(`⚠️ ${result.errors.length} 项失败:`);
|
|
746
435
|
for (const e of result.errors) console.log(` - ${e}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kld-sdd",
|
|
3
|
-
"version": "2.7.8-
|
|
3
|
+
"version": "2.7.8-5",
|
|
4
4
|
"description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
|
|
5
5
|
"main": "lib/init.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"bench:check": "node test/benchmark-check.cjs",
|
|
12
|
-
"test": "node --test test/delivery-hooks.test.cjs test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/spec-detect.cjs && node test/workspace-layout.cjs && node test/sync-repos-spec-choice.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/deploy-strategy.cjs && node test/uninstall.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs test/runtime/usage-reporter.test.cjs test/runtime/usage-reporting.test.cjs test/runtime/git-identity.test.cjs test/runtime/kb-state-location.test.cjs && node test/runtime/metrics-v3.test.cjs && node test/runtime/fixback.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
|
|
12
|
+
"test": "node --test test/delivery-hooks.test.cjs test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/spec-detect.cjs && node test/workspace-layout.cjs && node test/sync-repos-spec-choice.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/deploy-strategy.cjs && node test/uninstall.cjs && node --test test/maintenance.test.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs test/runtime/usage-reporter.test.cjs test/runtime/usage-reporting.test.cjs test/runtime/git-identity.test.cjs test/runtime/kb-state-location.test.cjs && node test/runtime/metrics-v3.test.cjs && node test/runtime/fixback.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"kld",
|
|
@@ -46,5 +46,8 @@
|
|
|
46
46
|
"skywalk-sdd/kb-sync-identity.cjs",
|
|
47
47
|
"README.md",
|
|
48
48
|
"USABILITY.md"
|
|
49
|
-
]
|
|
49
|
+
],
|
|
50
|
+
"sddMaintenance": {
|
|
51
|
+
"toolOnly": 1
|
|
52
|
+
}
|
|
50
53
|
}
|
|
@@ -167,7 +167,25 @@ function writeWorkspaceFile(workspaceRoot, layout) {
|
|
|
167
167
|
'',
|
|
168
168
|
];
|
|
169
169
|
const filePath = path.join(workspaceRoot, WORKSPACE_FILE);
|
|
170
|
-
fs.
|
|
170
|
+
if (fs.existsSync(filePath)) {
|
|
171
|
+
// sync-repos 只追加新仓;既有行、注释、自定义字段和暂时离线的仓库保留。
|
|
172
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
173
|
+
const saved = parseWorkspaceYaml(raw);
|
|
174
|
+
if (!saved.spec_path || path.resolve(workspaceRoot, saved.spec_path) !== path.resolve(workspaceRoot, specRel)) {
|
|
175
|
+
throw new Error('已有多仓清单的 Spec 指向不一致,请先核对配置');
|
|
176
|
+
}
|
|
177
|
+
const known = new Set(saved.code_repos.map(rel => path.resolve(workspaceRoot, rel.replace(/^(["'])(.*)\1$/, '$2'))));
|
|
178
|
+
const additions = codeRels.filter(rel => !known.has(path.resolve(workspaceRoot, rel)));
|
|
179
|
+
if (!additions.length) return filePath;
|
|
180
|
+
const newline = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
181
|
+
const existing = raw.split(/\r?\n/);
|
|
182
|
+
const header = existing.findIndex(line => /^code_repos:\s*(?:#.*)?$/.test(line));
|
|
183
|
+
if (header < 0) throw new Error('已有多仓清单缺少 code_repos 列表,请先修复配置');
|
|
184
|
+
existing.splice(header + 1, 0, ...additions.map(rel => ` - ${rel}`));
|
|
185
|
+
fs.writeFileSync(filePath, existing.join(newline), 'utf8');
|
|
186
|
+
} else {
|
|
187
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
188
|
+
}
|
|
171
189
|
return filePath;
|
|
172
190
|
}
|
|
173
191
|
|