@besile/scm-cli 2026.3.29

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.
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 WFS
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * @deprecated Import from src/internal/errors/scm-errors.js instead
7
+ */
8
+ export {
9
+ ScmError,
10
+ ScmAuthError,
11
+ ScmTokenExpiredError,
12
+ ScmTokenInvalidError,
13
+ ScmUserNotLoggedInError,
14
+ ScmApiError,
15
+ ScmTimeoutError,
16
+ ScmNetworkError,
17
+ ScmParamError,
18
+ ScmPermissionDeniedError,
19
+ SCM_ERROR,
20
+ } from '../internal/errors/scm-errors.js';
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 WFS
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * @deprecated Import from src/internal/output/logger.js instead
7
+ */
8
+ export {
9
+ scmLogger,
10
+ setRuntimeLoggerFactory,
11
+ setLogLevel,
12
+ getLogLevel,
13
+ coreLogger,
14
+ tokenLogger,
15
+ clientLogger,
16
+ loginLogger,
17
+ ordersLogger,
18
+ } from '../internal/output/logger.js';
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 WFS
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * 统一认证模块 - 处理 Token 读取、登录验证和交互式登录
7
+ */
8
+
9
+ import { createInterface } from 'node:readline/promises';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ const TOKEN_FILE = join(homedir(), '.scm_cache', 'user_tokens.json');
15
+
16
+ /**
17
+ * 获取缓存的 Token
18
+ *
19
+ * @param {string} openId - 飞书用户 openId
20
+ * @returns {Promise<string>} Token 字符串
21
+ * @throws {Error} Token 不存在或已过期
22
+ */
23
+ export async function getCachedToken(openId) {
24
+ try {
25
+ const data = await readFile(TOKEN_FILE, 'utf-8');
26
+ const tokens = JSON.parse(data);
27
+ const userToken = tokens[openId];
28
+
29
+ if (!userToken) {
30
+ throw new Error(`未找到 openId=${openId} 的缓存 Token,请先登录`);
31
+ }
32
+
33
+ const now = Date.now() / 1000;
34
+ if (userToken.expires_at <= now) {
35
+ throw new Error('Token 已过期,请重新登录');
36
+ }
37
+
38
+ return userToken.access_token;
39
+ } catch (error) {
40
+ if (error.code === 'ENOENT') {
41
+ throw new Error('未找到 Token 缓存,请先登录');
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 检查 Token 是否有效(不抛出异常)
49
+ *
50
+ * @param {string} openId - 飞书用户 openId
51
+ * @returns {Promise<{valid: boolean, message: string}>} 检查结果
52
+ */
53
+ export async function checkToken(openId) {
54
+ try {
55
+ const data = await readFile(TOKEN_FILE, 'utf-8');
56
+ const tokens = JSON.parse(data);
57
+ const userToken = tokens[openId];
58
+
59
+ if (!userToken) {
60
+ return {
61
+ valid: false,
62
+ message: `未找到 openId=${openId} 的缓存 Token`,
63
+ };
64
+ }
65
+
66
+ const now = Date.now() / 1000;
67
+ if (userToken.expires_at <= now) {
68
+ return {
69
+ valid: false,
70
+ message: 'Token 已过期,请重新登录',
71
+ };
72
+ }
73
+
74
+ return { valid: true, message: 'Token 有效' };
75
+ } catch (error) {
76
+ if (error.code === 'ENOENT') {
77
+ return {
78
+ valid: false,
79
+ message: '未找到 Token 缓存文件,请先登录',
80
+ };
81
+ }
82
+ return {
83
+ valid: false,
84
+ message: `检查 Token 时出错: ${error.message}`,
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Token 文件路径
91
+ */
92
+ export const TOKEN_FILE_PATH = TOKEN_FILE;
93
+
94
+ /**
95
+ * 打印未登录提示信息
96
+ *
97
+ * @param {string} openId - 飞书用户 openId
98
+ */
99
+ export function printLoginHint(openId) {
100
+ console.error('\n⚠️ 登录验证失败');
101
+ console.error(` 未找到 openId=${openId} 的缓存 Token`);
102
+ console.error('\n请先执行登录命令:');
103
+ console.error(` scm-cli auth login --openId ${openId} --mobile <手机号> --password <密码>`);
104
+ console.error('\n或查看已缓存的 Token:');
105
+ console.error(` scm-cli auth token show ${openId}`);
106
+ }
107
+
108
+ /**
109
+ * 交互式登录 - 提示用户输入手机号和密码
110
+ *
111
+ * @param {string} openId - 飞书用户 openId
112
+ * @returns {Promise<{success: boolean, mobile?: string, password?: string}>} 用户输入的凭证
113
+ */
114
+ export async function promptLogin(openId) {
115
+ console.log('\n📱 请输入您的登录信息:');
116
+ console.log(` openId: ${openId}\n`);
117
+
118
+ const rl = createInterface({
119
+ input: process.stdin,
120
+ output: process.stdout,
121
+ });
122
+
123
+ try {
124
+ const mobile = await rl.question(' 手机号: ');
125
+ const password = await rl.question(' 密码: ', { hideEchoBack: true });
126
+
127
+ console.log(); // 换行
128
+
129
+ if (!mobile.trim() || !password.trim()) {
130
+ console.error('❌ 手机号和密码不能为空');
131
+ return { success: false };
132
+ }
133
+
134
+ return { success: true, mobile: mobile.trim(), password: password.trim() };
135
+ } finally {
136
+ rl.close();
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 需要认证的命令装饰器
142
+ *
143
+ * @param {Function} handler - 命令处理函数
144
+ * @returns {Function} 包装后的处理函数
145
+ */
146
+ export function withAuth(handler) {
147
+ return async function (opts) {
148
+ const tokenCheck = await checkToken(opts.openId);
149
+ if (!tokenCheck.valid) {
150
+ printLoginHint(opts.openId);
151
+ process.exit(1);
152
+ }
153
+ return handler(opts);
154
+ };
155
+ }
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 WFS
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * SCM Client - 供应链管理系统统一客户端
7
+ *
8
+ * 参照 openclaw-lark 的 ToolClient 模式实现:
9
+ * - 单例模式管理客户端实例
10
+ * - Token 自动获取与刷新
11
+ * - 请求重试与超时控制
12
+ * - 统一错误处理
13
+ */
14
+
15
+ import { createCipheriv, randomBytes } from 'node:crypto';
16
+ import { promises as fs } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import {
20
+ ScmError,
21
+ ScmAuthError,
22
+ ScmTokenExpiredError,
23
+ ScmTokenInvalidError,
24
+ ScmApiError,
25
+ ScmTimeoutError,
26
+ ScmNetworkError,
27
+ SCM_ERROR,
28
+ } from '../errors/scm-errors.js';
29
+ import { clientLogger, tokenLogger } from '../output/logger.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Constants
33
+ // ---------------------------------------------------------------------------
34
+ const DEFAULT_BASE_URL = 'https://fbscmtest.cogo.club';
35
+ const DEFAULT_TIMEOUT_MS = 120000; // 2 分钟
36
+ const DEFAULT_RETRY_COUNT = 2;
37
+ // Token 提前刷新时间(秒),默认 12 小时
38
+ // 可通过环境变量 SCM_TOKEN_BUFFER_SECONDS 覆盖
39
+ const DEFAULT_TOKEN_BUFFER_SEC = parseInt(process.env.SCM_TOKEN_BUFFER_SECONDS || '43200', 10);
40
+ const CACHE_DIR = join(homedir(), '.scm_cache');
41
+ const TOKEN_FILE = join(CACHE_DIR, 'user_tokens.json');
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // AES Encryption (for password)
45
+ // ---------------------------------------------------------------------------
46
+ function getAesKey() {
47
+ const key = process.env.SCM_AES_KEY;
48
+ if (!key) {
49
+ // 返回默认值,这样插件可以正常加载
50
+ return Buffer.from('8B4D23539C63D58D', 'ascii');
51
+ }
52
+ if (key.length !== 16) {
53
+ throw new ScmAuthError('环境变量 SCM_AES_KEY 长度必须为 16 字符');
54
+ }
55
+ return Buffer.from(key, 'ascii');
56
+ }
57
+
58
+ function getAesIv() {
59
+ const iv = process.env.SCM_AES_IV;
60
+ if (!iv) {
61
+ // 返回默认值,这样插件可以正常加载
62
+ return Buffer.from('826BFC61FE22DE18', 'ascii');
63
+ }
64
+ if (iv.length !== 16) {
65
+ throw new ScmAuthError('环境变量 SCM_AES_IV 长度必须为 16 字符');
66
+ }
67
+ return Buffer.from(iv, 'ascii');
68
+ }
69
+
70
+ function aesEncrypt(data) {
71
+ const key = getAesKey();
72
+ const iv = getAesIv();
73
+ const cipher = createCipheriv('aes-128-cbc', key, iv);
74
+ const dataBytes = Buffer.from(data, 'utf-8');
75
+ const encrypted = Buffer.concat([cipher.update(dataBytes), cipher.final()]);
76
+ return encrypted.toString('base64');
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Token Manager (per-user)
81
+ // ---------------------------------------------------------------------------
82
+ class TokenManager {
83
+ constructor(baseUrl) {
84
+ this.baseUrl = baseUrl.replace(/\/$/, '');
85
+ this._cache = {};
86
+ this._initialized = false;
87
+ }
88
+
89
+ async _ensureInitialized() {
90
+ if (this._initialized) return;
91
+ await this._loadAllTokens();
92
+ this._initialized = true;
93
+ }
94
+
95
+ async _loadAllTokens() {
96
+ try {
97
+ await fs.mkdir(CACHE_DIR, { recursive: true });
98
+ const data = await fs.readFile(TOKEN_FILE, 'utf-8');
99
+ this._cache = JSON.parse(data);
100
+ tokenLogger.debug('Token 缓存加载成功', { count: Object.keys(this._cache).length });
101
+ } catch (error) {
102
+ if (error.code !== 'ENOENT') {
103
+ tokenLogger.warn('Token 缓存加载失败', { error: error.message });
104
+ }
105
+ this._cache = {};
106
+ }
107
+ }
108
+
109
+ async _saveAllTokens() {
110
+ try {
111
+ await fs.mkdir(CACHE_DIR, { recursive: true });
112
+ await fs.writeFile(TOKEN_FILE, JSON.stringify(this._cache, null, 2), 'utf-8');
113
+ if (process.platform !== 'win32') {
114
+ await fs.chmod(TOKEN_FILE, 0o600);
115
+ }
116
+ } catch (error) {
117
+ tokenLogger.error('保存 Token 失败', { error: error.message });
118
+ }
119
+ }
120
+
121
+ async getToken(openId, username, password) {
122
+ if (!openId) {
123
+ throw new ScmAuthError('必须提供飞书 open_id');
124
+ }
125
+
126
+ await this._ensureInitialized();
127
+
128
+ const userData = this._cache[openId];
129
+ const now = Date.now() / 1000;
130
+
131
+ if (userData && (userData.expires_at > now + DEFAULT_TOKEN_BUFFER_SEC)) {
132
+ tokenLogger.debug('使用缓存 Token', { openId });
133
+ return userData.access_token;
134
+ }
135
+
136
+ if (!username || !password) {
137
+ throw new ScmTokenExpiredError('用户 Token 已过期,需要提供 SCM 账号和密码重新登录', { openId });
138
+ }
139
+
140
+ tokenLogger.info('Token 失效,正在重新登录', { openId, username });
141
+ const newTokenInfo = await this._performLogin(username, password);
142
+
143
+ this._cache[openId] = {
144
+ access_token: newTokenInfo.access_token,
145
+ expires_at: newTokenInfo.expires_at,
146
+ user_id: newTokenInfo.user_id || '',
147
+ username: newTokenInfo.username || username,
148
+ last_login: now,
149
+ };
150
+
151
+ await this._saveAllTokens();
152
+ tokenLogger.info('登录成功,Token 已更新', { openId });
153
+
154
+ return newTokenInfo.access_token;
155
+ }
156
+
157
+ async _performLogin(username, password) {
158
+ const encryptedPwd = aesEncrypt(password);
159
+
160
+ // 记录登录信息用于调试
161
+ clientLogger.info('登录参数', {
162
+ username,
163
+ passwordLength: password ? password.length : 0,
164
+ encryptedPassword: encryptedPwd,
165
+ aesKey: getAesKey().toString('hex'),
166
+ aesIv: getAesIv().toString('hex'),
167
+ });
168
+
169
+ const loginUrl = `${this.baseUrl}/admin-api/system/auth/login`;
170
+
171
+ const payload = {
172
+ mobile: username,
173
+ password: encryptedPwd,
174
+ };
175
+
176
+ clientLogger.debug('发送登录请求', { url: loginUrl, username, payload });
177
+
178
+ const response = await fetch(loginUrl, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify(payload),
182
+ });
183
+
184
+ const responseText = await response.text();
185
+ let data;
186
+
187
+ try {
188
+ data = JSON.parse(responseText);
189
+ } catch {
190
+ throw new ScmApiError(`响应格式错误,无法解析 JSON: ${responseText.substring(0, 200)}`);
191
+ }
192
+
193
+ if (!response.ok) {
194
+ throw new ScmNetworkError(`HTTP 错误 ${response.status}: ${response.statusText}`);
195
+ }
196
+
197
+ if (data.code === 200 || data.success === true) {
198
+ const tokenInfo = data.data || {};
199
+ const accessToken = tokenInfo.access_token || tokenInfo.token;
200
+ const expiresIn = tokenInfo.expires_in || 12 * 60 * 60;
201
+
202
+ if (!accessToken) {
203
+ throw new ScmAuthError('登录成功但未返回 access_token');
204
+ }
205
+
206
+ clientLogger.info('登录成功', { username, expiresIn });
207
+
208
+ return {
209
+ access_token: accessToken,
210
+ expires_at: Date.now() / 1000 + expiresIn,
211
+ user_id: tokenInfo.userId || '',
212
+ username: tokenInfo.username || username,
213
+ };
214
+ } else {
215
+ throw new ScmAuthError(`登录失败:${data.msg || `错误码: ${data.code}`}`, data.code);
216
+ }
217
+ }
218
+
219
+ async clearUserToken(openId) {
220
+ await this._ensureInitialized();
221
+ if (this._cache[openId]) {
222
+ delete this._cache[openId];
223
+ await this._saveAllTokens();
224
+ tokenLogger.info('用户 Token 已清除', { openId });
225
+ }
226
+ }
227
+
228
+ getUserId(openId) {
229
+ const userData = this._cache[openId] || {};
230
+ return userData.user_id || '';
231
+ }
232
+
233
+ getUsername(openId) {
234
+ const userData = this._cache[openId] || {};
235
+ return userData.username || '';
236
+ }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // ScmClient
241
+ // ---------------------------------------------------------------------------
242
+ export class ScmClient {
243
+ constructor() {
244
+ this._baseUrl = DEFAULT_BASE_URL;
245
+ this._tokenManager = null;
246
+ this._runtime = null;
247
+ }
248
+
249
+ static getInstance() {
250
+ if (!ScmClient.instance) {
251
+ ScmClient.instance = new ScmClient();
252
+ }
253
+ return ScmClient.instance;
254
+ }
255
+
256
+ setRuntime(runtime) {
257
+ this._runtime = runtime;
258
+ clientLogger.info('Runtime 已设置', { hasRuntime: !!runtime });
259
+ }
260
+
261
+ setBaseUrl(url) {
262
+ this._baseUrl = url;
263
+ clientLogger.info('Base URL 已更新', { baseUrl: url });
264
+ }
265
+
266
+ getBaseUrl() {
267
+ return this._baseUrl;
268
+ }
269
+
270
+ getTokenManager() {
271
+ if (!this._tokenManager) {
272
+ this._tokenManager = new TokenManager(this._baseUrl);
273
+ }
274
+ return this._tokenManager;
275
+ }
276
+
277
+ /**
278
+ * 获取用户 Token
279
+ */
280
+ async getToken(openId, username, password) {
281
+ return this.getTokenManager().getToken(openId, username, password);
282
+ }
283
+
284
+ /**
285
+ * 执行 API 请求
286
+ * @param {string} endpoint - API 端点
287
+ * @param {Object} options - 请求选项
288
+ * @param {Object} context - 请求上下文(包含 token)
289
+ */
290
+ async invoke(endpoint, options = {}, context = {}) {
291
+ const { method = 'POST', body, timeout = DEFAULT_TIMEOUT_MS, retry = DEFAULT_RETRY_COUNT } = options;
292
+ const { token, headers = {} } = context;
293
+
294
+ const url = `${this._baseUrl}${endpoint}`;
295
+ const requestHeaders = {
296
+ 'Content-Type': 'application/json',
297
+ ...headers,
298
+ };
299
+
300
+ if (token) {
301
+ requestHeaders['Authorization'] = `Bearer ${token}`;
302
+ }
303
+
304
+ let lastError;
305
+ for (let attempt = 0; attempt <= retry; attempt++) {
306
+ try {
307
+ const result = await this._doRequest(url, {
308
+ method,
309
+ headers: requestHeaders,
310
+ body,
311
+ timeout,
312
+ });
313
+ return result;
314
+ } catch (error) {
315
+ lastError = error;
316
+
317
+ // 判断是否应该重试
318
+ if (!this._shouldRetry(error, attempt, retry)) {
319
+ throw error;
320
+ }
321
+
322
+ clientLogger.warn('请求失败,准备重试', {
323
+ endpoint,
324
+ attempt: attempt + 1,
325
+ maxRetries: retry,
326
+ error: error.message,
327
+ });
328
+ }
329
+ }
330
+
331
+ throw lastError;
332
+ }
333
+
334
+ async _doRequest(url, options) {
335
+ const { method, headers, body, timeout } = options;
336
+
337
+ clientLogger.debug('发送请求', { method, url });
338
+
339
+ const controller = new AbortController();
340
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
341
+
342
+ let response;
343
+ try {
344
+ response = await fetch(url, {
345
+ method,
346
+ headers,
347
+ body: body ? JSON.stringify(body) : undefined,
348
+ signal: controller.signal,
349
+ });
350
+ clearTimeout(timeoutId);
351
+ } catch (error) {
352
+ clearTimeout(timeoutId);
353
+ if (error.name === 'AbortError') {
354
+ throw new ScmTimeoutError('请求超时', { url, timeout });
355
+ }
356
+ throw new ScmNetworkError(`网络错误: ${error.message}`, {}, { originalError: error.message });
357
+ }
358
+
359
+ const responseText = await response.text();
360
+ clientLogger.debug('收到响应', { status: response.status, url });
361
+
362
+ let result;
363
+ try {
364
+ result = JSON.parse(responseText);
365
+ } catch {
366
+ throw new ScmApiError(`响应格式错误: ${responseText.substring(0, 200)}`);
367
+ }
368
+
369
+ // 检查业务状态码
370
+ if (result.code !== 0 && result.code !== 200 && result.success !== true) {
371
+ // 检查是否是 token 相关错误
372
+ if (result.code === 401 || result.code === SCM_ERROR.AUTH_TOKEN_EXPIRED) {
373
+ throw new ScmTokenExpiredError(result.msg || 'Token 已过期', { code: result.code });
374
+ }
375
+ if (result.code === SCM_ERROR.AUTH_TOKEN_INVALID) {
376
+ throw new ScmTokenInvalidError(result.msg || 'Token 无效', { code: result.code });
377
+ }
378
+
379
+ throw new ScmApiError(result.msg || `API 错误 ${result.code}`, result.code);
380
+ }
381
+
382
+ return result.data;
383
+ }
384
+
385
+ _shouldRetry(error, attempt, maxRetries) {
386
+ if (attempt >= maxRetries) return false;
387
+
388
+ // 网络错误和超时可以重试
389
+ if (error instanceof ScmNetworkError) return true;
390
+ if (error instanceof ScmTimeoutError) return true;
391
+
392
+ // Token 过期可以重试(会触发刷新)
393
+ if (error instanceof ScmTokenExpiredError) return true;
394
+
395
+ return false;
396
+ }
397
+
398
+ /**
399
+ * 清除用户 Token
400
+ */
401
+ async clearUserToken(openId) {
402
+ return this.getTokenManager().clearUserToken(openId);
403
+ }
404
+
405
+ /**
406
+ * 获取用户信息
407
+ */
408
+ getUserInfo(openId) {
409
+ const tm = this.getTokenManager();
410
+ return {
411
+ userId: tm.getUserId(openId),
412
+ username: tm.getUsername(openId),
413
+ };
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Singleton export
419
+ // ---------------------------------------------------------------------------
420
+ export const scmClient = ScmClient.getInstance();
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 WFS
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * CLI 配置加载器
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { homedir } from 'node:os';
13
+ import { existsSync } from 'node:fs';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ /**
19
+ * 加载配置
20
+ * 优先级: 环境变量 > 用户配置 > 默认值
21
+ */
22
+ export function loadConfig() {
23
+ // 环境变量
24
+ const baseUrl = process.env.SCM_BASE_URL || 'https://fbscmtest.cogo.club';
25
+ const aesKey = process.env.SCM_AES_KEY || '8B4D23539C63D58D';
26
+ const aesIv = process.env.SCM_AES_IV || '826BFC61FE22DE18';
27
+ const debug = process.env.SCM_DEBUG === 'true';
28
+
29
+ // 用户配置 (可选)
30
+ const configPath = join(homedir(), '.scmrc');
31
+ let userConfig = {};
32
+ if (existsSync(configPath)) {
33
+ try {
34
+ const content = readFile(configPath, 'utf-8');
35
+ userConfig = JSON.parse(content);
36
+ } catch {
37
+ // 忽略解析错误
38
+ }
39
+ }
40
+
41
+ return {
42
+ baseUrl,
43
+ aesKey,
44
+ aesIv,
45
+ debug,
46
+ cacheDir: join(homedir(), '.scm_cache'),
47
+ tokenFile: join(homedir(), '.scm_cache', 'user_tokens.json'),
48
+ ...userConfig,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * 创建日志函数
54
+ */
55
+ export function createLogger(debug = false) {
56
+ return {
57
+ log: (...args) => console.log(...args),
58
+ error: (...args) => console.error(...args),
59
+ debug: debug ? (...args) => console.log('[DEBUG]', ...args) : () => {},
60
+ info: (...args) => console.log('[INFO]', ...args),
61
+ };
62
+ }