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/api.mjs ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 业务 API 封装:`/api/data`(业务面)+ `/api/oauth/*`(授权面)。
3
+ *
4
+ * 设计约定:
5
+ * 1. **只在这里碰 HTTP**。命令层拿到的要么是 `data`,要么是抛出的 CliError。
6
+ * 2. `data()` 返回的是信封里的 `data` 字段(服务端 `ok(x)` → `{code:0,data:x}`)。
7
+ * 如果某个 action 的 data 是标量(字符串/数组),也原样返回。
8
+ * 3. **不做自动重试**。限流(429)自动重试会掩盖问题,也让"写操作"变得不可预测(重试下单=重复下单!)。
9
+ * 只在错误里给出 retry_after,让人/脚本决定。
10
+ * 4. token 只在 Authorization 头里出现;**任何日志/错误里都不带 token**。
11
+ */
12
+
13
+ import { request, encodeForm } from './http.mjs';
14
+ import { classifyResponse, toCliError, networkError, CliError } from './errors.mjs';
15
+
16
+ export function createApi({ config, fetchImpl = globalThis.fetch } = {}) {
17
+ const base = config.base;
18
+ const timeoutMs = config.httpTimeoutMs;
19
+
20
+ async function raw(url, options = {}) {
21
+ return request(url, { ...options, fetchImpl, timeoutMs: options.timeoutMs ?? timeoutMs });
22
+ }
23
+
24
+ /** 决定成败的唯一依据是 body.code(项目约定 HTTP 恒 200) */
25
+ function unwrap(res, action) {
26
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
27
+ const cls = classifyResponse({
28
+ httpStatus: res.status,
29
+ body: res.body,
30
+ action,
31
+ retryAfterHeader: res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null,
32
+ cmdName: config.cmdName || 'wkgeo',
33
+ });
34
+ if (!cls.ok) throw toCliError(cls, { action, raw: { status: res.status, body: res.body } });
35
+ return res.body ? res.body.data : null;
36
+ }
37
+
38
+ return {
39
+ config,
40
+
41
+ /** 业务面:POST /api/data {action, ...payload} */
42
+ async data(action, payload = {}, { token } = {}) {
43
+ if (!action) throw new CliError('内部错误:缺少 action');
44
+ const res = await raw(`${base}/api/data`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ Accept: 'application/json',
49
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
50
+ },
51
+ body: JSON.stringify({ action, ...payload }),
52
+ });
53
+ return unwrap(res, action);
54
+ },
55
+
56
+ /** 授权服务器元数据(授权前用它发现 authorization_endpoint / token 有效期策略) */
57
+ async metadata() {
58
+ const res = await raw(`${base}/api/oauth/metadata`, { method: 'GET', headers: { Accept: 'application/json' } });
59
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
60
+ if (res.status !== 200 || !res.body) {
61
+ const cls = classifyResponse({ httpStatus: res.status, body: res.body, action: 'metadata', cmdName: config.cmdName || 'wkgeo' });
62
+ throw toCliError(cls, { action: 'metadata', raw: { status: res.status, body: res.body } });
63
+ }
64
+ return res.body;
65
+ },
66
+
67
+ /**
68
+ * 用户信息(身份的唯一权威来源)。
69
+ * ⚠️ 这个端点**不走统一信封**:HTTP 401 + `{error:'invalid_token'}`。
70
+ */
71
+ async userinfo(token) {
72
+ const res = await raw(`${base}/api/oauth/userinfo`, {
73
+ method: 'GET',
74
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
75
+ });
76
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
77
+ if (res.status === 200 && res.body && res.body.sub) return res.body;
78
+ const cls = classifyResponse({ httpStatus: res.status, body: res.body, action: 'userinfo', cmdName: config.cmdName || 'wkgeo' });
79
+ throw toCliError(cls, { action: 'userinfo', raw: { status: res.status, body: res.body } });
80
+ },
81
+
82
+ /**
83
+ * 换令牌 / 刷新令牌(OAuth 标准表单)。
84
+ * ⚠️ 成功响应里**有明文 access_token** —— 返回值只允许写进凭据文件,禁止打印。
85
+ */
86
+ async token(form) {
87
+ const res = await raw(`${base}/api/oauth/token`, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
90
+ body: encodeForm(form),
91
+ });
92
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
93
+ if (res.status === 200 && res.body && res.body.access_token) return res.body;
94
+ const cls = classifyResponse({ httpStatus: res.status, body: res.body, action: 'token', cmdName: config.cmdName || 'wkgeo' });
95
+ throw toCliError(cls, { action: 'token', raw: { status: res.status, body: res.body } });
96
+ },
97
+
98
+ /**
99
+ * 原样发一个 OAuth 表单请求,**不抛异常**(返回 status/body/networkError)。
100
+ *
101
+ * 为什么需要它:设备码轮询里 `authorization_pending` / `slow_down` / `expired_token`
102
+ * 都是**正常预期**的中间态,不是错误 —— 用一个会抛的封装会让轮询逻辑写成一团 try/catch。
103
+ */
104
+ async oauthForm(pathname, form) {
105
+ const res = await raw(`${base}${pathname}`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
108
+ body: encodeForm(form),
109
+ });
110
+ return res;
111
+ },
112
+
113
+ /** 自省(RFC 7662):返回 {active,...},**不是错误**,调用方自己看 active */
114
+ async introspect(token) {
115
+ const res = await raw(`${base}/api/oauth/introspect`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
118
+ body: encodeForm({ token }),
119
+ });
120
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
121
+ if (res.status === 200 && res.body) return res.body;
122
+ const cls = classifyResponse({ httpStatus: res.status, body: res.body, action: 'introspect', cmdName: config.cmdName || 'wkgeo' });
123
+ throw toCliError(cls, { action: 'introspect', raw: { status: res.status, body: res.body } });
124
+ },
125
+
126
+ /**
127
+ * 吊销(RFC 7009)。
128
+ * best-effort:未知 token 也回 200;网络失败**不阻塞 logout**(本地删掉才是关键),
129
+ * 但把失败原因返回给调用方,让 logout 能如实告诉用户"服务端没吊销成功"。
130
+ */
131
+ async revoke(token) {
132
+ try {
133
+ const res = await raw(`${base}/api/oauth/revoke`, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
136
+ body: encodeForm({ token, client_id: config.clientId }),
137
+ });
138
+ if (res.networkError) return { ok: false, reason: res.networkError };
139
+ if (res.status >= 200 && res.status < 300) return { ok: true };
140
+ return { ok: false, reason: `HTTP ${res.status} ${res.text ? res.text.slice(0, 200) : ''}`.trim() };
141
+ } catch (e) {
142
+ return { ok: false, reason: (e && e.message) || String(e) };
143
+ }
144
+ },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * 具名动作封装(不用 this,避免解构后 this 丢失 —— 踩过)。
150
+ * 用法:`const api = createApi({config}); await api.data('listSites', {...}, {token})`
151
+ */
152
+ export const ACTIONS = {
153
+ sites: {
154
+ list: 'listSites',
155
+ get: 'getSite',
156
+ create: 'createSite',
157
+ remove: 'deleteSite',
158
+ purge: 'purgeSite',
159
+ },
160
+ wallet: {
161
+ get: 'getMyWallet',
162
+ billing: 'getMyBilling',
163
+ },
164
+ orders: {
165
+ createQuota: 'createQuotaOrder',
166
+ check: 'checkOrder',
167
+ list: 'listMyOrders',
168
+ cancel: 'cancelOrder',
169
+ },
170
+ catalog: {
171
+ products: 'listProducts',
172
+ entitlements: 'listMyEntitlements',
173
+ },
174
+ apps: {
175
+ list: 'listMyOAuthAuthorizations',
176
+ revoke: 'revokeMyOAuthAuthorization',
177
+ },
178
+ };
package/lib/args.mjs ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 命令行解析(手写,零依赖)。
3
+ *
4
+ * 为什么手写而不是 yargs/commander:
5
+ * 1. 老板要求尽量零第三方依赖(npx 一行跑、审计面小)。
6
+ * 2. 我们只需要"多词命令 + 少量标志",手写 100 行就够,而且**能精确控制报错文案**(中文)。
7
+ *
8
+ * 支持的写法:
9
+ * wkgeo sites create --name "我的站" --customer <id>
10
+ * wkgeo orders create --product=storage_mb --units 1
11
+ * wkgeo login --no-browser # --no-xxx → xxx=false
12
+ * wkgeo wallet -j # 短标志:-h/-v/-y/-j
13
+ *
14
+ * ⚠️ 已知取舍:`--name -abc` 这种"值本身以 - 开头"的写法会被当成布尔标志。
15
+ * 需要时用 `--name=-abc`。这也是大多数 CLI 的行为,不值得为它加复杂度。
16
+ */
17
+
18
+ import { UsageError } from './errors.mjs';
19
+
20
+ /** 全局标志:任何命令都接受(写在 spec 之外,避免每个命令重复声明) */
21
+ export const GLOBAL_FLAGS = {
22
+ base: { type: 'string', desc: 'API 基址(默认线上网关,可用 WKONGAI_BASE 覆盖)' },
23
+ 'web-base': { type: 'string', desc: '前端基址(授权页兜底地址)' },
24
+ 'client-id': { type: 'string', desc: 'OAuth 客户端 id(默认 wkgeo-cli)' },
25
+ scope: { type: 'string', desc: '申请的能力范围(空格分隔)' },
26
+ credentials: { type: 'string', desc: '凭据文件路径(默认 ~/.wkongai/credentials.json)' },
27
+ 'callback-port': { type: 'string', desc: '本地回调端口(默认 53682)' },
28
+ 'callback-host': { type: 'string', desc: '本地回调监听地址(**只允许回环**,默认 127.0.0.1)' },
29
+ /**
30
+ * ⚠️ 单位是**秒**(不是毫秒)—— 原来按毫秒解释,`--timeout 600` 变成 600 毫秒、
31
+ * 0.8 秒就超时,用户按直觉写"600 秒"反而必然失败;`--help` 里当时也没写单位。
32
+ * 现在:纯数字按秒,也接受显式后缀 `600s` / `500ms`;非法值明确报错。
33
+ */
34
+ timeout: { type: 'string', desc: '等待授权回调的超时,**默认单位秒**(--timeout 600 = 600 秒;也支持 600s / 500ms;默认 300 秒)' },
35
+ 'http-timeout': { type: 'string', desc: '单次 HTTP 请求超时毫秒数(默认 30000)' },
36
+ json: { type: 'boolean', desc: '以 JSON 输出(默认:非 TTY 时自动 JSON)' },
37
+ text: { type: 'boolean', desc: '强制人类可读输出(管道里默认是 JSON,用它覆盖)' },
38
+ color: { type: 'boolean', desc: '彩色输出(--no-color 关闭)' },
39
+ verbose: { type: 'boolean', desc: '额外诊断信息(原始响应会先脱敏)' },
40
+ help: { type: 'boolean', desc: '显示帮助' },
41
+ version: { type: 'boolean', desc: '显示版本' },
42
+ };
43
+
44
+ const SHORT_FLAGS = { h: 'help', v: 'version', j: 'json' };
45
+
46
+ /** kebab-case → camelCase:`client-id` → `clientId` */
47
+ export function camel(key) {
48
+ return String(key).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
49
+ }
50
+
51
+ /**
52
+ * 通用解析:只做"切成 positionals + flags",不校验具体命令。
53
+ * @returns {{positionals:string[], flags:Record<string, string|boolean>}}
54
+ */
55
+ export function parseArgv(argv = []) {
56
+ const flags = {};
57
+ const positionals = [];
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = String(argv[i]);
60
+ if (a === '--') {
61
+ positionals.push(...argv.slice(i + 1).map(String));
62
+ break;
63
+ }
64
+ if (a.startsWith('--')) {
65
+ const eq = a.indexOf('=');
66
+ let key = eq === -1 ? a.slice(2) : a.slice(2, eq);
67
+ let val = eq === -1 ? undefined : a.slice(eq + 1);
68
+ if (!key) throw new UsageError('参数格式错误:`--` 后面没有标志名');
69
+ if (val === undefined) {
70
+ const next = argv[i + 1];
71
+ // 下一个词不是标志 → 当作本标志的值;否则本标志是布尔
72
+ if (next !== undefined && !String(next).startsWith('-')) {
73
+ val = String(next);
74
+ i++;
75
+ } else {
76
+ val = true;
77
+ }
78
+ }
79
+ if (key.startsWith('no-') && key.length > 3) flags[key.slice(3)] = false;
80
+ else flags[key] = val;
81
+ continue;
82
+ }
83
+ if (/^-[A-Za-z]$/.test(a)) {
84
+ const long = SHORT_FLAGS[a.slice(1)];
85
+ if (!long) throw new UsageError(`无法识别的短参数:${a}`);
86
+ flags[long] = true;
87
+ continue;
88
+ }
89
+ if (a.startsWith('-') && a !== '-') {
90
+ throw new UsageError(`无法识别的参数:${a}`);
91
+ }
92
+ positionals.push(a);
93
+ }
94
+ return { positionals, flags };
95
+ }
96
+
97
+ /**
98
+ * 按命令 spec 校验/转换标志。
99
+ * 未知标志直接报错(而不是静默忽略):拼错 `--nmae` 时静默忽略会让用户以为成功了,
100
+ * 这是最坑的一类"看起来成功其实没生效"。
101
+ */
102
+ export function coerceFlags(spec, flags) {
103
+ const defs = { ...GLOBAL_FLAGS, ...(spec.flags || {}) };
104
+ /**
105
+ * ⚠️ 特例:`--no-xxx` 在 `parseArgv` 里被统一当成"把 xxx 关掉"(`--no-color` / `--no-browser`),
106
+ * 但有的标志**名字本身就以 `no-` 开头**,而且是个**正向开关** —— 例如
107
+ * `login --no-wait`("不要等待,申请完码就退出")。
108
+ *
109
+ * 解析阶段拿不到命令 spec、无法区分这两种情况,所以会把它变成 `wait:false`。
110
+ * 这里按"注册表"还原:只有当 spec 里**确实定义了** `no-wait`、
111
+ * 且**没有人**定义 `wait` 时,`wait:false` 才只可能来自用户写的 `--no-wait`。
112
+ * (踩过的坑:不加这段的话 `login --no-wait` 会报"不认识的参数:--wait",
113
+ * 而 `--wait` 在帮助里根本不存在,用户完全看不懂自己写错了什么。)
114
+ */
115
+ const restored = { ...flags };
116
+ for (const key of Object.keys(defs)) {
117
+ if (!key.startsWith('no-')) continue;
118
+ const negated = key.slice(3);
119
+ if (defs[negated] === undefined && restored[key] === undefined && restored[negated] === false) {
120
+ restored[key] = true;
121
+ delete restored[negated];
122
+ }
123
+ }
124
+
125
+ const out = {};
126
+ const unknown = [];
127
+ for (const [key, val] of Object.entries(restored)) {
128
+ const def = defs[key];
129
+ if (!def) {
130
+ unknown.push(key);
131
+ continue;
132
+ }
133
+ const name = camel(key);
134
+ if (def.type === 'boolean') {
135
+ out[name] = val === false ? false : val === true ? true : !/^(false|0|no)$/i.test(String(val));
136
+ } else if (def.type === 'number') {
137
+ const n = Number(val);
138
+ if (!Number.isFinite(n)) throw new UsageError(`参数 --${key} 需要数字,收到:${val}`);
139
+ out[name] = n;
140
+ } else {
141
+ if (val === true) throw new UsageError(`参数 --${key} 缺少值(写成 --${key}=值 也可以)`);
142
+ out[name] = String(val);
143
+ }
144
+ }
145
+ if (unknown.length) {
146
+ const usable = Object.keys(defs).sort().join(' / ');
147
+ throw new UsageError(`不认识的参数:${unknown.map((u) => '--' + u).join(', ')}\n本命令可用参数:${usable}`);
148
+ }
149
+ for (const [key, def] of Object.entries(spec.flags || {})) {
150
+ if (def.required && out[camel(key)] === undefined) {
151
+ throw new UsageError(`缺少必填参数 --${key}${def.desc ? `(${def.desc})` : ''}`);
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** 位置参数校验:只支持"按顺序、必填在前"的简单形态 */
158
+ export function checkPositionals(spec, rest) {
159
+ const wanted = spec.args || [];
160
+ const max = wanted.length;
161
+ if (rest.length > max) {
162
+ throw new UsageError(`参数过多:${rest.slice(max).map((r) => `"${r}"`).join(' ')}\n用法:${spec.usage}`);
163
+ }
164
+ for (let i = 0; i < wanted.length; i++) {
165
+ if (wanted[i].required && (rest[i] === undefined || rest[i] === '')) {
166
+ throw new UsageError(`缺少参数 <${wanted[i].name}>${wanted[i].desc ? `(${wanted[i].desc})` : ''}\n用法:${spec.usage}`);
167
+ }
168
+ }
169
+ const out = {};
170
+ wanted.forEach((w, i) => {
171
+ if (rest[i] !== undefined) out[w.name] = rest[i];
172
+ });
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * 从位置参数里认出命令(支持两词命令)。
178
+ * @param {Array} commands 命令 spec 数组(必须带 name,如 'sites create')
179
+ */
180
+ export function resolveCommand(commands, positionals) {
181
+ const byName = new Map(commands.map((c) => [c.name, c]));
182
+ const two = positionals.length >= 2 ? `${positionals[0]} ${positionals[1]}` : null;
183
+ if (two && byName.has(two)) return { spec: byName.get(two), rest: positionals.slice(2) };
184
+ if (positionals.length >= 1 && byName.has(positionals[0])) {
185
+ return { spec: byName.get(positionals[0]), rest: positionals.slice(1) };
186
+ }
187
+ // 命令名不完整(比如只打了 `sites`):给出该分组下的可用命令
188
+ const group = positionals[0];
189
+ if (group) {
190
+ const siblings = commands.filter((c) => c.name.startsWith(`${group} `)).map((c) => c.name);
191
+ if (siblings.length) {
192
+ throw new UsageError(`命令不完整:"${group}" 需要子命令\n可用:${siblings.join(' / ')}`);
193
+ }
194
+ }
195
+ return null;
196
+ }
package/lib/cli.mjs ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * CLI 入口逻辑(与 `process` 解耦,方便单测直接调 `run(argv, {stdout: 假对象})`)。
3
+ *
4
+ * 退出码(写进 README / SKILL.md,AI 端可以据此分支):
5
+ * 0 成功 | 1 失败 | 2 用法错误 | 3 需要(重新)授权 | 4 被限流
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import { parseArgv, resolveCommand } from './args.mjs';
10
+ import { createRuntime, createOutput } from './runtime.mjs';
11
+ import { COMMANDS, renderHelp } from './commands/index.mjs';
12
+ import { CliError, EXIT } from './errors.mjs';
13
+ import { DEFAULT_BASE, DEFAULT_CLIENT_ID } from './config.mjs';
14
+
15
+ function readVersion() {
16
+ try {
17
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
18
+ return pkg.version || '0.0.0';
19
+ } catch {
20
+ return '0.0.0';
21
+ }
22
+ }
23
+
24
+ export async function run(argv = [], io = {}) {
25
+ const stdout = io.stdout || process.stdout;
26
+ const stderr = io.stderr || process.stderr;
27
+ const env = io.env || process.env;
28
+
29
+ // 先按"最保守"的方式猜输出模式:即使后面解析失败,报错也应该是机器可读的
30
+ const jsonRequested = argv.includes('--json') || argv.includes('-j');
31
+ const textForced = argv.includes('--text');
32
+ let json = jsonRequested || (!textForced && !stdout.isTTY);
33
+
34
+ const fallbackOut = (j) =>
35
+ createOutput({
36
+ stdout,
37
+ stderr,
38
+ json: j,
39
+ color: false,
40
+ // 解析都没成功时也要给出"当前会打哪个环境",否则 AI 端拿到的 meta 是空的
41
+ meta: () => ({
42
+ base: env.WKONGAI_BASE || DEFAULT_BASE,
43
+ client_id: env.WKONGAI_CLIENT_ID || DEFAULT_CLIENT_ID,
44
+ }),
45
+ });
46
+
47
+ let commandName = null;
48
+ let runtime = null;
49
+
50
+ try {
51
+ const parsed = parseArgv(argv);
52
+
53
+ if (parsed.flags.version === true || parsed.positionals[0] === 'version') {
54
+ stdout.write(`wkgeo-cli ${readVersion()}\n`);
55
+ return EXIT.OK;
56
+ }
57
+
58
+ // 帮助:`--help` / `-h` / `help` / `help <命令>` / `<命令> --help`
59
+ if (parsed.flags.help === true || parsed.positionals[0] === 'help') {
60
+ let want = null;
61
+ if (parsed.positionals[0] === 'help') {
62
+ want = parsed.positionals.slice(1).join(' ') || null;
63
+ } else {
64
+ // `wkgeo wallet txns --help` 要能显示 wallet txns 的帮助(不是总帮助)
65
+ try {
66
+ const hit = resolveCommand(COMMANDS, parsed.positionals);
67
+ want = hit ? hit.spec.name : parsed.positionals.join(' ') || null;
68
+ } catch {
69
+ want = parsed.positionals.join(' ') || null;
70
+ }
71
+ }
72
+ stdout.write(renderHelp(want) + '\n');
73
+ return EXIT.OK;
74
+ }
75
+
76
+ commandName = parsed.positionals.join(' ') || null;
77
+ runtime = createRuntime({
78
+ argv,
79
+ commands: COMMANDS,
80
+ env,
81
+ stdout,
82
+ stderr,
83
+ fetchImpl: io.fetchImpl,
84
+ now: io.now,
85
+ openBrowser: io.openBrowser,
86
+ /** `child_process.spawn` 的注入点(只为单测:验证"AI 场景一次都不 spawn 浏览器") */
87
+ spawnImpl: io.spawnImpl,
88
+ sleep: io.sleep,
89
+ /**
90
+ * 「设备码先成功后,本地回调监听再多留多久」的注入点(毫秒;见 lib/login.mjs 的
91
+ * DEFAULT_POST_DEVICE_GRACE_MS)。生产不传 = 默认 3 秒;单测注入小值即可确定性地
92
+ * 验证"迟到的回调拿到已完成页"(不必真的等 3 秒)。
93
+ */
94
+ postDeviceGraceMs: io.postDeviceGraceMs,
95
+ /**
96
+ * 代理检测的注入点(见 lib/proxy.mjs)。生产上不传,走 `process.platform` + 真 `reg/scutil`;
97
+ * 单测传入才能在任意平台上验证 Windows/macOS 的解析分支,且不去起真子进程。
98
+ */
99
+ platform: io.platform,
100
+ exec: io.exec,
101
+ /** 同上:`skill` 手册位置的注入点(单测验证"包里没有手册"分支时用,见 lib/commands/skill.mjs) */
102
+ skillUrl: io.skillUrl,
103
+ });
104
+ json = runtime.out.json;
105
+ commandName = runtime.spec.name;
106
+
107
+ await runtime.spec.run(runtime);
108
+ return EXIT.OK;
109
+ } catch (err) {
110
+ const out = runtime ? runtime.out : fallbackOut(json);
111
+ const e =
112
+ err instanceof CliError
113
+ ? err
114
+ : new CliError((err && err.message) || String(err), {
115
+ kind: 'internal',
116
+ hint: '这是 CLI 内部错误;请带上完整命令与输出反馈',
117
+ cause: err,
118
+ });
119
+
120
+ out.emitErr(commandName, e);
121
+
122
+ if (e.showHelp && !json) {
123
+ stderr.write('\n' + renderHelp() + '\n');
124
+ }
125
+ if (e.kind === 'usage' && !e.hint) {
126
+ // 用法错误给一条"下一步",人/AI 都不用去猜
127
+ stderr.write('提示:运行 `wkgeo --help` 看所有命令,或 `wkgeo <命令> --help` 看单个命令。\n');
128
+ }
129
+ if (runtime && runtime.flags.verbose && err && err.stack) {
130
+ stderr.write(err.stack + '\n');
131
+ }
132
+ return e.exitCode === undefined ? EXIT.ERROR : e.exitCode;
133
+ }
134
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 应用授权命令:`apps list` / `apps revoke`
3
+ *
4
+ * 这两个动作**用户自助**(能力 `billing:self`,所有登录用户都有),
5
+ * 所以用 OAuth 令牌本身就能调 —— 不需要管理员。
6
+ *
7
+ * ⚠️ 线上状态:`listMyOAuthAuthorizations` / `revokeMyOAuthAuthorization` 是**新加的 action**,
8
+ * 线上是否已部署要看 data 云函数有没有发版。未部署时服务端会返回
9
+ * 「未知操作: xxx」或「动作 xxx 未在权限表中登记」——这不是用户的错,
10
+ * 必须把这句话翻译成"服务端还没部署这个功能",否则会让人以为是自己用错了命令。
11
+ */
12
+
13
+ import { CliError } from '../errors.mjs';
14
+ import { formatTime } from '../format.mjs';
15
+
16
+ /** 判断"这个 action 线上还没部署"(新旧两种 dispatcher 的兜底文案都认) */
17
+ function isActionNotDeployed(err) {
18
+ const msg = String((err && err.message) || '');
19
+ return /未知操作|未在权限表中登记|未知的 action|Unknown action/i.test(msg) || (err && err.kind === 'unknown' && /action/i.test(msg));
20
+ }
21
+
22
+ export const appsListCommand = {
23
+ name: 'apps list',
24
+ summary: '我授权过的应用(应用名 / 首次授权时间 / 过期时间 / 最后使用时间 / 能力)',
25
+ usage: 'wkgeo apps list',
26
+ async run(ctx) {
27
+ const { out } = ctx;
28
+ let res;
29
+ try {
30
+ res = await ctx.callData('listMyOAuthAuthorizations', {});
31
+ } catch (e) {
32
+ if (isActionNotDeployed(e)) {
33
+ throw new CliError('服务端尚未部署「我已授权的应用」接口(listMyOAuthAuthorizations)', {
34
+ kind: 'not_deployed',
35
+ code: e.code,
36
+ hint: '这是服务端缺口(data 云函数未发版),不是命令用错了;本地仍可用 `wkgeo logout` 清理登录态',
37
+ raw: e.raw,
38
+ });
39
+ }
40
+ throw e;
41
+ }
42
+ const rows = (res && Array.isArray(res.rows) ? res.rows : []).map((r) => ({
43
+ client_id: r.client_id,
44
+ client_name: r.client_name || r.client_id,
45
+ is_public: r.is_public === undefined ? null : !!r.is_public,
46
+ scopes: Array.isArray(r.scopes) ? r.scopes : [],
47
+ first_authorized_at: r.first_authorized_at || null,
48
+ expires_at: r.expires_at || null,
49
+ last_used_at: r.last_used_at || null,
50
+ token_count: Number(r.token_count) || 0,
51
+ is_current_cli: r.client_id === ctx.config.clientId,
52
+ }));
53
+ const data = { rows, total: rows.length, current_cli_client_id: ctx.config.clientId };
54
+
55
+ if (!out.json) {
56
+ out.head(`我已授权的应用(${rows.length} 个)`);
57
+ out.table(
58
+ [
59
+ { title: '应用', key: 'client_name', width: 22 },
60
+ { title: 'client_id', key: 'client_id', width: 26 },
61
+ { title: '令牌数', key: 'token_count', width: 8 },
62
+ { title: '首次授权', key: 'first_authorized_at', format: (v) => formatTime(v), width: 20 },
63
+ { title: '过期时间', key: 'expires_at', format: (v) => formatTime(v), width: 20 },
64
+ { title: '最后使用', key: 'last_used_at', format: (v) => formatTime(v), width: 20 },
65
+ ],
66
+ rows,
67
+ );
68
+ out.info(` 当前 CLI 使用的 client_id:${ctx.config.clientId}`);
69
+ out.info(' 吊销某个应用:wkgeo apps revoke <client_id>');
70
+ }
71
+ ctx.done(data);
72
+ return data;
73
+ },
74
+ };
75
+
76
+ export const appsRevokeCommand = {
77
+ name: 'apps revoke',
78
+ summary: '吊销某个应用对我的授权(该应用所有令牌立即失效;之后需要重新授权)',
79
+ usage: 'wkgeo apps revoke <client_id>',
80
+ args: [{ name: 'clientId', required: true, desc: '应用的 client_id(见 apps list)' }],
81
+ async run(ctx) {
82
+ const { out, config } = ctx;
83
+ const clientId = ctx.positional.clientId;
84
+ let res;
85
+ try {
86
+ res = await ctx.callData('revokeMyOAuthAuthorization', { client_id: clientId });
87
+ } catch (e) {
88
+ if (isActionNotDeployed(e)) {
89
+ throw new CliError('服务端尚未部署「一键吊销授权」接口(revokeMyOAuthAuthorization)', {
90
+ kind: 'not_deployed',
91
+ code: e.code,
92
+ hint: '这是服务端缺口(data 云函数未发版);临时止血可调用 POST /api/oauth/revoke 吊销单把令牌',
93
+ raw: e.raw,
94
+ });
95
+ }
96
+ throw e;
97
+ }
98
+ const revoked = Number(res && res.revoked) || 0;
99
+ const isSelf = clientId === config.clientId;
100
+ const data = {
101
+ client_id: clientId,
102
+ revoked,
103
+ is_current_cli_client: isSelf,
104
+ local_credentials_still_present: isSelf ? !!ctx.readCredentials() : null,
105
+ };
106
+
107
+ if (!out.json) {
108
+ out.ok(`已吊销应用「${clientId}」的授权,作废令牌 ${revoked} 把`);
109
+ if (isSelf) {
110
+ out.warn('你吊销的是**当前 CLI 自己**的授权:本地令牌已失效,请重新运行 `wkgeo login`(或 `wkgeo logout` 清掉本地凭据)');
111
+ }
112
+ }
113
+ ctx.done(data);
114
+ return data;
115
+ },
116
+ };