kld-sdd 2.6.16 → 2.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/kld-sdd-init.js +10 -0
  2. package/kld-sdd-guide.html +22 -0
  3. package/lib/device-auth-cli.js +130 -0
  4. package/lib/device-auth.js +345 -0
  5. package/lib/init-account-binding.js +108 -0
  6. package/lib/init.js +238 -55
  7. package/lib/skills-bundle.js +20 -1
  8. package/lib/tool-profiles.js +8 -0
  9. package/package.json +7 -3
  10. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  11. package/skywalk-sdd/context-client.cjs +38 -87
  12. package/skywalk-sdd/index.cjs +860 -132
  13. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  14. package/skywalk-sdd/kb-upload.cjs +505 -0
  15. package/skywalk-sdd/lib/shared.cjs +811 -0
  16. package/skywalk-sdd/lib/usage-contract.cjs +276 -0
  17. package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
  18. package/skywalk-sdd/lib/user-config.cjs +157 -0
  19. package/skywalk-sdd/metrics-v3.cjs +138 -8
  20. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  21. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  22. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  23. package/skywalk-sdd/ontology/id.cjs +26 -5
  24. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  25. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  26. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  27. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  28. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  29. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  30. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  31. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  32. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  33. package/skywalk-sdd/spec-root.cjs +31 -0
  34. package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
  35. package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
  36. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  37. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  38. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  39. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  40. package/templates/openspec/proposal.md +7 -3
  41. package/templates/openspec/spec.md +3 -3
  42. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  43. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  44. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  45. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  46. package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
  47. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  48. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
  49. package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
  50. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  52. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
  53. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
  54. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  55. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  56. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  57. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  58. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  59. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  60. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  61. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  62. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  63. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  64. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  65. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  66. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  67. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  68. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  69. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  70. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -9,6 +9,7 @@
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
12
13
  */
13
14
 
14
15
  const path = require('path');
@@ -50,6 +51,15 @@ if (args[0] === 'log') {
50
51
  process.exit(1);
51
52
  }
52
53
  console.log(`✅ sync-repos 完成,接入 ${result.attached.length} 个代码仓`);
54
+ } 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
+ });
53
63
  } else {
54
64
  // 正常初始化模式
55
65
  const { main } = require('../lib/init');
@@ -1116,6 +1116,28 @@ node skywalk-sdd/log.cjs semantic-observe --project=. --change=add-user-auth">
1116
1116
  <div class="metric reveal"><div class="key">P-H</div><h4>Telemetry 健康度</h4><div class="value">doctor health × 100</div><p>与 P-C 阶段闭合率一起判断数据是否足够可信。</p></div>
1117
1117
  </div>
1118
1118
 
1119
+ <p class="lead reveal" style="margin-top:32px"><b>KB 实时进度同步(Progress Agent)</b>:本地 JSONL 事实经 canonical CloudEvent 适配后发送到 Engineering KB;仅 durable ACK(accepted / duplicate_accepted)且仍 active 的 Run 才会发送 heartbeat。Agent 是<strong>事件触发、有限会话</strong>的用户级单实例——<strong>不再默认开机自启动/长期驻留</strong>:每条新 SDD 事实 durable append 后自动拉起(<code>ensureAgentRunning</code> 按 <code>agent.lock</code> 探测避免重复 spawn),按 5/15/10 分钟三档租约(clean idle / active silence / retry budget)自行退出;旧版开机自启动会在 init/config 时被幂等迁移移除。</p>
1120
+ <div class="codeblock reveal">
1121
+ <button type="button" class="copy" data-copy="# 配置 KB(需 progress:write scope)后 init 会自动 register,best-effort 迁移旧自启动 + 拉起当前会话 Agent
1122
+ npx kld-sdd --tool codex
1123
+
1124
+ # 手动单次同步(强制 batch → ACK 落盘 → heartbeat,不进入有限会话循环)
1125
+ node skywalk-sdd/progress/agent-main.cjs --once --user-state-root=%LOCALAPPDATA%/kld-sdd/progress
1126
+
1127
+ # 手动触发有限会话(正常由新 SDD 事实自动拉起,一般无需手动执行)
1128
+ node skywalk-sdd/progress/agent-main.cjs --user-state-root=%LOCALAPPDATA%/kld-sdd/progress
1129
+
1130
+ # 查看自启动/迁移状态(degraded 时可再次 config/init 恢复)
1131
+ node -e &quot;require('./skywalk-sdd/progress/autostart.cjs').statusAutostart({testMode:true}).then(console.log)&quot;">复制</button>
1132
+ <pre><span class="k-c"># apiBase 必须包含 /api,例如 http://host:8090/api</span>
1133
+ <span class="k-c"># 状态目录:%LOCALAPPDATA%/kld-sdd/progress(Windows)或 ~/.local/state/kld-sdd/progress</span>
1134
+ <span class="k-p">➜</span> <span class="k-s">node</span> skywalk-sdd/progress/agent-main.cjs <span class="k-f">--once</span> --user-state-root=&lt;USER_STATE_ROOT&gt;
1135
+ <span class="k-p">➜</span> <span class="k-s">node</span> skywalk-sdd/progress/agent-main.cjs --user-state-root=&lt;USER_STATE_ROOT&gt; <span class="k-o">(triggered finite session)</span>
1136
+ <span class="k-p">➜</span> <span class="k-s">node</span> skywalk-sdd/progress/autostart.cjs <span class="k-f">status</span> <span class="k-o">(platform adapter)</span></pre>
1137
+ </div>
1138
+ <div class="callout reveal"><b>破坏性对齐(CloudEvent 出站):</b>出站 envelope 已与 KB canonical 契约对齐——<code>type</code> 为 <code>com.kunlunzhima.sdd.&lt;event_kind&gt;.v1</code>,<code>source</code> 为 <code>urn:kld-sdd:installation:…:workspace:…</code>,payload 含 <code>seq</code>/<code>change_key</code>,哈希模式 <code>canonical-json-v1</code>。旧版自定义 type/source/外壳<strong>不再兼容</strong>;升级后请以 KB schema/fixture 为准,勿依赖旧出站字段。</div>
1139
+ <div class="callout reveal"><b>排障:</b>401/403 或 rejected_scope → 检查 API Key 是否含 <code>progress:write</code>;schema 拒绝只隔离单条事件;<code>autostart=degraded</code> 不破坏 kb-state,可重跑 config/init;heartbeat 不含 <code>active</code> 字段,资格来自 durable ACK + 本地 active Run 交集;可选 <code>agent_state</code>(<code>active</code>/<code>sleeping</code>/<code>terminal</code>)仅在会话启动/唤醒与计划退出前 best-effort 上报。</div>
1140
+
1119
1141
  <p class="lead reveal" style="margin-top:32px"><b>事件与单变更报告</b>:事件落 <code>skywalk-sdd/events/</code>、活跃状态落 <code>skywalk-sdd/state/</code>(均 gitignored);归档报告落 <code>openspec/changes/archive/&lt;date&gt;-&lt;name&gt;/reports/</code>。</p>
1120
1142
  <div class="codeblock reveal">
1121
1143
  <button type="button" class="copy" data-copy="node skywalk-sdd/log.cjs start --command=propose --project=. --change=add-user-auth --agent=codex --source=opsx-skill --session-id=sess_001
@@ -0,0 +1,130 @@
1
+ // kld-T03 — auth 子命令 CLI 薄层
2
+ // 仅做参数解析、交互提示与退出码;业务逻辑在 lib/device-auth.js + skywalk-sdd/lib/user-config.cjs
3
+ 'use strict';
4
+
5
+ const readline = require('node:readline');
6
+
7
+ const {
8
+ resolveServer,
9
+ runDeviceFlow,
10
+ DEFAULT_SERVER_URL,
11
+ } = require('./device-auth.js');
12
+ const {
13
+ readUserConfig,
14
+ clearUserToken,
15
+ redactToken,
16
+ USER_CONFIG_VERSION,
17
+ } = require('../skywalk-sdd/lib/user-config.cjs');
18
+ const { normalizeServerUrl } = require('../skywalk-sdd/lib/usage-contract.cjs');
19
+
20
+ async function ask(rl, question) {
21
+ return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
22
+ }
23
+
24
+ async function cmdStatus() {
25
+ const cfg = readUserConfig();
26
+ if (!cfg || !cfg.token) {
27
+ console.log('未绑定平台账号。');
28
+ console.log('使用 `kld-sdd auth login` 完成 Device Flow 绑定。');
29
+ return 0;
30
+ }
31
+ console.log('已绑定平台账号:');
32
+ console.log(` server: ${cfg.server || '(未设置)'}`);
33
+ console.log(` userName: ${cfg.userName || '(未知)'}`);
34
+ console.log(` boundAt: ${cfg.boundAt || '(未知)'}`);
35
+ console.log(` token: ${redactToken(cfg.token)}`);
36
+ return 0;
37
+ }
38
+
39
+ async function cmdLogout() {
40
+ const removed = clearUserToken();
41
+ if (removed) {
42
+ console.log('✅ 已清除本地绑定(其他配置保留)。');
43
+ } else {
44
+ console.log('未检测到已绑定 token。');
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ async function cmdLogin(args) {
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ try {
52
+ // 解析 server:已有绑定 → env → 交互输入
53
+ let server = resolveServer();
54
+ const forceArg = args.find((a) => a.startsWith('--server='));
55
+ if (forceArg) {
56
+ const normalized = normalizeServerUrl(forceArg.slice('--server='.length));
57
+ if (!normalized) {
58
+ console.error('❌ --server 必须是 http/https URL');
59
+ return 1;
60
+ }
61
+ server = normalized;
62
+ }
63
+ if (!server) {
64
+ const answer = await ask(rl, `请输入平台地址(http/https,回车默认 ${DEFAULT_SERVER_URL}):`);
65
+ const normalized = normalizeServerUrl(answer || DEFAULT_SERVER_URL);
66
+ if (!normalized) {
67
+ console.error('❌ 非法地址,必须 http/https');
68
+ return 1;
69
+ }
70
+ server = normalized;
71
+ }
72
+
73
+ const existing = readUserConfig();
74
+ if (existing && existing.token && existing.server === server) {
75
+ const answer = await ask(rl, `已绑定 ${server}(userName=${existing.userName || '未知'}),是否更新绑定?[y/N] `);
76
+ if (!/^[yY]/.test(answer)) {
77
+ console.log('保留原有绑定。');
78
+ return 0;
79
+ }
80
+ }
81
+
82
+ const pkg = require('../package.json');
83
+ console.log(`正在通过 Device Flow 绑定到 ${server} ...`);
84
+ const ac = new AbortController();
85
+ const sigint = () => { ac.abort(); };
86
+ process.on('SIGINT', sigint);
87
+ try {
88
+ const result = await runDeviceFlow({
89
+ server,
90
+ clientName: 'kld-sdd',
91
+ clientVersion: pkg && pkg.version ? String(pkg.version) : 'unknown',
92
+ onPrint: (msg) => console.log(msg),
93
+ signal: ac.signal,
94
+ });
95
+ if (result.status === 'bound') {
96
+ console.log('✅ 绑定成功。');
97
+ return 0;
98
+ }
99
+ if (result.status === 'aborted') {
100
+ console.log('已取消。');
101
+ return 130;
102
+ }
103
+ if (result.status === 'expired') {
104
+ console.log('⏱ 授权已过期,请重试。');
105
+ return 1;
106
+ }
107
+ if (result.status === 'denied') {
108
+ console.log('已拒绝授权。');
109
+ return 1;
110
+ }
111
+ console.error(`❌ 绑定失败:${result.reason || 'unknown'}`);
112
+ return 1;
113
+ } finally {
114
+ process.removeListener('SIGINT', sigint);
115
+ }
116
+ } finally {
117
+ rl.close();
118
+ }
119
+ }
120
+
121
+ async function runAuthCommand(sub, args) {
122
+ if (sub === 'status') return cmdStatus(args);
123
+ if (sub === 'login') return cmdLogin(args);
124
+ if (sub === 'logout') return cmdLogout(args);
125
+ console.error(`未知 auth 子命令:${sub}`);
126
+ console.error('用法: kld-sdd auth [status|login|logout] [--server=<url>]');
127
+ return 2;
128
+ }
129
+
130
+ module.exports = { runAuthCommand };
@@ -0,0 +1,345 @@
1
+ // kld-T03 — Device Flow 授权客户端
2
+ // 职责:code/poll、浏览器打开降级、过期/拒绝/Ctrl+C 状态、原子写入用户配置。
3
+ // 与 kb-sdd 跨仓契约对齐;Node 14 兼容;不依赖全局 fetch。
4
+ 'use strict';
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const {
11
+ normalizeServerUrl,
12
+ parseDeviceCodeResponse,
13
+ classifyDevicePollResponse,
14
+ postJson,
15
+ unwrapApiResponse,
16
+ } = require('../skywalk-sdd/lib/usage-contract.cjs');
17
+ const {
18
+ readUserConfig,
19
+ writeUserConfig,
20
+ USER_CONFIG_VERSION,
21
+ } = require('../skywalk-sdd/lib/user-config.cjs');
22
+
23
+ const DEFAULT_TIMEOUT_MS = 15000;
24
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
25
+ const DEFAULT_EXPIRES_IN_MS = 600 * 1000;
26
+ // 交互输入平台地址时的默认值(回车即采用)
27
+ const DEFAULT_SERVER_URL = 'http://10.29.213.80:8080/';
28
+
29
+ /**
30
+ * 解析 server URL:已有用户绑定 → 项目现有 KB server(kb-state.json.api)→ env KLD_SDD_SERVER → null。
31
+ * @param {{homeDir?:string, env?:object, projectRoot?:string}} [options]
32
+ * @returns {string|null} 规范化后的 server URL;未找到返回 null
33
+ */
34
+ function resolveServer(options = {}) {
35
+ const homeDir = options.homeDir;
36
+ const env = options.env || process.env;
37
+ const projectRoot = options.projectRoot || process.cwd();
38
+ // 1. 已有用户绑定
39
+ const existing = readUserConfig(homeDir);
40
+ if (existing && typeof existing.server === 'string') {
41
+ const normalized = normalizeServerUrl(existing.server);
42
+ if (normalized) return normalized;
43
+ }
44
+ // 2. 项目现有 KB server(kld-fixback YELLOW#3)
45
+ try {
46
+ const kbStatePath = path.join(projectRoot, 'kb-state.json');
47
+ if (fs.existsSync(kbStatePath)) {
48
+ const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
49
+ if (kbState && typeof kbState.api === 'string') {
50
+ const normalized = normalizeServerUrl(kbState.api);
51
+ if (normalized) return normalized;
52
+ }
53
+ }
54
+ } catch {
55
+ // kb-state 读取失败时静默,继续下一级
56
+ }
57
+ // 3. env KLD_SDD_SERVER
58
+ if (env && typeof env.KLD_SDD_SERVER === 'string') {
59
+ const normalized = normalizeServerUrl(env.KLD_SDD_SERVER);
60
+ if (normalized) return normalized;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * 请求 Device Flow code。
67
+ * @param {string} server — 已规范化的 server URL
68
+ * @param {{clientName?:string,clientVersion?:string,timeoutMs?:number}} [options]
69
+ * @returns {Promise<{deviceCode,userCode,verificationUri,verificationUriComplete,expiresIn,interval}>}
70
+ * @throws {Error} 网络失败 / 响应非法
71
+ */
72
+ async function requestDeviceCode(server, options = {}) {
73
+ const normalized = normalizeServerUrl(server);
74
+ if (!normalized) throw new Error('server must be http/https URL');
75
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
76
+ ? options.timeoutMs
77
+ : DEFAULT_TIMEOUT_MS;
78
+ const payload = {
79
+ clientName: typeof options.clientName === 'string' && options.clientName
80
+ ? options.clientName
81
+ : 'kld-sdd',
82
+ clientVersion: typeof options.clientVersion === 'string' && options.clientVersion
83
+ ? options.clientVersion
84
+ : 'unknown',
85
+ };
86
+ const result = await postJson(`${normalized}/api/v1/auth/device/code`, payload, { timeoutMs });
87
+ if (result.statusCode == null) {
88
+ throw new Error(`device code request failed: ${result.error ? result.error.message : 'network error'}`);
89
+ }
90
+ if (result.statusCode < 200 || result.statusCode >= 300) {
91
+ throw new Error(`device code request HTTP ${result.statusCode}`);
92
+ }
93
+ // 兼容两种格式:直接 data 或 ApiResponse 信封
94
+ let data = result.body;
95
+ if (result.body && typeof result.body === 'object' && typeof result.body.code === 'number') {
96
+ data = unwrapApiResponse(result.body);
97
+ }
98
+ const parsed = parseDeviceCodeResponse(data);
99
+ if (!parsed) throw new Error('device code response missing required fields (deviceCode/userCode/verificationUri)');
100
+ return parsed;
101
+ }
102
+
103
+ /**
104
+ * 轮询 Device Flow token,直到 authorized / expired / denied / aborted / error。
105
+ *
106
+ * @param {string} server — 已规范化 server URL
107
+ * @param {string} deviceCode
108
+ * @param {{intervalMs?:number,expiresInMs?:number,timeoutMs?:number,signal?:AbortSignal,onPending?:Function}} [options]
109
+ * @returns {Promise<{kind:'authorized',token:string,user:*}|{kind:'expired'}|{kind:'denied'}|{kind:'aborted'}|{kind:'error',message?:string}>}
110
+ */
111
+ async function pollDeviceToken(server, deviceCode, options = {}) {
112
+ const normalized = normalizeServerUrl(server);
113
+ if (!normalized) return { kind: 'error', message: 'invalid server' };
114
+ const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0
115
+ ? options.intervalMs
116
+ : DEFAULT_POLL_INTERVAL_MS;
117
+ const expiresInMs = Number.isFinite(options.expiresInMs) && options.expiresInMs > 0
118
+ ? options.expiresInMs
119
+ : DEFAULT_EXPIRES_IN_MS;
120
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
121
+ ? options.timeoutMs
122
+ : DEFAULT_TIMEOUT_MS;
123
+ const signal = options.signal;
124
+ const onPending = typeof options.onPending === 'function' ? options.onPending : null;
125
+
126
+ const startedAt = Date.now();
127
+ const deadline = startedAt + expiresInMs;
128
+ let currentInterval = intervalMs;
129
+ let consecutiveNetworkErrors = 0;
130
+ const MAX_CONSECUTIVE_NETWORK_ERRORS = 5;
131
+
132
+ // 主循环
133
+ // eslint-disable-next-line no-constant-condition
134
+ while (true) {
135
+ if (signal && signal.aborted) return { kind: 'aborted' };
136
+ if (Date.now() >= deadline) return { kind: 'expired' };
137
+
138
+ const result = await postJson(
139
+ `${normalized}/api/v1/auth/device/poll`,
140
+ { deviceCode },
141
+ { timeoutMs: Math.min(timeoutMs, Math.max(1, deadline - Date.now())) },
142
+ );
143
+ if (signal && signal.aborted) return { kind: 'aborted' };
144
+
145
+ if (result.statusCode == null) {
146
+ consecutiveNetworkErrors += 1;
147
+ if (consecutiveNetworkErrors >= MAX_CONSECUTIVE_NETWORK_ERRORS) {
148
+ return { kind: 'error', message: result.error ? result.error.message : 'network error' };
149
+ }
150
+ } else {
151
+ consecutiveNetworkErrors = 0;
152
+ }
153
+
154
+ // 平台可能返回成功信封({code:0,message,data}):先解包再分类,
155
+ // 与 requestDeviceCode 的信封处理对齐;非 0 code 或裸契约响应保持原样,
156
+ // 交给分类器走 RFC 8628 error 字段等防御路径(向后兼容)。
157
+ let pollBody = result.body;
158
+ if (pollBody && typeof pollBody === 'object' && pollBody.code === 0) {
159
+ try {
160
+ pollBody = unwrapApiResponse(pollBody);
161
+ } catch {
162
+ pollBody = result.body;
163
+ }
164
+ }
165
+ const classified = classifyDevicePollResponse(result.statusCode, pollBody);
166
+ if (classified.kind === 'authorized') {
167
+ return { kind: 'authorized', token: classified.token, user: classified.user };
168
+ }
169
+ if (classified.kind === 'expired') return { kind: 'expired' };
170
+ if (classified.kind === 'denied') return { kind: 'denied' };
171
+ if (classified.kind === 'slow_down') {
172
+ currentInterval = Math.min(currentInterval + 1000, 10000);
173
+ }
174
+ if (classified.kind === 'error' && result.statusCode != null && result.statusCode >= 500) {
175
+ // 5xx 当网络错误处理,继续
176
+ } else if (classified.kind === 'error') {
177
+ return { kind: 'error', message: classified.message };
178
+ }
179
+ // pending / slow_down / 5xx 都继续轮询
180
+ if (onPending) {
181
+ try { onPending({ kind: classified.kind, intervalMs: currentInterval }); } catch { /* ignore */ }
182
+ }
183
+ const remaining = deadline - Date.now();
184
+ if (remaining <= 0) return { kind: 'expired' };
185
+ const sleepMs = Math.min(currentInterval, remaining);
186
+ await sleep(sleepMs, signal);
187
+ if (signal && signal.aborted) return { kind: 'aborted' };
188
+ }
189
+ }
190
+
191
+ function sleep(ms, signal) {
192
+ return new Promise((resolve) => {
193
+ const timer = setTimeout(done, ms);
194
+ function done() {
195
+ if (signal) signal.removeEventListener('abort', done);
196
+ clearTimeout(timer);
197
+ resolve();
198
+ }
199
+ if (signal) {
200
+ if (signal.aborted) return done();
201
+ signal.addEventListener('abort', done, { once: true });
202
+ }
203
+ });
204
+ }
205
+
206
+ /**
207
+ * 构造各平台打开浏览器的命令(纯函数,便于测试)。
208
+ * win32 用 PowerShell Start-Process(ShellExecute 打开默认浏览器):
209
+ * - cmd /c start:部分机器静默失败,退出码 0 但浏览器未开(探测服务器无请求);
210
+ * - explorer.exe <url>:URL 含 ? query 时解析失败,退而弹出文件夹窗口(真机复现);
211
+ * - Start-Process 经探测验证可完整传递带 query 的 URL。
212
+ * 用 -EncodedCommand(UTF-16LE base64)传参,Node→CreateProcess→PowerShell 全程无引号转义问题。
213
+ * @param {'win32'|'darwin'|string} platform
214
+ * @param {string} url
215
+ * @returns {{cmd:string,args:string[]}}
216
+ */
217
+ function buildOpenCommand(platform, url) {
218
+ const target = String(url);
219
+ if (platform === 'win32') {
220
+ // 单引号在 PowerShell 单引号字符串中用 '' 转义;URL 正常不含单引号,防御处理
221
+ const ps = `Start-Process '${target.replace(/'/g, "''")}'`;
222
+ const encoded = Buffer.from(ps, 'utf16le').toString('base64');
223
+ return { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded] };
224
+ }
225
+ if (platform === 'darwin') return { cmd: 'open', args: [target] };
226
+ return { cmd: 'xdg-open', args: [target] };
227
+ }
228
+
229
+ /**
230
+ * 打开浏览器(best-effort)。任何失败均不抛错,返回 false 让调用方走打印降级。
231
+ * @param {string} url
232
+ * @returns {Promise<boolean>}
233
+ */
234
+ async function openSystemBrowser(url) {
235
+ if (typeof url !== 'string' || !url) return false;
236
+ const platform = process.platform;
237
+ const { cmd, args } = buildOpenCommand(platform, url);
238
+ return new Promise((resolve) => {
239
+ let child;
240
+ try {
241
+ // win32 不能 detached:DETACHED_PROCESS(无控制台)下 PowerShell Start-Process
242
+ // 会静默失败——退出码 0 但浏览器未开(真机矩阵验证)。子进程秒退 + unref,
243
+ // 不依赖 detached 也能让父进程正常退出。
244
+ child = spawn(cmd, args, { detached: platform !== 'win32', stdio: 'ignore' });
245
+ } catch {
246
+ resolve(false);
247
+ return;
248
+ }
249
+ let settled = false;
250
+ const finish = (ok) => {
251
+ if (settled) return;
252
+ settled = true;
253
+ resolve(ok);
254
+ };
255
+ child.on('error', () => finish(false));
256
+ child.on('close', (code) => finish(code === 0));
257
+ child.unref();
258
+ // 浏览器启动命令通常立刻返回;超过 3s 未返回视为已脱手
259
+ setTimeout(() => finish(true), 3000).unref();
260
+ });
261
+ }
262
+
263
+ /**
264
+ * 完整 Device Flow 编排。
265
+ *
266
+ * @param {object} options
267
+ * @param {string} options.server — 已规范化 server URL(必需)
268
+ * @param {string} [options.clientName]
269
+ * @param {string} [options.clientVersion]
270
+ * @param {string} [options.homeDir] — 注入 home(测试用)
271
+ * @param {Function} [options.openUrl] — 自定义浏览器打开;返回 Promise<boolean>
272
+ * @param {Function} [options.onPrint] — 输出提示(默认 console.log)
273
+ * @param {number} [options.intervalMs]
274
+ * @param {number} [options.expiresInMs]
275
+ * @param {AbortSignal} [options.signal]
276
+ * @returns {Promise<{status:'bound'|'expired'|'denied'|'aborted'|'error',reason?:string,config?:object}>}
277
+ */
278
+ async function runDeviceFlow(options = {}) {
279
+ const normalized = normalizeServerUrl(options.server || '');
280
+ const onPrint = typeof options.onPrint === 'function' ? options.onPrint : () => {};
281
+ if (!normalized) {
282
+ return { status: 'error', reason: 'server must be http/https URL' };
283
+ }
284
+ const openUrl = typeof options.openUrl === 'function' ? options.openUrl : openSystemBrowser;
285
+
286
+ let code;
287
+ try {
288
+ code = await requestDeviceCode(normalized, options);
289
+ } catch (err) {
290
+ return { status: 'error', reason: String(err && err.message || err) };
291
+ }
292
+
293
+ // 打开浏览器;失败时打印降级
294
+ let opened = false;
295
+ try {
296
+ opened = await openUrl(code.verificationUriComplete || code.verificationUri);
297
+ } catch {
298
+ opened = false;
299
+ }
300
+ if (!opened) {
301
+ onPrint(`请访问以下链接完成授权:`);
302
+ onPrint(` ${code.verificationUriComplete || code.verificationUri}`);
303
+ onPrint(`验证码:${code.userCode}`);
304
+ }
305
+
306
+ const pollResult = await pollDeviceToken(normalized, code.deviceCode, {
307
+ intervalMs: Number.isFinite(options.intervalMs) ? options.intervalMs : code.interval * 1000,
308
+ expiresInMs: Number.isFinite(options.expiresInMs) ? options.expiresInMs : code.expiresIn * 1000,
309
+ signal: options.signal,
310
+ });
311
+
312
+ if (pollResult.kind === 'aborted') return { status: 'aborted' };
313
+ if (pollResult.kind === 'expired') return { status: 'expired' };
314
+ if (pollResult.kind === 'denied') return { status: 'denied' };
315
+ if (pollResult.kind === 'error') return { status: 'error', reason: pollResult.message || 'poll error' };
316
+
317
+ // authorized:原子写入配置
318
+ const existing = readUserConfig(options.homeDir) || {};
319
+ const displayName = pollResult.user && typeof pollResult.user === 'object'
320
+ ? (pollResult.user.displayName || pollResult.user.userName || pollResult.user.userId || null)
321
+ : null;
322
+ const next = Object.assign({}, existing, {
323
+ version: USER_CONFIG_VERSION,
324
+ server: normalized,
325
+ token: pollResult.token,
326
+ userName: displayName || existing.userName || null,
327
+ boundAt: new Date().toISOString(),
328
+ });
329
+ try {
330
+ writeUserConfig(next, options.homeDir);
331
+ } catch (err) {
332
+ return { status: 'error', reason: `write config failed: ${err && err.message || err}` };
333
+ }
334
+ return { status: 'bound', config: next };
335
+ }
336
+
337
+ module.exports = {
338
+ resolveServer,
339
+ requestDeviceCode,
340
+ pollDeviceToken,
341
+ runDeviceFlow,
342
+ openSystemBrowser,
343
+ buildOpenCommand,
344
+ DEFAULT_SERVER_URL,
345
+ };
@@ -0,0 +1,108 @@
1
+ // kld-B1 — init 向导平台账号绑定(Device Flow)编排
2
+ // 职责:已绑定提示更新 / 未绑定引导授权 / 跳过写仅 server 配置 / 失败不阻塞 init。
3
+ // 依赖注入(runDeviceFlow/readUserConfig/writeUserConfig/resolveServer/ask/onPrint)便于单测 mock。
4
+ // 提示语不出现“使用统计”字样(B1 评审要求)。
5
+ 'use strict';
6
+
7
+ const deviceAuth = require('./device-auth');
8
+ const userConfig = require('../skywalk-sdd/lib/user-config.cjs');
9
+
10
+ const LATER_LOGIN_HINT = '稍后运行 npx kld-sdd auth login 绑定平台账号';
11
+ const DEFAULT_SERVER_URL = deviceAuth.DEFAULT_SERVER_URL;
12
+
13
+ function defaultPrint(line) {
14
+ console.log(line);
15
+ }
16
+
17
+ /**
18
+ * 账号绑定向导。
19
+ *
20
+ * @param {object} options
21
+ * @param {Function} [options.ask] — 交互提问(question)=> Promise<answer>;非交互环境可不传
22
+ * @param {boolean} [options.skipAuth] — --skip-auth:不询问,打印稍后绑定提示
23
+ * @param {Function} [options.isInteractive] — 是否交互终端(默认按 stdin.isTTY 判定)
24
+ * @param {Function} [options.onPrint] — 输出(默认 console.log)
25
+ * @param {Function} [options.readUserConfig] — 默认 skywalk-sdd/lib/user-config.cjs
26
+ * @param {Function} [options.writeUserConfig]
27
+ * @param {Function} [options.resolveServer] — 默认 lib/device-auth.js
28
+ * @param {Function} [options.runDeviceFlow]
29
+ * @param {string} [options.homeDir] — 注入 home(测试用)
30
+ * @param {AbortSignal} [options.signal] — Ctrl+C 等外部取消信号
31
+ * @returns {Promise<{status:'bound'|'kept'|'skipped'|'failed'|'deferred',userName?:string,config?:object,reason?:string}>}
32
+ */
33
+ async function runAccountBinding(options = {}) {
34
+ const print = typeof options.onPrint === 'function' ? options.onPrint : defaultPrint;
35
+ const readCfg = typeof options.readUserConfig === 'function' ? options.readUserConfig : userConfig.readUserConfig;
36
+ const writeCfg = typeof options.writeUserConfig === 'function' ? options.writeUserConfig : userConfig.writeUserConfig;
37
+ const resolveSrv = typeof options.resolveServer === 'function' ? options.resolveServer : deviceAuth.resolveServer;
38
+ const runFlow = typeof options.runDeviceFlow === 'function' ? options.runDeviceFlow : deviceAuth.runDeviceFlow;
39
+ const isInteractive = typeof options.isInteractive === 'function'
40
+ ? options.isInteractive
41
+ : () => Boolean(process.stdin && process.stdin.isTTY);
42
+ const homeDir = options.homeDir;
43
+
44
+ // --skip-auth 或 stdin 非 TTY:不询问,打印一行稍后绑定提示
45
+ if (options.skipAuth || !isInteractive() || typeof options.ask !== 'function') {
46
+ print(`🔗 ${LATER_LOGIN_HINT}`);
47
+ return { status: 'deferred' };
48
+ }
49
+ const ask = options.ask;
50
+
51
+ const existing = readCfg(homeDir);
52
+ if (existing && existing.token) {
53
+ const boundDate = typeof existing.boundAt === 'string' && existing.boundAt
54
+ ? existing.boundAt.slice(0, 10)
55
+ : '未知日期';
56
+ const answer = String(await ask(`? 已绑定账号:${existing.userName || '未知用户'}(${boundDate} 绑定)。是否需要更新用户绑定?(y/N) `))
57
+ .trim().toLowerCase();
58
+ if (answer !== 'y' && answer !== 'yes') {
59
+ print(`✓ 保留现有绑定:${existing.userName || '未知用户'}`);
60
+ return { status: 'kept', userName: existing.userName || null, config: existing };
61
+ }
62
+ }
63
+
64
+ const bindAnswer = String(await ask('? 是否绑定平台账号?(Y/n) ')).trim().toLowerCase();
65
+ if (bindAnswer === 'n' || bindAnswer === 'no') {
66
+ // 选择不绑定/跳过:写仅含 server 的用户级 config(resolveServer 无结果则不写)
67
+ const server = resolveSrv({ homeDir });
68
+ if (server) {
69
+ try {
70
+ writeCfg({ version: userConfig.USER_CONFIG_VERSION, server }, homeDir);
71
+ } catch (err) {
72
+ print(`⚠️ 写入用户配置失败(${err && err.message ? err.message : err}),已跳过`);
73
+ }
74
+ }
75
+ print(`🔗 ${LATER_LOGIN_HINT}`);
76
+ return { status: 'skipped' };
77
+ }
78
+
79
+ let server = resolveSrv({ homeDir });
80
+ if (!server) {
81
+ const input = String(await ask(`? 请输入平台地址(http/https,回车默认 ${DEFAULT_SERVER_URL}):`)).trim();
82
+ server = input || DEFAULT_SERVER_URL;
83
+ }
84
+
85
+ print('请在浏览器完成授权,等待中…(按 Ctrl+C 取消)');
86
+ const result = await runFlow({ server, homeDir, onPrint: print, signal: options.signal });
87
+ if (result && result.status === 'bound') {
88
+ const name = result.config && result.config.userName ? result.config.userName : '未知用户';
89
+ print(`✓ 已绑定:${name}`);
90
+ return { status: 'bound', userName: name, config: result.config };
91
+ }
92
+
93
+ const status = result && result.status;
94
+ const hints = {
95
+ expired: '⚠️ 授权已过期,未完成绑定',
96
+ denied: '⚠️ 授权被拒绝,未完成绑定',
97
+ aborted: '⚠️ 已取消绑定',
98
+ };
99
+ print(hints[status] || `⚠️ 绑定失败${result && result.reason ? `:${result.reason}` : ''}`);
100
+ print(`🔗 ${LATER_LOGIN_HINT}`);
101
+ return { status: 'failed', reason: status || 'error' };
102
+ }
103
+
104
+ module.exports = {
105
+ runAccountBinding,
106
+ LATER_LOGIN_HINT,
107
+ DEFAULT_SERVER_URL,
108
+ };