@ws1298191366/cli 0.0.0-stage → 0.1.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 +64 -2
- package/bin/jeecg.js +62 -0
- package/lib/auth.js +419 -0
- package/lib/store.js +29 -0
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jeecg/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在浏览器里授权登录 JeecgBoot / 敲敲云 / 积木报表,给 AI skills 提供 token,不用再从 F12 复制 `X-Access-Token`。
|
|
4
|
+
|
|
5
|
+
需要 Node.js 18 以上,不依赖第三方包。
|
|
6
|
+
|
|
7
|
+
## 取 token(一条命令)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
jeecg auth token --host http://127.0.0.1:8080/jeecg-boot --type jeecgboot
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- 已登录:直接输出 token;快过期时自动用 refresh_token 换新的
|
|
14
|
+
- 没登录过,或者登录已过期 / 被撤销:自动打开默认浏览器进入授权页,没登录网页的先登录,确认账号(敲敲云还要选租户)后点「授权」,浏览器提示「授权成功」,命令输出 token
|
|
15
|
+
- 第一次要给地址和产品标识,之后会记住,不用再带
|
|
16
|
+
- 提示信息都打到 stderr,stdout 只有 token,所以脚本里直接这样用:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python xxx.py --api-base <地址> --token "$(jeecg auth token)"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
token 由 shell 在执行时代入,不会出现在命令和对话记录里。自动授权时最多等 110 秒(AI 工具执行命令一般 2 分钟超时),超时会提示重新执行。
|
|
23
|
+
|
|
24
|
+
## 产品标识 --type
|
|
25
|
+
|
|
26
|
+
决定请求后端哪组接口:
|
|
27
|
+
|
|
28
|
+
| --type | 产品 | --host 填什么 | 请求的接口 |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| `jeecgboot` | JeecgBoot | 后端地址,例如 `http://127.0.0.1:8080/jeecg-boot` | `{host}/.well-known/...`、`{host}/oauth2/token` |
|
|
31
|
+
| `qiaoqiaoyun` | 敲敲云 | 同上(SaaS 为 `https://api.qiaoqiaoyun.com`) | 同上,和 JeecgBoot 是同一套后端 |
|
|
32
|
+
| `jimureport` | 积木报表 | 应用地址,例如 `http://127.0.0.1:8085`(带不带 `/jmreport` 都行) | `{host}/jmreport/.well-known/...`、`{host}/jmreport/oauth2/token` |
|
|
33
|
+
|
|
34
|
+
- 不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云和 JeecgBoot 后端一样探测不出来,要明确写 `--type qiaoqiaoyun`
|
|
35
|
+
- 产品标识和登录信息存在一起,`jeecg auth status --json` 里的 `type` 字段就是它,skill 据此判断连的是哪个产品
|
|
36
|
+
- 授权页地址由后端返回:JeecgBoot / 敲敲云的授权页在它们自己的前端(后端配置 `jeecg.domainUrl.pc`),积木的授权页在积木自己的页面
|
|
37
|
+
|
|
38
|
+
## 其他命令
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
jeecg auth login [--host <地址>] [--type <产品>] # 只登录(最多等 5 分钟),不输出 token
|
|
42
|
+
jeecg auth status --json # 本机登录状态:地址、产品、用户、租户(不输出 token)
|
|
43
|
+
jeecg auth logout [--host <地址>] # 撤销授权并删除本机凭证
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
没有浏览器的环境加 `--no-open`,把打印的链接复制到浏览器打开,同样要点「授权」。
|
|
47
|
+
|
|
48
|
+
## 服务器上的地址
|
|
49
|
+
|
|
50
|
+
- `--host` 填前端调接口用的后端地址(nginx 转发后的外网地址,例如 `https://boot.example.com/jeecgboot`),换 token 也走这个地址
|
|
51
|
+
- 后端要把 `jeecg.domainUrl.pc` 配成前端的外网地址,授权页地址是用它拼出来的;默认值是 `http://localhost:3100`,没改时 CLI 会提示
|
|
52
|
+
- 授权后浏览器跳回的是你电脑上的 `127.0.0.1`,服务器不需要访问你的电脑,也不用开端口
|
|
53
|
+
- 多个环境各登录一次,用 `--host` 切换,最后登录的是默认地址
|
|
54
|
+
|
|
55
|
+
## 积木报表的宿主要求
|
|
56
|
+
|
|
57
|
+
积木没有自己的用户体系,宿主要在 `JmReportTokenServiceI` 里实现 `createToken`(给 CLI 签发宿主认的 token),建议同时实现 `getLoginUrl`(没登录时跳哪)和 `revokeToken`(作废 token);没实现 `createToken` 会提示「没有开启在线授权」。集成在 JeecgBoot 里的积木不用这套,用 `--type jeecgboot` 登录 JeecgBoot 地址即可,token 积木也认。
|
|
58
|
+
|
|
59
|
+
## 凭证
|
|
60
|
+
|
|
61
|
+
存在 `~/.jeecg/credentials.json`,按地址区分,文件权限 600。JeecgBoot / 敲敲云的 access_token 有效期和网页登录一样(84 小时),积木 2 小时;refresh_token 30 天,每次刷新换新的。
|
|
62
|
+
|
|
63
|
+
## 后端要求
|
|
64
|
+
|
|
65
|
+
JeecgBoot / 敲敲云要有 `/.well-known/oauth-authorization-server`、`/oauth2/token`、`/oauth2/revoke` 和前端授权页 `/oauth2/authorize`;积木要有 `/jmreport/` 下的同名接口(LHZP-2457)。老版本会提示「没有开启在线授权」。
|
package/bin/jeecg.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const auth = require('../lib/auth');
|
|
4
|
+
|
|
5
|
+
const USAGE = `jeecg 命令行工具
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
jeecg auth token [--host <地址>] [--type <产品>] [--scope <权限>] [--no-open]
|
|
9
|
+
输出可用的 access_token,给脚本用:--token "$(jeecg auth token)"
|
|
10
|
+
快过期自动刷新;没登录过或登录已失效时自动打开浏览器授权,点「授权」后输出(提示信息打到 stderr,不影响取值)
|
|
11
|
+
jeecg auth login [--host <地址>] [--type <产品>] [--scope <权限>] [--no-open]
|
|
12
|
+
只登录(最多等 5 分钟),不输出 token;没有浏览器的环境加 --no-open,自己复制链接打开
|
|
13
|
+
jeecg auth status [--host <地址>] [--json]
|
|
14
|
+
查看本机登录状态:地址、产品、用户、租户(不输出 token)
|
|
15
|
+
jeecg auth logout [--host <地址>]
|
|
16
|
+
撤销授权并删除本机凭证
|
|
17
|
+
|
|
18
|
+
--type 产品标识,决定请求后端哪组接口:
|
|
19
|
+
jeecgboot JeecgBoot,地址填后端地址,例如 http://127.0.0.1:8080/jeecg-boot
|
|
20
|
+
qiaoqiaoyun 敲敲云,和 JeecgBoot 同一套后端,地址同上(SaaS 为 https://api.qiaoqiaoyun.com)
|
|
21
|
+
jimureport 积木报表,地址填应用地址,例如 http://127.0.0.1:8085(带不带 /jmreport 都行)
|
|
22
|
+
不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云要明确写 --type qiaoqiaoyun`;
|
|
23
|
+
|
|
24
|
+
const VALUE_OPTIONS = ['host', 'scope', 'type'];
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = { _: [] };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null;
|
|
31
|
+
if (a === '--no-open') {
|
|
32
|
+
args.open = false;
|
|
33
|
+
} else if (a === '--json') {
|
|
34
|
+
args.json = true;
|
|
35
|
+
} else if (a === '-h' || a === '--help') {
|
|
36
|
+
args.help = true;
|
|
37
|
+
} else if (VALUE_OPTIONS.includes(name)) {
|
|
38
|
+
// 支持 --host x 和 --host=x 两种写法
|
|
39
|
+
args[name] = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
|
|
40
|
+
} else {
|
|
41
|
+
args._.push(a);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const args = parseArgs(process.argv.slice(2));
|
|
49
|
+
const [group, cmd] = args._;
|
|
50
|
+
const handler = group === 'auth' ? { login: auth.login, status: auth.status, token: auth.token, logout: auth.logout }[cmd] : null;
|
|
51
|
+
if (args.help || !handler) {
|
|
52
|
+
console.log(USAGE);
|
|
53
|
+
process.exitCode = args.help ? 0 : 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await handler(args);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main().catch((e) => {
|
|
60
|
+
console.error(e.message || e);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
});
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* jeecg auth login / status / token / logout
|
|
4
|
+
*
|
|
5
|
+
* 登录流程(OAuth2 授权码 + PKCE,和 Teambition CLI 的 auth login 一样):
|
|
6
|
+
* 1. 按产品标识读后端的授权配置 .well-known/oauth-authorization-server,拿到授权页地址
|
|
7
|
+
* 2. 在本机 127.0.0.1 随机端口起一个回调服务,打开默认浏览器进入授权页
|
|
8
|
+
* 3. 用户在页面上点「授权」后,浏览器带着授权码跳回回调地址
|
|
9
|
+
* 4. 用授权码 + code_verifier 换 token,存到 ~/.jeecg/credentials.json
|
|
10
|
+
*
|
|
11
|
+
* 输出约定:stdout 只放给程序用的结果(token、status --json),提示信息一律打到 stderr,
|
|
12
|
+
* 这样 --token "$(jeecg auth token)" 里就算触发了登录,拿到的也只有 token
|
|
13
|
+
*/
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const http = require('http');
|
|
16
|
+
const readline = require('readline');
|
|
17
|
+
const { spawn } = require('child_process');
|
|
18
|
+
const store = require('./store');
|
|
19
|
+
|
|
20
|
+
/** 后端内置的公共客户端,没有 secret,靠 PKCE 防止授权码被截获后冒用 */
|
|
21
|
+
const CLIENT_ID = 'jeecg-cli';
|
|
22
|
+
/** 手动执行 jeecg auth login 时,等用户点授权最多 5 分钟(和后端授权码有效期一致) */
|
|
23
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
|
+
/** jeecg auth token 自动登录时只等 110 秒:AI 工具执行命令默认 2 分钟超时,先自己超时退出、给出提示,别被直接杀掉 */
|
|
25
|
+
const AUTO_LOGIN_TIMEOUT_MS = 110 * 1000;
|
|
26
|
+
/** access_token 还剩 5 分钟就提前刷新 */
|
|
27
|
+
const REFRESH_AHEAD_MS = 5 * 60 * 1000;
|
|
28
|
+
const HOST_EXAMPLE = 'JeecgBoot / 敲敲云 http://127.0.0.1:8080/jeecg-boot,积木报表 http://127.0.0.1:8085';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 产品标识(--type),决定请求后端哪组接口:
|
|
32
|
+
* - jeecgboot、qiaoqiaoyun:同一套后端,接口在后端地址下,例如 http://127.0.0.1:8080/jeecg-boot/oauth2/token;
|
|
33
|
+
* 标识用来区分产品,skill 从 jeecg auth status --json 里的 type 知道连的是谁
|
|
34
|
+
* - jimureport:积木是嵌在宿主应用里的,接口挂在 /jmreport 下,例如 http://127.0.0.1:8085/jmreport/oauth2/token
|
|
35
|
+
*/
|
|
36
|
+
const PRODUCTS = {
|
|
37
|
+
'jeecgboot': { name: 'JeecgBoot', prefix: '' },
|
|
38
|
+
qiaoqiaoyun: { name: '敲敲云', prefix: '' },
|
|
39
|
+
jimureport: { name: '积木报表', prefix: '/jmreport' },
|
|
40
|
+
};
|
|
41
|
+
/** 没给 --type 时的探测顺序;敲敲云和 JeecgBoot 后端一样探测不出来,要用 --type qiaoqiaoyun 标明 */
|
|
42
|
+
const DETECT_ORDER = ['jeecgboot', 'jimureport'];
|
|
43
|
+
|
|
44
|
+
async function login(args) {
|
|
45
|
+
const { host, type } = await resolveTarget(args, true);
|
|
46
|
+
const cred = await browserLogin(host, type, args, LOGIN_TIMEOUT_MS);
|
|
47
|
+
console.error(successText(cred));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 输出可用的 access_token(给脚本用:--token "$(jeecg auth token)"),一条命令把取 token 的几种情况都处理掉:
|
|
52
|
+
* 1. 已登录且没过期:直接输出
|
|
53
|
+
* 2. 快过期:用 refresh_token 换新的
|
|
54
|
+
* 3. 没登录过,或者登录已过期 / 被撤销:自动打开浏览器授权,用户点「授权」后输出
|
|
55
|
+
*/
|
|
56
|
+
async function token(args) {
|
|
57
|
+
const { host, type } = await resolveTarget(args, true);
|
|
58
|
+
const cred = store.load().hosts[host];
|
|
59
|
+
if (cred && Date.now() < cred.expiresAt - REFRESH_AHEAD_MS) {
|
|
60
|
+
process.stdout.write(cred.accessToken + '\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const refreshed = cred ? await refresh(host, cred) : null;
|
|
64
|
+
if (refreshed) {
|
|
65
|
+
process.stdout.write(refreshed.accessToken + '\n');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.error(cred ? `登录已过期或已被撤销(${host}),重新授权:` : `还没有登录 ${host},开始授权:`);
|
|
69
|
+
const fresh = await browserLogin(host, type || (cred && cred.type), args, AUTO_LOGIN_TIMEOUT_MS);
|
|
70
|
+
console.error(successText(fresh));
|
|
71
|
+
process.stdout.write(fresh.accessToken + '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 只看本机保存的登录信息,不输出 token;--json 给程序读地址、产品和租户
|
|
76
|
+
*/
|
|
77
|
+
async function status(args) {
|
|
78
|
+
const data = store.load();
|
|
79
|
+
const hosts = args.host ? [(await resolveTarget(args, false)).host] : Object.keys(data.hosts);
|
|
80
|
+
const list = hosts
|
|
81
|
+
.filter((h) => data.hosts[h])
|
|
82
|
+
.map((h) => ({
|
|
83
|
+
host: h,
|
|
84
|
+
type: typeOf(data.hosts[h]),
|
|
85
|
+
username: data.hosts[h].username,
|
|
86
|
+
tenant_id: data.hosts[h].tenantId,
|
|
87
|
+
default: h === data.defaultHost,
|
|
88
|
+
}));
|
|
89
|
+
if (args.json) {
|
|
90
|
+
console.log(JSON.stringify({ logged_in: list.length > 0, default_host: data.defaultHost, hosts: list }, null, 2));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (list.length === 0) {
|
|
94
|
+
console.log('还没有登录,请执行:jeecg auth login --host <地址> --type <jeecgboot|qiaoqiaoyun|jimureport>');
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const c of list) {
|
|
99
|
+
console.log(`${c.default ? '* ' : ' '}${c.host} ${PRODUCTS[c.type].name} 用户 ${c.username}${c.tenant_id != null ? ` 租户 ${c.tenant_id}` : ''}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function logout(args) {
|
|
104
|
+
const { host } = await resolveTarget(args, false);
|
|
105
|
+
const data = store.load();
|
|
106
|
+
const cred = data.hosts[host];
|
|
107
|
+
if (!cred) {
|
|
108
|
+
console.log(`本机没有 ${host} 的登录信息`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await fetch(cred.revocationEndpoint, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
115
|
+
body: new URLSearchParams({ token: cred.refreshToken, client_id: CLIENT_ID }),
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.log(`服务器撤销失败(${e.message}),只删除了本机凭证`);
|
|
119
|
+
}
|
|
120
|
+
delete data.hosts[host];
|
|
121
|
+
if (data.defaultHost === host) {
|
|
122
|
+
data.defaultHost = Object.keys(data.hosts)[0] || null;
|
|
123
|
+
}
|
|
124
|
+
store.save(data);
|
|
125
|
+
console.log(`已退出 ${host}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 浏览器授权登录,成功后保存凭证并设为默认地址
|
|
130
|
+
*/
|
|
131
|
+
async function browserLogin(host, type, args, timeoutMs) {
|
|
132
|
+
// 1. 按产品标识请求对应的授权配置;没给标识时按 DETECT_ORDER 依次探测
|
|
133
|
+
let meta = null;
|
|
134
|
+
for (const t of type ? [type] : DETECT_ORDER) {
|
|
135
|
+
meta = await fetchMetadata(host, PRODUCTS[t].prefix);
|
|
136
|
+
if (meta) {
|
|
137
|
+
type = t;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!meta) {
|
|
142
|
+
throw new Error(notEnabledText(host, type));
|
|
143
|
+
}
|
|
144
|
+
// 换 token、撤销直接用用户给的地址拼:nginx 常把外网 /jeecgboot 转发到后端 /jeecg-boot,
|
|
145
|
+
// 后端按请求算出来的 token_endpoint(.../jeecg-boot/oauth2/token)在外网访问不到
|
|
146
|
+
const base = host + PRODUCTS[type].prefix;
|
|
147
|
+
const tokenEndpoint = `${base}/oauth2/token`;
|
|
148
|
+
const revocationEndpoint = `${base}/oauth2/revoke`;
|
|
149
|
+
warnLocalAuthorizePage(meta.authorization_endpoint, host);
|
|
150
|
+
|
|
151
|
+
// 2. 本机起回调服务,打开授权页
|
|
152
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
153
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
154
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
155
|
+
const callback = await startCallbackServer(state);
|
|
156
|
+
const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
|
|
157
|
+
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
response_type: 'code',
|
|
160
|
+
client_id: CLIENT_ID,
|
|
161
|
+
redirect_uri: redirectUri,
|
|
162
|
+
code_challenge: challenge,
|
|
163
|
+
code_challenge_method: 'S256',
|
|
164
|
+
state,
|
|
165
|
+
});
|
|
166
|
+
if (args.scope) {
|
|
167
|
+
params.set('scope', args.scope);
|
|
168
|
+
}
|
|
169
|
+
const authorizeUrl = `${meta.authorization_endpoint}?${params}`;
|
|
170
|
+
console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl}\n`);
|
|
171
|
+
if (args.open !== false) {
|
|
172
|
+
openBrowser(authorizeUrl);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3. 等用户点「授权」,用授权码换 token
|
|
176
|
+
let code;
|
|
177
|
+
try {
|
|
178
|
+
code = await callback.waitForCode(timeoutMs);
|
|
179
|
+
} finally {
|
|
180
|
+
callback.close();
|
|
181
|
+
}
|
|
182
|
+
const t = await requestToken(tokenEndpoint, {
|
|
183
|
+
grant_type: 'authorization_code',
|
|
184
|
+
code,
|
|
185
|
+
code_verifier: verifier,
|
|
186
|
+
redirect_uri: redirectUri,
|
|
187
|
+
client_id: CLIENT_ID,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const data = store.load();
|
|
191
|
+
data.hosts[host] = toCredential(t, type, tokenEndpoint, revocationEndpoint);
|
|
192
|
+
data.defaultHost = host;
|
|
193
|
+
store.save(data);
|
|
194
|
+
return data.hosts[host];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 用 refresh_token 换新 token;登录已过期或被撤销时返回 null(由调用方重新授权)
|
|
199
|
+
*/
|
|
200
|
+
async function refresh(host, cred) {
|
|
201
|
+
try {
|
|
202
|
+
const t = await requestToken(cred.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: cred.refreshToken, client_id: CLIENT_ID });
|
|
203
|
+
const data = store.load();
|
|
204
|
+
data.hosts[host] = toCredential(t, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint);
|
|
205
|
+
store.save(data);
|
|
206
|
+
return data.hosts[host];
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (e.oauthError !== 'invalid_grant') {
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
// refresh_token 每次刷新都会换新的:两个脚本同时刷新时后到的会失败,先看看另一个进程是不是已经刷新好了
|
|
212
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
213
|
+
const latest = store.load().hosts[host];
|
|
214
|
+
return latest && latest.refreshToken !== cred.refreshToken ? latest : null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 地址和产品标识:--host 优先,其次上次登录的地址;都没有时在终端里提示输入(第一次使用)
|
|
220
|
+
*/
|
|
221
|
+
async function resolveTarget(args, allowPrompt) {
|
|
222
|
+
let type = args.type || null;
|
|
223
|
+
if (type && !PRODUCTS[type]) {
|
|
224
|
+
throw new Error(`不认识的 --type:${type},可选 ${Object.keys(PRODUCTS).join(' / ')}`);
|
|
225
|
+
}
|
|
226
|
+
let host;
|
|
227
|
+
if (args.host) {
|
|
228
|
+
host = normalizeHost(args.host);
|
|
229
|
+
} else {
|
|
230
|
+
const data = store.load();
|
|
231
|
+
if (data.defaultHost) {
|
|
232
|
+
host = data.defaultHost;
|
|
233
|
+
} else if (allowPrompt && process.stdin.isTTY) {
|
|
234
|
+
host = normalizeHost(await prompt(`请输入地址(${HOST_EXAMPLE}):`));
|
|
235
|
+
} else {
|
|
236
|
+
throw new Error(`还没有登录过,请用 --host 指定地址(${HOST_EXAMPLE})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 积木地址常带着 /jmreport(例如从浏览器复制的 http://127.0.0.1:8085/jmreport),统一存应用地址,接口前缀按标识拼
|
|
240
|
+
if (/\/jmreport$/i.test(host) && (!type || type === 'jimureport')) {
|
|
241
|
+
host = host.slice(0, -'/jmreport'.length);
|
|
242
|
+
type = 'jimureport';
|
|
243
|
+
}
|
|
244
|
+
return { host, type };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeHost(host) {
|
|
248
|
+
const h = String(host || '').trim().replace(/\/+$/, '');
|
|
249
|
+
if (!/^https?:\/\/[^/]+/i.test(h)) {
|
|
250
|
+
throw new Error(`地址要以 http:// 或 https:// 开头(${HOST_EXAMPLE})`);
|
|
251
|
+
}
|
|
252
|
+
return h;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 读授权配置;连不上直接报错,接口不存在(老版本、不是这个产品、宿主没开启)返回 null
|
|
257
|
+
*/
|
|
258
|
+
async function fetchMetadata(host, prefix) {
|
|
259
|
+
let res;
|
|
260
|
+
try {
|
|
261
|
+
res = await fetch(`${host}${prefix}/.well-known/oauth-authorization-server`);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
throw new Error(`连不上 ${host}:${(e.cause && e.cause.code) || e.message}`);
|
|
264
|
+
}
|
|
265
|
+
// 可能是 404,也可能被 Shiro 拦成 401(Token失效),前端开发服务器还会返回 index.html
|
|
266
|
+
const meta = await res.json().catch(() => ({}));
|
|
267
|
+
return res.ok && meta.authorization_endpoint && meta.token_endpoint ? meta : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function notEnabledText(host, type) {
|
|
271
|
+
if (type === 'jimureport') {
|
|
272
|
+
return `${host} 的积木报表没有开启在线授权:独立部署要宿主实现 JmReportTokenServiceI.createToken;集成在 JeecgBoot 里的积木请用 --type jeecgboot 登录 JeecgBoot 地址`;
|
|
273
|
+
}
|
|
274
|
+
return `${host} 没有开启在线授权:后端版本太旧,或者地址 / --type 不对(${HOST_EXAMPLE})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function successText(cred) {
|
|
278
|
+
return `登录成功(${PRODUCTS[typeOf(cred)].name}):${cred.username}${cred.tenantId != null ? `,租户 ${cred.tenantId}` : ''}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 以前存的凭证没有 type,都是 JeecgBoot 地址 */
|
|
282
|
+
function typeOf(cred) {
|
|
283
|
+
return PRODUCTS[cred.type] ? cred.type : 'jeecgboot';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 授权页地址来自后端配置 jeecg.domainUrl.pc,默认是 http://localhost:3100;部署到服务器没改的话,浏览器会打开本机地址
|
|
288
|
+
*/
|
|
289
|
+
function warnLocalAuthorizePage(authorizeEndpoint, host) {
|
|
290
|
+
const isLocal = (u) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
|
|
291
|
+
if (isLocal(authorizeEndpoint) && !isLocal(host)) {
|
|
292
|
+
const origin = new URL(authorizeEndpoint).origin;
|
|
293
|
+
console.error(`注意:后端返回的授权页是 ${origin},服务器上可能没配置 jeecg.domainUrl.pc(要配成前端的外网地址)。`);
|
|
294
|
+
console.error(`临时办法:把下面链接里的 ${origin} 换成前端地址,再在浏览器里打开。\n`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function requestToken(endpoint, form) {
|
|
299
|
+
const res = await fetch(endpoint, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
302
|
+
body: new URLSearchParams(form),
|
|
303
|
+
});
|
|
304
|
+
const body = await res.json().catch(() => ({}));
|
|
305
|
+
if (!res.ok || !body.access_token) {
|
|
306
|
+
// OAuth 错误在 error_description 里;后端抛异常时走的是 JeecgBoot 的 Result 格式,原因在 message 里
|
|
307
|
+
const err = new Error(`换取 token 失败:${body.error_description || body.error || body.message || `HTTP ${res.status}`}`);
|
|
308
|
+
err.oauthError = body.error;
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
return body;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function toCredential(t, type, tokenEndpoint, revocationEndpoint) {
|
|
315
|
+
return {
|
|
316
|
+
type,
|
|
317
|
+
username: t.username,
|
|
318
|
+
tenantId: t.tenant_id == null ? null : t.tenant_id,
|
|
319
|
+
scope: t.scope || null,
|
|
320
|
+
accessToken: t.access_token,
|
|
321
|
+
refreshToken: t.refresh_token,
|
|
322
|
+
expiresAt: Date.now() + Number(t.expires_in || 0) * 1000,
|
|
323
|
+
tokenEndpoint,
|
|
324
|
+
revocationEndpoint,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 本机回调服务:只监听 127.0.0.1,state 对不上的请求直接忽略(不是这次登录发起的)
|
|
330
|
+
*/
|
|
331
|
+
function startCallbackServer(expectedState) {
|
|
332
|
+
let settle;
|
|
333
|
+
const result = new Promise((resolve, reject) => {
|
|
334
|
+
settle = { resolve, reject };
|
|
335
|
+
});
|
|
336
|
+
// 失败由 waitForCode 返回给调用方,这里只是避免回调早于 waitForCode 时报 unhandled rejection
|
|
337
|
+
result.catch(() => {});
|
|
338
|
+
const server = http.createServer((req, res) => {
|
|
339
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
340
|
+
if (url.pathname !== '/callback' || url.searchParams.get('state') !== expectedState) {
|
|
341
|
+
res.writeHead(404, { Connection: 'close' });
|
|
342
|
+
res.end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (url.searchParams.get('error')) {
|
|
346
|
+
sendPage(res, '已取消授权', '你在浏览器中取消了授权,可以关闭此页面。');
|
|
347
|
+
settle.reject(new Error('已在浏览器中取消授权'));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const code = url.searchParams.get('code');
|
|
351
|
+
if (!code) {
|
|
352
|
+
sendPage(res, '授权失败', '回调里没有授权码,请回到终端重新登录。');
|
|
353
|
+
settle.reject(new Error('回调里没有授权码'));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
sendPage(res, '授权成功', 'jeecg CLI 已完成授权,现在可以关闭此页面,返回终端继续使用。');
|
|
357
|
+
settle.resolve(code);
|
|
358
|
+
});
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
server.on('error', reject);
|
|
361
|
+
server.listen(0, '127.0.0.1', () => {
|
|
362
|
+
resolve({
|
|
363
|
+
port: server.address().port,
|
|
364
|
+
waitForCode(timeoutMs) {
|
|
365
|
+
const timer = setTimeout(() => settle.reject(new Error(`等待授权超时(${Math.round(timeoutMs / 1000)} 秒),请重新执行`)), timeoutMs);
|
|
366
|
+
return result.finally(() => clearTimeout(timer));
|
|
367
|
+
},
|
|
368
|
+
close() {
|
|
369
|
+
server.close();
|
|
370
|
+
// 浏览器会保持长连接,不断开的话进程要等几秒才退出
|
|
371
|
+
if (server.closeAllConnections) {
|
|
372
|
+
server.closeAllConnections();
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function sendPage(res, title, message) {
|
|
381
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
|
|
382
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>jeecg CLI ${title}</title></head>
|
|
383
|
+
<body style="margin:0;background:#f5f7fa;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh">
|
|
384
|
+
<div style="background:#fff;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:40px 56px;text-align:center">
|
|
385
|
+
<h2 style="margin:0 0 12px">${title}</h2><div style="color:#8c8c8c">${message}</div></div></body></html>`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function openBrowser(url) {
|
|
389
|
+
// Windows 不走 cmd /c start:URL 里的 & 会被 cmd 当成命令分隔符截断
|
|
390
|
+
const [cmd, cmdArgs] =
|
|
391
|
+
process.platform === 'win32'
|
|
392
|
+
? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
393
|
+
: process.platform === 'darwin'
|
|
394
|
+
? ['open', [url]]
|
|
395
|
+
: ['xdg-open', [url]];
|
|
396
|
+
const tip = () => console.error('没能自动打开浏览器,请手动复制上面的链接到浏览器打开');
|
|
397
|
+
try {
|
|
398
|
+
const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
|
|
399
|
+
child.on('error', tip);
|
|
400
|
+
child.unref();
|
|
401
|
+
} catch (e) {
|
|
402
|
+
tip();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 提示打到 stderr:在 $(jeecg auth token) 里第一次使用时,stdout 会被当成 token 收走
|
|
408
|
+
*/
|
|
409
|
+
function prompt(question) {
|
|
410
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
411
|
+
return new Promise((resolve) =>
|
|
412
|
+
rl.question(question, (answer) => {
|
|
413
|
+
rl.close();
|
|
414
|
+
resolve(answer);
|
|
415
|
+
})
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = { login, status, token, logout };
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* 本机凭证:按后端地址存在 ~/.jeecg/credentials.json(文件权限 600)
|
|
4
|
+
* 结构:{ defaultHost, hosts: { "<后端地址>": { username, tenantId, accessToken, refreshToken, expiresAt, ... } } }
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const DIR = path.join(os.homedir(), '.jeecg');
|
|
11
|
+
const FILE = path.join(DIR, 'credentials.json');
|
|
12
|
+
|
|
13
|
+
function load() {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
|
16
|
+
return { defaultHost: data.defaultHost || null, hosts: data.hosts || {} };
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { defaultHost: null, hosts: {} };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function save(data) {
|
|
23
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
fs.writeFileSync(FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
25
|
+
// 文件已存在时 writeFileSync 不会改权限,这里再收紧一次(Windows 上由用户目录的 ACL 保护)
|
|
26
|
+
fs.chmodSync(FILE, 0o600);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { load, save, FILE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ws1298191366/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云,给 AI skills 提供 token",
|
|
5
|
+
"publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" },
|
|
6
|
+
"bin": {
|
|
7
|
+
"jeecg": "bin/jeecg.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
}
|
|
17
|
+
}
|