kld-sdd 2.6.21 → 2.7.3
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 +3 -9
- package/lib/init.js +20 -57
- package/package.json +2 -2
- package/skywalk-sdd/index.cjs +13 -0
- package/skywalk-sdd/lib/git-identity.cjs +270 -0
- package/skywalk-sdd/lib/usage-contract.cjs +10 -79
- package/skywalk-sdd/lib/usage-reporter.cjs +210 -104
- package/skywalk-sdd/lib/user-config.cjs +21 -46
- package/skywalk-sdd/ontology/artifact-parser.cjs +1 -2
- package/templates/git-hooks/commit-msg +58 -15
- package/templates/git-hooks/hooks.config +19 -0
- package/templates/git-hooks/pre-commit +45 -11
- package/templates/git-hooks/pre-push +53 -11
- package/templates/git-hooks/pre-push-consistency-check.cjs +112 -2
- package/lib/device-auth-cli.js +0 -130
- package/lib/device-auth.js +0 -345
- package/lib/init-account-binding.js +0 -108
package/bin/kld-sdd-init.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* log SDD Telemetry CLI(记录阶段事件、查询指标)
|
|
10
10
|
* link-spec 将代码仓关联到本地 spec clone(git config sdd.specPath)
|
|
11
11
|
* sync-repos 工作目录下发现新增代码仓并轻量接入(不装 skills)
|
|
12
|
-
* auth 用户账号绑定:login / logout / status
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
const path = require('path');
|
|
@@ -52,14 +51,9 @@ if (args[0] === 'log') {
|
|
|
52
51
|
}
|
|
53
52
|
console.log(`✅ sync-repos 完成,接入 ${result.attached.length} 个代码仓`);
|
|
54
53
|
} else if (args[0] === 'auth') {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
process.exit(code);
|
|
59
|
-
}).catch((error) => {
|
|
60
|
-
console.error(`❌ auth 失败: ${error.message}`);
|
|
61
|
-
process.exit(1);
|
|
62
|
-
});
|
|
54
|
+
// 登录体系已随 2.7.0 移除:仅保留 deprecation 提示,不进入 init 向导
|
|
55
|
+
console.log('ℹ️ auth 子命令已于 2.7.0 移除:使用统计按本机 git 身份(user.name/user.email)上报,无需绑定。');
|
|
56
|
+
process.exit(0);
|
|
63
57
|
} else {
|
|
64
58
|
// 正常初始化模式
|
|
65
59
|
const { main } = require('../lib/init');
|
package/lib/init.js
CHANGED
|
@@ -30,7 +30,6 @@ const {
|
|
|
30
30
|
const { deployCodebuddyHookPack } = require('./deploy-codebuddy-hooks');
|
|
31
31
|
const workspaceLayout = require('./workspace-layout');
|
|
32
32
|
const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
33
|
-
const { runAccountBinding } = require('./init-account-binding');
|
|
34
33
|
|
|
35
34
|
let rl = null;
|
|
36
35
|
|
|
@@ -896,13 +895,16 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
|
|
|
896
895
|
console.log(' ⚠️ lib/shared.cjs 源文件缺失,多个脚本将缺少共用工具依赖');
|
|
897
896
|
}
|
|
898
897
|
|
|
899
|
-
// kld-fixback: 部署使用统计上报模块(usage-contract/usage-reporter/user-config)
|
|
898
|
+
// kld-fixback: 部署使用统计上报模块(usage-contract/usage-reporter/user-config/git-identity)
|
|
900
899
|
// 这些模块被部署后的 skywalk-sdd/log.cjs(即 index.cjs 副本)在 cmdStart 中 require,
|
|
901
|
-
//
|
|
900
|
+
// 缺失时会上报静默失效。四件套必须同时部署,路径与 npm 包内布局一致。
|
|
901
|
+
// ⚠️ usage-reporter.cjs 顶层 require('./git-identity.cjs'):缺它时模块加载即抛
|
|
902
|
+
// MODULE_NOT_FOUND,被 cmdStart try/catch 吞掉后上报 100% 静默丢失。
|
|
902
903
|
const usageModules = [
|
|
903
|
-
{ src: 'usage-contract.cjs', desc: '使用统计契约(七值/
|
|
904
|
-
{ src: 'usage-reporter.cjs', desc: '使用事件上报器(3s deadline + pending 队列)' },
|
|
905
|
-
{ src: 'user-config.cjs', desc: '用户级配置(~/.kld-sdd/config.json
|
|
904
|
+
{ src: 'usage-contract.cjs', desc: '使用统计契约(七值/ApiResponse/HTTP)' },
|
|
905
|
+
{ src: 'usage-reporter.cjs', desc: '使用事件上报器(git 身份缓存 + 3s deadline + pending 队列)' },
|
|
906
|
+
{ src: 'user-config.cjs', desc: '用户级配置(~/.kld-sdd/config.json,仅 server)' },
|
|
907
|
+
{ src: 'git-identity.cjs', desc: 'git 身份持久缓存与后台刷新(usage-reporter 的硬依赖)' },
|
|
906
908
|
];
|
|
907
909
|
for (const mod of usageModules) {
|
|
908
910
|
const src = path.join(pkgPath, 'skywalk-sdd', 'lib', mod.src);
|
|
@@ -1095,22 +1097,17 @@ function deployCodeRepoHooks(codeRepoRoot, specRepoRoot, options = {}) {
|
|
|
1095
1097
|
if (!fs.existsSync(specHooksDir)) {
|
|
1096
1098
|
fs.mkdirSync(specHooksDir, { recursive: true });
|
|
1097
1099
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
'
|
|
1101
|
-
|
|
1102
|
-
'pre-push-consistency-check.cjs',
|
|
1103
|
-
];
|
|
1100
|
+
// 复制全部 .cjs 脚本 + hooks.config 配置文件
|
|
1101
|
+
const hookScripts = fs.existsSync(gitHooksTemplateDir)
|
|
1102
|
+
? fs.readdirSync(gitHooksTemplateDir).filter(f => f.endsWith('.cjs') || f === 'hooks.config')
|
|
1103
|
+
: [];
|
|
1104
1104
|
for (const script of hookScripts) {
|
|
1105
1105
|
const src = path.join(gitHooksTemplateDir, script);
|
|
1106
1106
|
const dst = path.join(specHooksDir, script);
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
if (fs.existsSync(src)) {
|
|
1110
|
-
fs.copyFileSync(src, dst);
|
|
1111
|
-
}
|
|
1107
|
+
// 始终覆盖,与 shell hook 的 marker 覆盖策略保持一致
|
|
1108
|
+
fs.copyFileSync(src, dst);
|
|
1112
1109
|
}
|
|
1113
|
-
console.log(` ✓ 确保 spec 仓 skywalk-sdd/git-hooks/
|
|
1110
|
+
console.log(` ✓ 确保 spec 仓 skywalk-sdd/git-hooks/ 脚本齐全(${hookScripts.length} 个文件)`);
|
|
1114
1111
|
|
|
1115
1112
|
// ── 2. 安装三个 shell hooks 到代码仓 .git/hooks/ ──
|
|
1116
1113
|
// 模板 hook 运行时自动定位 spec 仓的 .cjs 脚本
|
|
@@ -1169,16 +1166,17 @@ function deployQualityGateTemplates(targetCwd = process.cwd(), options = {}) {
|
|
|
1169
1166
|
const targetCiDir = path.join(dataDir, 'ci');
|
|
1170
1167
|
|
|
1171
1168
|
if (fs.existsSync(gitHooksTemplateDir)) {
|
|
1172
|
-
//
|
|
1169
|
+
// 复制 .cjs 脚本 + hooks.config 到 skywalk-sdd/git-hooks/
|
|
1170
|
+
// shell hook 模板(commit-msg/pre-commit/pre-push)不落此目录
|
|
1173
1171
|
if (!fs.existsSync(targetGitHooksDir)) {
|
|
1174
1172
|
fs.mkdirSync(targetGitHooksDir, { recursive: true });
|
|
1175
1173
|
}
|
|
1176
1174
|
for (const file of fs.readdirSync(gitHooksTemplateDir)) {
|
|
1177
|
-
if (file.endsWith('.cjs')) {
|
|
1175
|
+
if (file.endsWith('.cjs') || file === 'hooks.config') {
|
|
1178
1176
|
fs.copyFileSync(path.join(gitHooksTemplateDir, file), path.join(targetGitHooksDir, file));
|
|
1179
1177
|
}
|
|
1180
1178
|
}
|
|
1181
|
-
console.log(' ✓ 部署 skywalk-sdd/git-hooks/(.cjs
|
|
1179
|
+
console.log(' ✓ 部署 skywalk-sdd/git-hooks/(.cjs 脚本 + hooks.config)');
|
|
1182
1180
|
} else {
|
|
1183
1181
|
console.log(` ⚠️ Git hooks 模板缺失: ${gitHooksTemplateDir}`);
|
|
1184
1182
|
}
|
|
@@ -1546,20 +1544,6 @@ skywalk-sdd/state/
|
|
|
1546
1544
|
}
|
|
1547
1545
|
}
|
|
1548
1546
|
|
|
1549
|
-
/**
|
|
1550
|
-
* B1 汇总块绑定文案:已绑定显示用户,未绑定引导 auth login;不出现“使用统计”字样
|
|
1551
|
-
*/
|
|
1552
|
-
function formatBindingSummaryLine(bindingResult) {
|
|
1553
|
-
if (
|
|
1554
|
-
bindingResult
|
|
1555
|
-
&& (bindingResult.status === 'bound' || bindingResult.status === 'kept')
|
|
1556
|
-
&& bindingResult.userName
|
|
1557
|
-
) {
|
|
1558
|
-
return ` ✓ 平台账号已绑定:${bindingResult.userName}`;
|
|
1559
|
-
}
|
|
1560
|
-
return ' 6. 绑定平台账号:npx kld-sdd auth login';
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
1547
|
/**
|
|
1564
1548
|
* 显示帮助信息
|
|
1565
1549
|
*/
|
|
@@ -1798,23 +1782,6 @@ async function main() {
|
|
|
1798
1782
|
}
|
|
1799
1783
|
removeOuterSkywalk(workspaceRoot);
|
|
1800
1784
|
|
|
1801
|
-
// 9. B1 账号绑定向导(部署完成后、最终汇总前;--skip-auth/非 TTY 自动跳过;失败不阻塞 init)
|
|
1802
|
-
const bindingController = new AbortController();
|
|
1803
|
-
const onBindingSigint = () => bindingController.abort();
|
|
1804
|
-
process.on('SIGINT', onBindingSigint);
|
|
1805
|
-
let bindingResult = null;
|
|
1806
|
-
try {
|
|
1807
|
-
bindingResult = await runAccountBinding({
|
|
1808
|
-
ask,
|
|
1809
|
-
skipAuth: args.includes('--skip-auth'),
|
|
1810
|
-
signal: bindingController.signal,
|
|
1811
|
-
});
|
|
1812
|
-
} catch (error) {
|
|
1813
|
-
console.log(`⚠️ 账号绑定向导异常(${error && error.message ? error.message : error}),已跳过`);
|
|
1814
|
-
} finally {
|
|
1815
|
-
process.removeListener('SIGINT', onBindingSigint);
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
1785
|
const selectedNames = selectedTools.map(t => TOOL_CONFIGS[t].name).join(', ');
|
|
1819
1786
|
|
|
1820
1787
|
console.log();
|
|
@@ -1894,8 +1861,6 @@ async function main() {
|
|
|
1894
1861
|
console.log(' 4. 激活 opsx-apply 申请实施,opsx-archive 归档完成');
|
|
1895
1862
|
console.log(' 5. 随时入库:node skywalk-sdd/kb-upload.cjs --folder=<变更目录> --minimum-level=propose-spec');
|
|
1896
1863
|
console.log(' (需先运行 /opsx-kb-config 配置知识库)');
|
|
1897
|
-
// B1: 汇总块绑定文案(不出现“使用统计”字样)
|
|
1898
|
-
console.log(formatBindingSummaryLine(bindingResult));
|
|
1899
1864
|
if (selectedTools.includes('codebuddy')) {
|
|
1900
1865
|
console.log(' - CodeBuddy 用户:SDD Hook Pack 已随初始化自动部署,门禁自动生效,无需手动配置');
|
|
1901
1866
|
}
|
|
@@ -1919,8 +1884,7 @@ async function main() {
|
|
|
1919
1884
|
}
|
|
1920
1885
|
|
|
1921
1886
|
// kld-T06: Progress Runtime 已删除,用户级 workspace 注册函数一并移除。
|
|
1922
|
-
//
|
|
1923
|
-
// 通过 `kld-sdd auth login` 显式触发。
|
|
1887
|
+
// 使用统计按本机 git 身份(user.name/user.email)上报,无需任何账号绑定。
|
|
1924
1888
|
|
|
1925
1889
|
module.exports = {
|
|
1926
1890
|
main,
|
|
@@ -1930,7 +1894,6 @@ module.exports = {
|
|
|
1930
1894
|
selectEditor,
|
|
1931
1895
|
isVerbose,
|
|
1932
1896
|
vlog,
|
|
1933
|
-
formatBindingSummaryLine,
|
|
1934
1897
|
deployOpsxSkills,
|
|
1935
1898
|
copyTemplatesToProject,
|
|
1936
1899
|
deployProfileArtifacts,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kld-sdd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.3",
|
|
4
4
|
"description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"kld-sdd-init": "bin/kld-sdd-init.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node
|
|
11
|
+
"test": "node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"kld",
|
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -5858,6 +5858,19 @@ function cmdEnd(args, options = {}) {
|
|
|
5858
5858
|
if (!options.silent) {
|
|
5859
5859
|
console.log(JSON.stringify(output, null, 2));
|
|
5860
5860
|
}
|
|
5861
|
+
// kld-usage-flush: stage_end 落库后冲刷 pending 队列(fire-and-forget,不产生新事件)。
|
|
5862
|
+
// 覆盖"start 时网络故障/无身份,end 时已恢复"的同会话补报场景;失败不影响 output 与退出码。
|
|
5863
|
+
// promise 以不可枚举属性挂返回对象供测试 await,JSON.stringify 输出不变。
|
|
5864
|
+
try {
|
|
5865
|
+
const { flushPendingUsage } = require('./lib/usage-reporter.cjs');
|
|
5866
|
+
const usageFlush = flushPendingUsage({ projectRoot }).catch(() => null);
|
|
5867
|
+
Object.defineProperty(output, 'usageFlush', {
|
|
5868
|
+
value: usageFlush,
|
|
5869
|
+
enumerable: false,
|
|
5870
|
+
writable: false,
|
|
5871
|
+
configurable: true,
|
|
5872
|
+
});
|
|
5873
|
+
} catch { /* flush 不可用不阻塞 */ }
|
|
5861
5874
|
return output;
|
|
5862
5875
|
}
|
|
5863
5876
|
|
|
@@ -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,7 +105,7 @@ 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 对象(如可解析)
|
|
@@ -192,7 +126,6 @@ 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;
|
|
@@ -213,7 +146,6 @@ function postJson(url, payload, options = {}) {
|
|
|
213
146
|
},
|
|
214
147
|
options && options.headers && typeof options.headers === 'object' ? options.headers : {},
|
|
215
148
|
);
|
|
216
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
217
149
|
|
|
218
150
|
const request = transport.request(
|
|
219
151
|
{
|
|
@@ -269,8 +201,7 @@ module.exports = {
|
|
|
269
201
|
mapCommandToUsageSkill,
|
|
270
202
|
normalizeServerUrl,
|
|
271
203
|
unwrapApiResponse,
|
|
272
|
-
parseDeviceCodeResponse,
|
|
273
|
-
classifyDevicePollResponse,
|
|
274
204
|
classifyUsageSubmitResponse,
|
|
275
205
|
postJson,
|
|
206
|
+
DEFAULT_SERVER_URL,
|
|
276
207
|
};
|