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.
@@ -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
- const sub = args[1] || 'status';
56
- const { runAuthCommand } = require('../lib/device-auth-cli.js');
57
- runAuthCommand(sub, args.slice(2)).then((code) => {
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
- // 缺失时会上报静默失效。三件套必须同时部署,路径与 npm 包内布局一致。
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: '使用统计契约(七值/Device/ApiResponse/HTTP)' },
904
- { src: 'usage-reporter.cjs', desc: '使用事件上报器(3s deadline + pending 队列)' },
905
- { src: 'user-config.cjs', desc: '用户级配置(~/.kld-sdd/config.json + cli_token)' },
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
- const hookScripts = [
1099
- 'pre-commit-sdd-check.cjs',
1100
- 'pre-commit-consistency-check.cjs',
1101
- 'pre-push-sdd-check.cjs',
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
- // 删除 !fs.existsSync(dst) 条件:原逻辑只在目标不存在时复制,导致模板升级后 spec 仓不会拿到最新脚本。
1108
- // 现在始终覆盖,与 shell hook 的 marker 覆盖策略保持一致。
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
- // 只复制 .cjs 脚本到 skywalk-sdd/git-hooks/,shell hook 模板(commit-msg/pre-commit/pre-push)不落此目录
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
- // 用户级 Device Flow 绑定由 lib/device-auth.js + skywalk-sdd/lib/user-config.cjs 负责,
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.6.21",
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 test/init-account-binding.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/device-auth.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"
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",
@@ -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 枚举、Device Flow 信封、ApiResponse 解包、
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'|'unauthorized'|'retry'|'drop'}
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 {{token?:string,timeoutMs?:number,headers?:object}} [options]
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
  };