kld-sdd 2.6.21 → 2.7.8
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 +12 -5
- package/USABILITY.md +84 -0
- package/bin/kld-sdd-init.js +3 -9
- package/kld-sdd-guide.html +16 -4
- package/lib/hook-gate-core.js +327 -0
- package/lib/init.js +241 -94
- package/lib/scale-thresholds.json +19 -0
- package/lib/skills-bundle.js +2 -1
- package/package.json +5 -3
- package/skywalk-sdd/context-client.cjs +50 -30
- package/skywalk-sdd/index.cjs +49 -16
- package/skywalk-sdd/kb-sync-identity.cjs +99 -451
- package/skywalk-sdd/kb-upload.cjs +67 -44
- package/skywalk-sdd/lib/git-identity.cjs +270 -0
- package/skywalk-sdd/lib/usage-contract.cjs +13 -81
- package/skywalk-sdd/lib/usage-reporter.cjs +235 -104
- package/skywalk-sdd/lib/user-config.cjs +21 -46
- package/skywalk-sdd/metrics-v3.cjs +2 -2
- package/skywalk-sdd/ontology/active-changes.cjs +2 -1
- package/skywalk-sdd/ontology/archive-package.cjs +1 -1
- package/skywalk-sdd/ontology/artifact-parser.cjs +8 -6
- package/skywalk-sdd/ontology/id.cjs +5 -4
- package/skywalk-sdd/ontology/identity-index.cjs +4 -3
- package/skywalk-sdd/ontology/list-changes.cjs +1 -1
- package/skywalk-sdd/ontology/runtime.cjs +7 -3
- package/skywalk-sdd/ontology/schema.cjs +2 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +74 -4
- package/skywalk-sdd/ontology/workspace-layout.cjs +25 -5
- package/skywalk-sdd/reporting/change-report-model.cjs +4 -3
- package/templates/git-hooks/commit-msg +71 -27
- package/templates/git-hooks/consistency-check-core.cjs +1087 -0
- package/templates/git-hooks/hooks.config +38 -0
- package/templates/git-hooks/pre-commit +62 -27
- package/templates/git-hooks/pre-commit-consistency-check.cjs +29 -332
- package/templates/git-hooks/pre-commit-sdd-check.cjs +98 -0
- package/templates/git-hooks/pre-push +70 -27
- package/templates/git-hooks/pre-push-consistency-check.cjs +61 -299
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +1 -1
- package/templates/openspec/tasks.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +43 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-apply/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +9 -13
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +54 -328
- package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -4
- package/templates/skills/kld-sdd/opsx-check/reference.md +58 -0
- package/templates/skills/kld-sdd/opsx-check/result-template.json +52 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +100 -0
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +82 -451
- package/templates/skills/kld-sdd/opsx-consistency-check/{reference.md → references/reference.md} +0 -1
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +517 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +10 -30
- package/templates/skills/kld-sdd/opsx-design/checklist.md +3 -4
- package/templates/skills/kld-sdd/opsx-design/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +12 -74
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +7 -5
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +28 -73
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +8 -8
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +28 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +12 -46
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +14 -61
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +3 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +38 -32
- package/templates/skills/kld-sdd/opsx-task/checklist.md +5 -6
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -0
- package/templates/skills/kld-sdd/tdd-rules/rules/tdd-strategy-selection.md +1 -1
- package/lib/device-auth-cli.js +0 -130
- package/lib/device-auth.js +0 -345
- package/lib/init-account-binding.js +0 -108
|
@@ -33,19 +33,48 @@ const {
|
|
|
33
33
|
httpGetJson,
|
|
34
34
|
} = require('./lib/shared.cjs');
|
|
35
35
|
|
|
36
|
+
function resolveUploadTarget(state, options = {}) {
|
|
37
|
+
if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
|
|
38
|
+
const targets = state.targets || [];
|
|
39
|
+
if (options.spaceId && options.kbId) {
|
|
40
|
+
return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
|
|
41
|
+
}
|
|
42
|
+
if (targets.length !== 1) throw new Error('请明确上传目标:配置一个知识库,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
|
|
43
|
+
if (!targets[0].spaceId || !targets[0].kbId) throw new Error('知识库目标缺少 spaceId/kbId');
|
|
44
|
+
return targets[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cacheKey(changeName, context) {
|
|
48
|
+
if (!context?.api || !context?.spaceId || !context?.kbId) return null;
|
|
49
|
+
return JSON.stringify([context.api.replace(/\/+$/, ''), context.spaceId, context.kbId, changeName]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertIngestionResult(result) {
|
|
53
|
+
if (result?.code != null && result.code !== 0) throw new Error(result.message || 'KB API 拒绝入库');
|
|
54
|
+
const job = result?.data || result;
|
|
55
|
+
if (!job || !['projecting', 'succeeded'].includes(job.status) || job.relationStatus !== 'committed') {
|
|
56
|
+
throw new Error(`INGEST_NOT_COMMITTED: ${job?.errorCode || job?.status || 'unknown'} ${job?.errorMessage || ''} job=${job?.jobId || 'unknown'}`);
|
|
57
|
+
}
|
|
58
|
+
if (Number(job.report?.version_conflicts || 0) > 0 || job.report?.publication_status === 'needs_review') {
|
|
59
|
+
throw new Error(`VERSION_CONFLICT: 事实包已保存为候选,当前事实未更新;请合并基线后重新发布。job=${job.jobId}`);
|
|
60
|
+
}
|
|
61
|
+
return job;
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
/**
|
|
37
65
|
* 读取 kb-state.json 中指定 change 的 archive 缓存。
|
|
38
66
|
* @param {string} specRoot
|
|
39
67
|
* @param {string} changeName
|
|
40
68
|
* @returns {object|null} { archive_id, content_hash, uploaded_at }
|
|
41
69
|
*/
|
|
42
|
-
function readArchiveCache(specRoot, changeName) {
|
|
70
|
+
function readArchiveCache(specRoot, changeName, context) {
|
|
43
71
|
const directPath = path.join(specRoot, 'kb-state.json');
|
|
44
72
|
const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
|
|
45
73
|
const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
|
|
46
74
|
const state = readJsonSafe(statePath);
|
|
47
75
|
if (!state || !state.archiveCache) return null;
|
|
48
|
-
|
|
76
|
+
const key = cacheKey(changeName, context);
|
|
77
|
+
return key ? state.archiveCache[key] || null : null;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
/**
|
|
@@ -55,16 +84,19 @@ function readArchiveCache(specRoot, changeName) {
|
|
|
55
84
|
* @param {string} archiveId
|
|
56
85
|
* @param {string} contentHash
|
|
57
86
|
*/
|
|
58
|
-
function updateKbStateCache(specRoot, changeName, archiveId, contentHash) {
|
|
87
|
+
function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
|
|
59
88
|
const directPath = path.join(specRoot, 'kb-state.json');
|
|
60
89
|
const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
|
|
61
90
|
const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
|
|
62
91
|
const state = readJsonSafe(statePath) || {};
|
|
63
92
|
if (!state.archiveCache) state.archiveCache = {};
|
|
64
|
-
|
|
93
|
+
const key = cacheKey(changeName, context);
|
|
94
|
+
if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
|
|
95
|
+
state.archiveCache[key] = {
|
|
65
96
|
archive_id: archiveId,
|
|
66
97
|
content_hash: contentHash,
|
|
67
98
|
uploaded_at: new Date().toISOString(),
|
|
99
|
+
target: context,
|
|
68
100
|
};
|
|
69
101
|
writeJsonAtomic(statePath, state);
|
|
70
102
|
console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
|
|
@@ -144,7 +176,11 @@ function readKbState(specRoot) {
|
|
|
144
176
|
*/
|
|
145
177
|
function validateRequiredFiles(folderPath, minimumLevel) {
|
|
146
178
|
const required = MINIMUM_LEVELS[minimumLevel] || MINIMUM_LEVELS[DEFAULT_LEVEL];
|
|
147
|
-
const
|
|
179
|
+
const specsDir = path.join(folderPath, 'specs');
|
|
180
|
+
const capabilityDirs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir, { withFileTypes: true })
|
|
181
|
+
.filter(e => e.isDirectory()).map(e => path.join(specsDir, e.name)) : [];
|
|
182
|
+
const missing = required.filter(f => !fs.existsSync(path.join(folderPath, f))
|
|
183
|
+
&& !(f !== 'proposal.md' && capabilityDirs.length > 0 && capabilityDirs.every(d => fs.existsSync(path.join(d, f)))));
|
|
148
184
|
if (missing.length > 0) {
|
|
149
185
|
throw new Error(
|
|
150
186
|
`[${minimumLevel}] 缺少必需文件: ${missing.join(', ')}\n` +
|
|
@@ -162,11 +198,11 @@ function validateRequiredFiles(folderPath, minimumLevel) {
|
|
|
162
198
|
/**
|
|
163
199
|
* 本地预检 project_id === spaceKey
|
|
164
200
|
*/
|
|
165
|
-
function preCheckProjectId(projectId, state) {
|
|
201
|
+
function preCheckProjectId(projectId, state, selectedTarget) {
|
|
166
202
|
if (!state.targets || !state.targets[0]) {
|
|
167
203
|
throw new Error('KB 配置中无 targets,请运行 /opsx-kb-config 配置知识库。');
|
|
168
204
|
}
|
|
169
|
-
const spaceKey = state.
|
|
205
|
+
const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
|
|
170
206
|
if (!spaceKey) {
|
|
171
207
|
console.warn('⚠️ kb-state.json targets[0].spaceKey 为空,跳过本地预检');
|
|
172
208
|
return;
|
|
@@ -255,15 +291,9 @@ function buildPackageFromFolder(specRoot, changeName, options) {
|
|
|
255
291
|
* 上传 zip 到 KB(使用 Node.js HTTP,替代 curl)
|
|
256
292
|
*/
|
|
257
293
|
async function uploadZip(zipPath, state, options) {
|
|
258
|
-
const target =
|
|
259
|
-
? { spaceId: options.spaceId, kbId: options.kbId }
|
|
260
|
-
: state.targets[0];
|
|
294
|
+
const target = resolveUploadTarget(state, options);
|
|
261
295
|
|
|
262
|
-
|
|
263
|
-
throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const apiUrl = state.api || 'http://localhost:8090/api';
|
|
296
|
+
const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
|
|
267
297
|
const uploadUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions`;
|
|
268
298
|
|
|
269
299
|
console.log(`→ 上传到 KB: ${target.spaceName || target.spaceId}/${target.kbName || target.kbId}`);
|
|
@@ -272,6 +302,7 @@ async function uploadZip(zipPath, state, options) {
|
|
|
272
302
|
|
|
273
303
|
try {
|
|
274
304
|
const result = await uploadFile(uploadUrl, zipPath, state.apiKey);
|
|
305
|
+
assertIngestionResult(result);
|
|
275
306
|
return result;
|
|
276
307
|
} catch (err) {
|
|
277
308
|
throw new Error(`上传失败: ${err.message}`);
|
|
@@ -339,6 +370,11 @@ async function main() {
|
|
|
339
370
|
changeName = path.basename(folderPath);
|
|
340
371
|
}
|
|
341
372
|
|
|
373
|
+
const expectedFolder = path.resolve(specRoot, 'openspec', 'changes', changeName);
|
|
374
|
+
if (path.resolve(folderPath) !== expectedFolder || !expectedFolder.startsWith(path.resolve(specRoot, 'openspec', 'changes') + path.sep)) {
|
|
375
|
+
throw new Error('文件夹上传仅接受当前 Spec 仓 openspec/changes/<change>;已归档内容请用 --zip 指定归档包');
|
|
376
|
+
}
|
|
377
|
+
|
|
342
378
|
if (!fs.existsSync(folderPath)) {
|
|
343
379
|
throw new Error(`变更目录不存在: ${folderPath}`);
|
|
344
380
|
}
|
|
@@ -362,7 +398,9 @@ async function main() {
|
|
|
362
398
|
if (!state.apiKey) {
|
|
363
399
|
throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
|
|
364
400
|
}
|
|
365
|
-
|
|
401
|
+
const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
|
|
402
|
+
preCheckProjectId(projectId, state, target);
|
|
403
|
+
const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
|
|
366
404
|
|
|
367
405
|
// 4. 仅校验模式(含语义校验)
|
|
368
406
|
if (args['validate-only']) {
|
|
@@ -390,26 +428,8 @@ async function main() {
|
|
|
390
428
|
});
|
|
391
429
|
|
|
392
430
|
try {
|
|
393
|
-
//
|
|
394
|
-
const
|
|
395
|
-
if (cache && cache.archive_id === manifest.archive_id) {
|
|
396
|
-
console.log('\n✓ 内容无变更(archive_id 与上次成功上传一致),跳过上传');
|
|
397
|
-
console.log(` cached archive_id: ${cache.archive_id}`);
|
|
398
|
-
console.log(` uploaded_at: ${cache.uploaded_at}`);
|
|
399
|
-
console.log('\n✓ 完成(本地缓存命中)');
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// 解析上传目标
|
|
404
|
-
const target = (args['space-id'] && args['kb-id'])
|
|
405
|
-
? { spaceId: args['space-id'], kbId: args['kb-id'] }
|
|
406
|
-
: state.targets[0];
|
|
407
|
-
|
|
408
|
-
if (!target || !target.spaceId || !target.kbId) {
|
|
409
|
-
throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const apiUrl = state.api || 'http://localhost:8090/api';
|
|
431
|
+
// Local receipts are informational only. Always verify the selected destination remotely.
|
|
432
|
+
const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
|
|
413
433
|
|
|
414
434
|
// === Step 5.5: KB 预检(网络级幂等确认)===
|
|
415
435
|
const archiveId = manifest.archive_id;
|
|
@@ -424,11 +444,13 @@ async function main() {
|
|
|
424
444
|
try {
|
|
425
445
|
const checkResult = await httpGetJson(checkUrl, state.apiKey);
|
|
426
446
|
|
|
427
|
-
if (checkResult && checkResult.data
|
|
447
|
+
if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
|
|
428
448
|
console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
|
|
429
449
|
console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
|
|
430
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash);
|
|
431
|
-
console.log(
|
|
450
|
+
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
451
|
+
console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
|
|
452
|
+
if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
|
|
453
|
+
console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
|
|
432
454
|
return;
|
|
433
455
|
}
|
|
434
456
|
|
|
@@ -467,8 +489,8 @@ async function main() {
|
|
|
467
489
|
console.log(` change_id: ${manifest.change_id}`);
|
|
468
490
|
console.log(` files: ${manifest.files.length}`);
|
|
469
491
|
|
|
470
|
-
//
|
|
471
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash);
|
|
492
|
+
// Canonical commit has been verified; a receipt does not imply projection completion.
|
|
493
|
+
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
472
494
|
|
|
473
495
|
// === 投影状态提示 ===
|
|
474
496
|
// 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
|
|
@@ -479,9 +501,9 @@ async function main() {
|
|
|
479
501
|
if (projStatus === 'complete') {
|
|
480
502
|
console.log('\n✓ 投影已完成,向量/图检索可用');
|
|
481
503
|
} else if (projStatus === 'degraded') {
|
|
482
|
-
console.log('\n⚠️
|
|
504
|
+
console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
|
|
483
505
|
} else if (projStatus === 'disabled') {
|
|
484
|
-
console.log('\nℹ️
|
|
506
|
+
console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
|
|
485
507
|
} else if (projStatus && projStatus !== 'complete') {
|
|
486
508
|
console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
|
|
487
509
|
console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
|
|
@@ -502,4 +524,5 @@ async function main() {
|
|
|
502
524
|
}
|
|
503
525
|
}
|
|
504
526
|
|
|
505
|
-
main();
|
|
527
|
+
if (require.main === module) main();
|
|
528
|
+
module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// kld-T07 — git 身份持久缓存与后台刷新(慢 git 环境韧性)
|
|
2
|
+
// 职责:~/.kld-sdd/git-identity.json 持久缓存读写(tmp+rename 原子);
|
|
3
|
+
// TTL 判定(身份 7 天 / tombstone 1 天);
|
|
4
|
+
// detached+unref 后台刷新(git config --get-regexp 单次调用,宽限 60s);
|
|
5
|
+
// in-progress 锁(mtime TTL 120s)防并发多 spawn;
|
|
6
|
+
// 仅 git 成功但字段缺失才写 tombstone;失败/超时不写,允许下次重试。
|
|
7
|
+
// 设计背景:企业终端 git 进程启动 7~34s,前台 execFileSync 必然超时;
|
|
8
|
+
// 稳态(缓存命中)零 git 调用、零额外延迟。
|
|
9
|
+
// 依赖注入(spawn/execFileSync/now/env/homeDir)便于单测;Node 14 兼容。
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const childProcess = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
const IDENTITY_FILE_NAME = 'git-identity.json';
|
|
18
|
+
const REFRESH_LOCK_NAME = 'git-identity.refresh.lock';
|
|
19
|
+
const IDENTITY_TTL_MS = 7 * 24 * 3600 * 1000; // 身份缓存 7 天
|
|
20
|
+
const TOMBSTONE_TTL_MS = 24 * 3600 * 1000; // tombstone 1 天
|
|
21
|
+
const REFRESH_LOCK_TTL_MS = 120 * 1000; // in-progress 锁 120s
|
|
22
|
+
const REFRESH_GIT_TIMEOUT_MS = 60 * 1000; // 后台 git 宽限 60s(适配 7-34s 慢机器)
|
|
23
|
+
|
|
24
|
+
/** 解析 home:显式 homeDir > env KLD_SDD_HOME > os.homedir(与 pendingPathFor 同注入模式) */
|
|
25
|
+
function resolveHome(homeDir, env) {
|
|
26
|
+
const e = env || process.env;
|
|
27
|
+
return homeDir || (e && e.KLD_SDD_HOME) || os.homedir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function identityPathFor(homeDir, env) {
|
|
31
|
+
return path.join(resolveHome(homeDir, env), '.kld-sdd', IDENTITY_FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function refreshLockPathFor(homeDir, env) {
|
|
35
|
+
return path.join(resolveHome(homeDir, env), '.kld-sdd', REFRESH_LOCK_NAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nowMs(options) {
|
|
39
|
+
return options && typeof options.now === 'function' ? options.now() : Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 原子写 JSON(tmp+rename),与 writePendingAtomic 同款 */
|
|
43
|
+
function writeJsonAtomic(filePath, obj) {
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
const tmp = path.join(
|
|
46
|
+
path.dirname(filePath),
|
|
47
|
+
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
|
48
|
+
);
|
|
49
|
+
fs.writeFileSync(tmp, `${JSON.stringify(obj)}\n`, 'utf8');
|
|
50
|
+
try {
|
|
51
|
+
fs.renameSync(tmp, filePath);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 写入身份缓存(readAt 可经 options.now 注入,测试 TTL 用)。
|
|
60
|
+
* @param {{userName:string,email:string}} identity
|
|
61
|
+
*/
|
|
62
|
+
function writeIdentityCache(identity, options = {}) {
|
|
63
|
+
writeJsonAtomic(identityPathFor(options.homeDir, options.env), {
|
|
64
|
+
userName: identity.userName,
|
|
65
|
+
email: identity.email,
|
|
66
|
+
readAt: new Date(nowMs(options)).toISOString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 写入 tombstone(确认无身份;TTL 1 天,防止每命令反复 spawn git) */
|
|
71
|
+
function writeTombstone(options = {}) {
|
|
72
|
+
writeJsonAtomic(identityPathFor(options.homeDir, options.env), {
|
|
73
|
+
unavailable: true,
|
|
74
|
+
readAt: new Date(nowMs(options)).toISOString(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 读身份缓存。
|
|
80
|
+
* @returns {{kind:'identity',userName:string,email:string,stale:boolean}
|
|
81
|
+
* |{kind:'tombstone',stale:boolean}
|
|
82
|
+
* |{kind:'miss'}}
|
|
83
|
+
* 损坏 JSON / 缺字段 / 文件不存在一律 miss
|
|
84
|
+
*/
|
|
85
|
+
function readIdentityCache(options = {}) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(identityPathFor(options.homeDir, options.env), 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
return { kind: 'miss' };
|
|
91
|
+
}
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return { kind: 'miss' };
|
|
97
|
+
}
|
|
98
|
+
if (!parsed || typeof parsed !== 'object') return { kind: 'miss' };
|
|
99
|
+
const readAt = Date.parse(typeof parsed.readAt === 'string' ? parsed.readAt : '');
|
|
100
|
+
const age = Number.isFinite(readAt) ? nowMs(options) - readAt : Infinity;
|
|
101
|
+
if (parsed.unavailable === true) {
|
|
102
|
+
return { kind: 'tombstone', stale: !(age >= 0 && age < TOMBSTONE_TTL_MS) };
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
typeof parsed.userName === 'string' && parsed.userName
|
|
106
|
+
&& typeof parsed.email === 'string' && parsed.email
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
kind: 'identity',
|
|
110
|
+
userName: parsed.userName,
|
|
111
|
+
email: parsed.email,
|
|
112
|
+
stale: !(age >= 0 && age < IDENTITY_TTL_MS),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'miss' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 获取 in-progress 锁:新鲜锁(<120s)存在或并发创建失败返回 false。
|
|
120
|
+
* 锁内容无语义,mtime 即心跳。
|
|
121
|
+
*/
|
|
122
|
+
function tryAcquireRefreshLock(options = {}) {
|
|
123
|
+
const lockPath = refreshLockPathFor(options.homeDir, options.env);
|
|
124
|
+
let stale = false;
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.statSync(lockPath);
|
|
127
|
+
if (nowMs(options) - st.mtimeMs < REFRESH_LOCK_TTL_MS) return false;
|
|
128
|
+
stale = true; // 过期锁:可接管
|
|
129
|
+
} catch { /* 无锁 */ }
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
132
|
+
// 新建用 'wx' 排他竞争;过期锁允许覆盖接管(并发接管最多多刷一次,幂等无害)
|
|
133
|
+
fs.writeFileSync(lockPath, String(nowMs(options)), { encoding: 'utf8', flag: stale ? 'w' : 'wx' });
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false; // 并发创建失败视为他人持锁
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function releaseRefreshLock(options = {}) {
|
|
141
|
+
try {
|
|
142
|
+
fs.unlinkSync(refreshLockPathFor(options.homeDir, options.env));
|
|
143
|
+
} catch { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 触发后台刷新:detached+unref 子进程运行本文件 refresh 模式。
|
|
148
|
+
* spawn 抛错时释放锁。返回是否真正触发(false=锁被持有或 spawn 失败)。
|
|
149
|
+
*/
|
|
150
|
+
function triggerBackgroundRefresh(options = {}) {
|
|
151
|
+
if (!tryAcquireRefreshLock(options)) return false;
|
|
152
|
+
const spawnFn = (options && options.spawn) || childProcess.spawn;
|
|
153
|
+
try {
|
|
154
|
+
const child = spawnFn(
|
|
155
|
+
process.execPath,
|
|
156
|
+
[__filename, 'refresh', '--home', resolveHome(options.homeDir, options.env)],
|
|
157
|
+
{ detached: true, stdio: 'ignore' },
|
|
158
|
+
);
|
|
159
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
releaseRefreshLock(options);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 主入口(同步,零 git):读持久缓存,必要时触发后台刷新。
|
|
169
|
+
* - 身份新鲜:直接返回,零 spawn
|
|
170
|
+
* - 身份过期:沿用旧值 + 触发刷新
|
|
171
|
+
* - tombstone 新鲜:返回 null,零 spawn
|
|
172
|
+
* - tombstone 过期 / miss:返回 null + 触发刷新
|
|
173
|
+
*
|
|
174
|
+
* @returns {{userName:string,email:string}|null}
|
|
175
|
+
*/
|
|
176
|
+
function ensureIdentity(options = {}) {
|
|
177
|
+
const cache = readIdentityCache(options);
|
|
178
|
+
if (cache.kind === 'identity') {
|
|
179
|
+
if (cache.stale) triggerBackgroundRefresh(options);
|
|
180
|
+
return { userName: cache.userName, email: cache.email };
|
|
181
|
+
}
|
|
182
|
+
if (cache.kind === 'tombstone' && !cache.stale) return null;
|
|
183
|
+
triggerBackgroundRefresh(options);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 后台刷新执行体(也可注入 execFileSync 在测试内 in-process 运行)。
|
|
189
|
+
* git 成功但字段缺失才写 tombstone;失败/超时什么都不写(允许下次重试)。
|
|
190
|
+
* 总是释放 in-progress 锁。
|
|
191
|
+
* @returns {boolean} git 调用是否成功(不代表一定写入了身份)
|
|
192
|
+
*/
|
|
193
|
+
function runRefresh(options = {}) {
|
|
194
|
+
const execFileSyncFn = (options && options.execFileSync) || childProcess.execFileSync;
|
|
195
|
+
try {
|
|
196
|
+
let out;
|
|
197
|
+
try {
|
|
198
|
+
out = String(execFileSyncFn('git', ['config', '--get-regexp', '^user\\.'], {
|
|
199
|
+
timeout: REFRESH_GIT_TIMEOUT_MS,
|
|
200
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
201
|
+
}));
|
|
202
|
+
} catch {
|
|
203
|
+
return false; // 失败/超时:不写 tombstone,允许下次重试
|
|
204
|
+
}
|
|
205
|
+
let userName = '';
|
|
206
|
+
let email = '';
|
|
207
|
+
for (const line of out.split('\n')) {
|
|
208
|
+
const m = line.match(/^(\S+)\s+(.*)$/);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
if (m[1] === 'user.name') userName = m[2].trim();
|
|
211
|
+
else if (m[1] === 'user.email') email = m[2].trim();
|
|
212
|
+
}
|
|
213
|
+
if (userName && email) {
|
|
214
|
+
writeIdentityCache({ userName, email }, options);
|
|
215
|
+
} else {
|
|
216
|
+
writeTombstone(options);
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
} finally {
|
|
220
|
+
releaseRefreshLock(options);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* refresh 子进程 CLI 入口:node git-identity.cjs refresh --home <dir>
|
|
226
|
+
* runRefresh 成功且回读缓存为 identity 时,lazy require usage-reporter 自触发一次
|
|
227
|
+
* pending 冲刷(身份就绪后数秒内补报,不再等下一次七值 start)。
|
|
228
|
+
* lazy require 规避循环依赖(usage-reporter 顶层已 require 本模块)。
|
|
229
|
+
* 返回 flush promise(无触发时为 resolved promise),便于测试 await。
|
|
230
|
+
*/
|
|
231
|
+
function runRefreshCli(argv, options = {}) {
|
|
232
|
+
const homeIdx = argv.indexOf('--home');
|
|
233
|
+
const homeDir = homeIdx >= 0 ? argv[homeIdx + 1] : undefined;
|
|
234
|
+
try {
|
|
235
|
+
const ok = runRefresh(Object.assign({}, options, { homeDir }));
|
|
236
|
+
if (!ok) return Promise.resolve(false);
|
|
237
|
+
// 回读缓存:仅真实写入 identity 才触发 flush(tombstone 不触发)
|
|
238
|
+
const cache = readIdentityCache({ homeDir });
|
|
239
|
+
if (cache.kind !== 'identity') return Promise.resolve(false);
|
|
240
|
+
try {
|
|
241
|
+
const { flushPendingUsage } = require('./usage-reporter.cjs');
|
|
242
|
+
return flushPendingUsage({ homeDir })
|
|
243
|
+
.catch(() => false)
|
|
244
|
+
.then(() => true);
|
|
245
|
+
} catch {
|
|
246
|
+
return Promise.resolve(true);
|
|
247
|
+
}
|
|
248
|
+
} catch { /* 永不抛到进程外 */ }
|
|
249
|
+
return Promise.resolve(false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (require.main === module) {
|
|
253
|
+
if (process.argv[2] === 'refresh') runRefreshCli(process.argv.slice(2));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
ensureIdentity,
|
|
258
|
+
readIdentityCache,
|
|
259
|
+
writeIdentityCache,
|
|
260
|
+
writeTombstone,
|
|
261
|
+
triggerBackgroundRefresh,
|
|
262
|
+
runRefresh,
|
|
263
|
+
runRefreshCli,
|
|
264
|
+
identityPathFor,
|
|
265
|
+
refreshLockPathFor,
|
|
266
|
+
IDENTITY_TTL_MS,
|
|
267
|
+
TOMBSTONE_TTL_MS,
|
|
268
|
+
REFRESH_LOCK_TTL_MS,
|
|
269
|
+
REFRESH_GIT_TIMEOUT_MS,
|
|
270
|
+
};
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// kld-T01 — CLI 使用统计上报契约
|
|
2
|
-
// 集中定义:七值 skill 枚举、
|
|
2
|
+
// 集中定义:七值 skill 枚举、ApiResponse 解包、内置默认上报地址、
|
|
3
3
|
// Node 14 兼容的 URL/HTTP 基础函数。与 kb-sdd 跨仓契约保持一致。
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
6
|
const http = require('node:http');
|
|
7
7
|
const https = require('node:https');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* 内置默认上报地址:四级 server 解析的最后一级兜底。
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_SERVER_URL = 'http://10.29.213.80:8080';
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* 七值使用统计 skill 枚举。
|
|
11
16
|
* test / explore 不属于统计范围,永不映射。
|
|
@@ -75,89 +80,18 @@ function unwrapApiResponse(body) {
|
|
|
75
80
|
return body.data === undefined ? null : body.data;
|
|
76
81
|
}
|
|
77
82
|
|
|
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
83
|
/**
|
|
148
84
|
* 分类 usage 上报响应。
|
|
149
85
|
* - confirmed: 2xx/204,服务端已确认,可从 pending 队列移除
|
|
150
|
-
* - unauthorized: 401,token 失效;调用方应原子删除本地 token 并提示一次
|
|
151
86
|
* - retry: 网络错误 / 429 / 5xx / timeout;保留在 pending 队列
|
|
152
|
-
* - drop: 其他 4xx
|
|
87
|
+
* - drop: 其他 4xx(含 401);不再重试但不上抛,也不清除任何本地状态
|
|
153
88
|
*
|
|
154
89
|
* @param {number|null} statusCode
|
|
155
|
-
* @returns {'confirmed'|'
|
|
90
|
+
* @returns {'confirmed'|'retry'|'drop'}
|
|
156
91
|
*/
|
|
157
92
|
function classifyUsageSubmitResponse(statusCode) {
|
|
158
93
|
if (statusCode == null || statusCode === 0) return 'retry';
|
|
159
94
|
if (statusCode >= 200 && statusCode < 300) return 'confirmed';
|
|
160
|
-
if (statusCode === 401) return 'unauthorized';
|
|
161
95
|
if (statusCode === 429) return 'retry';
|
|
162
96
|
if (statusCode >= 500) return 'retry';
|
|
163
97
|
return 'drop';
|
|
@@ -171,14 +105,14 @@ function classifyUsageSubmitResponse(statusCode) {
|
|
|
171
105
|
*
|
|
172
106
|
* @param {string} url — 完整 URL(必须 http/https)
|
|
173
107
|
* @param {object} payload — 请求体(JSON 序列化)
|
|
174
|
-
* @param {{
|
|
108
|
+
* @param {{timeoutMs?:number,headers?:object}} [options]
|
|
175
109
|
* @returns {Promise<{statusCode:number|null,body:*,data:*,error:*}>}
|
|
176
110
|
* statusCode: HTTP 状态码,网络错误为 null
|
|
177
111
|
* body: 原始解析后的 JSON 对象(如可解析)
|
|
178
112
|
* data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
|
|
179
113
|
* error: 仅在 statusCode=null 时携带 Error;否则为 null
|
|
180
114
|
*/
|
|
181
|
-
function postJson(url, payload, options = {}) {
|
|
115
|
+
function postJson(url, payload = {}, options = {}) {
|
|
182
116
|
return new Promise((resolve, reject) => {
|
|
183
117
|
let target;
|
|
184
118
|
try {
|
|
@@ -192,14 +126,14 @@ function postJson(url, payload, options = {}) {
|
|
|
192
126
|
return;
|
|
193
127
|
}
|
|
194
128
|
const transport = target.protocol === 'https:' ? https : http;
|
|
195
|
-
const token = options && typeof options.token === 'string' && options.token ? options.token : null;
|
|
196
129
|
const timeoutMs = Number.isFinite(options && options.timeoutMs) && options.timeoutMs > 0
|
|
197
130
|
? Math.floor(options.timeoutMs)
|
|
198
131
|
: 30000;
|
|
199
132
|
|
|
200
133
|
let body;
|
|
201
134
|
try {
|
|
202
|
-
|
|
135
|
+
// payload 缺省为 {}(默认参数),null 仍按 "null" 序列化,与原三元行为一致
|
|
136
|
+
body = JSON.stringify(payload);
|
|
203
137
|
} catch (err) {
|
|
204
138
|
reject(new Error(`payload serialization error: ${err.message}`));
|
|
205
139
|
return;
|
|
@@ -213,7 +147,6 @@ function postJson(url, payload, options = {}) {
|
|
|
213
147
|
},
|
|
214
148
|
options && options.headers && typeof options.headers === 'object' ? options.headers : {},
|
|
215
149
|
);
|
|
216
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
217
150
|
|
|
218
151
|
const request = transport.request(
|
|
219
152
|
{
|
|
@@ -269,8 +202,7 @@ module.exports = {
|
|
|
269
202
|
mapCommandToUsageSkill,
|
|
270
203
|
normalizeServerUrl,
|
|
271
204
|
unwrapApiResponse,
|
|
272
|
-
parseDeviceCodeResponse,
|
|
273
|
-
classifyDevicePollResponse,
|
|
274
205
|
classifyUsageSubmitResponse,
|
|
275
206
|
postJson,
|
|
207
|
+
DEFAULT_SERVER_URL,
|
|
276
208
|
};
|