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.
@@ -1,345 +0,0 @@
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
- };
@@ -1,108 +0,0 @@
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
- };