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