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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * 钱包命令:`wallet` / `wallet txns` / `wallet recharge`
3
+ *
4
+ * 这是"钱包余额支付"链路的**观察窗口**:每次下单/支付/退款后都用它复核余额,
5
+ * 报告里的"事前/事后一分不差"就是拿它的数字对比的。
6
+ *
7
+ * ⚠️ 关于"钱包流水"的接口事实(已核对服务端代码):
8
+ * * `getMyWallet` 自己就带了最近流水(`data.rows`,按时间倒序,limit 1~100)——
9
+ * **普通用户看自己的流水就是走它**,这就是 `wallet txns` 的实现。
10
+ * * `adminListWalletTxns` 是**管理员**接口(能力 `billing:manage`),
11
+ * OAuth 令牌默认拿不到,也不该拿 —— 所以 CLI **不会**去调它。
12
+ * * 服务端**没有**"我的流水分页/按时间筛选"的独立接口(只有 limit,没有 offset)。
13
+ * 这是个真实缺口,已写进报告。
14
+ */
15
+
16
+ import { withTrailingSlash } from '../config.mjs';
17
+ import { UsageError, CliError, EXIT } from '../errors.mjs';
18
+ import { formatMoney, formatTime } from '../format.mjs';
19
+ import { openUrlForUser, browserPasteText, browserOpenedText, browserFailedHintLines } from '../login.mjs';
20
+ import { renderQrOptional } from '../qr.mjs';
21
+
22
+ /**
23
+ * 拼出「我们自己的网上收银台」地址:`<webBase>/pay/?order_id=<id>&token=<支付令牌>`
24
+ *
25
+ * ## 为什么必须走这里、不许在命令里手拼
26
+ *
27
+ * 1. **尾斜杠必须在 `?` 之前**(本仓库铁律,踩过线上事故):
28
+ * 前端是静态导出,产物是"每个路由一个目录"(`dist/pay/index.html`),
29
+ * 托管侧(OSS 静态托管 / 阿里云 ESA)对**不带尾斜杠**的目录路径会返回 302,
30
+ * 而这个 302 **不携带原始 query** —— 于是 `?order_id=…` 在浏览器里
31
+ * 根本到不了页面,用户看到的是一个"没有订单号的收银台"。
32
+ * 所以统一走 `withTrailingSlash()`(全项目唯一构造入口,见 `config.mjs`)。
33
+ * 2. 参数一律走 `URLSearchParams` 编码:手拼一旦漏编码,
34
+ * 订单号里任何特殊字符都会把后面的参数吃掉(登录回跳那条链踩过同样的坑)。
35
+ * ⚠️ 令牌是 **base64url**,含 `-` / `_` / `.`,看着"很安全",但**也必须编码** ——
36
+ * 别因为"它字面上没特殊字符"就手拼。
37
+ *
38
+ * ⚠️ 老板定的产品流程就是这条:**在网上生成订单 → 打开我们网站的收银台页面**,
39
+ * 而不是"在本地搞一个二维码让别人扫"。
40
+ *
41
+ * ## ★ 2026-10-02 新增第三个参数:订单支付令牌(`token`)
42
+ * 老板原话:「你拿到 token 之后,**以那个 token 为基础来进行支付**,
43
+ * 而不是还要他用**用户名那一套**来进行支付。」
44
+ *
45
+ * 现场:用户是在 **AI 客户端右侧的内置浏览器**里付这一单,那里**通常没有网页登录态**
46
+ * (甚至跟他平时登录后台的账号不是同一个)。服务端在下单时把"这一单是谁的"签成
47
+ * 一把**只对这一单有效**的令牌(`createRechargeOrder` 的 `pay_token`)交给我们,
48
+ * 收银台凭它就能读这一单的支付参数 —— **不需要网页登录**。
49
+ * ⚠️ 令牌只授权"读这一单的支付参数":读不到钱包、读不到订单列表、不能下单、不能取消。
50
+ *
51
+ * ⚠️ 幂等/兼容:服务端**没给**令牌(老版本云函数未部署 / 密钥未配置)时,
52
+ * `payUrl` 照旧只有 `order_id` —— 那条路**仍然要求网页登录**,
53
+ * 行为与改造前逐字一致(不因为"新功能没上线"就把命令搞失败)。
54
+ *
55
+ * @param {string} webBase 前端基址。★ 2026-10-02 起传的是 `config.payWebBase`
56
+ * (`https://pay.wkgeo.com`),老站基址只是兜底
57
+ * @param {string} orderId 订单号
58
+ * @param {string} [payToken] 订单支付令牌(`pay_token`),没有就不带这个参数
59
+ * @returns {string|null} 订单号缺失时返回 null(没订单号就拼不出能用的收银台地址)
60
+ */
61
+ export function buildPayUrl(webBase, orderId, payToken) {
62
+ const id = orderId === undefined || orderId === null ? '' : String(orderId).trim();
63
+ if (!id) return null;
64
+ const base = String(webBase || '').replace(/\/+$/, '');
65
+ const params = { order_id: id };
66
+ const token =
67
+ payToken === undefined || payToken === null ? '' : String(payToken).trim();
68
+ if (token) params.token = token;
69
+ const query = new URLSearchParams(params).toString();
70
+ return withTrailingSlash(`${base}/pay`) + `?${query}`;
71
+ }
72
+
73
+ const KIND_LABEL = {
74
+ recharge: '充值',
75
+ pay: '支付',
76
+ refund: '退款',
77
+ adjust: '调账',
78
+ grant: '赠送',
79
+ withdraw: '提现',
80
+ };
81
+
82
+ function normalizeTxn(r) {
83
+ const amount = Number(r.amount_cents) || 0;
84
+ return {
85
+ id: r.id ?? null,
86
+ kind: r.kind || null,
87
+ kind_label: KIND_LABEL[r.kind] || r.kind || null,
88
+ amount_cents: amount,
89
+ amount_text: `${amount >= 0 ? '+' : '-'}${formatMoney(Math.abs(amount))}`,
90
+ balance_after_cents: r.balance_after === undefined || r.balance_after === null ? null : Number(r.balance_after),
91
+ balance_after_text: r.balance_after === undefined || r.balance_after === null ? '-' : formatMoney(r.balance_after),
92
+ order_id: r.order_id || null,
93
+ remark: r.remark || null,
94
+ created_at: r.created_at || null,
95
+ };
96
+ }
97
+
98
+ async function loadWallet(ctx, limit) {
99
+ const res = await ctx.callData('getMyWallet', { limit });
100
+ const rows = (res && Array.isArray(res.rows) ? res.rows : []).map(normalizeTxn);
101
+ return { balance_cents: Number((res && res.balance_cents) || 0), rows };
102
+ }
103
+
104
+ const TXN_COLUMNS = [
105
+ { title: '时间', key: 'created_at', format: (v) => formatTime(v), width: 20 },
106
+ { title: '类型', key: 'kind_label', width: 8 },
107
+ { title: '金额', key: 'amount_text', width: 12 },
108
+ { title: '余额后', key: 'balance_after_text', width: 12 },
109
+ { title: '备注', key: 'remark', width: 34 },
110
+ ];
111
+
112
+ /* ------------------------------------------------------------------ */
113
+ /* wallet(总览) */
114
+ /* ------------------------------------------------------------------ */
115
+
116
+ export const walletCommand = {
117
+ name: 'wallet',
118
+ summary: '查钱包余额与最近流水(加 --entitlements 看生效中的权益)',
119
+ usage: 'wkgeo wallet [--limit 10] [--entitlements]',
120
+ flags: {
121
+ limit: { type: 'string', desc: '流水条数(1~100,默认 10)' },
122
+ entitlements: { type: 'boolean', desc: '同时列出我的权益(加量包/套餐生效情况)' },
123
+ },
124
+ async run(ctx) {
125
+ const { out, flags } = ctx;
126
+ const limit = flags.limit !== undefined ? Number(flags.limit) : 10;
127
+ const wallet = await loadWallet(ctx, limit);
128
+
129
+ let entitlements = null;
130
+ if (flags.entitlements) {
131
+ try {
132
+ const e = await ctx.callData('listMyEntitlements', {});
133
+ entitlements = (e && e.rows) || [];
134
+ } catch (err) {
135
+ // 权益接口未部署/无权限时不该让"查余额"这个核心动作失败
136
+ out.warn(`读取权益失败(不影响余额):${err.message}`);
137
+ entitlements = null;
138
+ }
139
+ }
140
+
141
+ const data = {
142
+ balance_cents: wallet.balance_cents,
143
+ balance_text: formatMoney(wallet.balance_cents),
144
+ rows: wallet.rows,
145
+ entitlements: entitlements
146
+ ? entitlements.map((x) => ({
147
+ quota_key: x.quota_key || null,
148
+ name: x.name || null,
149
+ amount: x.amount ?? null,
150
+ used: x.used ?? null,
151
+ status: x.status || null,
152
+ expires_at: x.expires_at || null,
153
+ }))
154
+ : undefined,
155
+ };
156
+
157
+ if (!out.json) {
158
+ out.head('钱包');
159
+ out.line(` 余额:${data.balance_text}`);
160
+ out.line('');
161
+ out.line('最近流水:');
162
+ out.table(TXN_COLUMNS, wallet.rows);
163
+ if (entitlements) {
164
+ out.line('');
165
+ out.line('生效中的权益:');
166
+ out.table(
167
+ [
168
+ { title: '配额', key: 'quota_key', width: 18 },
169
+ { title: '数量', key: 'amount', width: 12 },
170
+ { title: '状态', key: 'status', width: 10 },
171
+ { title: '到期', key: 'expires_at', format: (v) => formatTime(v), width: 20 },
172
+ ],
173
+ entitlements,
174
+ );
175
+ }
176
+ }
177
+ ctx.done(data);
178
+ return data;
179
+ },
180
+ };
181
+
182
+ /* ------------------------------------------------------------------ */
183
+ /* wallet txns */
184
+ /* ------------------------------------------------------------------ */
185
+
186
+ export const walletTxnsCommand = {
187
+ name: 'wallet txns',
188
+ summary: '钱包流水(走"我的钱包"接口自带的流水,普通用户即可读)',
189
+ usage: 'wkgeo wallet txns [--limit 20]',
190
+ flags: {
191
+ limit: { type: 'string', desc: '流水条数(1~100,默认 20)' },
192
+ },
193
+ async run(ctx) {
194
+ const { out, flags } = ctx;
195
+ const limit = flags.limit !== undefined ? Number(flags.limit) : 20;
196
+ const wallet = await loadWallet(ctx, limit);
197
+ const data = {
198
+ balance_cents: wallet.balance_cents,
199
+ balance_text: formatMoney(wallet.balance_cents),
200
+ rows: wallet.rows,
201
+ total: wallet.rows.length,
202
+ note: '服务端当前只支持 limit,没有 offset/时间筛选(我的流水缺口)',
203
+ };
204
+ if (!out.json) {
205
+ out.head(`钱包流水(最近 ${wallet.rows.length} 条)`);
206
+ out.line(` 当前余额:${data.balance_text}`);
207
+ out.line('');
208
+ out.table(TXN_COLUMNS, wallet.rows);
209
+ }
210
+ ctx.done(data);
211
+ return data;
212
+ },
213
+ };
214
+
215
+ /* ------------------------------------------------------------------ */
216
+ /* wallet recharge */
217
+ /* ------------------------------------------------------------------ */
218
+
219
+ export const walletRechargeCommand = {
220
+ name: 'wallet recharge',
221
+ summary:
222
+ '充值下单:创建一笔微信 Native 待支付订单,**并打开我们网站的收银台页面**扫码支付(不会自动扣款)',
223
+ usage: 'wkgeo wallet recharge --amount-cents <分> [--no-browser] [--open-browser] [--dry-run]',
224
+ flags: {
225
+ 'amount-cents': { type: 'string', required: true, desc: '充值金额,单位**分**(如 100 = ¥1.00,最低 100,最高 10000000)' },
226
+ 'no-browser': { type: 'boolean', desc: '不自动打开浏览器(自动化/无桌面环境用;地址仍会打印并放在 pay_url 里)' },
227
+ /**
228
+ * ★ 老板最终拍板走**传统路线**:默认**总是尝试打开**系统浏览器(人在终端与 AI 场景都一样),
229
+ * 所以本开关现在**与默认行为等价**。保留它只为两件事:
230
+ * 1. **显式语义**(脚本里写"我就是要弹"比依赖默认值更清楚);
231
+ * 2. **兼容**既有脚本(不许删)。
232
+ * ⚠️ `--no-browser` 优先(关掉就是关掉,见 login.mjs 的 resolveBrowserIntent)。
233
+ */
234
+ 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
235
+ 'dry-run': { type: 'boolean', desc: '只打印将要做什么,不真的创建订单' },
236
+ },
237
+ async run(ctx) {
238
+ const { out, flags } = ctx;
239
+ const amountCents = Number(flags.amountCents);
240
+ if (!Number.isInteger(amountCents) || amountCents <= 0) {
241
+ throw new UsageError(`--amount-cents 必须是正整数(单位:分),收到:${flags.amountCents}`);
242
+ }
243
+ if (amountCents < 100) throw new UsageError('充值金额不能低于 ¥1.00(100 分)');
244
+ if (amountCents > 10000000) throw new UsageError('单笔充值不能超过 ¥100,000.00(10000000 分)');
245
+
246
+ if (flags.dryRun === true) {
247
+ const data = {
248
+ dry_run: true,
249
+ amount_cents: amountCents,
250
+ amount_text: formatMoney(amountCents),
251
+ created: false,
252
+ /** 真跑时会给出的收银台地址(彩排阶段还没有订单号,所以是 null) */
253
+ pay_url: null,
254
+ };
255
+ if (!out.json) {
256
+ out.head('充值(彩排,未创建订单)');
257
+ out.line(` 将创建一笔 ¥${(amountCents / 100).toFixed(2)} 的微信 Native 待支付订单`);
258
+ out.info(' 去掉 --dry-run 即真正创建;创建后 CLI 会**打开我们网站的收银台页面**让你扫码支付(不会自动扣款)。');
259
+ }
260
+ ctx.done(data);
261
+ return data;
262
+ }
263
+
264
+ /**
265
+ * ⚠️ 这里会**在微信侧创建一笔真实的待支付商户订单**(不扣款、不自动支付)。
266
+ * 这是"老板要测充值"必须的能力,但绝不能在自动化测试里跑 ——
267
+ * 测试只在离线桩里验证请求体,真钱流程由人手动完成。
268
+ */
269
+ const res = await ctx.callData('createRechargeOrder', { amount_cents: amountCents });
270
+ const orderId = (res && res.order_id) || null;
271
+ /**
272
+ * ★ 我们自己的**网上收银台**地址(老板定的产品流程:走网上充值页面,
273
+ * 而不是在本地生成一个二维码页面让人扫)。
274
+ * 拼装口径与尾斜杠规则见本文件顶部的 `buildPayUrl()`。
275
+ *
276
+ * ★ 第三个参数 = 服务端在**下单那一刻**签发的订单支付令牌(`pay_token`):
277
+ * 带上它,收银台在**没有网页登录态**的 AI 端内置浏览器里也能付这一单
278
+ * (老板:"以那个 token 为基础来进行支付,而不是还要他用用户名那一套")。
279
+ * ⚠️ 老服务端没这个字段时就是 `undefined` ⇒ 地址退回只有 `order_id` 的老形态。
280
+ *
281
+ * ★ 2026-10-02(三站拆分):基址改用**收银台子域** `config.payWebBase`
282
+ * (`https://pay.wkgeo.com`)。`config.webBase` 只作为老配置对象的兜底
283
+ * (联调时 `WKONGAI_WEB_BASE` 总覆盖会把两者设成同一个值)。
284
+ */
285
+ const payUrl = buildPayUrl(
286
+ ctx.config && (ctx.config.payWebBase || ctx.config.webBase),
287
+ orderId,
288
+ res && res.pay_token,
289
+ );
290
+ const data = {
291
+ order_id: orderId,
292
+ out_trade_no: (res && res.out_trade_no) || null,
293
+ paid: !!(res && res.paid),
294
+ amount_cents: Number((res && res.amount_cents) || amountCents),
295
+ amount_text: formatMoney((res && res.amount_cents) || amountCents),
296
+ pay_amount_cents: res && res.pay_amount_cents !== undefined ? Number(res.pay_amount_cents) : null,
297
+ /** 微信侧的支付链接:**保留给自动化**,但人看的流程走 pay_url(下面那行) */
298
+ code_url: (res && res.code_url) || null,
299
+ /** ★ 收银台地址:人点它、自动化也可以直接开它 */
300
+ pay_url: payUrl,
301
+ expires_at_minutes: (res && res.expires_at_minutes) || null,
302
+ wallet_paid_cents: Number((res && res.wallet_paid_cents) || 0),
303
+ open_browser: { attempted: false, opened: false, tried: [], url: payUrl || '', skipped_reason: null },
304
+ /** ★ 给 AI 端"照着念"的一句话(下面 openUrlForUser 之后会按实际情况覆盖) */
305
+ paste_text: payUrl ? browserPasteText(payUrl, { kind: 'pay' }) : null,
306
+ };
307
+
308
+ /**
309
+ * 自动打开收银台。
310
+ *
311
+ * ⚠️ 复用 `login.mjs` 的 `openUrlForUser`(多策略 + 结果归一化 + ★「默认总是尝试打开」闸门),
312
+ * **不是**在这里再写一份 spawn —— 那几个坑(cmd start 的引号、URL 里的 `&`
313
+ * 被 shell 吃掉、error 事件没挂)在那边已经踩过并修过,复制一份必然会再踩。
314
+ * ⚠️ 打开失败**绝不允许**让命令失败:地址已经完整打印(人类可读)并放在
315
+ * `--json` 的 `pay_url` 里,手动复制照样能完成支付。
316
+ * ⚠️ 闸门在 `openUrlForUser` **内部**:这里不再自己判断"要不要开"(除了 payUrl 有没有),
317
+ * 否则总有一天会出现"授权不弹、支付还弹"。
318
+ */
319
+ if (payUrl) {
320
+ data.open_browser = await openUrlForUser(payUrl, {
321
+ deps: {
322
+ openBrowser: ctx.openBrowser,
323
+ spawnImpl: ctx.spawnImpl,
324
+ platform: ctx.platform,
325
+ verbose: !!(ctx.config && ctx.config.verbose),
326
+ /** ★ 与 login 同源的四个事实(见 login.mjs 的 resolveBrowserIntent) */
327
+ isTTY: ctx.isTTY === true,
328
+ json: out.json === true,
329
+ forceOpen: flags.openBrowser === true,
330
+ noBrowser: flags.noBrowser === true,
331
+ },
332
+ log: (m) => out.info(m),
333
+ });
334
+ data.paste_text = browserPasteText(payUrl, { kind: 'pay' });
335
+ }
336
+
337
+ if (!out.json) {
338
+ out.head('充值订单已创建');
339
+ out.line(` 订单号:${data.order_id}`);
340
+ out.line(` 金额:${data.amount_text}`);
341
+ out.line(` 有效期:${data.expires_at_minutes ?? '?'} 分钟`);
342
+ if (payUrl) {
343
+ out.line('');
344
+ out.line(' 收银台(我们网站的充值页面,请在这里扫码支付):');
345
+ out.line(` ${payUrl}`);
346
+ if (flags.noBrowser === true) {
347
+ out.info(' (已按 --no-browser 跳过自动打开)请把上面这一整行复制到浏览器地址栏打开。');
348
+ } else if (data.open_browser.opened) {
349
+ // ★ 成功:明说"已经打开了"(老板最终拍板走传统路线:弹系统浏览器)
350
+ out.line(browserOpenedText({ kind: 'pay' }));
351
+ } else {
352
+ /**
353
+ * 打开失败:与 login 同一段文案(`browserFailedHintLines`)——
354
+ * 1. **醒目**:说清"没能自动打开",别让用户以为还在打开中;
355
+ * 2. **可操作**:把**完整地址再打一遍**,并说清动作是"复制→粘贴到地址栏";
356
+ * 3. 说清打开之后干什么(用微信扫码)。
357
+ */
358
+ for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: ctx.isTTY !== true })) {
359
+ out.line(line);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * ★ 2026-10-02(老板):**支付完成之后那个页面会自己关掉** —— 提前交代一句。
365
+ * 为什么要说:页面"自己没了"时,用户第一反应是"是不是崩了 / 是不是没付成功";
366
+ * 提前说一句,这件事就变成"预期之内"。
367
+ * ⚠️ 后半句"没关就手动关掉即可"**不能省**:`window.close()` 只对**脚本打开的**窗口
368
+ * 有效,收银台是导航过去的 ⇒ **大概率关不掉**(详见
369
+ * `admin-web/src/pages/billing/pay` 的「尝试自动关闭」注释)。
370
+ */
371
+ out.info('(支付成功后页面会自动尝试关闭;没关就手动关掉即可。)');
372
+
373
+ /**
374
+ * 终端二维码:**只在网页打不开时**作为兜底。
375
+ * ⚠️ 老板明确否掉了"本地生成二维码让别人扫"当**主流程**的做法:
376
+ * 二维码只是收银台页面内部的一个展示件;终端里画码这条路必须降级,
377
+ * 而且要说清"正常应该在我们网站的收银台页面上扫"。
378
+ */
379
+ const 网页没打开 = data.open_browser.attempted && !data.open_browser.opened;
380
+ if (网页没打开 && data.code_url) {
381
+ out.line('');
382
+ out.info(' 兜底:网页确实打不开时,也可以直接用微信扫下面这个二维码。');
383
+ out.info(' (正常流程请在上面那个收银台页面里扫码支付。)');
384
+ const qrShown = await renderQrOptional(data.code_url, out);
385
+ if (!qrShown) {
386
+ out.info(' (未安装可选的 qrcode-terminal,无法在终端画码;请用上面的收银台地址付款)');
387
+ }
388
+ }
389
+ out.line('');
390
+ out.line(` 支付完成后运行:wkgeo orders check ${data.order_id} # 查支付结果与最新余额`);
391
+ } else if (data.code_url) {
392
+ out.warn('没有拼出收银台地址(订单号缺失),只能给出微信支付链接');
393
+ out.line(` ${data.code_url}`);
394
+ } else {
395
+ out.warn('服务端没有返回 code_url(可能微信支付参数未配置),请到网页后台核对');
396
+ }
397
+ out.info(' CLI 不会替你付款,也不会模拟支付成功。');
398
+ } else if (payUrl) {
399
+ /**
400
+ * ★ `--json` / 非 TTY 时,**人看的那段提示不能消失**:走 stderr(`out.notice`),
401
+ * stdout 依然只有机器可读 JSON;结构化字段(`pay_url` / `paste_text` / `open_browser`)
402
+ * 照样都在 stdout 里。
403
+ *
404
+ * ★ 老板最终拍板走传统路线后,这里的分支变成"如实播报结果":
405
+ * * `--no-browser` → 明说跳过了(地址仍要打印,用户得能复制);
406
+ * * 打开成功 → 说一句"已为你打开浏览器";
407
+ * * 打开失败 → ⚠️ 那段**含完整地址**的醒目提示(AI 端/用户就靠它手动打开)。
408
+ * ⚠️ 失败提示**不能只给 JSON**:真客户不会去 `pay_url` 字段里翻地址。
409
+ */
410
+ if (flags.noBrowser === true) {
411
+ out.notice('(已按 --no-browser 跳过自动打开)请手动复制 pay_url 到浏览器地址栏打开。');
412
+ } else if (data.open_browser.opened) {
413
+ out.notice(browserOpenedText({ kind: 'pay' }));
414
+ } else {
415
+ for (const line of browserFailedHintLines(payUrl, { kind: 'pay', agent: true })) out.notice(line);
416
+ }
417
+ }
418
+ ctx.done(data);
419
+ return data;
420
+ },
421
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 命令注册表 + 帮助文本。
3
+ *
4
+ * 为什么注册表要同时带着 `usage`/`flags`/`args`:
5
+ * * 解析器用它做校验(未知参数直接报错,不静默忽略)
6
+ * * `--help` 用它生成文本 —— **帮助与实际行为同源**,不会出现"文档写了但命令不支持"
7
+ */
8
+
9
+ import { GLOBAL_FLAGS } from '../args.mjs';
10
+ import { loginCommand, logoutCommand, whoamiCommand, configCommand } from './auth.mjs';
11
+ import { sitesListCommand, sitesCreateCommand, sitesDeleteCommand } from './sites.mjs';
12
+ import { walletCommand, walletTxnsCommand, walletRechargeCommand } from './billing.mjs';
13
+ import {
14
+ productsListCommand,
15
+ ordersCreateCommand,
16
+ ordersCheckCommand,
17
+ ordersPayCommand,
18
+ ordersGetCommand,
19
+ ordersListCommand,
20
+ } from './orders.mjs';
21
+ import { appsListCommand, appsRevokeCommand } from './apps.mjs';
22
+ import { skillCommand } from './skill.mjs';
23
+
24
+ export const COMMANDS = [
25
+ loginCommand,
26
+ whoamiCommand,
27
+ logoutCommand,
28
+ sitesCreateCommand,
29
+ sitesListCommand,
30
+ sitesDeleteCommand,
31
+ walletCommand,
32
+ walletTxnsCommand,
33
+ walletRechargeCommand,
34
+ productsListCommand,
35
+ ordersCreateCommand,
36
+ ordersCheckCommand,
37
+ ordersPayCommand,
38
+ ordersGetCommand,
39
+ ordersListCommand,
40
+ appsListCommand,
41
+ appsRevokeCommand,
42
+ /**
43
+ * `skill`:把随包分发的 `SKILL.md` 原文打印出来(AI 端自取手册)。
44
+ * 放在 config 前面 —— 两者同属「(基础)」,都是"不登录也能跑"的本地命令。
45
+ */
46
+ skillCommand,
47
+ configCommand,
48
+ ];
49
+
50
+ export const COMMAND_NAMES = COMMANDS.map((c) => c.name);
51
+
52
+ /**
53
+ * 分组规则:两词命令(`sites create`)归到第一个词下;
54
+ * 单词命令如果**同时是某个分组名**(`wallet` 有 `wallet txns` 子命令)也归到自己名下。
55
+ * 否则归「(基础)」。这样帮助里的分组跟人的直觉一致。
56
+ */
57
+ function groupOf(command) {
58
+ if (command.name.includes(' ')) return command.name.split(' ')[0];
59
+ if (COMMANDS.some((c) => c.name.startsWith(`${command.name} `))) return command.name;
60
+ return '(基础)';
61
+ }
62
+
63
+ /**
64
+ * 会"按时间等"的通用参数必须把**单位**写进帮助里。
65
+ *
66
+ * 踩过的坑:`--timeout` 原来按毫秒解释,`--timeout 600` 变成 600 毫秒(0.8 秒就超时),
67
+ * 而帮助里连单位都没写 —— 用户完全没法从 `--help` 看出自己写错了。
68
+ * 这里直接引用 `GLOBAL_FLAGS` 的描述(**帮助与解析同源**,不会出现"文档写了但行为不一样")。
69
+ */
70
+ function renderTimedGlobalFlags() {
71
+ return [
72
+ ` --timeout <时长> ${GLOBAL_FLAGS.timeout.desc}`,
73
+ ` --http-timeout <毫秒> ${GLOBAL_FLAGS['http-timeout'].desc}`,
74
+ ];
75
+ }
76
+
77
+ /** 人类可读帮助。`wkgeo --help` / `wkgeo help` / `wkgeo sites create --help` 都走它 */
78
+ export function renderHelp(name = null) {
79
+ const line = (s = '') => s;
80
+ const spec = name ? COMMANDS.find((c) => c.name === name) : null;
81
+
82
+ if (spec) {
83
+ const out = [];
84
+ out.push(line(`wkgeo ${spec.name} —— ${spec.summary}`));
85
+ out.push('');
86
+ out.push(line('用法:'));
87
+ for (const u of String(spec.usage).split('\n')) out.push(line(` ${u}`));
88
+ if (spec.args && spec.args.length) {
89
+ out.push('');
90
+ out.push(line('位置参数:'));
91
+ for (const a of spec.args) out.push(line(` <${a.name}>${a.required ? '(必填)' : ''} ${a.desc || ''}`));
92
+ }
93
+ const own = Object.entries(spec.flags || {});
94
+ if (own.length) {
95
+ out.push('');
96
+ out.push(line('命令参数:'));
97
+ for (const [k, v] of own) out.push(line(` --${k}${v.type === 'boolean' ? '' : ' <值>'}${v.required ? '(必填)' : ''} ${v.desc || ''}`));
98
+ }
99
+ /**
100
+ * 命令自带的"特别说明"(可选)。目前只有 `skill` 用 ——
101
+ * 它**不受**"非终端自动 JSON"约定约束,必须在自己的帮助里说清楚,
102
+ * 否则用户按通用说明去管道里拿 JSON 会拿到 markdown(反之亦然)。
103
+ */
104
+ if (spec.notes && spec.notes.length) {
105
+ out.push('');
106
+ out.push(line('特别说明:'));
107
+ for (const n of spec.notes) out.push(line(` ${n}`));
108
+ }
109
+ out.push('');
110
+ out.push(line('通用参数:--json / --text / --base / --client-id / --credentials / --verbose / --help'));
111
+ out.push(line('(默认:输出到管道/非终端时自动使用 --json)'));
112
+ out.push('');
113
+ out.push(line('带单位的通用参数(写清单位,避免"600 被当成 600 毫秒"这类误解):'));
114
+ for (const l of renderTimedGlobalFlags()) out.push(line(l));
115
+ return out.join('\n');
116
+ }
117
+
118
+ const groups = new Map();
119
+ for (const c of COMMANDS) {
120
+ const g = groupOf(c);
121
+ if (!groups.has(g)) groups.set(g, []);
122
+ groups.get(g).push(c);
123
+ }
124
+
125
+ const out = [];
126
+ out.push('云站平台官方 CLI —— 授权登录一次,之后建站 / 下单 / 钱包支付 / 查余额都不用再登录');
127
+ out.push('');
128
+ out.push('用法:wkgeo <命令> [参数]');
129
+ out.push('');
130
+ for (const [g, list] of groups) {
131
+ out.push(`${g}:`);
132
+ for (const c of list) out.push(` ${c.name.padEnd(16)} ${c.summary}`);
133
+ out.push('');
134
+ }
135
+ out.push('常用示例:');
136
+ out.push(' wkgeo login # 浏览器授权一次,令牌存本地(30 天)');
137
+ out.push(' wkgeo whoami # 看当前登录的是谁');
138
+ out.push(' wkgeo sites create --name 我的站 # 建站(返回站点 id)');
139
+ out.push(' wkgeo products list # 能买什么');
140
+ out.push(' wkgeo orders create --product storage_mb --units 1 # 下单(余额够就当场付清)');
141
+ out.push(' wkgeo wallet # 查余额 / wallet txns 看流水');
142
+ out.push(' wkgeo wallet recharge --amount-cents 1000 # 充值下单(微信扫码,需人工支付)');
143
+ out.push(' wkgeo orders check <订单ID> # 查支付结果 + 最新余额');
144
+ out.push(' wkgeo orders list # 我的订单');
145
+ /**
146
+ * ⚠️ 这条示例是为了让 AI 端**能自己发现**手册入口:
147
+ * 手册一直随包分发(package.json 的 files 里有 SKILL.md),但在加 `skill` 命令之前
148
+ * 没有任何命令能把它取出来 —— AI 只能去 node_modules 里翻,等于"分发了但拿不到"。
149
+ */
150
+ out.push(' wkgeo skill # 打印给 AI 端的操作手册(随包分发)');
151
+ out.push('');
152
+ out.push('通用参数:--json(机器可读;非终端下默认开启)/ --text(强制人类可读)');
153
+ out.push(' --base <url> / --client-id <id> / --credentials <path> / --verbose / --help / --version');
154
+ out.push('');
155
+ out.push('带单位的通用参数(写清单位,避免"600 被当成 600 毫秒"这类误解):');
156
+ for (const l of renderTimedGlobalFlags()) out.push(l);
157
+ out.push('');
158
+ out.push('退出码:0 成功 | 1 失败 | 2 用法错误 | 3 需要(重新)授权 | 4 被限流');
159
+ return out.join('\n');
160
+ }