kld-sdd 2.6.17 → 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.
- package/bin/kld-sdd-init.js +10 -0
- package/kld-sdd-guide.html +22 -0
- package/lib/device-auth-cli.js +130 -0
- package/lib/device-auth.js +345 -0
- package/lib/init-account-binding.js +108 -0
- package/lib/init.js +182 -58
- package/package.json +3 -3
- package/skywalk-sdd/index.cjs +142 -25
- package/skywalk-sdd/lib/shared.cjs +150 -2
- package/skywalk-sdd/lib/usage-contract.cjs +276 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
- package/skywalk-sdd/lib/user-config.cjs +157 -0
- package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
- package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
- package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +27 -6
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +12 -1
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -1375,30 +1375,35 @@ function appendEventUnlocked(dataDir, changeName, event) {
|
|
|
1375
1375
|
appendMarkdownLog(dataDir, changeName, event);
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
|
-
/** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1378
|
+
/** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
|
|
1379
|
+
* kld-T06:Progress Runtime 已删除,appendEvent 仅负责本地持久化,不再做事件富化、
|
|
1380
|
+
* agent 拉起、metadata refresh 或 artifact snapshot。
|
|
1381
|
+
*/
|
|
1382
|
+
function appendEvent(dataDir, changeName, event, options = {}) {
|
|
1383
|
+
if (options.lockHeld) {
|
|
1384
|
+
appendEventUnlocked(dataDir, changeName, event);
|
|
1385
|
+
return { idempotent: false, event };
|
|
1386
|
+
}
|
|
1387
|
+
const result = withEventWriteLock(dataDir, changeName, () => {
|
|
1388
|
+
if (event?.type === 'stage_end') {
|
|
1389
|
+
const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
|
|
1390
|
+
if (existing.length > 0) {
|
|
1391
|
+
const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
|
|
1392
|
+
if (latest.result !== event.result) {
|
|
1393
|
+
const error = new Error(
|
|
1394
|
+
`E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
|
|
1395
|
+
);
|
|
1396
|
+
error.code = 'E_STAGE_END_CONFLICT';
|
|
1397
|
+
throw error;
|
|
1398
|
+
}
|
|
1399
|
+
return { idempotent: true, event: latest };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
appendEventUnlocked(dataDir, changeName, event);
|
|
1403
|
+
return { idempotent: false, event };
|
|
1404
|
+
});
|
|
1405
|
+
return result;
|
|
1406
|
+
}
|
|
1402
1407
|
|
|
1403
1408
|
/** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
|
|
1404
1409
|
* execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
|
|
@@ -5385,6 +5390,20 @@ function cmdStart(args) {
|
|
|
5385
5390
|
appendEvent(dataDir, event.change, event);
|
|
5386
5391
|
writeActiveStage(projectRoot, event);
|
|
5387
5392
|
|
|
5393
|
+
// kld-T05: 七值命令成功写入 stage_start 后,异步上报使用事件。
|
|
5394
|
+
// test/explore 与失败路径不上报;上报失败不影响本地事件与退出码。
|
|
5395
|
+
const usagePromise = (() => {
|
|
5396
|
+
try {
|
|
5397
|
+
const { mapCommandToUsageSkill } = require('./lib/usage-contract.cjs');
|
|
5398
|
+
const skill = mapCommandToUsageSkill(command);
|
|
5399
|
+
if (!skill) return null;
|
|
5400
|
+
const { reportUsageOnce } = require('./lib/usage-reporter.cjs');
|
|
5401
|
+
return reportUsageOnce({ skill, startedAt: timestamp }).catch(() => null);
|
|
5402
|
+
} catch {
|
|
5403
|
+
return null;
|
|
5404
|
+
}
|
|
5405
|
+
})();
|
|
5406
|
+
|
|
5388
5407
|
// 输出 JSON,方便 AI Agent 解析 event_id
|
|
5389
5408
|
const output = {
|
|
5390
5409
|
event_id: eventId,
|
|
@@ -5392,6 +5411,9 @@ function cmdStart(args) {
|
|
|
5392
5411
|
message: `SDD ${command} 阶段开始记录(change: ${event.change})`,
|
|
5393
5412
|
};
|
|
5394
5413
|
console.log(JSON.stringify(output, null, 2));
|
|
5414
|
+
// 返回 usage 上报 promise(可能为 null),便于测试或调用方 await;
|
|
5415
|
+
// 现有 main() 不读返回值,行为兼容。
|
|
5416
|
+
return usagePromise;
|
|
5395
5417
|
}
|
|
5396
5418
|
|
|
5397
5419
|
const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
|
|
@@ -5738,6 +5760,9 @@ function cmdEnd(args, options = {}) {
|
|
|
5738
5760
|
...event.details.archive_result,
|
|
5739
5761
|
...actualPaths,
|
|
5740
5762
|
};
|
|
5763
|
+
if (event.details.archive_result.archive_id && !event.archive_id) {
|
|
5764
|
+
event.archive_id = event.details.archive_result.archive_id;
|
|
5765
|
+
}
|
|
5741
5766
|
}
|
|
5742
5767
|
|
|
5743
5768
|
appendEvent(dataDir, event.change, event);
|
|
@@ -5745,6 +5770,37 @@ function cmdEnd(args, options = {}) {
|
|
|
5745
5770
|
clearActiveStage(projectRoot, startEvent);
|
|
5746
5771
|
}
|
|
5747
5772
|
|
|
5773
|
+
// Archive stage_end is remapped to archive_linked on the wire. Also emit an explicit
|
|
5774
|
+
// run_ended so monitors that only listen for terminal kinds still close the run.
|
|
5775
|
+
if (command === 'archive' && (result === 'success' || result === 'partial' || result === 'failed' || result === 'failure')) {
|
|
5776
|
+
try {
|
|
5777
|
+
const terminalResult = result === 'failure' ? 'failed' : result;
|
|
5778
|
+
appendEvent(dataDir, event.change, cleanOptionalFields({
|
|
5779
|
+
schema_version: SCHEMA_VERSION,
|
|
5780
|
+
event_id: generateEventId(),
|
|
5781
|
+
type: 'stage_end',
|
|
5782
|
+
event_kind: 'run_ended',
|
|
5783
|
+
source: event.source,
|
|
5784
|
+
command: 'archive',
|
|
5785
|
+
stage: 'archive',
|
|
5786
|
+
change: event.change,
|
|
5787
|
+
agent_type: event.agent_type,
|
|
5788
|
+
project_root: projectRoot,
|
|
5789
|
+
session_id: event.session_id,
|
|
5790
|
+
git_sha: event.git_sha,
|
|
5791
|
+
timestamp: nowISO(),
|
|
5792
|
+
result: terminalResult,
|
|
5793
|
+
summary: event.summary || '变更归档完成,运行已结束',
|
|
5794
|
+
details: {
|
|
5795
|
+
terminal_of: event.event_id,
|
|
5796
|
+
archive_id: event.details?.archive_result?.archive_id,
|
|
5797
|
+
},
|
|
5798
|
+
}));
|
|
5799
|
+
} catch (err) {
|
|
5800
|
+
try { console.error(`[telemetry] run_ended 补发失败(不阻塞): ${err.message}`); } catch {}
|
|
5801
|
+
}
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5748
5804
|
// 终态摘要必须基于已经落库的 archive stage_end。
|
|
5749
5805
|
if (command === 'archive') {
|
|
5750
5806
|
try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
|
|
@@ -6246,6 +6302,64 @@ function normalizeStrictFinalOutputSnapshot(details) {
|
|
|
6246
6302
|
};
|
|
6247
6303
|
}
|
|
6248
6304
|
|
|
6305
|
+
/**
|
|
6306
|
+
* Best-effort per-file byte sizes for apply delivery snapshots.
|
|
6307
|
+
* Looks under projectRoot first, then its parent (split specs/code workspace).
|
|
6308
|
+
* Never fails the record path — missing files simply omit size entries.
|
|
6309
|
+
*/
|
|
6310
|
+
function attachFinalOutputFileSizes(details, projectRoot) {
|
|
6311
|
+
const snapshot = details?.final_output_snapshot;
|
|
6312
|
+
if (!snapshot || !Array.isArray(snapshot.files) || snapshot.files.length === 0) {
|
|
6313
|
+
return details;
|
|
6314
|
+
}
|
|
6315
|
+
const existing = (snapshot.file_sizes && typeof snapshot.file_sizes === 'object'
|
|
6316
|
+
&& !Array.isArray(snapshot.file_sizes))
|
|
6317
|
+
? { ...snapshot.file_sizes }
|
|
6318
|
+
: {};
|
|
6319
|
+
const roots = [];
|
|
6320
|
+
const pushRoot = (candidate) => {
|
|
6321
|
+
if (!candidate || typeof candidate !== 'string') return;
|
|
6322
|
+
const resolved = path.resolve(candidate);
|
|
6323
|
+
if (!roots.some((item) => item.toLowerCase?.() === resolved.toLowerCase() || item === resolved)) {
|
|
6324
|
+
roots.push(resolved);
|
|
6325
|
+
}
|
|
6326
|
+
};
|
|
6327
|
+
pushRoot(projectRoot);
|
|
6328
|
+
if (projectRoot) pushRoot(path.dirname(projectRoot));
|
|
6329
|
+
|
|
6330
|
+
let changed = false;
|
|
6331
|
+
for (const relative of snapshot.files) {
|
|
6332
|
+
const rel = String(relative || '').trim().replace(/\\/g, '/');
|
|
6333
|
+
if (!rel || Number.isInteger(existing[rel])) continue;
|
|
6334
|
+
for (const root of roots) {
|
|
6335
|
+
try {
|
|
6336
|
+
const absolute = path.resolve(root, ...rel.split('/'));
|
|
6337
|
+
const rootKey = process.platform === 'win32' ? root.toLowerCase() : root;
|
|
6338
|
+
const absKey = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
|
|
6339
|
+
const prefix = rootKey.endsWith(path.sep) ? rootKey : `${rootKey}${path.sep}`;
|
|
6340
|
+
if (absKey !== rootKey && !absKey.startsWith(prefix)) continue;
|
|
6341
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
|
|
6342
|
+
const bytes = fs.statSync(absolute).size;
|
|
6343
|
+
if (Number.isInteger(bytes) && bytes >= 0) {
|
|
6344
|
+
existing[rel] = bytes;
|
|
6345
|
+
changed = true;
|
|
6346
|
+
break;
|
|
6347
|
+
}
|
|
6348
|
+
} catch {
|
|
6349
|
+
// try next root
|
|
6350
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6353
|
+
if (!changed && Object.keys(existing).length === 0) return details;
|
|
6354
|
+
return {
|
|
6355
|
+
...details,
|
|
6356
|
+
final_output_snapshot: {
|
|
6357
|
+
...snapshot,
|
|
6358
|
+
file_sizes: existing,
|
|
6359
|
+
},
|
|
6360
|
+
};
|
|
6361
|
+
}
|
|
6362
|
+
|
|
6249
6363
|
function normalizeStrictTestResult(args, details) {
|
|
6250
6364
|
const input = details?.test_results;
|
|
6251
6365
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
@@ -7362,7 +7476,10 @@ function cmdRecord(args) {
|
|
|
7362
7476
|
details = normalizeStrictCheckResult(details, args.result);
|
|
7363
7477
|
} else if (strict && type === 'final_output_snapshot') {
|
|
7364
7478
|
strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
|
|
7365
|
-
details =
|
|
7479
|
+
details = attachFinalOutputFileSizes(
|
|
7480
|
+
normalizeStrictFinalOutputSnapshot(details),
|
|
7481
|
+
projectRoot,
|
|
7482
|
+
);
|
|
7366
7483
|
} else if (strict) {
|
|
7367
7484
|
strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
|
|
7368
7485
|
}
|
|
@@ -347,12 +347,27 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
|
|
|
347
347
|
return { config: null, path: null, warnings };
|
|
348
348
|
}
|
|
349
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
|
+
|
|
350
365
|
/**
|
|
351
366
|
* 从 KB 配置 + 环境变量解析 KB 连接参数。
|
|
352
367
|
*
|
|
353
368
|
* @param {object} stateConfig — loadKbState().config
|
|
354
369
|
* @param {object} [env=process.env]
|
|
355
|
-
* @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array }}
|
|
370
|
+
* @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array, projectName: string|null }}
|
|
356
371
|
*/
|
|
357
372
|
function resolveKbParams(stateConfig = {}, env = process.env) {
|
|
358
373
|
const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
|
|
@@ -361,7 +376,136 @@ function resolveKbParams(stateConfig = {}, env = process.env) {
|
|
|
361
376
|
? stateConfig.targets[0].spaceId : '';
|
|
362
377
|
const kbId = stateConfig.targets && stateConfig.targets.length > 0
|
|
363
378
|
? stateConfig.targets[0].kbId : '';
|
|
364
|
-
|
|
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 };
|
|
365
509
|
}
|
|
366
510
|
|
|
367
511
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -656,6 +800,10 @@ module.exports = {
|
|
|
656
800
|
IDE_DIRS,
|
|
657
801
|
loadKbState,
|
|
658
802
|
resolveKbParams,
|
|
803
|
+
resolveKbScopes,
|
|
804
|
+
normalizeProjectName,
|
|
805
|
+
verifyProjectMembershipViaServer,
|
|
806
|
+
setKbProjectName,
|
|
659
807
|
// HTTP
|
|
660
808
|
requestJson,
|
|
661
809
|
httpGetJson,
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// kld-T01 — CLI 使用统计上报契约
|
|
2
|
+
// 集中定义:七值 skill 枚举、Device Flow 信封、ApiResponse 解包、
|
|
3
|
+
// Node 14 兼容的 URL/HTTP 基础函数。与 kb-sdd 跨仓契约保持一致。
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const https = require('node:https');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 七值使用统计 skill 枚举。
|
|
11
|
+
* test / explore 不属于统计范围,永不映射。
|
|
12
|
+
*/
|
|
13
|
+
const USAGE_SKILLS = Object.freeze([
|
|
14
|
+
'propose',
|
|
15
|
+
'spec',
|
|
16
|
+
'design',
|
|
17
|
+
'task',
|
|
18
|
+
'check',
|
|
19
|
+
'apply',
|
|
20
|
+
'archive',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const USAGE_SKILL_SET = new Set(USAGE_SKILLS);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 将 opsx 命令映射到使用统计 skill。
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @returns {string|null} 七值之一;非七值返回 null
|
|
29
|
+
*/
|
|
30
|
+
function mapCommandToUsageSkill(command) {
|
|
31
|
+
if (typeof command !== 'string' || !command) return null;
|
|
32
|
+
return USAGE_SKILL_SET.has(command) ? command : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 规范化 server URL:只接受 http/https,去尾斜杠,去首尾空白。
|
|
37
|
+
* @param {string} raw
|
|
38
|
+
* @returns {string|null} 规范化后的 base URL;非法返回 null
|
|
39
|
+
*/
|
|
40
|
+
function normalizeServerUrl(raw) {
|
|
41
|
+
if (typeof raw !== 'string') return null;
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (!trimmed) return null;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(trimmed);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
51
|
+
// 重组:protocol + // + host + pathname(去尾斜杠),忽略 search/hash
|
|
52
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
53
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解开 ApiResponse 信封:{ code, message, data } → data;code 非 0 抛错。
|
|
58
|
+
* @param {object} body — 已 JSON.parse 的响应体
|
|
59
|
+
* @returns {*} data 字段内容
|
|
60
|
+
* @throws {Error} code 非 0、非对象、缺 code 字段
|
|
61
|
+
*/
|
|
62
|
+
function unwrapApiResponse(body) {
|
|
63
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
64
|
+
throw new Error('ApiResponse must be an object');
|
|
65
|
+
}
|
|
66
|
+
if (typeof body.code !== 'number') {
|
|
67
|
+
throw new Error('ApiResponse missing numeric code');
|
|
68
|
+
}
|
|
69
|
+
if (body.code !== 0) {
|
|
70
|
+
const msg = typeof body.message === 'string' && body.message
|
|
71
|
+
? body.message
|
|
72
|
+
: `server error code ${body.code}`;
|
|
73
|
+
throw new Error(msg);
|
|
74
|
+
}
|
|
75
|
+
return body.data === undefined ? null : body.data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 解析 Device Flow code 响应。
|
|
80
|
+
* kb-sdd 契约:{ deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn?, interval? }
|
|
81
|
+
* @param {object} body
|
|
82
|
+
* @returns {{deviceCode:string,userCode:string,verificationUri:string,verificationUriComplete:string|null,expiresIn:number,interval:number}|null}
|
|
83
|
+
*/
|
|
84
|
+
function parseDeviceCodeResponse(body) {
|
|
85
|
+
if (!body || typeof body !== 'object') return null;
|
|
86
|
+
if (typeof body.deviceCode !== 'string' || !body.deviceCode) return null;
|
|
87
|
+
if (typeof body.userCode !== 'string' || !body.userCode) return null;
|
|
88
|
+
if (typeof body.verificationUri !== 'string' || !body.verificationUri) return null;
|
|
89
|
+
const expiresIn = Number.isFinite(body.expiresIn) && body.expiresIn > 0
|
|
90
|
+
? Math.floor(body.expiresIn)
|
|
91
|
+
: 600;
|
|
92
|
+
const interval = Number.isFinite(body.interval) && body.interval > 0
|
|
93
|
+
? Math.floor(body.interval)
|
|
94
|
+
: 2;
|
|
95
|
+
return {
|
|
96
|
+
deviceCode: body.deviceCode,
|
|
97
|
+
userCode: body.userCode,
|
|
98
|
+
verificationUri: body.verificationUri,
|
|
99
|
+
verificationUriComplete: typeof body.verificationUriComplete === 'string' && body.verificationUriComplete
|
|
100
|
+
? body.verificationUriComplete
|
|
101
|
+
: null,
|
|
102
|
+
expiresIn,
|
|
103
|
+
interval,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 分类 Device Flow poll 响应。
|
|
109
|
+
* kb-sdd 主契约:{ status: 'pending'|'authorized'|'expired'|'denied', token?, user? }
|
|
110
|
+
* 兼容 RFC 8628:{ error: 'authorization_pending'|'slow_down'|'expired_token'|'access_denied' }
|
|
111
|
+
*
|
|
112
|
+
* @param {number|null} statusCode — HTTP 状态码;网络错误为 null
|
|
113
|
+
* @param {object|null} body — 已解析 JSON;解析失败为 null
|
|
114
|
+
* @returns {{kind:'authorized',token:string,user:*}|{kind:'pending'}|{kind:'slow_down'}|{kind:'expired'}|{kind:'denied'}|{kind:'error',message?:string}}
|
|
115
|
+
*/
|
|
116
|
+
function classifyDevicePollResponse(statusCode, body) {
|
|
117
|
+
const payload = body && typeof body === 'object' ? body : {};
|
|
118
|
+
// kb-sdd 主契约:status 字段
|
|
119
|
+
if (typeof payload.status === 'string') {
|
|
120
|
+
const s = payload.status;
|
|
121
|
+
if (s === 'authorized') {
|
|
122
|
+
if (typeof payload.token === 'string' && payload.token) {
|
|
123
|
+
return { kind: 'authorized', token: payload.token, user: payload.user === undefined ? null : payload.user };
|
|
124
|
+
}
|
|
125
|
+
return { kind: 'error', message: 'authorized but missing token' };
|
|
126
|
+
}
|
|
127
|
+
if (s === 'pending') return { kind: 'pending' };
|
|
128
|
+
if (s === 'expired') return { kind: 'expired' };
|
|
129
|
+
if (s === 'denied') return { kind: 'denied' };
|
|
130
|
+
return { kind: 'error', message: `unknown status: ${s}` };
|
|
131
|
+
}
|
|
132
|
+
// RFC 8628 兼容:error 字段
|
|
133
|
+
if (typeof payload.error === 'string') {
|
|
134
|
+
const e = payload.error;
|
|
135
|
+
if (e === 'authorization_pending') return { kind: 'pending' };
|
|
136
|
+
if (e === 'slow_down') return { kind: 'slow_down' };
|
|
137
|
+
if (e === 'expired_token') return { kind: 'expired' };
|
|
138
|
+
if (e === 'access_denied') return { kind: 'denied' };
|
|
139
|
+
return { kind: 'error', message: e };
|
|
140
|
+
}
|
|
141
|
+
// 无识别字段:网络错误或 HTTP 异常
|
|
142
|
+
if (statusCode == null) return { kind: 'error', message: 'network error' };
|
|
143
|
+
if (statusCode >= 400) return { kind: 'error', message: `HTTP ${statusCode}` };
|
|
144
|
+
return { kind: 'error', message: 'unrecognized response' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 分类 usage 上报响应。
|
|
149
|
+
* - confirmed: 2xx/204,服务端已确认,可从 pending 队列移除
|
|
150
|
+
* - unauthorized: 401,token 失效;调用方应原子删除本地 token 并提示一次
|
|
151
|
+
* - retry: 网络错误 / 429 / 5xx / timeout;保留在 pending 队列
|
|
152
|
+
* - drop: 其他 4xx;不再重试但不上抛
|
|
153
|
+
*
|
|
154
|
+
* @param {number|null} statusCode
|
|
155
|
+
* @returns {'confirmed'|'unauthorized'|'retry'|'drop'}
|
|
156
|
+
*/
|
|
157
|
+
function classifyUsageSubmitResponse(statusCode) {
|
|
158
|
+
if (statusCode == null || statusCode === 0) return 'retry';
|
|
159
|
+
if (statusCode >= 200 && statusCode < 300) return 'confirmed';
|
|
160
|
+
if (statusCode === 401) return 'unauthorized';
|
|
161
|
+
if (statusCode === 429) return 'retry';
|
|
162
|
+
if (statusCode >= 500) return 'retry';
|
|
163
|
+
return 'drop';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Node 14 兼容的 POST JSON 实现。
|
|
168
|
+
* - 仅用核心 http/https,不依赖全局 fetch
|
|
169
|
+
* - 任何网络/解析错误均不抛,返回 statusCode=null 让调用方分类
|
|
170
|
+
* - 4xx/5xx 不抛错,返回 statusCode + body 供分类
|
|
171
|
+
*
|
|
172
|
+
* @param {string} url — 完整 URL(必须 http/https)
|
|
173
|
+
* @param {object} payload — 请求体(JSON 序列化)
|
|
174
|
+
* @param {{token?:string,timeoutMs?:number,headers?:object}} [options]
|
|
175
|
+
* @returns {Promise<{statusCode:number|null,body:*,data:*,error:*}>}
|
|
176
|
+
* statusCode: HTTP 状态码,网络错误为 null
|
|
177
|
+
* body: 原始解析后的 JSON 对象(如可解析)
|
|
178
|
+
* data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
|
|
179
|
+
* error: 仅在 statusCode=null 时携带 Error;否则为 null
|
|
180
|
+
*/
|
|
181
|
+
function postJson(url, payload, options = {}) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
let target;
|
|
184
|
+
try {
|
|
185
|
+
target = new URL(url);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
reject(new Error(`invalid url: ${err.message}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
191
|
+
reject(new Error(`only http/https supported, got ${target.protocol}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
195
|
+
const token = options && typeof options.token === 'string' && options.token ? options.token : null;
|
|
196
|
+
const timeoutMs = Number.isFinite(options && options.timeoutMs) && options.timeoutMs > 0
|
|
197
|
+
? Math.floor(options.timeoutMs)
|
|
198
|
+
: 30000;
|
|
199
|
+
|
|
200
|
+
let body;
|
|
201
|
+
try {
|
|
202
|
+
body = JSON.stringify(payload === undefined ? {} : payload);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
reject(new Error(`payload serialization error: ${err.message}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const headers = Object.assign(
|
|
209
|
+
{
|
|
210
|
+
Accept: 'application/json',
|
|
211
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
212
|
+
'Content-Length': Buffer.byteLength(body),
|
|
213
|
+
},
|
|
214
|
+
options && options.headers && typeof options.headers === 'object' ? options.headers : {},
|
|
215
|
+
);
|
|
216
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
217
|
+
|
|
218
|
+
const request = transport.request(
|
|
219
|
+
{
|
|
220
|
+
hostname: target.hostname,
|
|
221
|
+
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
222
|
+
path: target.pathname + target.search,
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers,
|
|
225
|
+
timeout: timeoutMs,
|
|
226
|
+
},
|
|
227
|
+
(response) => {
|
|
228
|
+
const chunks = [];
|
|
229
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
230
|
+
response.on('end', () => {
|
|
231
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
232
|
+
let parsedBody = null;
|
|
233
|
+
if (raw) {
|
|
234
|
+
try {
|
|
235
|
+
parsedBody = JSON.parse(raw);
|
|
236
|
+
} catch {
|
|
237
|
+
parsedBody = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let data;
|
|
241
|
+
if (parsedBody && typeof parsedBody === 'object' && parsedBody.code === 0) {
|
|
242
|
+
try {
|
|
243
|
+
data = unwrapApiResponse(parsedBody);
|
|
244
|
+
} catch {
|
|
245
|
+
data = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
resolve({
|
|
249
|
+
statusCode: response.statusCode == null ? null : response.statusCode,
|
|
250
|
+
body: parsedBody,
|
|
251
|
+
data,
|
|
252
|
+
error: null,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
request.on('timeout', () => {
|
|
258
|
+
request.destroy(new Error(`request timeout after ${timeoutMs}ms`));
|
|
259
|
+
});
|
|
260
|
+
request.on('error', (err) => {
|
|
261
|
+
resolve({ statusCode: null, body: null, data: undefined, error: err });
|
|
262
|
+
});
|
|
263
|
+
request.end(body);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
USAGE_SKILLS,
|
|
269
|
+
mapCommandToUsageSkill,
|
|
270
|
+
normalizeServerUrl,
|
|
271
|
+
unwrapApiResponse,
|
|
272
|
+
parseDeviceCodeResponse,
|
|
273
|
+
classifyDevicePollResponse,
|
|
274
|
+
classifyUsageSubmitResponse,
|
|
275
|
+
postJson,
|
|
276
|
+
};
|