wkgeo-cli 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.
package/lib/errors.mjs ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * 错误模型:把「服务端返回」翻译成「人能照做的下一步」。
3
+ *
4
+ * 本项目的**硬约定:HTTP 恒 200,错误在 body 的 `code` 里**(网关适配层统一信封)。
5
+ * 所以判断成败**不能看 HTTP 状态码**,必须看 `body.code`。这一点踩过坑:
6
+ * 早期 CLR 只看 `res.ok`,结果 403/429 全被当成"成功",然后拿着空 data 崩在下一步。
7
+ *
8
+ * 但有三个地方**不遵守**这个约定,必须单独处理(否则会把 OAuth 标准错误当业务码):
9
+ * 1. `GET /api/oauth/userinfo` → HTTP 401 + `{error:'invalid_token', error_description}`
10
+ * 2. `POST /api/oauth/token` → HTTP 400 + `{error:'invalid_grant', error_description}`
11
+ * 3. `POST /api/oauth/introspect` → HTTP 200 + `{active:false}`(**不是错误**,是"无效")
12
+ *
13
+ * 关于"过期"这个信号,线上新旧两种版本都存在,**两种都要认**:
14
+ * * 新:`40101` 令牌已过期 | `40102` 令牌已被吊销(本次另一个改动加的)
15
+ * * 旧:只有 `401` + 文案"登录已过期,请重新登录"(过期/被吊销/没登录**完全一样**)
16
+ * → 旧版只能给"运行 wkgeo login 重新授权"这种兼容提示,不能编造"是被吊销了"。
17
+ */
18
+
19
+ export const EXIT = {
20
+ OK: 0,
21
+ /** 一般性失败(业务错误、服务端错误、网络错误、参数不合法之外的异常) */
22
+ ERROR: 1,
23
+ /** 用法错误(命令/参数不对)—— 与"跑失败"区分开,脚本里好判断 */
24
+ USAGE: 2,
25
+ /** 需要(重新)授权:未登录 / 令牌过期 / 已被吊销 */
26
+ AUTH: 3,
27
+ /** 被限流:**明确不要**引导重新授权 */
28
+ RATE_LIMIT: 4,
29
+ };
30
+
31
+ /** 需要重新授权时统一的下一步提示。命令名固定用官方 bin 名 `wkgeo`。 */
32
+ export function loginHint(cmd = 'wkgeo') {
33
+ return `运行 \`${cmd} login\` 重新授权(浏览器里通常还有登录态,点一下同意即可)`;
34
+ }
35
+
36
+ export class CliError extends Error {
37
+ constructor(message, opts = {}) {
38
+ super(message);
39
+ this.name = 'CliError';
40
+ this.kind = opts.kind || 'error';
41
+ /** 服务端原始 code(数字或字符串),**必须保留**,不能吞掉 */
42
+ this.code = opts.code === undefined ? null : opts.code;
43
+ this.hint = opts.hint || null;
44
+ this.retryAfter = opts.retryAfter === undefined ? null : opts.retryAfter;
45
+ this.exitCode = opts.exitCode === undefined ? EXIT.ERROR : opts.exitCode;
46
+ /** 原始响应(已脱敏),--verbose 时打印,便于排查"服务端到底说了什么" */
47
+ this.raw = opts.raw === undefined ? null : opts.raw;
48
+ this.cause = opts.cause;
49
+ }
50
+ }
51
+
52
+ export class UsageError extends CliError {
53
+ constructor(message, opts = {}) {
54
+ super(message, { ...opts, kind: 'usage', exitCode: EXIT.USAGE });
55
+ this.name = 'UsageError';
56
+ }
57
+ }
58
+
59
+ /** 网络层失败:**保留原始 message**,绝不吞成"失败了" */
60
+ export function networkError(detail, extra = {}) {
61
+ return new CliError(`网络请求失败:${detail}`, {
62
+ kind: 'network',
63
+ hint: '检查网络/代理或稍后重试;若持续失败请确认服务是否正常(服务地址可用 --base 临时切换)',
64
+ raw: extra.raw === undefined ? null : extra.raw,
65
+ cause: extra.cause,
66
+ });
67
+ }
68
+
69
+ /** 把服务端的 `code` 统一成数字(服务端有时回字符串) */
70
+ function toCodeNumber(code) {
71
+ if (code === undefined || code === null || code === '') return null;
72
+ const n = Number(code);
73
+ return Number.isFinite(n) ? n : null;
74
+ }
75
+
76
+ /**
77
+ * "令牌无效"这一类错误(code=401 或 OAuth 标准的 invalid_token)到底该引导用户做什么。
78
+ *
79
+ * 背景(线上实测,2026-09-30):
80
+ * * `/api/data` 与 `/api/oauth/userinfo` 现在都回 **HTTP 401 + `error:invalid_token`**,
81
+ * 但 `error_description` 已经能区分:`访问令牌已被吊销,请重新授权` / 过期文案;
82
+ * * 旧版则是一律 `登录已过期,请重新登录`;
83
+ * * 新版的独立错误码 `40101/40102` **还没上线**(本次另一个改动在做)。
84
+ * 所以这里**只按文案细化提示**,绝不编造 code —— 报告里要能说清"是文案判的还是码判的"。
85
+ */
86
+ function unauthenticatedClass(message, { httpStatus = 401, cmdName = 'wkgeo' } = {}) {
87
+ const text = String(message || '');
88
+ const base = {
89
+ code: text && httpStatus ? httpStatus : httpStatus,
90
+ exitCode: EXIT.AUTH,
91
+ };
92
+ if (/吊销|撤销|revoked/i.test(text)) {
93
+ return {
94
+ kind: 'revoked',
95
+ ok: false,
96
+ message: text,
97
+ hint: `授权已被吊销,请重新登录授权:\`${cmdName} login\`(服务端没有给出可区分的错误码,是按文案判定的)`,
98
+ retryAfter: null,
99
+ ...base,
100
+ };
101
+ }
102
+ if (/过期|expired/i.test(text)) {
103
+ return {
104
+ kind: 'expired',
105
+ ok: false,
106
+ message: text,
107
+ hint: `${loginHint(cmdName)}(服务端没有给出可区分的错误码,是按文案判定的)`,
108
+ retryAfter: null,
109
+ ...base,
110
+ };
111
+ }
112
+ return {
113
+ kind: 'unauthenticated',
114
+ ok: false,
115
+ message: text || '登录已过期,请重新登录',
116
+ hint: `${loginHint(cmdName)}(服务端只回了 401/invalid_token,"过期"与"被吊销"分不出来)`,
117
+ retryAfter: null,
118
+ ...base,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * 判定一次响应。
124
+ * @returns {{kind:string, ok:boolean, code:any, message:string, hint:string|null, retryAfter:number|null, exitCode:number}}
125
+ * kind 取值:ok / network / expired / revoked / unauthenticated / rate_limited /
126
+ * forbidden / step_up / bad_request / not_found / server / denied / unknown
127
+ */
128
+ export function classifyResponse({ httpStatus = 200, body = null, action = '', retryAfterHeader = null, cmdName = 'wkgeo' } = {}) {
129
+ const ok = (extra = {}) => ({ kind: 'ok', ok: true, code: 0, message: '', hint: null, retryAfter: null, exitCode: EXIT.OK, ...extra });
130
+ const bad = (kind, message, extra = {}) => ({
131
+ kind,
132
+ ok: false,
133
+ code: extra.code === undefined ? null : extra.code,
134
+ message: message || '请求失败(服务端未给出原因)',
135
+ hint: extra.hint === undefined ? null : extra.hint,
136
+ retryAfter: extra.retryAfter === undefined ? null : extra.retryAfter,
137
+ exitCode: extra.exitCode === undefined ? EXIT.ERROR : extra.exitCode,
138
+ });
139
+
140
+ const retryAfterOf = (src) => {
141
+ const cand = [
142
+ src && src.retry_after,
143
+ src && src.data && src.data.retry_after,
144
+ retryAfterHeader,
145
+ ];
146
+ for (const c of cand) {
147
+ const n = Number(c);
148
+ if (Number.isFinite(n) && n >= 0) return n;
149
+ }
150
+ return null;
151
+ };
152
+
153
+ // ---- 1. 网络层(连不上/超时):没有 body,status 0 ----
154
+ if (httpStatus === 0) {
155
+ return bad('network', typeof body === 'string' ? body : (body && body.message) || '连接失败', {
156
+ hint: '检查网络或稍后重试',
157
+ });
158
+ }
159
+
160
+ // ---- 2. OAuth 标准错误形状(userinfo / token / revoke / introspect)----
161
+ if (body && typeof body === 'object' && typeof body.error === 'string' && body.code === undefined) {
162
+ const err = body.error;
163
+ const desc = String(body.error_description || body.message || err);
164
+ if (err === 'invalid_token') {
165
+ // userinfo 的 401:OAuth 标准只有一个 invalid_token,但线上文案已经能区分吊销/过期
166
+ return unauthenticatedClass(desc, { httpStatus: httpStatus || 401, cmdName });
167
+ }
168
+ if (err === 'slow_down') {
169
+ const ra = retryAfterOf(body);
170
+ return bad('rate_limited', desc, {
171
+ code: 429,
172
+ retryAfter: ra,
173
+ hint: ra === null ? '调用过于频繁,请稍后重试(**不需要**重新授权)' : `调用过于频繁,请 ${ra} 秒后重试(**不需要**重新授权)`,
174
+ exitCode: EXIT.RATE_LIMIT,
175
+ });
176
+ }
177
+ if (err === 'invalid_grant') {
178
+ return bad('bad_request', desc, {
179
+ code: 400,
180
+ hint: '授权码可能已过期(5 分钟)或被使用过;重新运行 `' + cmdName + ' login` 即可',
181
+ exitCode: EXIT.ERROR,
182
+ });
183
+ }
184
+ if (err === 'access_denied') {
185
+ return bad('denied', desc, { code: 403, hint: '用户拒绝了授权' });
186
+ }
187
+ if (err === 'server_error') return bad('server', desc, { code: httpStatus || 500 });
188
+ return bad('unknown', desc, { code: httpStatus, exitCode: httpStatus >= 500 ? EXIT.ERROR : EXIT.ERROR });
189
+ }
190
+
191
+ // ---- 3. 项目统一信封 {code, message, data} ----
192
+ const codeNum = toCodeNumber(body && body.code);
193
+ const message = String((body && (body.message || body.msg)) || '');
194
+ const ra = retryAfterOf(body);
195
+
196
+ if (codeNum === 0) return ok();
197
+
198
+ if (codeNum === 40101) {
199
+ return bad('expired', message || '访问令牌已过期', {
200
+ code: 40101,
201
+ hint: loginHint(cmdName),
202
+ exitCode: EXIT.AUTH,
203
+ });
204
+ }
205
+ if (codeNum === 40102) {
206
+ return bad('revoked', message || '访问令牌已被吊销', {
207
+ code: 40102,
208
+ hint: `授权已被吊销,请重新登录授权:\`${cmdName} login\``,
209
+ exitCode: EXIT.AUTH,
210
+ });
211
+ }
212
+ if (codeNum === 401) {
213
+ return unauthenticatedClass(message, { httpStatus: 401, cmdName });
214
+ }
215
+ if (codeNum === 429 || httpStatus === 429) {
216
+ return bad('rate_limited', message || '调用过于频繁', {
217
+ code: 429,
218
+ retryAfter: ra,
219
+ // ⚠️ 限流**绝不能**引导用户去重新授权:重授权解决不了限流,还会白跑一遍浏览器。
220
+ hint: ra === null
221
+ ? '调用过于频繁,请稍后重试(**不需要**重新授权)'
222
+ : `调用过于频繁,请 ${ra} 秒后重试(**不需要**重新授权)`,
223
+ exitCode: EXIT.RATE_LIMIT,
224
+ });
225
+ }
226
+ if (codeNum === 403) {
227
+ const hint = /未授予|scope|令牌范围/i.test(message)
228
+ ? `令牌的能力不足(scope 收敛)。若确实需要该能力:\`${cmdName} login --scope "site:read site:write billing:self"\` 重新授权`
229
+ : '权限不足:敏感动作(动钱/改权限)需要二次验证,OAuth 令牌做不到,请在网页后台操作';
230
+ return bad('forbidden', message || '无权限', { code: 403, hint });
231
+ }
232
+ if (codeNum === 428) {
233
+ return bad('step_up', message || '需要二次验证', {
234
+ code: 428,
235
+ hint: '该动作要求 5 分钟内重新输入密码(step-up)。CLI 令牌没有密码入口 → 请在网页后台操作',
236
+ });
237
+ }
238
+ if (codeNum === 404) return bad('not_found', message || '资源不存在', { code: 404 });
239
+ if (codeNum === 400) return bad('bad_request', message || '请求不合法', { code: 400 });
240
+ if (codeNum !== null && codeNum >= 500) return bad('server', message || `服务端错误(${codeNum})`, { code: codeNum });
241
+ if (codeNum !== null) return bad('unknown', message || `未知错误(code=${codeNum})`, { code: codeNum });
242
+
243
+ // ---- 4. 既没有 code 也没有 error:按 HTTP 状态兜底(例如网关 5xx 直出)----
244
+ if (httpStatus >= 500) return bad('server', message || `服务端错误(HTTP ${httpStatus})`, { code: httpStatus });
245
+ if (httpStatus === 401) {
246
+ return unauthenticatedClass(message, { httpStatus: 401, cmdName });
247
+ }
248
+ if (httpStatus >= 400) return bad('unknown', message || `请求失败(HTTP ${httpStatus})`, { code: httpStatus });
249
+ if (httpStatus >= 200 && httpStatus < 300) {
250
+ // HTTP 200 但没有 code 字段:可能是纯 REST 形状(比如 PG REST 直出数组)。
251
+ // 这种不算错,交给调用方处理 body 本身。
252
+ return ok({ code: null });
253
+ }
254
+ return bad('unknown', message || `无法识别的响应(HTTP ${httpStatus})`, { code: httpStatus });
255
+ }
256
+
257
+ /** 把 classify 结果变成可抛出的 CliError */
258
+ export function toCliError(classified, { action = '', raw = null } = {}) {
259
+ const where = action ? `(${action})` : '';
260
+ return new CliError(`${classified.message}${where ? ' ' + where : ''}`, {
261
+ kind: classified.kind,
262
+ code: classified.code,
263
+ hint: classified.hint,
264
+ retryAfter: classified.retryAfter,
265
+ exitCode: classified.exitCode,
266
+ raw,
267
+ });
268
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * 输出格式化:金额、掩码、表格、脱敏、颜色。
3
+ *
4
+ * 安全铁律(本文件就是守卫者):
5
+ * * 令牌/授权码/verifier **永远不进终端**。需要指代时只给"前 6 位 + 长度"的掩码。
6
+ * * 任何"把服务端原始响应打出来"的地方都必须先过 `redact()` ——
7
+ * 因为 `/api/oauth/token` 的响应体里就有明文 access_token。
8
+ */
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* 掩码 / 脱敏 */
12
+ /* ------------------------------------------------------------------ */
13
+
14
+ /** 掩码:保留前 6 位 + 总长度,够定位是哪一把,但抄不走 */
15
+ export function maskToken(value) {
16
+ const s = String(value ?? '');
17
+ if (!s) return '(空)';
18
+ if (s.length <= 8) return `${s.slice(0, 2)}…(${s.length} 位)`;
19
+ return `${s.slice(0, 6)}…(${s.length} 位)`;
20
+ }
21
+
22
+ /** 需要脱敏的键名(大小写不敏感、子串匹配) */
23
+ const SECRET_KEY_PATTERNS = [
24
+ 'access_token',
25
+ 'refresh_token',
26
+ 'client_secret',
27
+ 'code_verifier',
28
+ 'authorization',
29
+ 'password',
30
+ 'id_token',
31
+ 'token_hash',
32
+ ];
33
+
34
+ /**
35
+ * ⚠️ 踩过的坑:一开始把键名 `code` 一律当秘密,结果把**错误码** `code: 40101`
36
+ * 也脱敏成了 «已脱敏»,AI 端拿不到 code 就没法分支处理。
37
+ * 第二版用"长度 ≥ 16 且不是纯数字",又把 `insufficient_balance`(20 字符的
38
+ * 语义化错误码)误伤了。现在阈值提到 32:授权码是 43 字符的随机串,
39
+ * 而错误码/产品代码通常短得多。
40
+ */
41
+ function looksLikeAuthorizationCode(value) {
42
+ return typeof value === 'string' && value.length >= 32;
43
+ }
44
+
45
+ function isSecretKey(key, value) {
46
+ const k = String(key).toLowerCase();
47
+ // 注意:这些**不是**秘密,别误伤:
48
+ // token_count / token_endpoint / token_type / *_ttl_seconds
49
+ // authorization_url —— 它就是要交给用户去浏览器打开的地址,
50
+ // 一开始被 `authorization` 子串误伤,结果 AI 端拿到的"授权网址"变成了 «已脱敏»(真实踩过)
51
+ if (
52
+ k === 'token_type' ||
53
+ k === 'token_count' ||
54
+ k === 'authorization_url' ||
55
+ k.endsWith('_endpoint') ||
56
+ k.endsWith('_ttl_seconds')
57
+ ) {
58
+ return false;
59
+ }
60
+ if (k === 'code') return looksLikeAuthorizationCode(value);
61
+ return SECRET_KEY_PATTERNS.some((p) => k.includes(p));
62
+ }
63
+
64
+ /** 递归脱敏一个对象(用于 --verbose / --debug 打印原始响应) */
65
+ export function redact(value, depth = 0) {
66
+ if (depth > 6) return '…';
67
+ if (value === null || value === undefined) return value;
68
+ if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
69
+ if (typeof value === 'object') {
70
+ const out = {};
71
+ for (const [k, v] of Object.entries(value)) {
72
+ out[k] = isSecretKey(k, v) ? `«已脱敏:${maskToken(v)}»` : redact(v, depth + 1);
73
+ }
74
+ return out;
75
+ }
76
+ return value;
77
+ }
78
+
79
+ /** 安全的 JSON 字符串(脱敏 + 容错循环引用) */
80
+ export function safeJson(value, indent = 2) {
81
+ try {
82
+ return JSON.stringify(redact(value), null, indent);
83
+ } catch {
84
+ return String(value);
85
+ }
86
+ }
87
+
88
+ /* ------------------------------------------------------------------ */
89
+ /* 金额 / 时间 */
90
+ /* ------------------------------------------------------------------ */
91
+
92
+ /** 分 → `¥1.00`(服务端所有金额都是"分",且不一定是整数,所以先 Number 再判) */
93
+ export function formatMoney(cents) {
94
+ const n = Number(cents);
95
+ if (!Number.isFinite(n)) return `¥${cents ?? '?'}`;
96
+ return `¥${(n / 100).toFixed(2)}`;
97
+ }
98
+
99
+ /** ISO 时间 → `2026-09-30 12:00:00`(本地时区;服务端回的是 UTC ISO) */
100
+ export function formatTime(value) {
101
+ if (!value) return '-';
102
+ const d = new Date(value);
103
+ if (Number.isNaN(d.getTime())) return String(value);
104
+ const p = (n) => String(n).padStart(2, '0');
105
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
106
+ }
107
+
108
+ /** 剩余时长的人话:`还剩 29 天 23 小时` */
109
+ export function formatRemaining(ms) {
110
+ if (!Number.isFinite(ms)) return '未知';
111
+ if (ms <= 0) return '已过期';
112
+ const s = Math.floor(ms / 1000);
113
+ const d = Math.floor(s / 86400);
114
+ const h = Math.floor((s % 86400) / 3600);
115
+ const m = Math.floor((s % 3600) / 60);
116
+ if (d > 0) return `还剩 ${d} 天 ${h} 小时`;
117
+ if (h > 0) return `还剩 ${h} 小时 ${m} 分`;
118
+ return `还剩 ${m} 分 ${s % 60} 秒`;
119
+ }
120
+
121
+ /* ------------------------------------------------------------------ */
122
+ /* 表格(按显示宽度对齐,中文算 2 列) */
123
+ /* ------------------------------------------------------------------ */
124
+
125
+ /** CJK / 全角字符按 2 列宽计算,否则中文表格会歪(踩过:全用 .length 对齐,列参差不齐) */
126
+ export function displayWidth(str) {
127
+ let w = 0;
128
+ for (const ch of String(str ?? '')) {
129
+ const cp = ch.codePointAt(0);
130
+ const wide =
131
+ (cp >= 0x1100 && cp <= 0x115f) ||
132
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
133
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
134
+ (cp >= 0xf900 && cp <= 0xfaff) ||
135
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
136
+ (cp >= 0xff00 && cp <= 0xff60) ||
137
+ (cp >= 0xffe0 && cp <= 0xffe6);
138
+ w += wide ? 2 : 1;
139
+ }
140
+ return w;
141
+ }
142
+
143
+ function padEndWide(str, width) {
144
+ const s = String(str ?? '');
145
+ const pad = width - displayWidth(s);
146
+ return pad > 0 ? s + ' '.repeat(pad) : s;
147
+ }
148
+
149
+ function truncateWide(str, width) {
150
+ let s = String(str ?? '');
151
+ if (displayWidth(s) <= width) return s;
152
+ let out = '';
153
+ let w = 0;
154
+ for (const ch of s) {
155
+ const cw = displayWidth(ch);
156
+ if (w + cw > width - 1) break;
157
+ out += ch;
158
+ w += cw;
159
+ }
160
+ return out + '…';
161
+ }
162
+
163
+ /**
164
+ * 渲染简单表格。
165
+ * @param {Array<{title:string, key:string, width?:number, format?:Function}>} columns
166
+ * @param {Array<object>} rows
167
+ */
168
+ export function renderTable(columns, rows) {
169
+ if (!rows || rows.length === 0) return '(没有数据)';
170
+ const cells = rows.map((r) =>
171
+ columns.map((c) => {
172
+ const raw = c.format ? c.format(r[c.key], r) : r[c.key];
173
+ return raw === undefined || raw === null || raw === '' ? '-' : String(raw);
174
+ }),
175
+ );
176
+ const widths = columns.map((c, i) => {
177
+ const natural = Math.max(displayWidth(c.title), ...cells.map((row) => displayWidth(row[i])));
178
+ const cap = c.width || 42;
179
+ return Math.min(natural, cap);
180
+ });
181
+ const head = columns.map((c, i) => padEndWide(c.title, widths[i])).join(' ');
182
+ const sep = widths.map((w) => '─'.repeat(w)).join(' ');
183
+ const body = cells
184
+ .map((row) => row.map((cell, i) => padEndWide(truncateWide(cell, widths[i]), widths[i])).join(' '))
185
+ .join('\n');
186
+ return `${head}\n${sep}\n${body}`;
187
+ }
188
+
189
+ /* ------------------------------------------------------------------ */
190
+ /* 颜色(只在 TTY 且没被 --no-color 关掉时用) */
191
+ /* ------------------------------------------------------------------ */
192
+
193
+ export function createPalette({ enabled = true } = {}) {
194
+ const wrap = (code) => (s) => (enabled ? `\u001b[${code}m${s}\u001b[0m` : String(s));
195
+ return {
196
+ enabled,
197
+ green: wrap('32'),
198
+ red: wrap('31'),
199
+ yellow: wrap('33'),
200
+ cyan: wrap('36'),
201
+ dim: wrap('2'),
202
+ bold: wrap('1'),
203
+ };
204
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * HTTP 薄封装:超时、JSON、错误不吞。
3
+ *
4
+ * 为什么不用 axios/got/node-fetch:
5
+ * 1. 老板明确要"尽量零第三方依赖"(npx 一行就能跑、审计面小、不怕供应链)。
6
+ * 2. Node 18+ 自带 fetch,够用。
7
+ *
8
+ * 为什么返回 `{status:0, networkError}` 而不是直接 throw:
9
+ * 调用方需要**保留原始 message**(DNS 失败?TLS 失败?超时?),
10
+ * 而这些信息只有 catch 里才有。统一在这里收口,上层就不会写出"失败了"这种吞错误的代码。
11
+ */
12
+
13
+ export const DEFAULT_TIMEOUT_MS = 30000;
14
+
15
+ /**
16
+ * @returns {Promise<{status:number, headers:any, text:string, body:any, parseError:string|null, networkError?:string, cause?:any}>}
17
+ */
18
+ export async function request(url, options = {}) {
19
+ const {
20
+ method = 'GET',
21
+ headers = {},
22
+ body,
23
+ timeoutMs = DEFAULT_TIMEOUT_MS,
24
+ fetchImpl = globalThis.fetch,
25
+ redirect = 'follow',
26
+ } = options;
27
+
28
+ if (typeof fetchImpl !== 'function') {
29
+ return { status: 0, headers: null, text: '', body: null, parseError: null, networkError: '当前 Node 没有可用的 fetch(需要 Node 18+)' };
30
+ }
31
+
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
34
+ try {
35
+ const res = await fetchImpl(url, { method, headers, body, signal: controller.signal, redirect });
36
+ const text = await res.text();
37
+ let parsed = null;
38
+ let parseError = null;
39
+ if (text) {
40
+ try {
41
+ parsed = JSON.parse(text);
42
+ } catch (e) {
43
+ parseError = e.message;
44
+ }
45
+ }
46
+ return { status: res.status, headers: res.headers, text, body: parsed, parseError };
47
+ } catch (e) {
48
+ const aborted = e && (e.name === 'AbortError' || /aborted/i.test(String(e.message)));
49
+ return {
50
+ status: 0,
51
+ headers: null,
52
+ text: '',
53
+ body: null,
54
+ parseError: null,
55
+ networkError: aborted ? `请求超时(${timeoutMs}ms 内没有响应)` : (e && e.message) || String(e),
56
+ cause: e,
57
+ };
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ /** form-urlencoded 编码(OAuth 的 token/revoke/introspect 三个端点都用它) */
64
+ export function encodeForm(obj) {
65
+ const usp = new URLSearchParams();
66
+ for (const [k, v] of Object.entries(obj || {})) {
67
+ if (v === undefined || v === null) continue;
68
+ usp.append(k, String(v));
69
+ }
70
+ return usp.toString();
71
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 库入口(给"想在 Node 里直接调"的用法,比如我们自己的 AI 端把 CLI 当函数用)。
3
+ *
4
+ * ⚠️ 这里导出的东西**都不打印令牌**;`login()` 返回的对象里含 access_token,
5
+ * 调用方自己要负责不要把它打到日志里(CLI 内部就是这么做的)。
6
+ */
7
+
8
+ export { run } from './cli.mjs';
9
+ export { createRuntime, createOutput, okEnvelope, errorEnvelope } from './runtime.mjs';
10
+ export { createApi } from './api.mjs';
11
+ export {
12
+ runLogin,
13
+ openInBrowser,
14
+ defaultOpenBrowser,
15
+ defaultOpenBrowserAsync,
16
+ openInBrowserWithFallback,
17
+ /** 「把网址交给用户浏览器」的唯一入口(login / 钱包充值共用) */
18
+ openUrlForUser,
19
+ } from './login.mjs';
20
+ export { COMMANDS, renderHelp } from './commands/index.mjs';
21
+ export {
22
+ resolveConfig,
23
+ stripTrailingSlash,
24
+ withTrailingSlash,
25
+ parseDurationMs,
26
+ DEFAULT_BASE,
27
+ DEFAULT_CLIENT_ID,
28
+ DEFAULT_SCOPE,
29
+ } from './config.mjs';
30
+ export {
31
+ readCredentials,
32
+ writeCredentials,
33
+ deleteCredentials,
34
+ credentialsFromToken,
35
+ checkFileMode,
36
+ } from './credentials.mjs';
37
+ export { classifyResponse, CliError, UsageError, EXIT } from './errors.mjs';
38
+ export { createVerifier, challengeOf, createState } from './pkce.mjs';
39
+ export { maskToken, redact, formatMoney, renderTable } from './format.mjs';