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,287 @@
1
+ /**
2
+ * 运行期上下文:把「解析命令行 → 生效配置 → API 客户端 → 输出器 → 登录态」串起来。
3
+ *
4
+ * 为什么要有这一层:
5
+ * 每个命令都需要那几样东西(配置/api/输出/凭据),如果每个命令自己 new 一遍,
6
+ * 测试就没法注入(不能替换 fetch、不能指定临时凭据文件)。
7
+ * 集中在这里之后,单测里 `createRuntime({argv, env, stdout, fetchImpl})` 就能把整条链路变成离线的。
8
+ *
9
+ * 输出契约(**给 AI 端消费用的,必须稳定**):
10
+ * * `--json`:stdout **只有一个 JSON 对象**(成功/失败都是),字段见 okEnvelope/errorEnvelope。
11
+ * * **非 TTY 时自动用 JSON**(AI/管道场景最省事,不用记得加 --json);
12
+ * 想强制人类可读输出用 `--text`。
13
+ * * 人类可读的诊断信息一律走 stderr,绝不混进 stdout 的 JSON 里。
14
+ * * 例外:`login` 天生长跑,JSON 模式下按 **JSONL 事件流**输出(见 commands/auth.mjs 的说明)。
15
+ */
16
+
17
+ import { parseArgv, coerceFlags, checkPositionals, resolveCommand } from './args.mjs';
18
+ import { resolveConfig } from './config.mjs';
19
+ import { createApi } from './api.mjs';
20
+ import { requireCredentials, readCredentials } from './credentials.mjs';
21
+ import { createPalette, renderTable, safeJson } from './format.mjs';
22
+ import { UsageError, EXIT } from './errors.mjs';
23
+ import { detectProxy, proxyWarningPayload } from './proxy.mjs';
24
+
25
+ /** 成功信封 */
26
+ export function okEnvelope(command, data, meta) {
27
+ return { ok: true, command, data, meta };
28
+ }
29
+
30
+ /** 失败信封:**结构化**,带 code / message / hint,让 AI 端能自己决定下一步 */
31
+ export function errorEnvelope(command, err, meta) {
32
+ return {
33
+ ok: false,
34
+ command,
35
+ error: {
36
+ kind: (err && err.kind) || 'error',
37
+ code: err && err.code !== undefined ? err.code : null,
38
+ message: (err && err.message) || String(err),
39
+ hint: (err && err.hint) || null,
40
+ retry_after: err && err.retryAfter !== undefined ? err.retryAfter : null,
41
+ },
42
+ meta,
43
+ };
44
+ }
45
+
46
+ /** 输出器:人类可读 + `--json` 两套;"令牌不进终端"这件事只在这里发生一次 */
47
+ export function createOutput({ stdout, stderr, json = false, color = true, meta = () => ({}) }) {
48
+ const p = createPalette({ enabled: color && !json });
49
+ const w = (s) => stdout.write(s);
50
+ const we = (s) => stderr.write(s);
51
+ return {
52
+ palette: p,
53
+ json,
54
+ line: (s = '') => {
55
+ if (!json) w(String(s) + '\n');
56
+ },
57
+ /**
58
+ * 原样写 stdout,**无视 json 模式**(注意与 line/info 的区别:那三个在 json 模式下什么都不写)。
59
+ *
60
+ * 为什么需要它:`skill` 的产物**本身就是文档**(markdown 原文)。全 CLI 的通用约定是
61
+ * "非终端自动 JSON",但把 markdown 套进 JSON 信封会毁掉可读性(换行被转义、重定向出来
62
+ * 不能直接读)。所以那条命令要一个"必须写 stdout、且不套信封"的出口 —— 就是这里,
63
+ * 保持"谁写 stdout"只在本文件里出现一次。
64
+ */
65
+ plain: (s = '') => w(String(s)),
66
+ head: (title) => {
67
+ if (!json) w(`\n${p.bold(title)}\n`);
68
+ },
69
+ ok: (s) => {
70
+ if (!json) w(`${p.green('✔')} ${s}\n`);
71
+ else we(`✔ ${s}\n`);
72
+ },
73
+ warn: (s) => {
74
+ if (!json) w(`${p.yellow('!')} ${s}\n`);
75
+ else we(`! ${s}\n`);
76
+ },
77
+ info: (s) => {
78
+ if (!json) w(`${p.dim(s)}\n`);
79
+ },
80
+ /**
81
+ * ★ 人类提示块:人读模式走 stdout,**JSON 模式走 stderr**。
82
+ *
83
+ * 为什么需要它(与 line/info 的区别):`--json` / 非 TTY 下 stdout 必须只有机器可读 JSON,
84
+ * 而"⚠️ 浏览器没能自动打开,请手动复制这个地址…(含完整 URL)"这类**要给用户看**的提示
85
+ * 又不能因为切了 JSON 就消失(用户得能照着它手动打开授权页/收银台)。
86
+ * 走 stderr 既不污染 stdout,AI 客户端也照样能看到。
87
+ */
88
+ notice: (s = '') => {
89
+ if (!json) w(String(s) + '\n');
90
+ else we(String(s) + '\n');
91
+ },
92
+ err: (s) => we(`${p.red('✖')} ${s}\n`),
93
+ raw: (s) => we(String(s) + '\n'),
94
+ table: (columns, rows) => {
95
+ if (!json) w(renderTable(columns, rows) + '\n');
96
+ },
97
+ /** 直接打一个对象(JSONL 事件流用),**不做信封包装** */
98
+ event: (obj) => w(safeJson(obj, 0) + '\n'),
99
+ emitOk: (command, data) => {
100
+ if (json) w(safeJson(okEnvelope(command, data, meta()), 2) + '\n');
101
+ },
102
+ emitErr: (command, err) => {
103
+ if (json) w(safeJson(errorEnvelope(command, err, meta()), 2) + '\n');
104
+ else {
105
+ const code = err && err.code !== null && err.code !== undefined ? `[code=${err.code}] ` : '';
106
+ we(`${p.red('✖')} ${code}${(err && err.message) || String(err)}\n`);
107
+ if (err && err.hint) we(` ${p.yellow('↳')} ${err.hint}\n`);
108
+ if (err && err.retryAfter !== undefined && err.retryAfter !== null) {
109
+ we(` ${p.yellow('↳')} 建议 ${err.retryAfter} 秒后重试\n`);
110
+ }
111
+ }
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {object} args
118
+ * @param {string[]} args.argv process.argv.slice(2)
119
+ * @param {Array} args.commands 命令注册表
120
+ * @param {object} [args.env]
121
+ * @param {object} [args.stdout] {write, isTTY}
122
+ * @param {object} [args.stderr]
123
+ * @param {Function} [args.fetchImpl]
124
+ */
125
+ export function createRuntime({
126
+ argv = [],
127
+ commands,
128
+ env = process.env,
129
+ stdout = process.stdout,
130
+ stderr = process.stderr,
131
+ fetchImpl = globalThis.fetch,
132
+ now = () => Date.now(),
133
+ /**
134
+ * 这两个是给"需要外部动作"的命令(login)用的注入点:
135
+ * openBrowser —— 测试里替换成空函数,避免真的弹浏览器
136
+ * sleep —— 设备码轮询的等待,测试里替换成假时钟
137
+ * ⚠️ 踩过的坑:一开始没把 fetchImpl/openBrowser 从 run() 透传到 runLogin/runDeviceLogin,
138
+ * 结果命令层单测会**真的去打网络/弹浏览器**(静默变成集成测试)。
139
+ */
140
+ openBrowser = undefined,
141
+ sleep = undefined,
142
+ /**
143
+ * 「设备码先成功后,本地回调监听再多留多久」的注入点(毫秒;见 lib/login.mjs 的
144
+ * `DEFAULT_POST_DEVICE_GRACE_MS`)。**只为单测**:注入一个小值就能确定性地验证
145
+ * "迟到的回调拿到'已完成'页",不必让用例真的等几秒。
146
+ */
147
+ postDeviceGraceMs = undefined,
148
+ /**
149
+ * `spawnImpl` —— `child_process.spawn` 的注入点(**只为单测**)。
150
+ *
151
+ * ★ 为什么必须透传到命令层才有意义:`--open-browser` / "非 TTY 不弹"这两条行为,
152
+ * 要在**命令层**端到端验证"到底有没有真的去 spawn 浏览器"。
153
+ * 只注入一个假的 `openBrowser` 只能证明"我们没调用注入的实现",
154
+ * 而真正要钉的是**生产接线**(defaultOpenBrowserAsync → child_process.spawn)在 AI 场景下
155
+ * 一次都不发生。生产不传 = 真 spawn。
156
+ */
157
+ spawnImpl = undefined,
158
+ /**
159
+ * 代理检测的两个注入点(见 lib/proxy.mjs):
160
+ * platform —— 让单测能在任意平台上验证 Windows/macOS 的解析分支;
161
+ * exec —— 跑 `reg query` / `scutil --proxy` 的函数。**单测必须注入**,
162
+ * 否则单测会真的去起子进程、结果还随宿主机代理设置漂移。
163
+ */
164
+ platform = process.platform,
165
+ exec,
166
+ /**
167
+ * `skill` 命令要读的那份手册的位置(默认 = 包根目录的 `SKILL.md`,见 lib/commands/skill.mjs)。
168
+ * 之所以做成注入点:**"包里没有手册"这个分支没法在真实仓库里造**(总不能把 SKILL.md 删了
169
+ * 或改名 —— 那是并行跑的其它用例依赖的真文件)。单测传入一个不存在的路径即可稳定覆盖该分支。
170
+ */
171
+ skillUrl = undefined,
172
+ } = {}) {
173
+ const startedAt = now();
174
+ const { positionals, flags: rawFlags } = parseArgv(argv);
175
+
176
+ const resolved = resolveCommand(commands, positionals);
177
+ if (!resolved) {
178
+ const err = new UsageError(`不认识的命令:${positionals.join(' ') || '(空)'}`);
179
+ err.showHelp = true;
180
+ throw err;
181
+ }
182
+ const { spec, rest } = resolved;
183
+ const flags = coerceFlags(spec, rawFlags);
184
+ const positional = checkPositionals(spec, rest);
185
+ const config = resolveConfig(flags, env);
186
+ config.cmdName = 'wkgeo';
187
+
188
+ /**
189
+ * 是否 JSON 输出。
190
+ * ⚠️ 默认值依赖 `isTTY`:**非 TTY(管道 / AI 子进程)自动切 JSON**。
191
+ * 这个默认值是刻意的 —— 父进程多半是脚本或 AI,人类可读的表格对他们没用。
192
+ */
193
+ const json = flags.json === true ? true : flags.text === true ? false : !stdout.isTTY;
194
+
195
+ const colorEnabled = config.color !== false && !env.NO_COLOR && !!stdout.isTTY && !json;
196
+
197
+ const api = createApi({ config, fetchImpl });
198
+
199
+ /**
200
+ * 代理检测结果:**懒加载 + 记忆化**。
201
+ *
202
+ * ⚠️ 为什么不在这里直接算:Windows 上要起一次 `reg.exe`(约 30-50ms 真机实测)。
203
+ * 如果每个命令、每次输出都探测一遍,等于给所有命令加一笔固定开销;
204
+ * 而"代理会不会挡回调"这件事**只有 login(和诊断用的 config)关心**。
205
+ * 所以:谁需要谁调 `runtime.proxyInfo()`;`meta()` 只在**已经算过**时才把它带出去,
206
+ * 这样 `--json` 的错误信封里也能带上它(AI 端据此判断"是不是代理的锅"),
207
+ * 同时不给其它命令增加任何成本。
208
+ */
209
+ const proxyState = { computed: false, info: null };
210
+ const proxyInfo = () => {
211
+ if (!proxyState.computed) {
212
+ proxyState.computed = true;
213
+ try {
214
+ proxyState.info = detectProxy({ platform, env, exec });
215
+ } catch (e) {
216
+ // 检测本身绝不能让命令失败:退化成"没检测到",只留一条诊断
217
+ proxyState.info = { detected: false, risk: false, loopback_bypassed: false, sources: [], platform, probe_error: String((e && e.message) || e) };
218
+ }
219
+ }
220
+ return proxyState.info;
221
+ };
222
+
223
+ const meta = () => {
224
+ const m = {
225
+ base: config.base,
226
+ client_id: config.clientId,
227
+ credentials_file: config.credentialsFile,
228
+ duration_ms: Math.max(0, now() - startedAt),
229
+ };
230
+ // 只有"真的探测过"才带这个字段:没探测过 = 结论未知,不该编一个 false 出来
231
+ if (proxyState.computed) m.proxy_warning = proxyWarningPayload(proxyState.info, { cmdName: config.cmdName || 'wkgeo' });
232
+ return m;
233
+ };
234
+
235
+ const out = createOutput({ stdout, stderr, json, color: colorEnabled, meta });
236
+
237
+ const runtime = {
238
+ spec,
239
+ flags,
240
+ positional,
241
+ config,
242
+ api,
243
+ out,
244
+ env,
245
+ now,
246
+ meta,
247
+ /** ★ 代理检测(懒加载 + 记忆化;见上面 proxyState 的说明) */
248
+ proxyInfo,
249
+ /** 透传给 login/device 的依赖(见上面 createRuntime 的说明) */
250
+ fetchImpl,
251
+ openBrowser,
252
+ spawnImpl,
253
+ sleep,
254
+ /** ★ 「设备码先成功后的监听宽限期」注入点(只为单测;见 createRuntime 的说明) */
255
+ postDeviceGraceMs,
256
+ platform,
257
+ /**
258
+ * ★ 「是不是人在终端」—— `resolveBrowserIntent()` 的输入之一。
259
+ *
260
+ * 为什么放在 runtime 里算:只有这里同时知道 **stdout 是不是 TTY** 与 **是不是 JSON 输出**
261
+ * (`json` 的默认值也依赖 `isTTY`,见上面)。命令层原样透传即可,
262
+ * **绝不能**各命令自己判断一遍 —— 那迟早会出现"授权不弹、支付还弹"。
263
+ */
264
+ isTTY: !!stdout.isTTY,
265
+ /** ★ `skill` 命令读手册的位置(默认包内 SKILL.md;单测可注入不存在的路径) */
266
+ skillUrl,
267
+ /** 读登录态(未登录/过期 → 抛带提示的 CliError,exit 3) */
268
+ requireAuth({ allowExpired = false } = {}) {
269
+ return requireCredentials(config.credentialsFile, { allowExpired, now: now() });
270
+ },
271
+ readCredentials() {
272
+ return readCredentials(config.credentialsFile);
273
+ },
274
+ /** 调业务接口:自动带上凭据里的 token */
275
+ callData(action, payload = {}, opts = {}) {
276
+ const creds = opts.credentials || runtime.requireAuth({ allowExpired: opts.allowExpired });
277
+ return api.data(action, payload, { token: creds.access_token });
278
+ },
279
+ /** 成功收尾(JSON 模式输出信封;人类模式什么都不做,由命令自己打印) */
280
+ done(data) {
281
+ out.emitOk(spec.name, data);
282
+ },
283
+ };
284
+ return runtime;
285
+ }
286
+
287
+ export { EXIT };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "wkgeo-cli",
3
+ "version": "0.2.0",
4
+ "description": "云站平台官方命令行客户端:授权登录一次,之后建站 / 下单 / 钱包支付 / 查余额都不用再登录(OAuth 授权码 + PKCE,公共客户端,零第三方依赖)",
5
+ "keywords": [
6
+ "wkgeo",
7
+ "云站",
8
+ "cli",
9
+ "oauth",
10
+ "pkce",
11
+ "cloudbase"
12
+ ],
13
+ "license": "UNLICENSED",
14
+ "private": false,
15
+ "type": "module",
16
+ "bin": {
17
+ "wkgeo": "./wkgeo.mjs"
18
+ },
19
+ "main": "./lib/index.mjs",
20
+ "exports": {
21
+ ".": "./lib/index.mjs"
22
+ },
23
+ "files": [
24
+ "wkgeo.mjs",
25
+ "lib",
26
+ "README.md",
27
+ "SKILL.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test \"test/*.test.mjs\"",
34
+ "start": "node wkgeo.mjs",
35
+ "build:zip": "node scripts/build-zip.mjs",
36
+ "build:exe": "node scripts/build-exe.mjs",
37
+ "verify:live": "node scripts/live-verify.mjs",
38
+ "verify:live:sql": "node scripts/live-sql.mjs",
39
+ "prepublishOnly": "node --test \"test/*.test.mjs\""
40
+ },
41
+ "dependencies": {},
42
+ "optionalDependencies": {},
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://www.wkgeo.com/"
49
+ },
50
+ "homepage": "https://www.wkgeo.com/"
51
+ }
package/wkgeo.mjs ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 云站平台官方 CLI 的可执行入口。
4
+ *
5
+ * 用法:
6
+ * npx -y wkgeo-cli@latest login # AI / 开发者首选(不装依赖,直接跑)
7
+ * npm i -g wkgeo-cli && wkgeo login # 全局安装后
8
+ * node cli/wkgeo.mjs login # 在本仓库里直接跑
9
+ *
10
+ * 为什么入口薄成这样:真正的逻辑都在 lib/ 里,且不依赖 process ——
11
+ * 这样单测可以直接 `run(['wallet'], {stdout: 假对象})`,把整条链路变成离线的。
12
+ */
13
+
14
+ import { run } from './lib/cli.mjs';
15
+
16
+ const code = await run(process.argv.slice(2));
17
+ process.exitCode = code;