kld-sdd 2.6.16 → 2.6.21

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.
Files changed (70) hide show
  1. package/bin/kld-sdd-init.js +10 -0
  2. package/kld-sdd-guide.html +22 -0
  3. package/lib/device-auth-cli.js +130 -0
  4. package/lib/device-auth.js +345 -0
  5. package/lib/init-account-binding.js +108 -0
  6. package/lib/init.js +238 -55
  7. package/lib/skills-bundle.js +20 -1
  8. package/lib/tool-profiles.js +8 -0
  9. package/package.json +7 -3
  10. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  11. package/skywalk-sdd/context-client.cjs +38 -87
  12. package/skywalk-sdd/index.cjs +860 -132
  13. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  14. package/skywalk-sdd/kb-upload.cjs +505 -0
  15. package/skywalk-sdd/lib/shared.cjs +811 -0
  16. package/skywalk-sdd/lib/usage-contract.cjs +276 -0
  17. package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
  18. package/skywalk-sdd/lib/user-config.cjs +157 -0
  19. package/skywalk-sdd/metrics-v3.cjs +138 -8
  20. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  21. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  22. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  23. package/skywalk-sdd/ontology/id.cjs +26 -5
  24. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  25. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  26. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  27. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  28. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  29. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  30. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  31. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  32. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  33. package/skywalk-sdd/spec-root.cjs +31 -0
  34. package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
  35. package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
  36. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  37. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  38. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  39. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  40. package/templates/openspec/proposal.md +7 -3
  41. package/templates/openspec/spec.md +3 -3
  42. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  43. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  44. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  45. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  46. package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
  47. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  48. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
  49. package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
  50. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  52. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
  53. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
  54. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  55. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  56. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  57. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  58. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  59. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  60. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  61. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  62. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  63. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  64. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  65. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  66. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  67. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  68. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  69. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  70. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -0,0 +1,811 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * shared.cjs — skywalk-sdd 脚本共用工具模块
5
+ *
6
+ * 统一以下 7 类重复实现(原有 5+ 份不同实现散落在各脚本中):
7
+ * 1. parseArgs — CLI 参数解析(原 5 份不同实现)
8
+ * 2. readJson/Safe — JSON 读取 + BOM 剥离(原 8 处,仅 1 处有 BOM 处理)
9
+ * 3. writeJsonAtomic — 原子写入 JSON(原 3 份实现)
10
+ * 4. ensureDir — 目录创建(零散内联)
11
+ * 5. copyDirRecursive — 递归复制目录(原 2 份相同实现)
12
+ * 6. resolveSpecRootFast — spec root 解析(原 3 份重复逻辑)
13
+ * 7. loadKbState — KB 配置加载(原 2 份,1 份缺 BOM 剥离)
14
+ * 8. httpRequest — HTTP 请求(context-client.cjs 提取,替代 kb-upload.cjs 的 curl)
15
+ *
16
+ * 使用方式:
17
+ * const { parseArgs, readJson, ... } = require('./lib/shared.cjs');
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const http = require('http');
24
+ const https = require('https');
25
+ const { URL } = require('url');
26
+
27
+ // ═══════════════════════════════════════════════════════════════
28
+ // 1. CLI 参数解析
29
+ // ═══════════════════════════════════════════════════════════════
30
+
31
+ /**
32
+ * 统一 CLI 参数解析器。
33
+ *
34
+ * 支持三种格式:
35
+ * --key=value → args.key = 'value'
36
+ * --key value → args.key = 'value'(下一个 token 不以 -- 开头时)
37
+ * --flag → args.flag = true
38
+ * -- → 忽略(分隔符)
39
+ *
40
+ * @param {string[]} argv — 通常传入 process.argv.slice(2)
41
+ * @returns {Record<string, string|boolean>}
42
+ */
43
+ function parseArgs(argv) {
44
+ const args = {};
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const token = argv[i];
47
+ if (token === '--') continue;
48
+ if (!token.startsWith('--')) continue;
49
+ const eq = token.indexOf('=');
50
+ if (eq !== -1) {
51
+ args[token.slice(2, eq)] = token.slice(eq + 1);
52
+ } else {
53
+ const key = token.slice(2);
54
+ const next = argv[i + 1];
55
+ if (next && !next.startsWith('--')) {
56
+ args[key] = next;
57
+ i++;
58
+ } else {
59
+ args[key] = true;
60
+ }
61
+ }
62
+ }
63
+ return args;
64
+ }
65
+
66
+ // ═══════════════════════════════════════════════════════════════
67
+ // 2. JSON 读取(带 BOM 剥离)
68
+ // ═══════════════════════════════════════════════════════════════
69
+
70
+ /**
71
+ * 剥离 UTF-8 BOM(PowerShell Out-File -Encoding utf8 会写入 U+FEFF)。
72
+ * @param {string} raw
73
+ * @returns {string}
74
+ */
75
+ function stripBom(raw) {
76
+ if (raw.charCodeAt(0) === 0xFEFF) return raw.slice(1);
77
+ return raw;
78
+ }
79
+
80
+ /**
81
+ * 读取并解析 JSON 文件,带 BOM 剥离。
82
+ * @param {string} filePath
83
+ * @returns {object}
84
+ * @throws {Error} 文件不存在或 JSON 解析失败时抛出
85
+ */
86
+ function readJson(filePath) {
87
+ let raw = fs.readFileSync(filePath, 'utf8');
88
+ raw = stripBom(raw);
89
+ return JSON.parse(raw);
90
+ }
91
+
92
+ /**
93
+ * 安全读取 JSON 文件,带 BOM 剥离。解析失败返回 null。
94
+ * @param {string} filePath
95
+ * @returns {object|null}
96
+ */
97
+ function readJsonSafe(filePath) {
98
+ try {
99
+ let raw = fs.readFileSync(filePath, 'utf8');
100
+ raw = stripBom(raw);
101
+ return JSON.parse(raw);
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ // ═══════════════════════════════════════════════════════════════
108
+ // 3. 原子写入
109
+ // ═══════════════════════════════════════════════════════════════
110
+
111
+ /**
112
+ * 原子写入文本文件(临时文件 → rename,含 fsync)。
113
+ * @param {string} filePath
114
+ * @param {string|Buffer} content
115
+ */
116
+ function writeTextAtomic(filePath, content) {
117
+ ensureDir(path.dirname(filePath));
118
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
119
+ let fd;
120
+ try {
121
+ fd = fs.openSync(tempPath, 'w');
122
+ fs.writeFileSync(fd, content, 'utf8');
123
+ fs.fsyncSync(fd);
124
+ fs.closeSync(fd);
125
+ fd = null;
126
+ fs.renameSync(tempPath, filePath);
127
+ } catch (error) {
128
+ if (fd != null) {
129
+ try { fs.closeSync(fd); } catch {}
130
+ }
131
+ try { fs.rmSync(tempPath, { force: true }); } catch {}
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 原子写入 JSON 文件(序列化 + 换行 + 原子写入)。
138
+ * @param {string} filePath
139
+ * @param {*} value — 可 JSON.stringify 的值
140
+ * @returns {Buffer} 写入的字节
141
+ */
142
+ function writeJsonAtomic(filePath, value) {
143
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
144
+ writeTextAtomic(filePath, bytes);
145
+ return bytes;
146
+ }
147
+
148
+ /**
149
+ * 仅在内容变化时写入(避免不必要的文件系统事件)。
150
+ * @param {string} filePath
151
+ * @param {string} content
152
+ * @returns {boolean} 是否实际写入
153
+ */
154
+ function writeIfChanged(filePath, content) {
155
+ if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) {
156
+ return false;
157
+ }
158
+ writeTextAtomic(filePath, content);
159
+ return true;
160
+ }
161
+
162
+ // ═══════════════════════════════════════════════════════════════
163
+ // 4. 目录 / 文件操作
164
+ // ═══════════════════════════════════════════════════════════════
165
+
166
+ /**
167
+ * 确保目录存在(recursive)。
168
+ * @param {string} dirPath
169
+ */
170
+ function ensureDir(dirPath) {
171
+ if (!fs.existsSync(dirPath)) {
172
+ fs.mkdirSync(dirPath, { recursive: true });
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 递归复制目录。
178
+ * @param {string} src
179
+ * @param {string} dest
180
+ */
181
+ function copyDirRecursive(src, dest) {
182
+ if (!fs.existsSync(dest)) {
183
+ fs.mkdirSync(dest, { recursive: true });
184
+ }
185
+ const entries = fs.readdirSync(src, { withFileTypes: true });
186
+ for (const entry of entries) {
187
+ const srcPath = path.join(src, entry.name);
188
+ const destPath = path.join(dest, entry.name);
189
+ if (entry.isDirectory()) {
190
+ copyDirRecursive(srcPath, destPath);
191
+ } else if (entry.isFile()) {
192
+ fs.copyFileSync(srcPath, destPath);
193
+ }
194
+ }
195
+ }
196
+
197
+ // ═══════════════════════════════════════════════════════════════
198
+ // 5. 日期 / hash / 路径工具
199
+ // ═══════════════════════════════════════════════════════════════
200
+
201
+ /**
202
+ * 返回今天的 ISO 日期(YYYY-MM-DD)。
203
+ */
204
+ function today() {
205
+ return new Date().toISOString().slice(0, 10);
206
+ }
207
+
208
+ /**
209
+ * 返回当前时间的 ISO 8601 时间戳。
210
+ */
211
+ function nowISO() {
212
+ return new Date().toISOString();
213
+ }
214
+
215
+ /**
216
+ * 计算字符串的 SHA-256 哈希。
217
+ */
218
+ function sha256(content) {
219
+ return crypto.createHash('sha256').update(content).digest('hex');
220
+ }
221
+
222
+ /**
223
+ * 将路径中的反斜杠转为正斜杠(Windows 路径归一化)。
224
+ */
225
+ function toPosix(value) {
226
+ return String(value || '').replace(/\\/g, '/');
227
+ }
228
+
229
+ // ═══════════════════════════════════════════════════════════════
230
+ // 6. Spec Root 解析
231
+ // ═══════════════════════════════════════════════════════════════
232
+
233
+ let _resolveSpecProjectRoot = null;
234
+
235
+ /**
236
+ * 惰性加载 resolve-spec-root.cjs(避免循环依赖)。
237
+ */
238
+ function getResolveSpecProjectRoot() {
239
+ if (!_resolveSpecProjectRoot) {
240
+ const mod = require(path.join(__dirname, '..', 'ontology', 'resolve-spec-root.cjs'));
241
+ _resolveSpecProjectRoot = mod.resolveSpecProjectRoot;
242
+ }
243
+ return _resolveSpecProjectRoot;
244
+ }
245
+
246
+ /**
247
+ * 快速解析 spec root 路径。
248
+ *
249
+ * 三级策略:
250
+ * 1. 如果 anchorDir 自身在 skywalk-sdd/ 内,父目录可能是 spec root → 验证后直接返回
251
+ * 2. 从 anchorDir 向上遍历查找 .sdd-spec-root → resolveSpecProjectRoot
252
+ * 3. 退回到 process.cwd() 解析
253
+ *
254
+ * @param {string} [anchorDir=__dirname] — 解析起点
255
+ * @returns {string} spec root 绝对路径
256
+ * @throws {Error} 找不到 spec root 时抛出
257
+ */
258
+ function resolveSpecRootFast(anchorDir = __dirname) {
259
+ // 策略 1:脚本自身在 skywalk-sdd/ 内,父目录即 spec root
260
+ if (path.basename(anchorDir) === 'skywalk-sdd') {
261
+ const directParent = path.dirname(anchorDir);
262
+ if (fs.existsSync(path.join(directParent, 'openspec')) ||
263
+ fs.existsSync(path.join(directParent, 'modules.yaml')) ||
264
+ fs.existsSync(path.join(directParent, 'sdd.config.yaml'))) {
265
+ return directParent;
266
+ }
267
+ }
268
+
269
+ // 策略 2:从 anchorDir 向上查找
270
+ const resolveFn = getResolveSpecProjectRoot();
271
+ let root = resolveFn(anchorDir);
272
+
273
+ // 策略 3:退回到 CWD
274
+ if (root === anchorDir) {
275
+ root = resolveFn(process.cwd());
276
+ }
277
+
278
+ if (root === anchorDir || root === process.cwd()) {
279
+ throw new Error(
280
+ '未找到 spec 包目录。\n' +
281
+ '配置方式: 在项目根目录创建 .sdd-spec-root 文件,写入 spec 包相对或绝对路径\n' +
282
+ '或使用: kld-sdd link-spec --path=<spec-clone>'
283
+ );
284
+ }
285
+ return root;
286
+ }
287
+
288
+ // ═══════════════════════════════════════════════════════════════
289
+ // 7. KB 配置加载
290
+ // ═══════════════════════════════════════════════════════════════
291
+
292
+ /** 已知的 IDE skills 目录名 */
293
+ const IDE_DIRS = [
294
+ '.codebuddy', '.claude', '.cursor', '.vscode',
295
+ '.kunlunzhima', '.opencode', '.workbuddy', '.codex',
296
+ ];
297
+
298
+ /**
299
+ * 加载 KB 配置(kb-state.json)。
300
+ *
301
+ * 从 anchorDir 逐层向上搜索(优先新路径,兼容旧路径):
302
+ * - <dir>/kb-state.json ← 新路径(spec 仓根目录)
303
+ * - <dir>/<ide>/skills/kb-state.json ← 新路径(IDE skills 目录)
304
+ * - <dir>/.shared/kb-state.json ← 旧路径(向后兼容)
305
+ * - <dir>/<ide>/skills/.shared/kb-state.json ← 旧路径(向后兼容)
306
+ *
307
+ * @param {string} [anchorDir=__dirname] — 搜索起点
308
+ * @param {object} [env=process.env] — 环境变量(用于 fallback)
309
+ * @returns {{ config: object|null, path: string|null, warnings: string[] }}
310
+ */
311
+ function loadKbState(anchorDir = __dirname, env = process.env) {
312
+ const warnings = [];
313
+ const candidatePaths = [];
314
+
315
+ const MAX_LEVELS = 6;
316
+ let current = path.resolve(anchorDir);
317
+ const fileRoot = path.parse(current).root;
318
+ for (let level = 0; level < MAX_LEVELS; level++) {
319
+ // 新路径(优先):直接放在目录下
320
+ candidatePaths.push(path.join(current, 'kb-state.json'));
321
+ // 新路径:IDE skills 目录下
322
+ for (const ide of IDE_DIRS) {
323
+ candidatePaths.push(path.join(current, ide, 'skills', 'kb-state.json'));
324
+ }
325
+ // 旧路径(向后兼容):.shared/ 子目录
326
+ candidatePaths.push(path.join(current, '.shared', 'kb-state.json'));
327
+ for (const ide of IDE_DIRS) {
328
+ candidatePaths.push(path.join(current, ide, 'skills', '.shared', 'kb-state.json'));
329
+ }
330
+ if (current === fileRoot) break;
331
+ current = path.dirname(current);
332
+ }
333
+
334
+ for (const p of candidatePaths) {
335
+ try {
336
+ if (fs.existsSync(p)) {
337
+ let raw = fs.readFileSync(p, 'utf8');
338
+ raw = stripBom(raw);
339
+ const config = JSON.parse(raw);
340
+ return { config, path: p, warnings };
341
+ }
342
+ } catch (e) {
343
+ warnings.push(`kb-state.json 解析失败 (${p}): ${e.message}`);
344
+ }
345
+ }
346
+
347
+ return { config: null, path: null, warnings };
348
+ }
349
+
350
+ /** 监控项目绑定名长度上限:与 batch schema project_name maxLength 对齐。 */
351
+ const PROJECT_NAME_MAX_LENGTH = 128;
352
+
353
+ /**
354
+ * 归一化监控项目绑定名:非字符串或 trim 后为空 → null。
355
+ *
356
+ * @param {*} value
357
+ * @returns {string|null}
358
+ */
359
+ function normalizeProjectName(value) {
360
+ if (typeof value !== 'string') return null;
361
+ const trimmed = value.trim();
362
+ return trimmed === '' ? null : trimmed;
363
+ }
364
+
365
+ /**
366
+ * 从 KB 配置 + 环境变量解析 KB 连接参数。
367
+ *
368
+ * @param {object} stateConfig — loadKbState().config
369
+ * @param {object} [env=process.env]
370
+ * @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array, projectName: string|null }}
371
+ */
372
+ function resolveKbParams(stateConfig = {}, env = process.env) {
373
+ const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
374
+ const token = stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
375
+ const spaceId = stateConfig.targets && stateConfig.targets.length > 0
376
+ ? stateConfig.targets[0].spaceId : '';
377
+ const kbId = stateConfig.targets && stateConfig.targets.length > 0
378
+ ? stateConfig.targets[0].kbId : '';
379
+ const projectName = normalizeProjectName(stateConfig.projectName)
380
+ || normalizeProjectName(env.ENGINEERING_KB_PROJECT_NAME);
381
+ return { apiBase, token, spaceId, kbId, targets: stateConfig.targets || [], projectName };
382
+ }
383
+
384
+ /**
385
+ * Normalize API client scope list from kb-state or env.
386
+ * @param {object} stateConfig
387
+ * @param {object} [env=process.env]
388
+ * @returns {string[]}
389
+ */
390
+ function resolveKbScopes(stateConfig = {}, env = process.env) {
391
+ const fromState = stateConfig.scopes
392
+ || stateConfig.apiKeyScopes
393
+ || stateConfig.api_client_scopes;
394
+ if (Array.isArray(fromState)) {
395
+ return fromState.map((scope) => String(scope).trim()).filter(Boolean);
396
+ }
397
+ if (typeof fromState === 'string' && fromState.trim()) {
398
+ return fromState.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
399
+ }
400
+ const fromEnv = env.ENGINEERING_KB_SCOPES || env.ENGINEERING_KB_API_SCOPES;
401
+ if (typeof fromEnv === 'string' && fromEnv.trim()) {
402
+ return fromEnv.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
403
+ }
404
+ return [];
405
+ }
406
+
407
+ // kld-T06: progress:write 校验、progress binding 读取已随 Progress Runtime 一并删除。
408
+ // Archive/Knowledge 的 api_client 能力由 resolveKbParams / httpGetJson / uploadFile 等保留。
409
+
410
+ /**
411
+ * 通过服务端 dimensions 端点校验当前 API Key 用户是否为指定项目成员。
412
+ *
413
+ * 用于 kb-config 配置期的项目绑定预校验;服务端 A3 仍在上传时做权威裁决
414
+ * (非成员置空、不阻断),因此本函数对端点缺失/网络失败软返回而不抛错。
415
+ *
416
+ * @param {string} specRoot — spec 包根目录(含 kb-state.json)
417
+ * @param {string} projectName — 待校验的项目名称
418
+ * @param {object} [options]
419
+ * @param {object} [options.env=process.env]
420
+ * @param {number} [options.timeoutMs=15000]
421
+ * @returns {Promise<{ ok: boolean, reason?: string, member: boolean|null, matchedProject?: object }>}
422
+ * @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state 缺失或缺 api/apiKey
423
+ */
424
+ async function verifyProjectMembershipViaServer(specRoot, projectName, options = {}) {
425
+ const env = options.env || process.env;
426
+ const normalized = normalizeProjectName(projectName);
427
+ if (!normalized || normalized.length > PROJECT_NAME_MAX_LENGTH) {
428
+ return { ok: false, reason: 'invalid_input', member: null };
429
+ }
430
+
431
+ const root = path.resolve(specRoot);
432
+ const { config: kbState, path: statePath } = loadKbState(root, env);
433
+ if (!kbState || !statePath) {
434
+ const error = new Error('kb-state.json not found');
435
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
436
+ error.missingFields = ['kb-state.json'];
437
+ throw error;
438
+ }
439
+
440
+ const kbParams = resolveKbParams(kbState, env);
441
+ const missing = [];
442
+ if (!kbParams.apiBase) missing.push('api');
443
+ if (!kbParams.token) missing.push('apiKey');
444
+ if (missing.length > 0) {
445
+ const error = new Error(`Progress binding incomplete: missing ${missing.join(', ')}`);
446
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
447
+ error.missingFields = missing;
448
+ throw error;
449
+ }
450
+
451
+ const apiBase = String(kbParams.apiBase).replace(/\/+$/, '');
452
+ const url = `${apiBase}/v1/sdd-runs/dimensions`;
453
+ let response;
454
+ try {
455
+ response = await httpGetJson(url, kbParams.token, options.timeoutMs || 15000);
456
+ } catch (error) {
457
+ // 鉴权失败(密钥失效/吊销)与端点缺失/网络异常分开归因,避免配置流程误导。
458
+ const statusMatch = error && /^HTTP (\d{3})\b/.exec(String(error.message || ''));
459
+ if (statusMatch && (statusMatch[1] === '401' || statusMatch[1] === '403')) {
460
+ return { ok: false, reason: 'auth_failed', member: null };
461
+ }
462
+ return { ok: false, reason: 'endpoint_unavailable', member: null };
463
+ }
464
+
465
+ const data = response && typeof response === 'object' && response.data != null
466
+ ? response.data
467
+ : response;
468
+ const projects = Array.isArray(data?.projects) ? data.projects : [];
469
+ const matched = projects.find((p) => {
470
+ const name = normalizeProjectName(p && (p.name || p.projectName));
471
+ return name === normalized;
472
+ });
473
+ return {
474
+ ok: true,
475
+ member: Boolean(matched),
476
+ matchedProject: matched
477
+ ? { id: matched.projectId || matched.id || null, name: normalized }
478
+ : undefined,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * 原子写入/清除 kb-state.json 的 projectName(监控项目绑定),保留其余键。
484
+ *
485
+ * @param {string} specRoot — spec 包根目录
486
+ * @param {string|null} projectName — 项目名;null/空白 = 清除绑定
487
+ * @returns {{ ok: true, path: string, projectName: string|null }}
488
+ * @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state.json 不存在
489
+ */
490
+ function setKbProjectName(specRoot, projectName, options = {}) {
491
+ const env = options.env || process.env;
492
+ const root = path.resolve(specRoot);
493
+ const { config: kbState, path: statePath } = loadKbState(root, env);
494
+ if (!kbState || !statePath) {
495
+ const error = new Error('kb-state.json not found');
496
+ error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
497
+ error.missingFields = ['kb-state.json'];
498
+ throw error;
499
+ }
500
+ const normalized = normalizeProjectName(projectName);
501
+ if (normalized && normalized.length > PROJECT_NAME_MAX_LENGTH) {
502
+ const error = new Error(`projectName 超长(>${PROJECT_NAME_MAX_LENGTH} 字符)`);
503
+ error.code = 'E_PROJECT_NAME_INVALID';
504
+ throw error;
505
+ }
506
+ const next = { ...kbState, projectName: normalized };
507
+ writeJsonAtomic(statePath, next);
508
+ return { ok: true, path: statePath, projectName: next.projectName };
509
+ }
510
+
511
+ // ═══════════════════════════════════════════════════════════════
512
+ // 7.5. PowerShell CLIXML 剥离
513
+ // ═══════════════════════════════════════════════════════════════
514
+
515
+ /**
516
+ * 检测并剥离 PowerShell CLIXML 包装。
517
+ *
518
+ * PowerShell 在捕获子进程 stdout 时,可能将其包装为 CLIXML 格式:
519
+ * <Objs ...><S S="...">line1</S><S S="...">line2</S>...</Objs>
520
+ * 这会导致 JSON.parse 失败。本函数检测并剥离该包装,还原纯文本。
521
+ *
522
+ * @param {string} output - 原始输出字符串
523
+ * @returns {string} 剥离 CLIXML 后的纯文本(非 CLIXML 则原样返回)
524
+ */
525
+ function stripCliXml(output) {
526
+ if (!output || typeof output !== 'string') return output;
527
+ const trimmed = output.trim();
528
+ if (!trimmed.startsWith('<Objs ') || !trimmed.endsWith('</Objs>')) {
529
+ return output;
530
+ }
531
+ // CLIXML 格式:提取 <S>...</S> 标签内的文本
532
+ const lines = [];
533
+ const regex = /<S[^>]*>([\s\S]*?)<\/S>/g;
534
+ let match;
535
+ while ((match = regex.exec(trimmed)) !== null) {
536
+ lines.push(match[1]
537
+ .replace(/&lt;/g, '<')
538
+ .replace(/&gt;/g, '>')
539
+ .replace(/&amp;/g, '&')
540
+ .replace(/&quot;/g, '"')
541
+ .replace(/&apos;/g, "'"));
542
+ }
543
+ return lines.length > 0 ? lines.join('\n') : output;
544
+ }
545
+
546
+ // ═══════════════════════════════════════════════════════════════
547
+ // 8. HTTP 请求
548
+ // ═══════════════════════════════════════════════════════════════
549
+
550
+ /**
551
+ * 发送 JSON POST 请求并返回解析后的响应。
552
+ *
553
+ * @param {string} url — 完整 URL
554
+ * @param {object} payload — 请求体(会被 JSON.stringify)
555
+ * @param {string} [token] — Bearer token
556
+ * @param {number} [timeoutMs=30000] — 超时
557
+ * @returns {Promise<object>} — 解析后的响应 data 字段
558
+ */
559
+ function requestJson(url, payload, token, timeoutMs = 30000) {
560
+ return new Promise((resolve, reject) => {
561
+ const target = new URL(url);
562
+ const transport = target.protocol === 'https:' ? https : http;
563
+
564
+ let body;
565
+ try {
566
+ body = JSON.stringify(payload);
567
+ JSON.parse(body); // 二次验证
568
+ } catch (jsonErr) {
569
+ reject(new Error(`payload serialization error: ${jsonErr.message}`));
570
+ return;
571
+ }
572
+
573
+ const headers = {
574
+ Accept: 'application/json',
575
+ 'Content-Type': 'application/json; charset=utf-8',
576
+ };
577
+ if (token) headers.Authorization = `Bearer ${token}`;
578
+
579
+ const request = transport.request(
580
+ {
581
+ hostname: target.hostname,
582
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
583
+ path: target.pathname + target.search,
584
+ method: 'POST',
585
+ headers,
586
+ timeout: timeoutMs,
587
+ },
588
+ (response) => {
589
+ let responseBody = '';
590
+ response.on('data', (chunk) => { responseBody += chunk; });
591
+ response.on('end', () => {
592
+ let parsed;
593
+ try {
594
+ parsed = responseBody ? JSON.parse(responseBody) : null;
595
+ } catch {
596
+ reject(new Error(`knowledge base returned invalid JSON (HTTP ${response.statusCode})`));
597
+ return;
598
+ }
599
+ if (response.statusCode >= 400) {
600
+ const statusLabel = response.statusCode === 400 ? 'Bad Request'
601
+ : response.statusCode === 401 ? 'Unauthorized'
602
+ : response.statusCode === 403 ? 'Forbidden'
603
+ : response.statusCode === 404 ? 'Not Found'
604
+ : `HTTP ${response.statusCode}`;
605
+ const serverMsg = parsed?.message || parsed?.error || '';
606
+ reject(new Error(serverMsg
607
+ ? `knowledge base ${statusLabel}: ${serverMsg}`
608
+ : `knowledge base ${statusLabel}`));
609
+ return;
610
+ }
611
+ if (parsed?.code != null && parsed.code !== 0) {
612
+ reject(new Error(parsed?.message || `knowledge base error code ${parsed.code}`));
613
+ return;
614
+ }
615
+ resolve(parsed.data);
616
+ });
617
+ },
618
+ );
619
+ request.on('timeout', () => request.destroy(new Error(`knowledge base timeout after ${timeoutMs}ms`)));
620
+ request.on('error', reject);
621
+ request.end(body);
622
+ });
623
+ }
624
+
625
+ /**
626
+ * 发送 GET 请求并返回解析后的完整响应 JSON(含 code/data 字段)。
627
+ *
628
+ * 用于 KB pre-check 等只读接口。返回完整的 API 响应体
629
+ * (如 `{code: 0, message: "ok", data: {...}}`),调用方通过 `.data` 访问业务数据。
630
+ * HTTP 4xx/5xx 会 reject。
631
+ *
632
+ * @param {string} url — 完整 URL(含 query string)
633
+ * @param {string} [token] — Bearer token
634
+ * @param {number} [timeoutMs=15000] — 超时
635
+ * @returns {Promise<object|null>} — 完整 API 响应 JSON(含 code/data);非 JSON 或错误时 reject
636
+ */
637
+ function httpGetJson(url, token, timeoutMs = 15000) {
638
+ return new Promise((resolve, reject) => {
639
+ const target = new URL(url);
640
+ const transport = target.protocol === 'https:' ? https : http;
641
+
642
+ const headers = {
643
+ Accept: 'application/json',
644
+ };
645
+ if (token) headers.Authorization = `Bearer ${token}`;
646
+
647
+ const request = transport.request(
648
+ {
649
+ hostname: target.hostname,
650
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
651
+ path: target.pathname + target.search,
652
+ method: 'GET',
653
+ headers,
654
+ timeout: timeoutMs,
655
+ },
656
+ (response) => {
657
+ let responseBody = '';
658
+ response.on('data', (chunk) => { responseBody += chunk; });
659
+ response.on('end', () => {
660
+ let parsed;
661
+ try {
662
+ parsed = responseBody ? JSON.parse(responseBody) : null;
663
+ } catch {
664
+ if (response.statusCode >= 400) {
665
+ reject(new Error(`HTTP ${response.statusCode} - ${responseBody.slice(0, 200)}`));
666
+ return;
667
+ }
668
+ reject(new Error(`invalid JSON response: ${responseBody.slice(0, 200)}`));
669
+ return;
670
+ }
671
+ if (response.statusCode >= 400) {
672
+ const serverMsg = parsed?.message || parsed?.error || responseBody.slice(0, 200);
673
+ reject(new Error(`HTTP ${response.statusCode}: ${serverMsg}`));
674
+ return;
675
+ }
676
+ resolve(parsed);
677
+ });
678
+ },
679
+ );
680
+ request.on('timeout', () => request.destroy(new Error(`timeout after ${timeoutMs}ms`)));
681
+ request.on('error', reject);
682
+ request.end();
683
+ });
684
+ }
685
+
686
+ /**
687
+ * 上传文件到 KB(multipart/form-data,替代 curl)。
688
+ *
689
+ * @param {string} url — 完整的上传 URL
690
+ * @param {string} filePath — 本地文件路径
691
+ * @param {string} token — Bearer token
692
+ * @param {object} [extraFields={}] — 额外的 form 字段
693
+ * @param {number} [timeoutMs=60000] — 超时
694
+ * @returns {Promise<object>} — 解析后的响应
695
+ */
696
+ function uploadFile(url, filePath, token, extraFields = {}, timeoutMs = 60000) {
697
+ return new Promise((resolve, reject) => {
698
+ const target = new URL(url);
699
+ const transport = target.protocol === 'https:' ? https : http;
700
+
701
+ const boundary = '----SDDBoundary' + crypto.randomBytes(8).toString('hex');
702
+ const fileName = path.basename(filePath);
703
+ const fileContent = fs.readFileSync(filePath);
704
+ const fileStat = fs.statSync(filePath);
705
+
706
+ // 构建 multipart body
707
+ const parts = [];
708
+
709
+ // 额外字段
710
+ for (const [key, value] of Object.entries(extraFields)) {
711
+ parts.push(Buffer.from(
712
+ `--${boundary}\r\n` +
713
+ `Content-Disposition: form-data; name="${key}"\r\n\r\n` +
714
+ `${value}\r\n`
715
+ ));
716
+ }
717
+
718
+ // 文件字段
719
+ parts.push(Buffer.from(
720
+ `--${boundary}\r\n` +
721
+ `Content-Disposition: form-data; name="package"; filename="${fileName}"\r\n` +
722
+ `Content-Type: application/zip\r\n\r\n`
723
+ ));
724
+ parts.push(fileContent);
725
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
726
+
727
+ const body = Buffer.concat(parts);
728
+
729
+ const headers = {
730
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
731
+ 'Content-Length': body.length,
732
+ };
733
+ if (token) headers.Authorization = `Bearer ${token}`;
734
+
735
+ const request = transport.request(
736
+ {
737
+ hostname: target.hostname,
738
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
739
+ path: target.pathname + target.search,
740
+ method: 'POST',
741
+ headers,
742
+ timeout: timeoutMs,
743
+ },
744
+ (response) => {
745
+ let responseBody = '';
746
+ response.on('data', (chunk) => { responseBody += chunk; });
747
+ response.on('end', () => {
748
+ let parsed;
749
+ try {
750
+ parsed = responseBody ? JSON.parse(responseBody) : null;
751
+ } catch {
752
+ if (response.statusCode >= 400) {
753
+ reject(new Error(`upload failed: HTTP ${response.statusCode} - ${responseBody.slice(0, 200)}`));
754
+ return;
755
+ }
756
+ reject(new Error(`upload returned invalid JSON: ${responseBody.slice(0, 200)}`));
757
+ return;
758
+ }
759
+ if (response.statusCode >= 400) {
760
+ const serverMsg = parsed?.message || parsed?.error || responseBody.slice(0, 200);
761
+ reject(new Error(`upload failed (HTTP ${response.statusCode}): ${serverMsg}`));
762
+ return;
763
+ }
764
+ resolve(parsed);
765
+ });
766
+ },
767
+ );
768
+ request.on('timeout', () => request.destroy(new Error(`upload timeout after ${timeoutMs}ms`)));
769
+ request.on('error', reject);
770
+ request.end(body);
771
+ });
772
+ }
773
+
774
+ // ═══════════════════════════════════════════════════════════════
775
+ // Module Exports
776
+ // ═══════════════════════════════════════════════════════════════
777
+
778
+ module.exports = {
779
+ // CLI
780
+ parseArgs,
781
+ // JSON I/O
782
+ stripBom,
783
+ stripCliXml,
784
+ readJson,
785
+ readJsonSafe,
786
+ writeTextAtomic,
787
+ writeJsonAtomic,
788
+ writeIfChanged,
789
+ // Filesystem
790
+ ensureDir,
791
+ copyDirRecursive,
792
+ // Utilities
793
+ today,
794
+ nowISO,
795
+ sha256,
796
+ toPosix,
797
+ // Spec root
798
+ resolveSpecRootFast,
799
+ // KB state
800
+ IDE_DIRS,
801
+ loadKbState,
802
+ resolveKbParams,
803
+ resolveKbScopes,
804
+ normalizeProjectName,
805
+ verifyProjectMembershipViaServer,
806
+ setKbProjectName,
807
+ // HTTP
808
+ requestJson,
809
+ httpGetJson,
810
+ uploadFile,
811
+ };