kld-sdd 2.8.1 → 2.8.2

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.
@@ -5,7 +5,7 @@
5
5
  * kb-upload.cjs — 知识库上传脚本
6
6
  *
7
7
  * 支持两种上传模式:
8
- * 1. 文件夹上传:自动跑语义管线 → 生成元数据 → 打包 zip → 上传
8
+ * 1. 文件夹上传:读取本地 Spec → 生成本体 JSON → 直接上传(无 ZIP、无 AI)
9
9
  * 2. Zip 上传:直接上传已有 zip 包
10
10
  *
11
11
  * 用法:
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * 历史产物(缺 entity-id / version-id):
18
18
  * 自动旁路物化 spec-ontology.json,无 semantic_key 冲突时自动确认并发布为 current;
19
- * 有冲突或 --require-review 时只上传候选,需人工 approve-spec-ontology 后再上传。
19
+ * --require-review 仅保存候选;明确审阅后用 --confirm-review 发布同一原文。
20
+ * 并发基线冲突整体拒绝,不覆盖团队 current。
20
21
  *
21
22
  * project_id 解析:
22
23
  * 1. $SPEC_ROOT/skywalk-sdd/project-identity.json 的 project_id
23
- * 2. 都没有 → 报错:请运行 /opsx-kb-config 配置知识库
24
+ * 2. 未配置的首次身份 → 使用已选上传库的 spaceKey 建立归属
25
+ * 3. 缺少空间元数据或已有绑定不符 → 停止并提示修正配置
24
26
  */
25
27
 
26
28
  const fs = require('fs');
@@ -37,16 +39,27 @@ const {
37
39
  isKbStateHostDir,
38
40
  uploadFile,
39
41
  httpGetJson,
42
+ resolveKbParams,
40
43
  } = require('./lib/shared.cjs');
41
44
 
42
45
  function resolveUploadTarget(state, options = {}) {
43
46
  if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
44
47
  const targets = Array.isArray(state?.targets) ? state.targets : [];
48
+ const configured = state?.uploadTarget;
45
49
  if (options.spaceId && options.kbId) {
46
- return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
50
+ const differs = Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget')
51
+ && (!configured || configured.spaceId !== options.spaceId || configured.kbId !== options.kbId);
52
+ if (differs && !options.overrideUploadTarget) {
53
+ throw new Error('UPLOAD_TARGET_CONFLICT: 命令中的上传库与助手保存的上传位置不同;请更新旧 Skill,默认不传目标参数。确需临时改投时,显式增加 --override-upload-target');
54
+ }
55
+ const selected = targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId);
56
+ if (options.overrideUploadTarget && !selected) throw new Error('临时上传目标必须在 kb-state.json 的 targets 中');
57
+ return selected || { spaceId: options.spaceId, kbId: options.kbId };
47
58
  }
48
59
 
49
- const configured = state?.uploadTarget;
60
+ if (Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget') && configured == null) {
61
+ throw new Error('尚未选择 Spec 上传知识库(uploadTarget 已清空);请在助手中选择上传位置,不能用读取库代替');
62
+ }
50
63
  if (configured != null) {
51
64
  if (!configured.spaceId || !configured.kbId) {
52
65
  throw new Error('kb-state.json 的 uploadTarget 不完整,请在 SDD 助手中重新选择 Spec 上传知识库');
@@ -106,9 +119,9 @@ function resolveKbStatePath(specRoot) {
106
119
  function readArchiveCache(specRoot, changeName, context) {
107
120
  const statePath = resolveKbStatePath(specRoot);
108
121
  const state = readJsonSafe(statePath);
109
- if (!state || !state.archiveCache) return null;
122
+ const local = require('./lib/kb-config.cjs').readReceipts(specRoot);
110
123
  const key = cacheKey(changeName, context);
111
- return key ? state.archiveCache[key] || null : null;
124
+ return key ? local.receipts?.[key] || local.legacyArchiveCache?.[key] || state?.archiveCache?.[key] || null : null;
112
125
  }
113
126
 
114
127
  /**
@@ -119,18 +132,14 @@ function readArchiveCache(specRoot, changeName, context) {
119
132
  * @param {string} contentHash
120
133
  */
121
134
  function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
122
- const statePath = resolveKbStatePath(specRoot);
123
- const state = readJsonSafe(statePath) || {};
124
- if (!state.archiveCache) state.archiveCache = {};
125
135
  const key = cacheKey(changeName, context);
126
136
  if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
127
- state.archiveCache[key] = {
137
+ require('./lib/kb-config.cjs').writeReceipt(specRoot, key, {
128
138
  archive_id: archiveId,
129
139
  content_hash: contentHash,
130
140
  uploaded_at: new Date().toISOString(),
131
141
  target: context,
132
- };
133
- writeJsonAtomic(statePath, state);
142
+ });
134
143
  console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
135
144
  }
136
145
 
@@ -164,13 +173,35 @@ function resolveSpecRoot(explicit) {
164
173
 
165
174
  /**
166
175
  * 解析 project_id
167
- * 优先级:project-identity.json → 报错
176
+ * 保留团队绑定;仅缺失或明确 UNCONFIGURED 的身份可从已选上传目标首次建立。
168
177
  */
169
- function resolveProjectId(specRoot) {
178
+ function resolveProjectId(specRoot, target) {
170
179
  const identityPath = path.join(specRoot, 'skywalk-sdd', 'project-identity.json');
180
+ if (!fs.existsSync(identityPath) && typeof target?.spaceKey === 'string' && target.spaceKey.trim()) {
181
+ ensureDir(path.dirname(identityPath));
182
+ try {
183
+ fs.writeFileSync(identityPath, JSON.stringify({
184
+ schema_version: 'kld-sdd-project-identity/v1',
185
+ project_id: target.spaceKey.trim(),
186
+ kb_space_id: target.spaceId,
187
+ kb_space_name: target.spaceName || '',
188
+ created_at: new Date().toISOString(),
189
+ }, null, 2) + '\n', { flag: 'wx' });
190
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
191
+ }
171
192
  if (fs.existsSync(identityPath)) {
172
193
  const identity = readJson(identityPath);
173
- if (identity.project_id) {
194
+ const provisional = !identity?.kb_space_id && typeof identity?._warning === 'string'
195
+ && identity._warning.startsWith('UNCONFIGURED') && /^urn:kld:sdd:project:/.test(identity.project_id || '');
196
+ if (provisional && typeof target?.spaceKey === 'string' && target.spaceKey.trim()) {
197
+ const next = { ...identity, previous_unconfigured_project_id: identity.project_id,
198
+ project_id: target.spaceKey.trim(), kb_space_id: target.spaceId, kb_space_name: target.spaceName || '',
199
+ configured_at: new Date().toISOString() };
200
+ delete next._warning;
201
+ writeJsonAtomic(identityPath, next);
202
+ return next.project_id;
203
+ }
204
+ if (typeof identity?.project_id === 'string' && identity.project_id.trim()) {
174
205
  return identity.project_id;
175
206
  }
176
207
  }
@@ -186,9 +217,9 @@ function resolveProjectId(specRoot) {
186
217
  * 位置校验:只读 `<specRoot>/kb-state.json`(specRoot 的 spec 根身份已由
187
218
  * resolveSpecRoot 在入口校验)。
188
219
  */
189
- function readKbState(specRoot) {
220
+ function readKbState(specRoot, env = process.env) {
190
221
  const statePath = resolveKbStatePath(specRoot);
191
- if (!fs.existsSync(statePath)) {
222
+ if (!fs.existsSync(statePath) && !fs.existsSync(path.join(specRoot, 'kb.config.json'))) {
192
223
  throw new Error(
193
224
  'KB 配置不存在(kb-state.json 缺失)。\n' +
194
225
  `期望位置: ${statePath}\n` +
@@ -196,7 +227,14 @@ function readKbState(specRoot) {
196
227
  );
197
228
  }
198
229
  try {
199
- return readJson(statePath);
230
+ const loaded = require('./lib/kb-config.cjs').readProjectConfig(specRoot);
231
+ const state = loaded.config;
232
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('配置内容必须是 JSON 对象');
233
+ if (env.SDD_KB_STATE_FILE && path.resolve(env.SDD_KB_STATE_FILE) !== statePath) {
234
+ console.warn('⚠️ SDD_KB_STATE_FILE 已忽略;上传统一读取所选 Spec 根目录的 kb-state.json');
235
+ }
236
+ const params = resolveKbParams(state, env);
237
+ return { ...state, api: params.apiBase, apiKey: params.token };
200
238
  } catch (e) {
201
239
  throw new Error(
202
240
  `KB 配置解析失败 (${statePath}): ${e.message}\n` +
@@ -205,6 +243,16 @@ function readKbState(specRoot) {
205
243
  }
206
244
  }
207
245
 
246
+ // Upload and identity synchronization share this entry; neither may reinterpret read targets.
247
+ function prepareUploadConfig(specRoot, options = {}) {
248
+ const state = readKbState(specRoot);
249
+ if (!state.apiKey) throw new Error('KB 配置中无 apiKey,请在助手中登录知识库或运行 /opsx-kb-config');
250
+ const target = resolveUploadTarget(state, options);
251
+ const projectId = resolveProjectId(specRoot, options.readOnlyIdentity ? undefined : target);
252
+ preCheckProjectId(projectId, state, target);
253
+ return { state, target, projectId };
254
+ }
255
+
208
256
  /**
209
257
  * 校验文件夹必需文件
210
258
  */
@@ -446,7 +494,26 @@ async function uploadZip(zipPath, state, options) {
446
494
  async function main() {
447
495
  const args = parseArgs(process.argv.slice(2));
448
496
 
449
- if (!args.folder && !args.package) {
497
+ if (args['check-config']) {
498
+ try {
499
+ const specRoot = resolveSpecRoot(args.project);
500
+ const state = readKbState(specRoot);
501
+ if (!state.apiKey) throw new Error('尚未配置知识库 API Key');
502
+ const target = resolveUploadTarget(state);
503
+ const identityPath = path.join(specRoot, 'skywalk-sdd/project-identity.json');
504
+ const identity = fs.existsSync(identityPath) ? readJson(identityPath) : null;
505
+ if (fs.existsSync(identityPath) && (!identity || typeof identity.project_id !== 'string' || !identity.project_id.trim())) throw new Error('项目身份文件缺少 project_id,已保留原文件,请先修复');
506
+ const provisional = identity && !identity.kb_space_id && typeof identity._warning === 'string'
507
+ && identity._warning.startsWith('UNCONFIGURED') && /^urn:kld:sdd:project:/.test(identity.project_id || '');
508
+ if (identity?.project_id && !provisional) preCheckProjectId(identity.project_id, state, target);
509
+ else if (!target.spaceKey) throw new Error('上传目标缺少项目标识 spaceKey;请在助手重新登录获取知识库列表,或运行 /opsx-kb-config 补齐');
510
+ console.log(JSON.stringify({ configFile: resolveKbStatePath(specRoot), api: state.api,
511
+ uploadTarget: { spaceId: target.spaceId, kbId: target.kbId, spaceKey: target.spaceKey, spaceName: target.spaceName, kbName: target.kbName }, projectId: identity?.project_id || null,
512
+ identityWillBeCreated: !identity && Boolean(target.spaceKey), identityWillBeBound: Boolean(provisional) }, null, 2));
513
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
514
+ return;
515
+ }
516
+ if (!args.folder && !args.package && !args.change) {
450
517
  console.error('用法:');
451
518
  console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> [--minimum-level=propose-spec] [--validate-only]');
452
519
  console.error(' node skywalk-sdd/kb-upload.cjs --package=<zip-path>');
@@ -483,6 +550,7 @@ async function main() {
483
550
  const result = await uploadZip(zipPath, state, {
484
551
  spaceId: args['space-id'],
485
552
  kbId: args['kb-id'],
553
+ overrideUploadTarget: Boolean(args['override-upload-target']),
486
554
  });
487
555
 
488
556
  console.log('\n── 上传结果 ──');
@@ -550,130 +618,11 @@ async function main() {
550
618
  return;
551
619
  }
552
620
 
553
- // 2. 解析 project_id
554
- const projectId = resolveProjectId(specRoot);
555
- console.log(`✓ project_id: ${projectId}`);
556
-
557
- // 3. 读取 KB 配置 + 预检
558
- const state = readKbState(specRoot);
559
- if (!state.apiKey) {
560
- throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
561
- }
562
- const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
563
- preCheckProjectId(projectId, state, target);
564
- const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
565
-
566
- // 5. 构建 Archive Package
567
- console.log('');
568
- const { zipPath, manifest, stagingDir, requiresReview, legacyStatus } = buildPackageFromFolder(specRoot, changeName, {
569
- reason: args.reason,
570
- requireReview: Boolean(args['require-review']),
571
- });
572
-
573
- try {
574
- // Local receipts are informational only. Always verify the selected destination remotely.
575
- const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
576
-
577
- // === Step 5.5: KB 预检(网络级幂等确认)===
578
- const archiveId = manifest.archive_id;
579
- const contentHash = manifest.content_hash;
580
- console.log(`→ Pre-check: archive_id=${archiveId}`);
581
- console.log(` content_hash=${contentHash}`);
582
-
583
- const checkUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions/check`
584
- + `?archiveId=${encodeURIComponent(archiveId)}`
585
- + `&contentHash=${encodeURIComponent(contentHash)}`;
586
-
587
- try {
588
- const checkResult = await httpGetJson(checkUrl, state.apiKey);
589
-
590
- if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
591
- console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
592
- console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
593
- updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
594
- console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
595
- if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
596
- console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
597
- return;
598
- }
599
-
600
- if (checkResult && checkResult.data && checkResult.data.exists && !checkResult.data.idempotent) {
601
- throw new Error(
602
- `ARCHIVE_IMMUTABILITY_VIOLATION: archive_id 已存在但 content_hash 不同\n` +
603
- ` archive_id: ${archiveId}\n` +
604
- ` 如果内容确实变更,请确认实体身份块已正确更新`
605
- );
606
- }
607
-
608
- console.log('✓ Pre-check 通过(KB 中无此 archive,继续上传)');
609
- } catch (checkErr) {
610
- // 降级:KB 后端未部署 /check 接口(404)→ 继续上传
611
- if (checkErr.message && checkErr.message.includes('HTTP 404')) {
612
- console.log('⚠️ KB 后端未部署 /check 接口,降级为直接上传');
613
- } else {
614
- throw checkErr;
615
- }
616
- }
617
-
618
- // 6. 上传
619
- console.log('');
620
- const result = await uploadZip(zipPath, state, {
621
- spaceId: target.spaceId,
622
- kbId: target.kbId,
623
- allowNeedsReview: requiresReview,
624
- });
625
-
626
- console.log('\n── 上传结果 ──');
627
- console.log(JSON.stringify(result, null, 2));
621
+ // Direct JSON transport reads the existing documents; ZIP remains for --package compatibility.
628
622
 
629
- // 7. 输出 manifest 摘要
630
- console.log('\n── Archive Package 摘要 ──');
631
- console.log(` archive_id: ${manifest.archive_id}`);
632
- console.log(` project_id: ${manifest.project_id}`);
633
- console.log(` change_id: ${manifest.change_id}`);
634
- console.log(` files: ${manifest.files.length}`);
635
-
636
- const publicationStatus = result?.data?.report?.publication_status
637
- || result?.report?.publication_status
638
- || '';
639
- if (publicationStatus === 'needs_review') {
640
- console.log('\n⚠️ 历史 Spec 已安全保存为候选,尚未进入 current 召回');
641
- console.log(` → 审核 ${path.join(folderPath, 'ontology', 'spec-ontology.json')}`);
642
- if (legacyStatus === 'needs_review') {
643
- console.log(' → 正文没有足够的结构化事实,只能作为证据保留');
644
- } else {
645
- console.log(' → 运行 approve-spec-ontology 后再次上传即可发布;不带 --require-review 上传则自动确认');
646
- }
647
- } else {
648
- // Published commit has been verified; a receipt does not imply projection completion.
649
- updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
650
- }
651
-
652
- // === 投影状态提示 ===
653
- // 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
654
- // 正常 SDD 流程中,Spec→Design 等阶段间有用户手动触发的时间间隔,投影通常在此期间完成。
655
- const projStatus = result?.data?.projectionStatus || result?.projectionStatus || '';
656
- const jobStatus = result?.data?.status || result?.status || '';
657
-
658
- if (projStatus === 'complete') {
659
- console.log('\n✓ 投影已完成,向量/图检索可用');
660
- } else if (projStatus === 'degraded') {
661
- console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
662
- } else if (projStatus === 'disabled') {
663
- console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
664
- } else if (projStatus && projStatus !== 'complete') {
665
- console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
666
- console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
667
- console.log(' → 如后续查询 degraded,可用 `context-client.cjs --check-only` 确认 KB 可用性');
668
- } else if (jobStatus === 'succeeded') {
669
- console.log('\n✓ 入库成功');
670
- }
671
-
672
- } finally {
673
- // 清理临时目录
674
- fs.rmSync(stagingDir, { recursive: true, force: true });
675
- console.log('\n✓ 临时文件已清理');
676
- }
623
+ const result = await require('./kb-ingest.cjs').uploadChanges(specRoot, [{ key: changeName }], { spaceId: args['space-id'], kbId: args['kb-id'], overrideUploadTarget: Boolean(args['override-upload-target']), requireReview: Boolean(args['require-review']), confirmReview: Boolean(args['confirm-review']) });
624
+ console.log(JSON.stringify(result, null, 2));
625
+ if (result.items.some(item => item.status === 'failed')) { for (const item of result.items.filter(i => i.status === 'failed')) console.error(item.message); process.exitCode = 1; }
677
626
 
678
627
  } catch (err) {
679
628
  console.error(`\n❌ ${err.message}`);
@@ -681,5 +630,6 @@ async function main() {
681
630
  }
682
631
  }
683
632
 
633
+ module.exports = { resolveUploadTarget, prepareUploadConfig, readKbState, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
634
+
684
635
  if (require.main === module) main();
685
- module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ // One resolver for tray, Skills, retrieval, uploads and reporting. Never export a
3
+ // personal file wholesale as team configuration.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const PUBLIC_KEYS = ['api', 'targets', 'uploadTarget'];
8
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
9
+ function endpoint(value) {
10
+ const url = new URL(value);
11
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('知识库服务地址无效');
12
+ return url.toString().replace(/\/$/, '').replace(/\/api$/, '') + '/api';
13
+ }
14
+ function objectFile(file) {
15
+ if (!fs.existsSync(file)) return null;
16
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
17
+ const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('配置必须是 JSON 对象');
19
+ return value;
20
+ }
21
+ function paths(root) {
22
+ return { team: path.join(root, 'kb.config.json'), personal: path.join(root, 'kb-state.json'), state: path.join(root, 'skywalk-sdd/state/ingestions.json') };
23
+ }
24
+ function unionTargets(a, b) {
25
+ const result = [];
26
+ for (const t of [...(a || []), ...(b || [])]) {
27
+ if (!t || typeof t.spaceId !== 'string' || typeof t.kbId !== 'string' || !t.spaceId || !t.kbId) throw new Error('知识库目标必须包含空间和知识库');
28
+ if (!result.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId)) result.push(t);
29
+ }
30
+ return result;
31
+ }
32
+ function readProjectConfig(root) {
33
+ const files = paths(root), personal = objectFile(files.personal) || {}, team = objectFile(files.team);
34
+ const revision = hash([files.team, files.personal].map(f => fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '').join('\0'));
35
+ let config = { ...personal }, warnings = [];
36
+ if (team) {
37
+ if (team.schemaVersion !== 1) throw new Error('团队知识库配置版本不兼容,请升级 kld-sdd');
38
+ if (Object.keys(team).some(k => !['schemaVersion', ...PUBLIC_KEYS].includes(k))) throw new Error('团队知识库配置包含非公共字段,请移到个人配置');
39
+ if (!Array.isArray(team.targets) || !Object.hasOwn(team, 'uploadTarget')) throw new Error('团队配置缺少读取范围或上传位置');
40
+ config = { ...personal, api: endpoint(team.api), targets: unionTargets(team.targets, personal.personalReadTargets), uploadTarget: team.uploadTarget };
41
+ }
42
+ const credentialApi = personal.credentialForApi || personal.api;
43
+ if (personal.apiKey && config.api && ((!credentialApi && team) || (credentialApi && credentialApi !== config.api && endpoint(credentialApi) !== endpoint(config.api)))) {
44
+ config.apiKey = ''; config.credentialMismatch = true;
45
+ warnings.push('知识库服务地址已变化,请重新登录;原 Key 已保留在本机');
46
+ }
47
+ // A changed shared server must not inherit a process-wide credential either.
48
+ if (team && !config.apiKey) config.credentialMismatch = true;
49
+ return { config, personal, team, files, revision, warnings, path: fs.existsSync(files.personal) || team ? files.personal : null };
50
+ }
51
+ function atomic(file, value) {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
54
+ const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
55
+ try { fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(temp, file); }
56
+ finally { fs.rmSync(temp, { force: true }); }
57
+ }
58
+ function saveProjectConfig(root, config, options = {}) {
59
+ return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
60
+ const before = readProjectConfig(root);
61
+ if (options.revision != null && options.revision !== before.revision) throw new Error('配置已被其他程序修改,请重新读取');
62
+ const api = endpoint(config.api);
63
+ protectPersonal(root);
64
+ const apiKey = config.credentialMismatch && !config.apiKey ? before.personal.apiKey : config.apiKey;
65
+ const sameKey = apiKey && apiKey === before.personal.apiKey;
66
+ const credentialApi = sameKey && !options.credentialVerified ? (before.personal.credentialForApi || before.personal.api || api) : api;
67
+ let personal = { ...before.personal, ...config, ...(apiKey !== undefined ? { apiKey } : {}), api, credentialForApi: credentialApi };
68
+ if (options.team === true || before.team) {
69
+ const targets = unionTargets(config.targets, []);
70
+ const selected = config.uploadTarget || null;
71
+ if (selected && !targets.some(t => t.spaceId === selected.spaceId && t.kbId === selected.kbId)) throw new Error('上传位置必须在所选知识库中');
72
+ if (options.team === true) {
73
+ const team = { schemaVersion: 1, api, targets: targets.map(t => Object.fromEntries(['spaceId', 'kbId', 'spaceKey', 'spaceName', 'kbName'].filter(k => typeof t[k] === 'string').map(k => [k, t[k]]))), uploadTarget: selected && { spaceId: selected.spaceId, kbId: selected.kbId } };
74
+ atomic(before.files.team, team);
75
+ } else {
76
+ if (api !== endpoint(before.team.api) || JSON.stringify(selected) !== JSON.stringify(before.team.uploadTarget)) throw new Error('修改团队地址或上传位置,请选择「团队共享」保存');
77
+ }
78
+ const defaults = options.team === true ? targets : before.team.targets;
79
+ personal.personalReadTargets = targets.filter(t => !defaults.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId));
80
+ delete personal.targets; delete personal.uploadTarget;
81
+ }
82
+ delete personal.credentialMismatch;
83
+ if (personal.archiveCache) {
84
+ const state = objectFile(before.files.state) || { schemaVersion: 1, receipts: {} };
85
+ state.legacyArchiveCache = { ...personal.archiveCache, ...state.legacyArchiveCache };
86
+ atomic(before.files.state, state); delete personal.archiveCache;
87
+ }
88
+ protectPersonal(root);
89
+ atomic(before.files.personal, personal);
90
+ return readProjectConfig(root);
91
+ });
92
+ }
93
+ function readReceipts(root) { return objectFile(paths(root).state) || { schemaVersion: 1, receipts: {} }; }
94
+ function writeReceipt(root, key, value) {
95
+ return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
96
+ const state = readReceipts(root); state.receipts ||= {}; state.receipts[key] = value;
97
+ protectPersonal(root); atomic(paths(root).state, state); return value;
98
+ });
99
+ }
100
+ function protectPersonal(root) {
101
+ const file = path.join(root, '.gitignore');
102
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('.gitignore 不能是符号链接');
103
+ const old = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
104
+ const missing = ['/kb-state.json', '/skywalk-sdd/state/'].filter(rule => !old.split(/\r?\n/).includes(rule));
105
+ if (missing.length) fs.writeFileSync(file, old + (old.endsWith('\n') || !old ? '' : '\n') + missing.join('\n') + '\n');
106
+ }
107
+ module.exports = { endpoint, paths, readProjectConfig, saveProjectConfig, readReceipts, writeReceipt, atomic, unionTargets };
@@ -352,12 +352,13 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
352
352
  let current = start;
353
353
  for (let level = 0; level < MAX_KB_STATE_LEVELS; level += 1) {
354
354
  const candidate = path.join(current, KB_STATE_FILENAME);
355
- if (fs.existsSync(candidate) && isKbStateHostDir(current)) {
355
+ if ((fs.existsSync(candidate) || fs.existsSync(path.join(current, 'kb.config.json'))) && isKbStateHostDir(current)) {
356
356
  try {
357
- const config = JSON.parse(stripBom(fs.readFileSync(candidate, 'utf8')));
358
- return { config, path: candidate, warnings };
357
+ return require('./kb-config.cjs').readProjectConfig(current);
359
358
  } catch (e) {
360
359
  warnings.push(`kb-state.json 解析失败 (${candidate}): ${e.message}`);
360
+ // A broken configuration belongs to this Spec. Never borrow another project's credentials.
361
+ return { config: null, path: candidate, warnings };
361
362
  }
362
363
  }
363
364
  if (current === fileRoot) break;
@@ -391,7 +392,7 @@ function normalizeProjectName(value) {
391
392
  */
392
393
  function resolveKbParams(stateConfig = {}, env = process.env) {
393
394
  const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
394
- const token = stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
395
+ const token = stateConfig.credentialMismatch ? '' : stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
395
396
  const spaceId = stateConfig.targets && stateConfig.targets.length > 0
396
397
  ? stateConfig.targets[0].spaceId : '';
397
398
  const kbId = stateConfig.targets && stateConfig.targets.length > 0
@@ -523,7 +524,8 @@ function setKbProjectName(specRoot, projectName, options = {}) {
523
524
  error.code = 'E_PROJECT_NAME_INVALID';
524
525
  throw error;
525
526
  }
526
- const next = { ...kbState, projectName: normalized };
527
+ const personal = require('./kb-config.cjs').readProjectConfig(root).personal;
528
+ const next = { ...personal, projectName: normalized };
527
529
  writeJsonAtomic(statePath, next);
528
530
  return { ok: true, path: statePath, projectName: next.projectName };
529
531
  }