@xinhe-oa/cli 0.0.0-stage → 0.2.0

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,903 @@
1
+ // 唯一的出网层。
2
+ //
3
+ // 所有请求都从这里出去,所以"每个请求该带什么"只有一处实现:
4
+ //
5
+ // - **固定 User-Agent `xinhe-oa-cli/1`**(不含版本号)。服务端把 refresh 绑在
6
+ // `HMAC(UA + X-Device-Id)` 上,UA 里出现版本号会让用户一升级 CLI 就
7
+ // `auth.device_mismatch`、被迫重新短信登录。版本号走 `X-CLI-Version`。
8
+ // - **每个请求带 `X-Device-Id`**(契约 §4)。
9
+ // - **每个请求带 `X-Request-Id`**:服务端会原样回显(`app/api/deps.py` 只接受
10
+ // `[A-Za-z0-9_.:-]` 且 ≤64 字符),于是 CLI 的 `meta.request_id` 与服务端审计里的
11
+ // 那一行是同一个值。
12
+ // - 写操作带 `Idempotency-Key`(可被 `--idempotency-key` 覆盖)。
13
+ // - 429 / 5xx / 连接失败**退避重试**。
14
+ //
15
+ // ## 为什么用 `node:http` / `node:https` 而不是 fetch / undici
16
+ //
17
+ // 1. **代理**:`fetch` 直接把环境变量里的 `*_PROXY` 当空气(undici 要显式给
18
+ // `ProxyAgent`),而客户机器上 `ALL_PROXY=socks5h://127.0.0.1:7890` 是常态。
19
+ // `node:http` 允许我们自己指定 `createConnection`,于是 HTTP 代理(CONNECT /
20
+ // 绝对 URI)与 SOCKS5 都能实现,而且**零依赖** —— 一个要装进客户机器的
21
+ // CLI,少一个依赖就少一份供应链风险。
22
+ // 2. **逐跳控制**:重定向、超时、重试要自己说了算(fetch 的 redirect/超时语义
23
+ // 与这里要的"重试的是同一个 request_id"对不上)。
24
+ //
25
+ // ## 代理(客户机器上最容易踩的坑)
26
+ //
27
+ // 环境里常见 `ALL_PROXY=socks5h://127.0.0.1:7890`(公司出口代理)。三件事:
28
+ //
29
+ // 1. **SOCKS5 要自己实现**(见 `openSocks5`):没有依赖可用;
30
+ // 2. **本机与私网地址永远直连**(回环、`.local`、RFC1918、link-local):
31
+ // 隔着代理去连 `127.0.0.1:8102` 必然连不上,而这是本地验收最常见的场景;
32
+ // 3. `config --set proxy=none`(或 `XINHE_OA_PROXY=none`)让客户在**内网直连**时
33
+ // 彻底关掉代理 —— 有些内网代理配置会静默把请求送进黑洞。
34
+
35
+ import http from 'node:http';
36
+ import https from 'node:https';
37
+ import net from 'node:net';
38
+ import tls from 'node:tls';
39
+
40
+ import { classify, CliError, Kind, loginHint } from './error.js';
41
+ import { ProxyMode } from './config.js';
42
+ import { newRequestId } from './util.js';
43
+ // 重试与告警都往 stderr 走(`output.js` 的 `warn`):**stdout 只放结果**。
44
+ import { warn } from './output.js';
45
+ import { VERSION } from '../version.js';
46
+
47
+ /** 固定 UA:**不含版本号**(见模块说明)。 */
48
+ export const USER_AGENT = 'xinhe-oa-cli/1';
49
+ /** 服务端把 refresh 的指纹绑在这个头 + UA 上。 */
50
+ export const DEVICE_HEADER = 'X-Device-Id';
51
+ /** 客户端自带的请求 id(服务端校验字符集后原样沿用)。 */
52
+ export const REQUEST_ID_HEADER = 'X-Request-Id';
53
+ /** 版本头(UA 里刻意不带版本号)。 */
54
+ export const VERSION_HEADER = 'X-CLI-Version';
55
+
56
+ /** 默认超时:连接 10s、整次请求 60s(`--timeout` 可覆盖整次请求那个)。 */
57
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
58
+ export const DEFAULT_TIMEOUT_MS = 60_000;
59
+ /** 最多重试几次(不含首次)。 */
60
+ const MAX_RETRIES = 3;
61
+ /** 重定向跟随上限(reqwest 侧是 `Policy::limited(5)`)。 */
62
+ const MAX_REDIRECTS = 5;
63
+
64
+ /** 代理策略判定结果(纯函数,便于单测)。 */
65
+ export const ProxyPlan = Object.freeze({
66
+ /** 本机/私网地址:直连,不经过任何代理。 */
67
+ DirectLoopback: 'direct_loopback',
68
+ /** 尊重环境代理。 */
69
+ System: 'system',
70
+ /** 用户显式关掉代理。 */
71
+ DirectByChoice: 'direct_by_choice',
72
+ });
73
+
74
+ /** `host` 是不是**不该走代理**的地址(回环 / 私网 / link-local / `.local`)。 */
75
+ export function isNonProxyHost(host) {
76
+ const trimmed = String(host ?? '').trim().replace(/^\[/, '').replace(/\]$/, '');
77
+ if (trimmed.length === 0) return false;
78
+ const lowered = trimmed.toLowerCase();
79
+ if (lowered === 'localhost' || lowered.endsWith('.localhost') || lowered.endsWith('.local')) {
80
+ return true;
81
+ }
82
+ // 主机名里没有点又只有数字/字母 → 内网短名(如 `api`、`oa-server`),按直连处理。
83
+ if (
84
+ !lowered.includes('.') &&
85
+ !lowered.includes(':') &&
86
+ net.isIP(lowered) === 0
87
+ ) {
88
+ return true;
89
+ }
90
+ const family = net.isIP(lowered);
91
+ if (family === 4) {
92
+ const parts = lowered.split('.').map((part) => Number(part));
93
+ const [a, b] = parts;
94
+ if (a === 127 || a === 10 || a === 0) return true;
95
+ if (a === 172 && b >= 16 && b <= 31) return true;
96
+ if (a === 192 && b === 168) return true;
97
+ if (a === 169 && b === 254) return true;
98
+ return false;
99
+ }
100
+ if (family === 6) {
101
+ const normalized = normalizeIpv6(lowered);
102
+ if (normalized === '::1' || normalized === '::') return true;
103
+ // fc00::/7(唯一本地地址)
104
+ const firstGroup = normalized.split(':')[0] ?? '';
105
+ if (firstGroup.length > 0) {
106
+ const value = Number.parseInt(firstGroup.padStart(4, '0'), 16);
107
+ if (Number.isFinite(value) && (value & 0xfe00) === 0xfc00) return true;
108
+ }
109
+ return false;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ function normalizeIpv6(address) {
115
+ // 只做够用的规范化:处理 `0:0:0:0:0:0:0:1` 与 `::1` 这两种写法。
116
+ if (address === '0:0:0:0:0:0:0:1') return '::1';
117
+ if (address === '0:0:0:0:0:0:0:0') return '::';
118
+ return address;
119
+ }
120
+
121
+ /** 给定 base_url 与代理策略,决定这一次怎么出网。 */
122
+ export function proxyPlan(baseUrl, mode) {
123
+ const host = hostOf(baseUrl) ?? '';
124
+ if (isNonProxyHost(host)) return ProxyPlan.DirectLoopback;
125
+ return mode === ProxyMode.None ? ProxyPlan.DirectByChoice : ProxyPlan.System;
126
+ }
127
+
128
+ /** 从 URL 里取主机名(只取 host 这一段,不做完整 URL 解析)。 */
129
+ export function hostOf(raw) {
130
+ const text = String(raw ?? '');
131
+ const withoutScheme = text.includes('://') ? text.slice(text.indexOf('://') + 3) : text;
132
+ let authority = withoutScheme.split(/[/?#]/)[0] ?? '';
133
+ const at = authority.lastIndexOf('@');
134
+ if (at >= 0) authority = authority.slice(at + 1);
135
+ if (authority.startsWith('[')) {
136
+ const end = authority.indexOf(']');
137
+ return end > 0 ? authority.slice(1, end) : null;
138
+ }
139
+ const host = authority.split(':')[0] ?? '';
140
+ return host.length === 0 ? null : host;
141
+ }
142
+
143
+ /** 把 base_url 规范化:补协议、去尾斜杠。 */
144
+ export function normalizeBaseUrl(raw) {
145
+ const trimmed = String(raw ?? '').trim().replace(/\/+$/, '');
146
+ return trimmed.includes('://') ? trimmed : `http://${trimmed}`;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // 环境代理解析(Node 不像 reqwest 那样自动读 *_PROXY,得自己来)
151
+ // ---------------------------------------------------------------------------
152
+ function envValue(...names) {
153
+ for (const name of names) {
154
+ const value = process.env[name];
155
+ if (value && value.trim().length > 0) return value.trim();
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /** NO_PROXY / no_proxy 匹配(支持 `*`、后缀匹配、host:port、CIDR 的常见写法)。 */
161
+ export function noProxyMatch(host, noProxyRaw) {
162
+ const raw = noProxyRaw ?? envValue('NO_PROXY', 'no_proxy');
163
+ if (!raw) return false;
164
+ const target = String(host ?? '').toLowerCase();
165
+ if (target.length === 0) return false;
166
+ for (const entryRaw of raw.split(',')) {
167
+ const entry = entryRaw.trim().toLowerCase();
168
+ if (entry.length === 0) continue;
169
+ if (entry === '*') return true;
170
+ const withoutPort = entry.includes(':') && net.isIP(entry) === 0 ? entry.split(':')[0] : entry;
171
+ const candidate = withoutPort.replace(/^\./, '');
172
+ const bare = target.includes(':') ? target.split(':')[0] : target;
173
+ if (bare === candidate || bare.endsWith(`.${candidate}`)) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * 解析要用的代理 URL。
180
+ *
181
+ * 优先级(与 curl 的习惯一致):目标 https → `HTTPS_PROXY`;否则 `HTTP_PROXY`;
182
+ * 都没有再退 `ALL_PROXY`。命中的代理若被 `NO_PROXY` 排除则直连。
183
+ */
184
+ export function resolveProxyUrl(targetUrl, mode) {
185
+ if (mode === ProxyMode.None) return null;
186
+ const parsed = new URL(targetUrl);
187
+ const isHttps = parsed.protocol === 'https:';
188
+ const candidate = isHttps
189
+ ? envValue('HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy')
190
+ : envValue('HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy');
191
+ if (!candidate) return null;
192
+ if (noProxyMatch(parsed.hostname)) return null;
193
+ if (candidate.includes('://')) return candidate;
194
+ return `http://${candidate}`;
195
+ }
196
+
197
+ /** 一次请求的可选修饰。 */
198
+ export function requestSpec(init = {}) {
199
+ return {
200
+ idempotencyKey: init.idempotencyKey ?? null,
201
+ body: init.body ?? null,
202
+ multipart: init.multipart ?? null,
203
+ query: init.query ?? [],
204
+ timeout: init.timeout ?? null,
205
+ };
206
+ }
207
+
208
+ /** multipart 表单(只支持文本字段 + 一个文件,够 `POST /attachments` 用)。 */
209
+ export function multipartBody(fields = [], file = null) {
210
+ return { fields, file };
211
+ }
212
+
213
+ /** 一次请求要带的认证方式。 */
214
+ export const Auth = Object.freeze({
215
+ none: () => ({ kind: 'none', token: null }),
216
+ bearer: (token) => ({ kind: 'bearer', token }),
217
+ });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // 客户端
221
+ // ---------------------------------------------------------------------------
222
+ export class Http {
223
+ /**
224
+ * @param {string} baseUrl
225
+ * @param {string} mode ProxyMode.System | ProxyMode.None
226
+ * @param {string} deviceId
227
+ * @param {number|null} timeoutMs 整次请求超时(毫秒)
228
+ */
229
+ constructor(baseUrl, mode, deviceId, timeoutMs = null) {
230
+ this.baseUrl = normalizeBaseUrl(baseUrl);
231
+ this.deviceId = deviceId ?? '';
232
+ this.mode = mode;
233
+ this.plan = proxyPlan(this.baseUrl, mode);
234
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
235
+ }
236
+
237
+ /** 这次出网走的是哪条路(`doctor` / `config --show` 用它解释代理行为)。 */
238
+ proxyPlan() {
239
+ return this.plan;
240
+ }
241
+
242
+ timeout() {
243
+ return this.timeoutMs;
244
+ }
245
+
246
+ /**
247
+ * 发一次请求。`command` 只用于错误提示(不进 URL)。
248
+ *
249
+ * 重试的前提:**每次尝试共用同一个 request_id** —— 重试的是"同一个请求",
250
+ * 服务端审计里因此只有一行(而不是三次失败的样子)。
251
+ */
252
+ async send(method, path, auth, spec, command) {
253
+ const url = `${this.baseUrl}${path}`;
254
+ const requestId = newRequestId();
255
+ let attempt = 0;
256
+ for (;;) {
257
+ let parsed;
258
+ try {
259
+ parsed = await this.attemptWithRedirects({
260
+ method,
261
+ url,
262
+ auth,
263
+ spec,
264
+ requestId,
265
+ redirects: 0,
266
+ });
267
+ } catch (err) {
268
+ if (err instanceof CliError) {
269
+ // 非 JSON 响应(网关/代理页)——退避后重试一次是合理的。
270
+ if (attempt < MAX_RETRIES) {
271
+ const wait = backoff(attempt, null);
272
+ warn(
273
+ `${command} 收到非 JSON 响应(${err.message}),${(wait / 1000).toFixed(1)}s 后重试`,
274
+ );
275
+ await sleep(wait);
276
+ attempt += 1;
277
+ continue;
278
+ }
279
+ throw err;
280
+ }
281
+ const retryable = err.retryable === true;
282
+ if (retryable && attempt < MAX_RETRIES) {
283
+ const wait = backoff(attempt, null);
284
+ warn(
285
+ `${command} 网络故障(${shortReason(err)}),${(wait / 1000).toFixed(1)}s 后重试(第 ${attempt + 1}/${MAX_RETRIES} 次)`,
286
+ );
287
+ await sleep(wait);
288
+ attempt += 1;
289
+ continue;
290
+ }
291
+ throw networkError(url, err, requestId);
292
+ }
293
+
294
+ if (shouldRetryStatus(parsed.status) && attempt < MAX_RETRIES) {
295
+ const wait = backoff(attempt, retryAfter(parsed));
296
+ warn(
297
+ `服务端返回 ${parsed.status},${(wait / 1000).toFixed(1)}s 后重试(第 ${attempt + 1}/${MAX_RETRIES} 次)`,
298
+ );
299
+ await sleep(wait);
300
+ attempt += 1;
301
+ continue;
302
+ }
303
+ if (parsed.status >= 200 && parsed.status < 300) return parsed;
304
+ throw this.serverError(parsed);
305
+ }
306
+ }
307
+
308
+ /** 单次尝试(含最多 5 跳重定向)。 */
309
+ async attemptWithRedirects({ method, url, auth, spec, requestId, redirects }) {
310
+ const response = await this.attempt({ method, url, auth, spec, requestId });
311
+ const location = headerValue(response.headers, 'location');
312
+ if (isRedirect(response.status) && location && redirects < MAX_REDIRECTS) {
313
+ // 5xx/429 之外的重定向:按 RFC 语义跟随(301/302/303 对非 GET 降级为 GET)。
314
+ const next = new URL(location, url).toString();
315
+ const downgrade = response.status === 303 || response.status === 301 || response.status === 302;
316
+ const nextMethod = downgrade && method !== 'GET' && method !== 'HEAD' ? 'GET' : method;
317
+ const nextSpec = nextMethod === method ? spec : requestSpec({ ...spec, body: null });
318
+ return this.attemptWithRedirects({
319
+ method: nextMethod,
320
+ url: next,
321
+ auth,
322
+ spec: nextSpec,
323
+ requestId,
324
+ redirects: redirects + 1,
325
+ });
326
+ }
327
+ return this.ingest(response, requestId);
328
+ }
329
+
330
+ /** 真正的一次 HTTP 往返(拿回原始响应)。 */
331
+ attempt({ method, url, auth, spec, requestId }) {
332
+ const target = new URL(url);
333
+ const isHttps = target.protocol === 'https:';
334
+ const headers = {
335
+ 'user-agent': USER_AGENT,
336
+ accept: 'application/json',
337
+ [REQUEST_ID_HEADER.toLowerCase()]: requestId,
338
+ [VERSION_HEADER.toLowerCase()]: VERSION,
339
+ };
340
+ if (this.deviceId.length > 0) headers[DEVICE_HEADER.toLowerCase()] = this.deviceId;
341
+ if (auth?.kind === 'bearer' && auth.token) {
342
+ headers.authorization = `Bearer ${auth.token}`;
343
+ }
344
+ if (spec.idempotencyKey) headers['idempotency-key'] = spec.idempotencyKey;
345
+
346
+ const search = new URLSearchParams();
347
+ for (const [key, value] of spec.query) search.append(key, value);
348
+ if (search.toString().length > 0) target.search = search.toString();
349
+
350
+ let payload = null;
351
+ if (spec.multipart) {
352
+ const built = buildMultipart(spec.multipart);
353
+ payload = built.payload;
354
+ headers['content-type'] = built.contentType;
355
+ } else if (spec.body !== null && spec.body !== undefined) {
356
+ payload = Buffer.from(JSON.stringify(spec.body), 'utf8');
357
+ headers['content-type'] = 'application/json';
358
+ }
359
+ if (payload) headers['content-length'] = String(payload.length);
360
+
361
+ const timeoutMs = spec.timeout ?? this.timeoutMs;
362
+ const plan = this.plan;
363
+ const proxyUrl =
364
+ plan === ProxyPlan.System ? resolveProxyUrl(target.toString(), this.mode) : null;
365
+
366
+ return new Promise((resolve, reject) => {
367
+ let settled = false;
368
+ let timeoutHandle = null;
369
+ let connectTimeoutHandle = null;
370
+
371
+ const fail = (err) => {
372
+ if (settled) return;
373
+ settled = true;
374
+ clearTimeout(timeoutHandle);
375
+ clearTimeout(connectTimeoutHandle);
376
+ reject(err);
377
+ };
378
+
379
+ const onResponse = (response) => {
380
+ if (settled) return;
381
+ settled = true;
382
+ clearTimeout(timeoutHandle);
383
+ clearTimeout(connectTimeoutHandle);
384
+ const chunks = [];
385
+ response.on('data', (chunk) => chunks.push(chunk));
386
+ response.on('end', () => {
387
+ resolve({
388
+ status: response.statusCode ?? 0,
389
+ headers: response.headers,
390
+ body: Buffer.concat(chunks),
391
+ });
392
+ });
393
+ response.on('error', (err) => fail(markNetwork(err)));
394
+ };
395
+
396
+ // 超时:整次请求(含建连、发送、收完响应体)。
397
+ timeoutHandle = setTimeout(() => {
398
+ const err = new Error('请求超时');
399
+ err.code = 'ETIMEDOUT';
400
+ err.retryable = true;
401
+ err.timedOut = true;
402
+ fail(err);
403
+ }, timeoutMs);
404
+ if (typeof timeoutHandle.unref === 'function') timeoutHandle.unref();
405
+
406
+ const requestOptions = {
407
+ method,
408
+ headers,
409
+ // 私网/回环与 `proxy=none` 都走这条:**完全不读**环境里的 *_PROXY。
410
+ agent: false,
411
+ };
412
+
413
+ const handleRequest = (req) => {
414
+ req.on('error', (err) => fail(markNetwork(err)));
415
+ if (payload) req.write(payload);
416
+ req.end();
417
+ };
418
+
419
+ if (proxyUrl) {
420
+ const proxy = new URL(proxyUrl);
421
+ if (proxy.protocol === 'socks5:' || proxy.protocol === 'socks5h:' || proxy.protocol === 'socks4:') {
422
+ const proxyPort = Number(proxy.port || 1080);
423
+ openSocks5({
424
+ proxyHost: proxy.hostname,
425
+ proxyPort,
426
+ targetHost: target.hostname,
427
+ targetPort: Number(target.port || (isHttps ? 443 : 80)),
428
+ timeoutMs: DEFAULT_CONNECT_TIMEOUT_MS,
429
+ })
430
+ .then((socket) => {
431
+ if (settled) {
432
+ socket.destroy();
433
+ return;
434
+ }
435
+ if (isHttps) {
436
+ const secure = tls.connect({
437
+ socket,
438
+ servername: target.hostname,
439
+ rejectUnauthorized: true,
440
+ });
441
+ secure.on('error', (err) => fail(markNetwork(err)));
442
+ secure.on('secureConnect', () => {
443
+ const req = https.request(
444
+ { ...requestOptions, createConnection: () => secure, servername: target.hostname },
445
+ onResponse,
446
+ );
447
+ handleRequest(req);
448
+ });
449
+ } else {
450
+ const req = http.request(
451
+ { ...requestOptions, createConnection: () => socket },
452
+ onResponse,
453
+ );
454
+ handleRequest(req);
455
+ }
456
+ })
457
+ .catch((err) => fail(markNetwork(err)));
458
+ return;
459
+ }
460
+ // HTTP 代理:https 目标走 CONNECT 隧道,http 目标发绝对 URI。
461
+ if (!isHttps) {
462
+ const proxyPort = Number(proxy.port || 80);
463
+ const req = http.request(
464
+ {
465
+ ...requestOptions,
466
+ host: proxy.hostname,
467
+ port: proxyPort,
468
+ path: target.toString(),
469
+ setHost: false,
470
+ headers: { ...headers, host: target.host },
471
+ },
472
+ onResponse,
473
+ );
474
+ handleRequest(req);
475
+ return;
476
+ }
477
+ const proxyPort = Number(proxy.port || 80);
478
+ const connectReq = http.request({
479
+ host: proxy.hostname,
480
+ port: proxyPort,
481
+ method: 'CONNECT',
482
+ path: `${target.hostname}:${Number(target.port || 443)}`,
483
+ headers: {
484
+ host: `${target.hostname}:${Number(target.port || 443)}`,
485
+ ...(proxy.username
486
+ ? {
487
+ 'proxy-authorization': `Basic ${Buffer.from(
488
+ `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`,
489
+ ).toString('base64')}`,
490
+ }
491
+ : {}),
492
+ },
493
+ agent: false,
494
+ });
495
+ connectTimeoutHandle = setTimeout(() => {
496
+ const err = new Error('连接代理超时');
497
+ err.code = 'ETIMEDOUT';
498
+ err.retryable = true;
499
+ err.timedOut = true;
500
+ connectReq.destroy(err);
501
+ }, DEFAULT_CONNECT_TIMEOUT_MS);
502
+ if (typeof connectTimeoutHandle.unref === 'function') connectTimeoutHandle.unref();
503
+ connectReq.on('connect', (res, socket) => {
504
+ clearTimeout(connectTimeoutHandle);
505
+ if (res.statusCode !== 200) {
506
+ socket.destroy();
507
+ fail(
508
+ Object.assign(new Error(`代理 CONNECT 失败(HTTP ${res.statusCode})`), {
509
+ retryable: false,
510
+ }),
511
+ );
512
+ return;
513
+ }
514
+ const secure = tls.connect({
515
+ socket,
516
+ servername: target.hostname,
517
+ rejectUnauthorized: true,
518
+ });
519
+ secure.on('error', (err) => fail(markNetwork(err)));
520
+ secure.on('secureConnect', () => {
521
+ const req = https.request(
522
+ { ...requestOptions, createConnection: () => secure, servername: target.hostname },
523
+ onResponse,
524
+ );
525
+ handleRequest(req);
526
+ });
527
+ });
528
+ connectReq.on('error', (err) => {
529
+ clearTimeout(connectTimeoutHandle);
530
+ fail(markNetwork(err));
531
+ });
532
+ connectReq.end();
533
+ return;
534
+ }
535
+
536
+ // 直连。
537
+ const options = {
538
+ ...requestOptions,
539
+ host: target.hostname,
540
+ port: target.port ? Number(target.port) : isHttps ? 443 : 80,
541
+ path: `${target.pathname}${target.search}`,
542
+ ...(isHttps ? { servername: target.hostname } : {}),
543
+ };
544
+ const req = isHttps ? https.request(options, onResponse) : http.request(options, onResponse);
545
+ handleRequest(req);
546
+ });
547
+ }
548
+
549
+ /** 响应 → `ApiResponse`(非 JSON 走 raw 字节,附件下载靠它)。 */
550
+ async ingest(response, fallbackRequestId) {
551
+ const responseRequestId = headerValue(response.headers, REQUEST_ID_HEADER) ?? fallbackRequestId;
552
+ const serverVersion = headerValue(response.headers, 'x-server-version');
553
+ const contentType = (headerValue(response.headers, 'content-type') ?? '').toLowerCase();
554
+ const isJson = contentType.includes('json');
555
+ if (!isJson) {
556
+ return {
557
+ status: response.status,
558
+ body: { __raw_bytes: response.body.length },
559
+ request_id: responseRequestId,
560
+ server_version: serverVersion,
561
+ raw: response.body,
562
+ };
563
+ }
564
+ const text = response.body.toString('utf8');
565
+ if (text.trim().length === 0) {
566
+ return {
567
+ status: response.status,
568
+ body: {},
569
+ request_id: responseRequestId,
570
+ server_version: serverVersion,
571
+ raw: null,
572
+ };
573
+ }
574
+ let body;
575
+ try {
576
+ body = JSON.parse(text);
577
+ } catch (err) {
578
+ throw new CliError(
579
+ Kind.Server,
580
+ 'cli.bad_response',
581
+ `服务端返回的不是 JSON(HTTP ${response.status}):${err.message}`,
582
+ )
583
+ .withDetails({ body_preview: preview(text) })
584
+ .withRequestId(responseRequestId);
585
+ }
586
+ return {
587
+ status: response.status,
588
+ body,
589
+ request_id: responseRequestId,
590
+ server_version: serverVersion,
591
+ raw: null,
592
+ };
593
+ }
594
+
595
+ /** 服务端错误 → CLI 错误(`error.code` → 退出码,契约 §3.2)。 */
596
+ serverError(response) {
597
+ const error = isPlainObject(response.body?.error) ? response.body.error : {};
598
+ const code = typeof error.code === 'string' ? error.code : 'internal.error';
599
+ const message =
600
+ typeof error.message === 'string' ? error.message : `服务端返回 HTTP ${response.status}`;
601
+ const details = error.details === undefined || error.details === null ? {} : error.details;
602
+ const hint = typeof error.hint === 'string' ? error.hint : null;
603
+
604
+ let kind;
605
+ if (
606
+ code === 'request.invalid' &&
607
+ (details?.reason === 'move_into_own_subtree' || details?.max_depth !== undefined)
608
+ ) {
609
+ // ORG-001 §5 场景 3:`org --move` 的两类状态拒绝(移进自己子树 / 超 32 层)
610
+ // 语义是"当前树形状下的状态冲突",归 5 —— 只有带这两个 details 的才归 5。
611
+ kind = Kind.Conflict;
612
+ } else if (code === 'request.invalid' && details?.reason === 'transfer_target_required') {
613
+ // ORG-004:缺 `--transfer-to` 且服务端解析不出默认目标。契约把这一条钉成
614
+ // **用法错误(退出码 2)**:客户端判不了"该企业有没有 tenant_owner"(那是服务端事实)。
615
+ kind = Kind.Usage;
616
+ } else {
617
+ kind = classify(code, response.status);
618
+ }
619
+
620
+ const err = new CliError(kind, code, message).withDetails(details);
621
+ err.requestId = response.request_id ?? null;
622
+ err.httpStatus = response.status;
623
+
624
+ // hint:服务端的 hint 优先(它最懂自己缺什么);凭据类错误一律补上登录命令。
625
+ if (code.startsWith('auth.')) {
626
+ err.hint = hint
627
+ ? `${hint};或者直接重新登录:xinhe-oa-cli login --phone <你的手机号>`
628
+ : loginHint(null);
629
+ } else {
630
+ err.hint = hint;
631
+ }
632
+ if (response.status === 404 && !err.hint) {
633
+ err.hint = '用 `xinhe-oa-cli schema` 看当前命令面支持的路径';
634
+ }
635
+ return err;
636
+ }
637
+ }
638
+
639
+ function isPlainObject(value) {
640
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
641
+ }
642
+
643
+ function headerValue(headers, name) {
644
+ const value = headers?.[name.toLowerCase()];
645
+ if (Array.isArray(value)) return value[0] ?? null;
646
+ return typeof value === 'string' ? value : null;
647
+ }
648
+
649
+ function isRedirect(status) {
650
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
651
+ }
652
+
653
+ function shouldRetryStatus(status) {
654
+ return status === 429 || status >= 500;
655
+ }
656
+
657
+ function retryAfter(response) {
658
+ const seconds = response.body?.error?.details?.retry_after_seconds;
659
+ return typeof seconds === 'number' ? String(seconds) : null;
660
+ }
661
+
662
+ /** 指数退避 + 抖动:0.4s / 0.8s / 1.6s(尊重 Retry-After,上限 30s)。 */
663
+ export function backoff(attempt, retryAfterRaw) {
664
+ if (retryAfterRaw !== null && retryAfterRaw !== undefined && String(retryAfterRaw).trim() !== '') {
665
+ const seconds = Number(String(retryAfterRaw).trim());
666
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0), 30) * 1000;
667
+ }
668
+ const base = 400 * 2 ** Math.min(attempt, 4);
669
+ const jitter = Math.floor(Math.random() * 200);
670
+ return Math.min(base + jitter, 30_000);
671
+ }
672
+
673
+ /**
674
+ * 退避等待。
675
+ *
676
+ * **这里绝对不能 `unref()`**:重试路径上此刻已经没有别的 handle(socket 刚关掉),
677
+ * 把定时器 unref 掉事件循环就直接空转 → Node 打印
678
+ * `Warning: Detected unsettled top-level await` 并以**退出码 13** 结束,
679
+ * 于是"退避重试"变成"静默死掉",而契约要求网络故障是 7、5xx 是 6。
680
+ * 请求超时/连接超时那两个定时器可以 unref(socket 本身撑着事件循环),这个不行。
681
+ */
682
+ function sleep(ms) {
683
+ return new Promise((resolve) => {
684
+ setTimeout(resolve, ms);
685
+ });
686
+ }
687
+
688
+ /** 网络错误打标:只有超时 / 连接类错误值得重试。 */
689
+ function markNetwork(err) {
690
+ const code = err?.code ?? '';
691
+ const retryable =
692
+ err?.timedOut === true ||
693
+ code === 'ETIMEDOUT' ||
694
+ code === 'ECONNREFUSED' ||
695
+ code === 'ECONNRESET' ||
696
+ code === 'ENOTFOUND' ||
697
+ code === 'EAI_AGAIN' ||
698
+ code === 'EPIPE' ||
699
+ code === 'EHOSTUNREACH' ||
700
+ code === 'ENETUNREACH' ||
701
+ code === 'ERR_SOCKET_CONNECTION_TIMEOUT';
702
+ err.retryable = retryable;
703
+ return err;
704
+ }
705
+
706
+ function shortReason(err) {
707
+ if (err?.timedOut === true || err?.code === 'ETIMEDOUT') return '请求超时';
708
+ if (err?.code === 'ECONNREFUSED') return '连接被拒绝或不可达';
709
+ if (err?.code === 'ENOTFOUND' || err?.code === 'EAI_AGAIN') return '域名解析失败';
710
+ if (err?.code === 'ECONNRESET') return '连接被重置';
711
+ const text = String(err?.message ?? '未知网络错误');
712
+ return text.split('\n')[0];
713
+ }
714
+
715
+ function networkError(url, err, requestId) {
716
+ const target = hostOf(url) ?? url;
717
+ const cliError = CliError.local(`连不上服务端 ${target}:${shortReason(err)}`).withRequestId(
718
+ requestId,
719
+ );
720
+ cliError.hint =
721
+ '检查:① `xinhe-oa-cli config --show` 里的 base_url ② 服务端是否在跑 ' +
722
+ '③ 若在本地/内网直连,关掉代理:`xinhe-oa-cli config --set proxy=none` ' +
723
+ '(或临时 `XINHE_OA_PROXY=none xinhe-oa-cli doctor`)';
724
+ cliError.details = { target_host: target };
725
+ return cliError;
726
+ }
727
+
728
+ function preview(text) {
729
+ const chars = Array.from(String(text));
730
+ return chars.length > 200 ? `${chars.slice(0, 200).join('')}…` : chars.join('');
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // multipart(手写:Content-Length 必须给准,否则有些网关会挂住)
735
+ // ---------------------------------------------------------------------------
736
+ function buildMultipart(form) {
737
+ const boundary = `----xinheoa${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
738
+ const parts = [];
739
+ for (const [key, value] of form.fields) {
740
+ parts.push(
741
+ Buffer.from(
742
+ `--${boundary}\r\nContent-Disposition: form-data; name="${escapeHeader(key)}"\r\n\r\n${value}\r\n`,
743
+ 'utf8',
744
+ ),
745
+ );
746
+ }
747
+ if (form.file) {
748
+ const [field, filename, bytes] = form.file;
749
+ parts.push(
750
+ Buffer.from(
751
+ `--${boundary}\r\nContent-Disposition: form-data; name="${escapeHeader(field)}"; filename="${escapeHeader(filename)}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
752
+ 'utf8',
753
+ ),
754
+ );
755
+ parts.push(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
756
+ parts.push(Buffer.from('\r\n', 'utf8'));
757
+ }
758
+ parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'));
759
+ return {
760
+ payload: Buffer.concat(parts),
761
+ contentType: `multipart/form-data; boundary=${boundary}`,
762
+ };
763
+ }
764
+
765
+ function escapeHeader(value) {
766
+ return String(value).replace(/[\r\n"]/g, '_');
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // SOCKS5(无依赖实现:客户机器上 `ALL_PROXY=socks5h://…` 是常态)
771
+ // ---------------------------------------------------------------------------
772
+ /**
773
+ * 建一条经 SOCKS5 代理到目标的 TCP 连接。
774
+ *
775
+ * 支持无认证与用户名/口令认证(RFC 1928/1929)。`socks5h` 与 `socks5` 都按
776
+ * **远端解析**处理:把域名交给代理去解析 —— 这正是 `socks5h` 那个 `h` 的含义,
777
+ * 而用 `socks5` 的人几乎都是想让代理去解析(本地解析在内网里常常失败)。
778
+ */
779
+ export function openSocks5({ proxyHost, proxyPort, targetHost, targetPort, timeoutMs }) {
780
+ return new Promise((resolve, reject) => {
781
+ const socket = net.connect({ host: proxyHost, port: proxyPort });
782
+ let settled = false;
783
+ const handle = setTimeout(() => {
784
+ if (settled) return;
785
+ settled = true;
786
+ socket.destroy();
787
+ const err = new Error('连接 SOCKS5 代理超时');
788
+ err.code = 'ETIMEDOUT';
789
+ err.retryable = true;
790
+ reject(err);
791
+ }, timeoutMs);
792
+ if (typeof handle.unref === 'function') handle.unref();
793
+
794
+ const finish = (err) => {
795
+ if (settled) return;
796
+ settled = true;
797
+ clearTimeout(handle);
798
+ socket.removeAllListeners('data');
799
+ socket.removeAllListeners('error');
800
+ if (err) {
801
+ socket.destroy();
802
+ reject(err);
803
+ } else {
804
+ resolve(socket);
805
+ }
806
+ };
807
+
808
+ socket.on('error', (err) => finish(err));
809
+ socket.on('connect', () => {
810
+ // 第一步:问候(无认证 + 用户名/口令都报上,让代理挑)。
811
+ const proxyUser = process.env.XINHE_OA_PROXY_USER;
812
+ const proxyPass = process.env.XINHE_OA_PROXY_PASSWORD;
813
+ const withAuth = Boolean(proxyUser);
814
+ socket.write(Buffer.from(withAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00]));
815
+ });
816
+
817
+ let stage = 'greeting';
818
+ let buffer = Buffer.alloc(0);
819
+
820
+ socket.on('data', (chunk) => {
821
+ buffer = Buffer.concat([buffer, chunk]);
822
+ for (;;) {
823
+ if (stage === 'greeting') {
824
+ if (buffer.length < 2) return;
825
+ const version = buffer[0];
826
+ const method = buffer[1];
827
+ buffer = buffer.subarray(2);
828
+ if (version !== 0x05) {
829
+ finish(Object.assign(new Error('SOCKS5 代理版本不匹配'), { retryable: false }));
830
+ return;
831
+ }
832
+ if (method === 0x02) {
833
+ const user = process.env.XINHE_OA_PROXY_USER ?? '';
834
+ const pass = process.env.XINHE_OA_PROXY_PASSWORD ?? '';
835
+ const userBuf = Buffer.from(user, 'utf8');
836
+ const passBuf = Buffer.from(pass, 'utf8');
837
+ socket.write(
838
+ Buffer.concat([
839
+ Buffer.from([0x01, userBuf.length]),
840
+ userBuf,
841
+ Buffer.from([passBuf.length]),
842
+ passBuf,
843
+ ]),
844
+ );
845
+ stage = 'auth';
846
+ continue;
847
+ }
848
+ if (method !== 0x00) {
849
+ finish(
850
+ Object.assign(new Error('SOCKS5 代理要求不支持的认证方式'), { retryable: false }),
851
+ );
852
+ return;
853
+ }
854
+ sendConnect();
855
+ continue;
856
+ }
857
+ if (stage === 'auth') {
858
+ if (buffer.length < 2) return;
859
+ const ok = buffer[1] === 0x00;
860
+ buffer = buffer.subarray(2);
861
+ if (!ok) {
862
+ finish(Object.assign(new Error('SOCKS5 认证失败'), { retryable: false }));
863
+ return;
864
+ }
865
+ sendConnect();
866
+ continue;
867
+ }
868
+ // stage === 'connect'
869
+ if (buffer.length < 4) return;
870
+ const reply = buffer[1];
871
+ const addressType = buffer[3];
872
+ let need = 4;
873
+ if (addressType === 0x01) need += 4 + 2;
874
+ else if (addressType === 0x04) need += 16 + 2;
875
+ else if (addressType === 0x03) need += 1 + (buffer[4] ?? 0) + 2;
876
+ else {
877
+ finish(Object.assign(new Error('SOCKS5 响应地址类型不认'), { retryable: false }));
878
+ return;
879
+ }
880
+ if (buffer.length < need) return;
881
+ buffer = buffer.subarray(need);
882
+ if (reply !== 0x00) {
883
+ finish(
884
+ Object.assign(new Error(`SOCKS5 连接被拒(reply=${reply})`), { retryable: false }),
885
+ );
886
+ return;
887
+ }
888
+ finish(null);
889
+ return;
890
+ }
891
+ });
892
+
893
+ function sendConnect() {
894
+ const hostBuf = Buffer.from(targetHost, 'utf8');
895
+ const portBuf = Buffer.alloc(2);
896
+ portBuf.writeUInt16BE(targetPort, 0);
897
+ socket.write(
898
+ Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]), hostBuf, portBuf]),
899
+ );
900
+ stage = 'connect';
901
+ }
902
+ });
903
+ }