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/README.md +681 -0
- package/SKILL.md +598 -0
- package/lib/api.mjs +178 -0
- package/lib/args.mjs +196 -0
- package/lib/cli.mjs +134 -0
- package/lib/commands/apps.mjs +116 -0
- package/lib/commands/auth.mjs +974 -0
- package/lib/commands/billing.mjs +421 -0
- package/lib/commands/index.mjs +160 -0
- package/lib/commands/orders.mjs +444 -0
- package/lib/commands/sites.mjs +140 -0
- package/lib/commands/skill.mjs +119 -0
- package/lib/config.mjs +260 -0
- package/lib/credentials.mjs +160 -0
- package/lib/device-store.mjs +132 -0
- package/lib/device.mjs +427 -0
- package/lib/errors.mjs +268 -0
- package/lib/format.mjs +204 -0
- package/lib/http.mjs +71 -0
- package/lib/index.mjs +39 -0
- package/lib/login.mjs +1332 -0
- package/lib/page.mjs +396 -0
- package/lib/pkce.mjs +48 -0
- package/lib/proxy.mjs +454 -0
- package/lib/qr.mjs +31 -0
- package/lib/runtime.mjs +287 -0
- package/package.json +51 -0
- package/wkgeo.mjs +17 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skill` 命令:把**随包分发**的 `SKILL.md` 原文打印出来。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有它(老板原话 + 我们踩的坑)
|
|
5
|
+
* 手册确实随 npm 包分发了(`package.json` 的 `files` 白名单里就有 `SKILL.md`),
|
|
6
|
+
* 但**没有任何命令能把它取出来** —— AI 端只能自己去 `node_modules` 里翻:
|
|
7
|
+
* `npx` 场景下包落在 `~/.npm/_npx/<hash>/node_modules/wkgeo-cli/`,
|
|
8
|
+
* 路径没法预期,还可能被 npx 缓存清理掉。换句话说 **"分发了但拿不到",约等于没分发**。
|
|
9
|
+
* 于是补上 `wkgeo skill`:一条命令拿到手册原文,AI 端不用猜路径、不用翻目录。
|
|
10
|
+
*
|
|
11
|
+
* ## 路径解析(⚠️ **绝不能**假设 cwd)
|
|
12
|
+
* 踩过的坑:最初想用 `process.cwd() + 'SKILL.md'`。但 AI 端几乎总是在**别的目录**里
|
|
13
|
+
* 跑这个 CLI(`npx` 更是如此),cwd 下根本没有这个文件 —— 只有安装目录里才有。
|
|
14
|
+
* 所以这里用**相对本模块**的 URL:`lib/commands/skill.mjs` → `../../SKILL.md` = 包根目录。
|
|
15
|
+
* 无论 npx 缓存 / 全局安装 / `npm link`,都指向同一份真实文件。
|
|
16
|
+
*
|
|
17
|
+
* ## 输出形态(**刻意的例外**,README §4 有记法说明)
|
|
18
|
+
* * 默认(**包括非终端/管道**)→ 纯文本原文,**不套 JSON 信封**。
|
|
19
|
+
* 理由:这份东西本身就是"给人/AI 读的 markdown"。套一层 JSON 之后 markdown 里的换行
|
|
20
|
+
* 会变成 `\n` 转义、AI 还得再解一层;而且 `wkgeo skill > SKILL.md` 应该直接得到
|
|
21
|
+
* 一份能读的文档,而不是 `{"ok":true,...}`。
|
|
22
|
+
* * `--json` → 包成标准信封 `{"ok":true,"command":"skill","data":{path,markdown,…}}`,
|
|
23
|
+
* 给"必须统一按 JSON 解析"的脚本/宿主用。
|
|
24
|
+
* * 出错照旧走通用规则(非终端默认 JSON 错误信封 + 退出码 1),且**不打原始堆栈**:
|
|
25
|
+
* 文件缺失是可预期的部署/裁剪问题,把 `ENOENT` 堆栈糊到用户和 AI 脸上没有意义。
|
|
26
|
+
*
|
|
27
|
+
* ## 鉴权
|
|
28
|
+
* 不需要登录、不发任何网络请求(纯本地读文件)—— 跟 `config` 一样,
|
|
29
|
+
* 所以未登录的机器上也能用它把手册取出来。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
import { CliError, EXIT } from '../errors.mjs';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 包根目录的 `SKILL.md`。
|
|
39
|
+
* `import.meta.url` = `<包根>/lib/commands/skill.mjs`,往上两级就是包根。
|
|
40
|
+
*/
|
|
41
|
+
export const SKILL_URL = new URL('../../SKILL.md', import.meta.url);
|
|
42
|
+
|
|
43
|
+
/** 允许传 URL(默认)或路径字符串(单测/排障时指到别处) */
|
|
44
|
+
export function resolveSkillUrl(src = SKILL_URL) {
|
|
45
|
+
if (src instanceof URL) return src;
|
|
46
|
+
return pathToFileURL(String(src));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 读手册原文。
|
|
51
|
+
*
|
|
52
|
+
* @param {URL|string} [src] 手册位置(默认包内 `SKILL.md`)
|
|
53
|
+
* @returns {{path:string, markdown:string, bytes:number, lines:number}}
|
|
54
|
+
* @throws {CliError} 文件不存在/读不了 → `kind="missing_skill"`,退出码 1,带中文 hint(无堆栈)
|
|
55
|
+
*/
|
|
56
|
+
export function readSkillMarkdown(src = SKILL_URL) {
|
|
57
|
+
const url = resolveSkillUrl(src);
|
|
58
|
+
let filePath = String(url);
|
|
59
|
+
try {
|
|
60
|
+
filePath = fileURLToPath(url);
|
|
61
|
+
} catch {
|
|
62
|
+
/* 给了非 file: 的 URL 时退回原样显示,别为了一个展示用的路径把命令搞崩 */
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const markdown = fs.readFileSync(url, 'utf8');
|
|
66
|
+
return {
|
|
67
|
+
path: filePath,
|
|
68
|
+
markdown,
|
|
69
|
+
bytes: Buffer.byteLength(markdown, 'utf8'),
|
|
70
|
+
lines: markdown.split('\n').length,
|
|
71
|
+
};
|
|
72
|
+
} catch (e) {
|
|
73
|
+
const why = (e && e.code) || '读取失败';
|
|
74
|
+
throw new CliError(`当前安装包里没有手册文件(SKILL.md):${filePath}(${why})`, {
|
|
75
|
+
kind: 'missing_skill',
|
|
76
|
+
code: (e && e.code) || null,
|
|
77
|
+
exitCode: EXIT.ERROR,
|
|
78
|
+
hint:
|
|
79
|
+
'这不是命令用错了:多半是开发目录里只拷了 lib/、或分发包被裁剪过(正常 npm 安装包里一定有 SKILL.md)。' +
|
|
80
|
+
'可改用官方包 `npx -y wkgeo-cli@latest skill` 取手册,或直接看仓库里的 cli/SKILL.md',
|
|
81
|
+
cause: e,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const skillCommand = {
|
|
87
|
+
name: 'skill',
|
|
88
|
+
summary: '打印给 AI 端的操作手册(随包分发)',
|
|
89
|
+
usage: 'wkgeo skill [--json]',
|
|
90
|
+
/**
|
|
91
|
+
* `--help` 里显式说明"本命令不吃通用输出约定",避免 AI 端按别的命令的习惯去猜:
|
|
92
|
+
* 它默认**永远**打 pure markdown(哪怕在管道里),只有显式 `--json` 才包信封。
|
|
93
|
+
*/
|
|
94
|
+
notes: [
|
|
95
|
+
'默认**永远输出手册原文(markdown)**,即使输出到管道/非终端也不套 JSON 信封 ——',
|
|
96
|
+
' 所以 `wkgeo skill > SKILL.md` 得到的就是一份可直接阅读的文档。',
|
|
97
|
+
'只有显式 `wkgeo skill --json` 才输出信封:{"ok":true,"command":"skill","data":{"path":…,"markdown":…,"bytes":…,"lines":…}}。',
|
|
98
|
+
'本命令不需要登录、不发任何网络请求(纯本地读包内的 SKILL.md)。',
|
|
99
|
+
],
|
|
100
|
+
async run(ctx) {
|
|
101
|
+
const { out } = ctx;
|
|
102
|
+
const info = readSkillMarkdown(ctx.skillUrl || SKILL_URL);
|
|
103
|
+
const data = { path: info.path, bytes: info.bytes, lines: info.lines, markdown: info.markdown };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ 判断依据必须是**用户显式写的 `--json`**(`ctx.flags.json`),**不能**用 `out.json` ——
|
|
107
|
+
* 后者在"非终端"时会自动变成 true(全 CLI 的通用约定),那样管道里
|
|
108
|
+
* `wkgeo skill > SKILL.md` 就会灌进一坨 JSON 信封,正是这里要避免的。
|
|
109
|
+
* 也就是说:这条命令是"非终端自动 JSON"约定的**唯一例外**(登录除外,那是 JSONL 事件流)。
|
|
110
|
+
*/
|
|
111
|
+
if (ctx.flags.json === true) {
|
|
112
|
+
ctx.done(data);
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
// 原文直出:不套信封、不加标题、不打诊断(stdout 里只有手册本身,重定向即可存成文件)
|
|
116
|
+
out.plain(info.markdown.endsWith('\n') ? info.markdown : info.markdown + '\n');
|
|
117
|
+
return data;
|
|
118
|
+
},
|
|
119
|
+
};
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 配置:默认值 + 环境变量 + 命令行覆盖(优先级:命令行 > 环境变量 > 内置默认)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:
|
|
5
|
+
* 1. CLI 里"这个值从哪来"最容易散落各处,散落之后就没法回答"线上到底打的是哪个环境"。
|
|
6
|
+
* 全部集中在这里,`wkgeo config` 才能一处打印出**实际生效**的值。
|
|
7
|
+
* 2. 测试要能注入环境(不污染 process.env),所以每个函数都带 env 参数。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 踩过的坑:**绝不把任何密钥/令牌写进这里**。
|
|
10
|
+
* 我们是 OAuth 公共客户端(public client),只用 PKCE,没有 client_secret ——
|
|
11
|
+
* 源码里出现 client_secret 就等于把"机密客户端"的凭据公开了(而且会被 git 记住)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { UsageError } from './errors.mjs';
|
|
18
|
+
|
|
19
|
+
/** 线上 CloudBase 网关(业务接口 /api/data、OAuth 服务端 /api/oauth/* 都在这个域下) */
|
|
20
|
+
export const DEFAULT_BASE =
|
|
21
|
+
'https://wkongai-d9gh0hpfs6ee72622-1251938240.ap-shanghai.app.tcloudbase.com';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 前端页面基址(三站拆分后**按站分开**)。
|
|
25
|
+
*
|
|
26
|
+
* ★ 2026-10-02(三站拆分):原来只有一个 `DEFAULT_WEB_BASE` 指向老站的
|
|
27
|
+
* `https://www.wkongai.cn/admin`;现在三个业务页各有独立子域:
|
|
28
|
+
* * 授权站 `https://oauth.wkgeo.com` —— 同意页 / 设备码页
|
|
29
|
+
* * 收银台 `https://pay.wkgeo.com` —— 支付页
|
|
30
|
+
* * 会员站 `https://user.wkgeo.com` —— 登录页 / 我的订单等
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ 授权页地址**优先从线上 metadata 的 `authorization_endpoint` 取**,这里只是兜底:
|
|
33
|
+
* 线上曾经把 authorization_endpoint 配成 http://localhost:8000(云函数默认值没配
|
|
34
|
+
* 环境变量),那种情况下 CLI 必须能自己纠正,否则打开的是用户本机端口。
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_OAUTH_WEB_BASE = 'https://oauth.wkgeo.com';
|
|
37
|
+
export const DEFAULT_PAY_WEB_BASE = 'https://pay.wkgeo.com';
|
|
38
|
+
export const DEFAULT_USER_WEB_BASE = 'https://user.wkgeo.com';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⚠️ 兼容保留:老站(管理端)基址。
|
|
42
|
+
* 三站拆分后**没有任何代码再用它打开页面**,但 `WKONGAI_WEB_BASE` 这个总覆盖开关
|
|
43
|
+
* 与 `web_base` 这个输出字段必须留着 —— 删了会破坏既有自动化脚本。
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_WEB_BASE = 'https://www.wkongai.cn/admin';
|
|
46
|
+
|
|
47
|
+
/** 官方公共客户端的默认 client_id(--client-id / WKONGAI_CLIENT_ID 可覆盖) */
|
|
48
|
+
export const DEFAULT_CLIENT_ID = 'wkgeo-cli';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 默认申请的 scope。
|
|
52
|
+
* 这四项刚好覆盖"建站 + 下单 + 钱包支付 + 查余额":
|
|
53
|
+
* site:read 看自己的站 site:write 建站/删站
|
|
54
|
+
* billing:self 读钱包/下单/支付
|
|
55
|
+
* 刻意**不申请** billing:manage / user:write 这类"动钱/改权限"的能力 —— 能力越小越好,
|
|
56
|
+
* 而且那些动作在服务端本来就要二次验证(OAuth 令牌没有密码入口,永远过不了),申请了也是废的。
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_SCOPE = 'site:read site:write billing:self';
|
|
59
|
+
|
|
60
|
+
/** 回调只监听回环;端口与服务端登记的 redirect_uri 一致(服务端对回环地址忽略端口比对) */
|
|
61
|
+
export const DEFAULT_CALLBACK_HOST = '127.0.0.1';
|
|
62
|
+
export const DEFAULT_CALLBACK_PORT = 53682;
|
|
63
|
+
export const DEFAULT_CALLBACK_PATH = '/callback';
|
|
64
|
+
|
|
65
|
+
/** 授权码本身 5 分钟过期,所以等待回调默认给 5 分钟就够(= 300 秒;`--timeout` 按**秒**传,见 parseDurationMs) */
|
|
66
|
+
export const DEFAULT_LOGIN_TIMEOUT_MS = 300000;
|
|
67
|
+
export const DEFAULT_HTTP_TIMEOUT_MS = 30000;
|
|
68
|
+
|
|
69
|
+
/** 本地凭据文件:`~/.wkongai/credentials.json`(0600) */
|
|
70
|
+
export function defaultCredentialsFile(env = process.env) {
|
|
71
|
+
if (env.WKONGAI_CREDENTIALS) return path.resolve(env.WKONGAI_CREDENTIALS);
|
|
72
|
+
const home = env.WKONGAI_HOME || os.homedir();
|
|
73
|
+
return path.join(home, '.wkongai', 'credentials.json');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 去掉结尾斜杠:所有拼接都假设 base 不带尾斜杠,混着写会拼出 `//api/data` */
|
|
77
|
+
export function stripTrailingSlash(u) {
|
|
78
|
+
return String(u || '').replace(/\/+$/, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 给「页面 URL」补上尾斜杠 —— **本项目所有会被托管侧 302 的地址都必须经过它**(唯一构造入口)。
|
|
83
|
+
*
|
|
84
|
+
* ## 为什么必须有这个函数(真机实测过的坑)
|
|
85
|
+
* 前端是静态导出(`exportStatic`),产物是「每个路由一个目录」(`dist/oauth/authorize/index.html`)。
|
|
86
|
+
* 托管侧(OSS 静态托管 / 阿里云 ESA)对**不带尾斜杠**的目录路径会返回 **302**,
|
|
87
|
+
* 而这个 302 **不携带原始 query**:
|
|
88
|
+
* ```
|
|
89
|
+
* GET /admin/oauth/authorize?client_id=…&state=… → 302 /admin/oauth/authorize/ ← query 全丢 ❌
|
|
90
|
+
* GET /admin/oauth/authorize/?client_id=…&state=… → 200,参数完好 ✔
|
|
91
|
+
* ```
|
|
92
|
+
* 后果是授权页显示「授权请求不完整」,而且**前端救不回来**:302 发生在任何 JS 执行之前,
|
|
93
|
+
* 带参数的地址根本没到过浏览器。
|
|
94
|
+
*
|
|
95
|
+
* ## 行为(有单测钉住)
|
|
96
|
+
* * 尾斜杠必须加在 **`?` 之前**(`/oauth/device?user_code=x` → `/oauth/device/?user_code=x`),
|
|
97
|
+
* 加到 `?` 后面就变成另一个含义了;
|
|
98
|
+
* * 已经带尾斜杠**不重复拼**(不会出现 `//`);
|
|
99
|
+
* * 路径里的连续斜杠折叠(`/oauth//device` → `/oauth/device/`),
|
|
100
|
+
* 但**保留协议头的 `//`** —— 踩过的坑:一开始对整个字符串做 `replace(/\/{2,}/g,'/')`,
|
|
101
|
+
* 结果 `https://www.wkongai.cn/admin/oauth/device` 被折成 `https:/www.wkongai.cn/...`
|
|
102
|
+
* (看着"还能用"是因为 URL 解析器帮忙兜了,但这种字符串一旦拼进别的地方就会出问题);
|
|
103
|
+
* * query / hash 原样保留,一个字符都不动(那里可能有已经编码好的 `redirect=`)。
|
|
104
|
+
*/
|
|
105
|
+
export function withTrailingSlash(pathAndQuery) {
|
|
106
|
+
const raw = String(pathAndQuery == null ? '' : pathAndQuery);
|
|
107
|
+
// 先按 `#` 切开:hash 里可能有 `?`,不能被当成 query 起点
|
|
108
|
+
const hashAt = raw.indexOf('#');
|
|
109
|
+
const hash = hashAt >= 0 ? raw.slice(hashAt) : '';
|
|
110
|
+
const beforeHash = hashAt >= 0 ? raw.slice(0, hashAt) : raw;
|
|
111
|
+
|
|
112
|
+
const queryAt = beforeHash.indexOf('?');
|
|
113
|
+
const query = queryAt >= 0 ? beforeHash.slice(queryAt) : '';
|
|
114
|
+
const rawPath = queryAt >= 0 ? beforeHash.slice(0, queryAt) : beforeHash;
|
|
115
|
+
|
|
116
|
+
// 协议头(`https://`)里的 `//` 是语法的一部分,**不能**被当成"重复斜杠"折叠掉
|
|
117
|
+
const schemeMatch = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.exec(rawPath);
|
|
118
|
+
const scheme = schemeMatch ? schemeMatch[0] : '';
|
|
119
|
+
const collapsed = scheme + (scheme ? rawPath.slice(scheme.length) : rawPath).replace(/\/{2,}/g, '/');
|
|
120
|
+
const path = !collapsed || collapsed.endsWith('/') ? collapsed || '/' : `${collapsed}/`;
|
|
121
|
+
return `${path}${query}${hash}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 时长后缀 → 毫秒倍率(只支持秒与毫秒两种,够用且不产生歧义) */
|
|
125
|
+
const DURATION_UNITS = { s: 1000, ms: 1 };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 解析「时长」参数。
|
|
129
|
+
*
|
|
130
|
+
* ## 为什么要有它(踩过的坑)
|
|
131
|
+
* `--timeout` 原来直接当**毫秒**用,于是 `--timeout 600` 变成 **600 毫秒** ——
|
|
132
|
+
* 用户按直觉写"600 秒",结果是 0.8 秒就超时失败,`--help` 里也没写单位,
|
|
133
|
+
* 属于"看起来成功其实用错"的那类坑。
|
|
134
|
+
*
|
|
135
|
+
* ## 约定
|
|
136
|
+
* * **默认单位是秒**:`600` = 600 秒(人类写超时几乎都按秒/分钟想);
|
|
137
|
+
* * 也接受显式后缀:`600s` / `500ms`(大小写都行);
|
|
138
|
+
* * 非法值**明确报错**(UsageError,退出码 2),而不是静默退回默认值 ——
|
|
139
|
+
* 静默兜底会让用户以为生效了,是最坑的一种"成功"。
|
|
140
|
+
*
|
|
141
|
+
* @param {string|number} raw 原始值(命令行字符串或环境变量)
|
|
142
|
+
* @param {object} [opts]
|
|
143
|
+
* @param {'s'|'ms'} [opts.defaultUnit] 不带后缀时的单位(默认 `'s'`)
|
|
144
|
+
* @param {string} [opts.flag] 出错信息里显示的参数名(如 `--timeout`)
|
|
145
|
+
* @returns {number} 毫秒
|
|
146
|
+
*/
|
|
147
|
+
export function parseDurationMs(raw, { defaultUnit = 's', flag = '--timeout' } = {}) {
|
|
148
|
+
const text = String(raw == null ? '' : raw).trim();
|
|
149
|
+
const bad = (why) =>
|
|
150
|
+
new UsageError(
|
|
151
|
+
`${why}\n用法:${flag} <时长>,默认单位是**秒**(例如 ${flag} 600 = 600 秒);` +
|
|
152
|
+
`也支持显式后缀 ${flag} 600s / ${flag} 500ms`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s)?$/i.exec(text);
|
|
156
|
+
if (!m) throw bad(`参数 ${flag} 需要时长,收到:${JSON.stringify(String(raw))}`);
|
|
157
|
+
const unit = (m[2] || defaultUnit).toLowerCase();
|
|
158
|
+
const ms = Number(m[1]) * DURATION_UNITS[unit];
|
|
159
|
+
if (!Number.isFinite(ms) || ms <= 0) throw bad(`参数 ${flag} 必须大于 0,收到:${JSON.stringify(String(raw))}`);
|
|
160
|
+
return Math.round(ms);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** scope 字符串 → 数组(空格或逗号分隔都吃;服务端两种都收) */
|
|
164
|
+
export function splitScope(scope) {
|
|
165
|
+
return String(scope || '')
|
|
166
|
+
.split(/[\s,]+/)
|
|
167
|
+
.map((s) => s.trim())
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toInt(v, fallback) {
|
|
172
|
+
const n = Number.parseInt(v, 10);
|
|
173
|
+
return Number.isFinite(n) ? n : fallback;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 把「命令行 flags + 环境变量」合成一份最终配置。
|
|
178
|
+
* @param {object} flags 已解析的命令行标志(键为 camelCase)
|
|
179
|
+
* @param {object} env 环境变量(默认 process.env)
|
|
180
|
+
*/
|
|
181
|
+
export function resolveConfig(flags = {}, env = process.env) {
|
|
182
|
+
const pick = (flagVal, envKey, dflt) => {
|
|
183
|
+
if (flagVal !== undefined && flagVal !== null && flagVal !== '') return flagVal;
|
|
184
|
+
if (env[envKey] !== undefined && env[envKey] !== '') return env[envKey];
|
|
185
|
+
return dflt;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const scopeRaw = pick(flags.scope, 'WKONGAI_SCOPE', DEFAULT_SCOPE);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* ★ 2026-10-02(三站拆分):三个业务子域的基址。
|
|
192
|
+
*
|
|
193
|
+
* 覆盖优先级(高 → 低):
|
|
194
|
+
* 1. `WKONGAI_WEB_BASE` / `--web-base` —— **总覆盖**:设了它就三站都用它
|
|
195
|
+
* (联调 / 回滚老站时一句环境变量搞定,不用改代码);
|
|
196
|
+
* 2. `WKONGAI_OAUTH_WEB_BASE` / `WKONGAI_PAY_WEB_BASE` / `WKONGAI_USER_WEB_BASE`
|
|
197
|
+
* —— 按站单独覆盖;
|
|
198
|
+
* 3. 各自的内置默认值(授权站 / 收银台 / 会员站子域)。
|
|
199
|
+
*/
|
|
200
|
+
const 总覆盖 = stripTrailingSlash(pick(flags.webBase, 'WKONGAI_WEB_BASE', ''));
|
|
201
|
+
const 取前端基址 = (envKey, dflt) =>
|
|
202
|
+
总覆盖 || stripTrailingSlash(pick(undefined, envKey, dflt));
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `--timeout` 的单位是**秒**(见 parseDurationMs 的说明),而环境变量
|
|
206
|
+
* `WKONGAI_LOGIN_TIMEOUT_MS` 名字里就写着 MS,**必须是毫秒** —— 两者不能共用一个默认单位,
|
|
207
|
+
* 否则 `WKONGAI_LOGIN_TIMEOUT_MS=300000` 会被当成 300000 秒(83 小时)。
|
|
208
|
+
*/
|
|
209
|
+
const loginTimeoutMs =
|
|
210
|
+
flags.timeout !== undefined && flags.timeout !== ''
|
|
211
|
+
? parseDurationMs(flags.timeout, { defaultUnit: 's', flag: '--timeout' })
|
|
212
|
+
: env.WKONGAI_LOGIN_TIMEOUT_MS !== undefined && env.WKONGAI_LOGIN_TIMEOUT_MS !== ''
|
|
213
|
+
? parseDurationMs(env.WKONGAI_LOGIN_TIMEOUT_MS, { defaultUnit: 'ms', flag: 'WKONGAI_LOGIN_TIMEOUT_MS' })
|
|
214
|
+
: DEFAULT_LOGIN_TIMEOUT_MS;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
base: stripTrailingSlash(pick(flags.base, 'WKONGAI_BASE', DEFAULT_BASE)),
|
|
218
|
+
/** ⚠️ 兼容字段:老站(管理端)基址。三站拆分后没有代码用它打开页面,但保留以免破坏自动化 */
|
|
219
|
+
webBase: 总覆盖 || stripTrailingSlash(pick(undefined, 'WKONGAI_WEB_BASE', DEFAULT_WEB_BASE)),
|
|
220
|
+
/** 授权站基址(同意页 / 设备码页) */
|
|
221
|
+
oauthWebBase: 取前端基址('WKONGAI_OAUTH_WEB_BASE', DEFAULT_OAUTH_WEB_BASE),
|
|
222
|
+
/** 收银台基址(支付页) */
|
|
223
|
+
payWebBase: 取前端基址('WKONGAI_PAY_WEB_BASE', DEFAULT_PAY_WEB_BASE),
|
|
224
|
+
/** 会员站基址(登录页 / 我的订单等) */
|
|
225
|
+
userWebBase: 取前端基址('WKONGAI_USER_WEB_BASE', DEFAULT_USER_WEB_BASE),
|
|
226
|
+
clientId: String(pick(flags.clientId, 'WKONGAI_CLIENT_ID', DEFAULT_CLIENT_ID)),
|
|
227
|
+
scope: splitScope(scopeRaw).join(' '),
|
|
228
|
+
credentialsFile: flags.credentials
|
|
229
|
+
? path.resolve(String(flags.credentials))
|
|
230
|
+
: defaultCredentialsFile(env),
|
|
231
|
+
callbackHost: String(pick(flags.callbackHost, 'WKONGAI_CALLBACK_HOST', DEFAULT_CALLBACK_HOST)),
|
|
232
|
+
callbackPort: toInt(pick(flags.callbackPort, 'WKONGAI_CALLBACK_PORT', DEFAULT_CALLBACK_PORT), DEFAULT_CALLBACK_PORT),
|
|
233
|
+
callbackPath: String(pick(flags.callbackPath, 'WKONGAI_CALLBACK_PATH', DEFAULT_CALLBACK_PATH)),
|
|
234
|
+
loginTimeoutMs,
|
|
235
|
+
httpTimeoutMs: toInt(pick(flags.httpTimeout, 'WKONGAI_HTTP_TIMEOUT_MS', DEFAULT_HTTP_TIMEOUT_MS), DEFAULT_HTTP_TIMEOUT_MS),
|
|
236
|
+
json: flags.json === true,
|
|
237
|
+
color: flags.color !== false,
|
|
238
|
+
verbose: flags.verbose === true,
|
|
239
|
+
version: '0.1.0',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 供 `wkgeo config` 展示:把"哪个来源生效"讲清楚(不含任何令牌) */
|
|
244
|
+
export function describeConfigSources(flags = {}, env = process.env) {
|
|
245
|
+
const src = (flagVal, envKey) => {
|
|
246
|
+
if (flagVal !== undefined && flagVal !== null && flagVal !== '') return '命令行';
|
|
247
|
+
if (env[envKey] !== undefined && env[envKey] !== '') return `环境变量 ${envKey}`;
|
|
248
|
+
return '内置默认';
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
base: src(flags.base, 'WKONGAI_BASE'),
|
|
252
|
+
client_id: src(flags.clientId, 'WKONGAI_CLIENT_ID'),
|
|
253
|
+
scope: src(flags.scope, 'WKONGAI_SCOPE'),
|
|
254
|
+
credentials_file: flags.credentials
|
|
255
|
+
? '命令行'
|
|
256
|
+
: env.WKONGAI_CREDENTIALS
|
|
257
|
+
? '环境变量 WKONGAI_CREDENTIALS'
|
|
258
|
+
: '内置默认 ~/.wkongai/credentials.json',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地凭据文件:`~/.wkongai/credentials.json`(权限 0600,目录 0700)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是这个位置 / 这个形态:
|
|
5
|
+
* * 放用户主目录(不是当前目录):CLI 在任何目录下跑都能用;也不会被 git 误提交。
|
|
6
|
+
* * 0600:文件里是**30 天的 bearer 令牌**,等同密码。同机器其它用户不该读到。
|
|
7
|
+
* * 原子写(先写临时文件再 rename):避免"写一半被 Ctrl+C"留下损坏的 JSON,
|
|
8
|
+
* 下次启动读不动、只能重新登录(踩过一次)。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ Windows 说明:NTFS 的 `chmod` 基本是空操作(Node 会接受但只映射只读位)。
|
|
11
|
+
* 所以我们**仍然调用 chmod 0600**(POSIX 上有效),并在读到"权限过宽"时给出警告。
|
|
12
|
+
* Windows 上真正的保护来自用户目录 ACL(默认只有本人可读),这一点在 README 里写明了。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { CliError, EXIT } from './errors.mjs';
|
|
18
|
+
|
|
19
|
+
export const CREDENTIALS_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** 读凭据:不存在返回 null;JSON 损坏 → 抛可照做的错误(不静默当未登录,否则用户不知道为什么) */
|
|
22
|
+
export function readCredentials(file) {
|
|
23
|
+
if (!file) return null;
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = fs.readFileSync(file, 'utf8');
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if (e && e.code === 'ENOENT') return null;
|
|
29
|
+
throw new CliError(`读取凭据文件失败:${e.message}`, {
|
|
30
|
+
kind: 'credentials',
|
|
31
|
+
hint: `文件位置:${file}`,
|
|
32
|
+
cause: e,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(text);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw new CliError(`凭据文件不是合法 JSON:${file}`, {
|
|
40
|
+
kind: 'credentials',
|
|
41
|
+
hint: '可以删掉它再运行 `wkgeo login`(或直接运行 `wkgeo login` 覆盖写入)',
|
|
42
|
+
cause: e,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.access_token) {
|
|
46
|
+
throw new CliError(`凭据文件缺少 access_token:${file}`, {
|
|
47
|
+
kind: 'credentials',
|
|
48
|
+
hint: '运行 `wkgeo logout` 清掉再 `wkgeo login`',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 凭据文件权限是否过宽(POSIX 有意义;Windows 上恒判"无法判断") */
|
|
55
|
+
export function checkFileMode(file) {
|
|
56
|
+
if (process.platform === 'win32') return { supported: false };
|
|
57
|
+
try {
|
|
58
|
+
const st = fs.statSync(file);
|
|
59
|
+
const mode = st.mode & 0o777;
|
|
60
|
+
return { supported: true, mode, tooOpen: (mode & 0o077) !== 0, modeText: mode.toString(8).padStart(3, '0') };
|
|
61
|
+
} catch {
|
|
62
|
+
return { supported: false };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 原子写凭据(0600)。返回写入后的权限信息,便于让 login 如实汇报。
|
|
68
|
+
*/
|
|
69
|
+
export function writeCredentials(file, data, { now = Date.now() } = {}) {
|
|
70
|
+
const dir = path.dirname(file);
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
// 目录已存在时 mkdir 不改权限,显式补一次(0600 文件放 0755 目录里意义会打折)
|
|
73
|
+
try {
|
|
74
|
+
if (process.platform !== 'win32') fs.chmodSync(dir, 0o700);
|
|
75
|
+
} catch {
|
|
76
|
+
/* 权限收紧失败不阻塞主流程,下面会汇报实际权限 */
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const payload = {
|
|
80
|
+
version: CREDENTIALS_VERSION,
|
|
81
|
+
...data,
|
|
82
|
+
saved_at: new Date(now).toISOString(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
86
|
+
const text = JSON.stringify(payload, null, 2) + '\n';
|
|
87
|
+
// 先以 0600 创建临时文件:避免"先写明文再 chmod"中间那一瞬间是 0644
|
|
88
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
89
|
+
try {
|
|
90
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
91
|
+
} finally {
|
|
92
|
+
fs.closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
fs.chmodSync(tmp, 0o600);
|
|
96
|
+
} catch {
|
|
97
|
+
/* Windows 上基本无效,忽略 */
|
|
98
|
+
}
|
|
99
|
+
fs.renameSync(tmp, file);
|
|
100
|
+
try {
|
|
101
|
+
fs.chmodSync(file, 0o600);
|
|
102
|
+
} catch {
|
|
103
|
+
/* 同上 */
|
|
104
|
+
}
|
|
105
|
+
return checkFileMode(file);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 删除凭据。返回是否真的删了(不存在 → false) */
|
|
109
|
+
export function deleteCredentials(file) {
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(file);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e && e.code === 'ENOENT') return false;
|
|
115
|
+
throw new CliError(`删除凭据文件失败:${e.message}`, { kind: 'credentials', hint: `文件位置:${file}` });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 令牌是否(按本地记录)已过期。留 60 秒时钟偏差余量,避免"刚好卡在边界"白跑一次请求。 */
|
|
120
|
+
export function isExpired(creds, { now = Date.now(), skewMs = 60000 } = {}) {
|
|
121
|
+
const exp = creds && creds.expires_at ? new Date(creds.expires_at).getTime() : null;
|
|
122
|
+
if (!exp || !Number.isFinite(exp)) return false; // 没有过期信息 → 交给服务端判
|
|
123
|
+
return exp - skewMs <= now;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 取登录态;未登录/已过期都给"照做就行"的错误(exit code 3) */
|
|
127
|
+
export function requireCredentials(file, { allowExpired = false, now = Date.now() } = {}) {
|
|
128
|
+
const creds = readCredentials(file);
|
|
129
|
+
if (!creds) {
|
|
130
|
+
throw new CliError('尚未登录:本地没有凭据文件', {
|
|
131
|
+
kind: 'unauthenticated',
|
|
132
|
+
hint: '先运行 `wkgeo login` 完成一次授权',
|
|
133
|
+
exitCode: EXIT.AUTH,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!allowExpired && isExpired(creds, { now })) {
|
|
137
|
+
throw new CliError(`本地记录的令牌已过期(过期时间 ${creds.expires_at})`, {
|
|
138
|
+
kind: 'expired',
|
|
139
|
+
hint: '运行 `wkgeo login` 重新授权',
|
|
140
|
+
exitCode: EXIT.AUTH,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return creds;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 由 token 响应构造凭据对象(**唯一**把 access_token 写进文件的地方) */
|
|
147
|
+
export function credentialsFromToken({ clientId, tokenResponse, account = null, now = Date.now(), scope = '' }) {
|
|
148
|
+
const expiresIn = Number(tokenResponse.expires_in);
|
|
149
|
+
return {
|
|
150
|
+
client_id: clientId,
|
|
151
|
+
access_token: tokenResponse.access_token,
|
|
152
|
+
refresh_token: tokenResponse.refresh_token || null,
|
|
153
|
+
token_type: tokenResponse.token_type || 'Bearer',
|
|
154
|
+
scope: tokenResponse.scope || scope || '',
|
|
155
|
+
expires_in: Number.isFinite(expiresIn) ? expiresIn : null,
|
|
156
|
+
obtained_at: new Date(now).toISOString(),
|
|
157
|
+
expires_at: Number.isFinite(expiresIn) ? new Date(now + expiresIn * 1000).toISOString() : null,
|
|
158
|
+
account,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「待完成的设备码登录」状态文件 —— 支撑 `login --device --no-wait` + `login --device-continue`。
|
|
3
|
+
*
|
|
4
|
+
* ===========================================================================
|
|
5
|
+
* ## 为什么需要它(老板 2026-10-02 的用法)
|
|
6
|
+
* "检测到有代理就**直接返回设备码**,用户把它输入到 AI 端的对话框里,Agent 拿这个
|
|
7
|
+
* 设备码也能获取……加一个这样的获取路径,因为这样比较友好。"
|
|
8
|
+
*
|
|
9
|
+
* AI 端(Agent)是**一轮一轮**工作的:它一次工具调用里没法"挂着等 10 分钟"再往下走
|
|
10
|
+
* (那会把用户晾住,还可能撞上工具超时)。所以更合适的形态是:
|
|
11
|
+
* ① `wkgeo login --device --no-wait` → 申请码、打印短码/网址、**立刻退出**
|
|
12
|
+
* ② AI 把短码/网址转达给用户,用户在自己浏览器里点「同意」
|
|
13
|
+
* ③ `wkgeo login --device-continue` → 用磁盘上那份状态换票、写凭据、完成登录
|
|
14
|
+
* ② 与 ③ 之间隔多久都行(只要没超过设备码有效期)。中间**不需要任何长驻进程**。
|
|
15
|
+
*
|
|
16
|
+
* ## 文件位置与权限
|
|
17
|
+
* 与凭据文件同目录、同前缀:`<credentials.json>.device-pending.json`。
|
|
18
|
+
* * 为什么跟凭据放一起:`--credentials` 指向哪,这份状态就跟着去哪 —— 测试/多环境
|
|
19
|
+
* 互不干扰(用户的 HOME 里也不会多出一个莫名其妙的文件)。
|
|
20
|
+
* * **0600**:`device_code` 是"用户一旦同意就能换到 30 天令牌"的东西,
|
|
21
|
+
* 等同于一次性凭据,绝不能比凭据文件宽松(踩过的坑:一开始用的是默认 0644)。
|
|
22
|
+
*
|
|
23
|
+
* ## ⚠️ 刻意不做的事
|
|
24
|
+
* * 不做自动清理定时器:这是一次性小文件,`--device-continue` 成功/失败(过期、拒绝)后
|
|
25
|
+
* 会立刻删掉;用户真想放弃时 `login --device` 会覆盖它。
|
|
26
|
+
* * 不在文件里放任何**账号**信息:此刻还不知道是谁在授权(用户还没点同意)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
import { CliError, EXIT } from './errors.mjs';
|
|
33
|
+
|
|
34
|
+
export const PENDING_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/** 待完成设备码的状态文件路径(与凭据文件同目录同前缀) */
|
|
37
|
+
export function pendingDeviceFile(credentialsFile) {
|
|
38
|
+
return `${credentialsFile}.device-pending.json`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 写状态文件(0600,原子写)。
|
|
43
|
+
* 与 credentials.mjs 的 writeCredentials 同一套做法:先 0600 建临时文件再 rename,
|
|
44
|
+
* 避免"写一半被 Ctrl+C"留下半个 JSON。
|
|
45
|
+
*/
|
|
46
|
+
export function savePendingDevice(file, device, { now = Date.now() } = {}) {
|
|
47
|
+
const dir = path.dirname(file);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
const payload = {
|
|
50
|
+
version: PENDING_VERSION,
|
|
51
|
+
base: device.base || null,
|
|
52
|
+
client_id: device.client_id || null,
|
|
53
|
+
device_code: device.device_code,
|
|
54
|
+
user_code: device.user_code,
|
|
55
|
+
verification_uri: device.verification_uri,
|
|
56
|
+
verification_uri_complete: device.verification_uri_complete || null,
|
|
57
|
+
open_url: device.open_url || device.verification_uri_complete || device.verification_uri,
|
|
58
|
+
expires_in: device.expires_in,
|
|
59
|
+
interval: device.interval,
|
|
60
|
+
expires_at: device.expires_at,
|
|
61
|
+
requested_at: device.requested_at,
|
|
62
|
+
saved_at: new Date(now).toISOString(),
|
|
63
|
+
};
|
|
64
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
65
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(fd, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
68
|
+
} finally {
|
|
69
|
+
fs.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
fs.chmodSync(tmp, 0o600);
|
|
73
|
+
} catch {
|
|
74
|
+
/* Windows 上基本无效,忽略(真正的保护来自用户目录 ACL) */
|
|
75
|
+
}
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 读状态文件:不存在返回 null;内容坏掉 → 抛"照做就行"的错误,绝不静默 */
|
|
81
|
+
export function readPendingDevice(file) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = fs.readFileSync(file, 'utf8');
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (e && e.code === 'ENOENT') return null;
|
|
87
|
+
throw new CliError(`读取待完成的设备码状态失败:${e.message}`, { kind: 'device_state', hint: `文件位置:${file}` });
|
|
88
|
+
}
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(text);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new CliError(`待完成的设备码状态文件不是合法 JSON:${file}`, {
|
|
94
|
+
kind: 'device_state',
|
|
95
|
+
hint: '删掉它再运行 `wkgeo login --device` 重新申请一个短码',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.device_code || !parsed.user_code) {
|
|
99
|
+
throw new CliError(`待完成的设备码状态文件缺少 device_code / user_code:${file}`, {
|
|
100
|
+
kind: 'device_state',
|
|
101
|
+
hint: '删掉它再运行 `wkgeo login --device` 重新申请一个短码',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 删除状态文件(幂等;不存在也算成功) */
|
|
108
|
+
export function deletePendingDevice(file) {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(file);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e && e.code === 'ENOENT') return false;
|
|
114
|
+
throw new CliError(`删除待完成的设备码状态失败:${e.message}`, { kind: 'device_state', hint: `文件位置:${file}` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 该状态是否已过期(`now` 可注入,便于单测) */
|
|
119
|
+
export function isPendingExpired(pending, { now = Date.now(), skewMs = 0 } = {}) {
|
|
120
|
+
const exp = Number(pending && pending.expires_at);
|
|
121
|
+
if (!Number.isFinite(exp) || exp <= 0) return false; // 没记录到期时刻 → 交给服务端判
|
|
122
|
+
return exp - skewMs <= now;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 给 `--device-continue` 用的:状态过期时的统一错误 */
|
|
126
|
+
export function pendingExpiredError(file) {
|
|
127
|
+
return new CliError('上一次的设备码已经过期(超过有效期),无法继续等待', {
|
|
128
|
+
kind: 'expired',
|
|
129
|
+
hint: `重新运行 \`wkgeo login --device\` 申请新的短码(状态文件:${file})`,
|
|
130
|
+
exitCode: EXIT.AUTH,
|
|
131
|
+
});
|
|
132
|
+
}
|