@ws1298191366/cli 0.1.0 → 0.1.2

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 CHANGED
@@ -39,16 +39,32 @@ token 由 shell 在执行时代入,不会出现在命令和对话记录里。
39
39
 
40
40
  ```bash
41
41
  jeecg auth login [--host <地址>] [--type <产品>] # 只登录(最多等 5 分钟),不输出 token
42
+ jeecg auth complete "<浏览器地址栏里的地址>" # 打不开浏览器时完成登录
42
43
  jeecg auth status --json # 本机登录状态:地址、产品、用户、租户(不输出 token)
43
44
  jeecg auth logout [--host <地址>] # 撤销授权并删除本机凭证
44
45
  ```
45
46
 
46
- 没有浏览器的环境加 `--no-open`,把打印的链接复制到浏览器打开,同样要点「授权」。
47
+ ## 打不开浏览器时(SSH、没有图形界面的 Linux)
48
+
49
+ - 在终端里:命令会打印授权链接,在自己电脑的浏览器里打开并点「授权」;浏览器跳回的 `127.0.0.1` 地址打不开,把地址栏里的完整地址粘贴回终端即可
50
+ - 被 AI 调用(不在终端里):命令打印授权链接后退出(退出码 2),用户授权后把地址栏的地址交给 `jeecg auth complete "<地址>"`(5 分钟内有效);也可以加 `--manual` 主动这样做
51
+ - 本机有浏览器但不想自动打开:加 `--no-open`
52
+
53
+ ## Windows PowerShell
54
+
55
+ PowerShell 里执行 `jeecg` 报「无法加载文件 ...\jeecg.ps1,因为在此系统上禁止运行脚本」时,执行一次:
56
+
57
+ ```powershell
58
+ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
59
+ ```
60
+
61
+ 只对当前用户生效,npm 全局装的 pnpm、yarn 等命令也需要这一步。不想改的话,用 `jeecg.cmd` 代替 `jeecg`。Git Bash、cmd 和 Python 的 `subprocess(..., shell=True)` 不受影响。
47
62
 
48
63
  ## 服务器上的地址
49
64
 
50
65
  - `--host` 填前端调接口用的后端地址(nginx 转发后的外网地址,例如 `https://boot.example.com/jeecgboot`),换 token 也走这个地址
51
- - 后端要把 `jeecg.domainUrl.pc` 配成前端的外网地址,授权页地址是用它拼出来的;默认值是 `http://localhost:3100`,没改时 CLI 会提示
66
+ - JeecgBoot / 敲敲云的授权页在前端,地址来自后端配置 `jeecg.domainUrl.pc`,最好配成前端的外网地址。它还是默认的 `http://localhost:3100` 而 `--host` 是服务器时,浏览器会打开你自己电脑上的 localhost;CLI 会去 `--host` 同一个域名下找前端(前端根目录的 `_app.config.js` 里记的后端地址和 `--host` 对得上才算),找到就改用它;前端和后端不在同一个域名时,用 `--web <前端地址>` 指定,例如 `--web https://boot.example.com`,指定过会记住
67
+ - 积木的授权页在积木自己的地址下,CLI 直接用 `--host` 拼,经过反向代理改了路径也没问题
52
68
  - 授权后浏览器跳回的是你电脑上的 `127.0.0.1`,服务器不需要访问你的电脑,也不用开端口
53
69
  - 多个环境各登录一次,用 `--host` 切换,最后登录的是默认地址
54
70
 
@@ -58,7 +74,7 @@ jeecg auth logout [--host <地址>] # 撤销授权并删除本
58
74
 
59
75
  ## 凭证
60
76
 
61
- 存在 `~/.jeecg/credentials.json`,按地址区分,文件权限 600。JeecgBoot / 敲敲云的 access_token 有效期和网页登录一样(84 小时),积木 2 小时;refresh_token 30 天,每次刷新换新的。
77
+ 存在 `~/.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 天,每次刷新换新的。
62
78
 
63
79
  ## 后端要求
64
80
 
package/bin/jeecg.js CHANGED
@@ -5,11 +5,13 @@ const auth = require('../lib/auth');
5
5
  const USAGE = `jeecg 命令行工具
6
6
 
7
7
  用法:
8
- jeecg auth token [--host <地址>] [--type <产品>] [--scope <权限>] [--no-open]
8
+ jeecg auth token [--host <地址>] [--type <产品>] [--web <前端地址>] [--scope <权限>] [--no-open] [--manual]
9
9
  输出可用的 access_token,给脚本用:--token "$(jeecg auth token)"
10
10
  快过期自动刷新;没登录过或登录已失效时自动打开浏览器授权,点「授权」后输出(提示信息打到 stderr,不影响取值)
11
- jeecg auth login [--host <地址>] [--type <产品>] [--scope <权限>] [--no-open]
12
- 只登录(最多等 5 分钟),不输出 token;没有浏览器的环境加 --no-open,自己复制链接打开
11
+ jeecg auth login [--host <地址>] [--type <产品>] [--web <前端地址>] [--scope <权限>] [--no-open] [--manual]
12
+ 只登录(最多等 5 分钟),不输出 token
13
+ jeecg auth complete "<浏览器地址栏里的地址>"
14
+ 打不开浏览器时用:授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把它交给这条命令完成登录
13
15
  jeecg auth status [--host <地址>] [--json]
14
16
  查看本机登录状态:地址、产品、用户、租户(不输出 token)
15
17
  jeecg auth logout [--host <地址>]
@@ -19,9 +21,17 @@ const USAGE = `jeecg 命令行工具
19
21
  jeecgboot JeecgBoot,地址填后端地址,例如 http://127.0.0.1:8080/jeecg-boot
20
22
  qiaoqiaoyun 敲敲云,和 JeecgBoot 同一套后端,地址同上(SaaS 为 https://api.qiaoqiaoyun.com)
21
23
  jimureport 积木报表,地址填应用地址,例如 http://127.0.0.1:8085(带不带 /jmreport 都行)
22
- 不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云要明确写 --type qiaoqiaoyun`;
24
+ 不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云要明确写 --type qiaoqiaoyun
23
25
 
24
- const VALUE_OPTIONS = ['host', 'scope', 'type'];
26
+ --web 前端地址(JeecgBoot / 敲敲云的授权页在前端):
27
+ 默认用后端配置 jeecg.domainUrl.pc;它还是 localhost 而 --host 是服务器时,自动改用同一域名下的前端;
28
+ 前端和后端不在同一个域名时用 --web 指定,例如 --web https://boot.example.com
29
+
30
+ 打不开浏览器时(SSH、没有图形界面的 Linux):
31
+ 在终端里:把浏览器地址栏的地址粘贴回终端即可
32
+ 被 AI 调用:命令打印授权链接后退出(退出码 2),用户授权后执行 jeecg auth complete "<地址>";也可以加 --manual 主动这样做`;
33
+
34
+ const VALUE_OPTIONS = ['host', 'scope', 'type', 'web'];
25
35
 
26
36
  function parseArgs(argv) {
27
37
  const args = { _: [] };
@@ -30,6 +40,8 @@ function parseArgs(argv) {
30
40
  const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null;
31
41
  if (a === '--no-open') {
32
42
  args.open = false;
43
+ } else if (a === '--manual') {
44
+ args.manual = true;
33
45
  } else if (a === '--json') {
34
46
  args.json = true;
35
47
  } else if (a === '-h' || a === '--help') {
@@ -47,7 +59,8 @@ function parseArgs(argv) {
47
59
  async function main() {
48
60
  const args = parseArgs(process.argv.slice(2));
49
61
  const [group, cmd] = args._;
50
- const handler = group === 'auth' ? { login: auth.login, status: auth.status, token: auth.token, logout: auth.logout }[cmd] : null;
62
+ const commands = { login: auth.login, token: auth.token, complete: auth.complete, status: auth.status, logout: auth.logout };
63
+ const handler = group === 'auth' ? commands[cmd] : null;
51
64
  if (args.help || !handler) {
52
65
  console.log(USAGE);
53
66
  process.exitCode = args.help ? 0 : 1;
package/lib/auth.js CHANGED
@@ -1,12 +1,15 @@
1
1
  'use strict';
2
2
  /**
3
- * jeecg auth login / status / token / logout
3
+ * jeecg auth login / token / complete / status / logout
4
4
  *
5
5
  * 登录流程(OAuth2 授权码 + PKCE,和 Teambition CLI 的 auth login 一样):
6
6
  * 1. 按产品标识读后端的授权配置 .well-known/oauth-authorization-server,拿到授权页地址
7
7
  * 2. 在本机 127.0.0.1 随机端口起一个回调服务,打开默认浏览器进入授权页
8
8
  * 3. 用户在页面上点「授权」后,浏览器带着授权码跳回回调地址
9
- * 4. 用授权码 + code_verifier 换 token,存到 ~/.jeecg/credentials.json
9
+ * 4. 用授权码 + code_verifier 换 token,存到 ~/.jeecg/credentials.json(refresh_token 加密,见 secret.js)
10
+ *
11
+ * 打不开本机浏览器时(SSH、没有图形界面的 Linux):在终端里可以把浏览器地址栏的地址粘贴回来;
12
+ * 被 AI 调用(不在终端里)时打印链接后退出,用户授权后把地址栏的地址交给 jeecg auth complete
10
13
  *
11
14
  * 输出约定:stdout 只放给程序用的结果(token、status --json),提示信息一律打到 stderr,
12
15
  * 这样 --token "$(jeecg auth token)" 里就算触发了登录,拿到的也只有 token
@@ -16,6 +19,7 @@ const http = require('http');
16
19
  const readline = require('readline');
17
20
  const { spawn } = require('child_process');
18
21
  const store = require('./store');
22
+ const secret = require('./secret');
19
23
 
20
24
  /** 后端内置的公共客户端,没有 secret,靠 PKCE 防止授权码被截获后冒用 */
21
25
  const CLIENT_ID = 'jeecg-cli';
@@ -25,6 +29,8 @@ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
25
29
  const AUTO_LOGIN_TIMEOUT_MS = 110 * 1000;
26
30
  /** access_token 还剩 5 分钟就提前刷新 */
27
31
  const REFRESH_AHEAD_MS = 5 * 60 * 1000;
32
+ /** 打不开浏览器、改成 jeecg auth complete 完成授权时的退出码,调用方据此知道这次没拿到 token */
33
+ const MANUAL_EXIT_CODE = 2;
28
34
  const HOST_EXAMPLE = 'JeecgBoot / 敲敲云 http://127.0.0.1:8080/jeecg-boot,积木报表 http://127.0.0.1:8085';
29
35
 
30
36
  /**
@@ -34,7 +40,7 @@ const HOST_EXAMPLE = 'JeecgBoot / 敲敲云 http://127.0.0.1:8080/jeecg-boot,
34
40
  * - jimureport:积木是嵌在宿主应用里的,接口挂在 /jmreport 下,例如 http://127.0.0.1:8085/jmreport/oauth2/token
35
41
  */
36
42
  const PRODUCTS = {
37
- 'jeecgboot': { name: 'JeecgBoot', prefix: '' },
43
+ jeecgboot: { name: 'JeecgBoot', prefix: '' },
38
44
  qiaoqiaoyun: { name: '敲敲云', prefix: '' },
39
45
  jimureport: { name: '积木报表', prefix: '/jmreport' },
40
46
  };
@@ -44,7 +50,9 @@ const DETECT_ORDER = ['jeecgboot', 'jimureport'];
44
50
  async function login(args) {
45
51
  const { host, type } = await resolveTarget(args, true);
46
52
  const cred = await browserLogin(host, type, args, LOGIN_TIMEOUT_MS);
47
- console.error(successText(cred));
53
+ if (cred) {
54
+ console.error(successText(cred));
55
+ }
48
56
  }
49
57
 
50
58
  /**
@@ -66,11 +74,33 @@ async function token(args) {
66
74
  return;
67
75
  }
68
76
  console.error(cred ? `登录已过期或已被撤销(${host}),重新授权:` : `还没有登录 ${host},开始授权:`);
69
- const fresh = await browserLogin(host, type || (cred && cred.type), args, AUTO_LOGIN_TIMEOUT_MS);
77
+ const fresh = await browserLogin(host, type || (cred && cred.type), { ...args, web: args.web || (cred && cred.web) }, AUTO_LOGIN_TIMEOUT_MS);
78
+ if (!fresh) {
79
+ // 打不开浏览器:已经打印了授权链接,等 jeecg auth complete,这次不输出 token
80
+ return;
81
+ }
70
82
  console.error(successText(fresh));
71
83
  process.stdout.write(fresh.accessToken + '\n');
72
84
  }
73
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
+
74
104
  /**
75
105
  * 只看本机保存的登录信息,不输出 token;--json 给程序读地址、产品和租户
76
106
  */
@@ -108,15 +138,19 @@ async function logout(args) {
108
138
  console.log(`本机没有 ${host} 的登录信息`);
109
139
  return;
110
140
  }
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}),只删除了本机凭证`);
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
+ }
119
152
  }
153
+ secret.remove(host, cred.refreshToken);
120
154
  delete data.hosts[host];
121
155
  if (data.defaultHost === host) {
122
156
  data.defaultHost = Object.keys(data.hosts)[0] || null;
@@ -126,7 +160,7 @@ async function logout(args) {
126
160
  }
127
161
 
128
162
  /**
129
- * 浏览器授权登录,成功后保存凭证并设为默认地址
163
+ * 浏览器授权登录,成功后保存凭证并设为默认地址;打不开浏览器又不在终端里时返回 null(改由 jeecg auth complete 完成)
130
164
  */
131
165
  async function browserLogin(host, type, args, timeoutMs) {
132
166
  // 1. 按产品标识请求对应的授权配置;没给标识时按 DETECT_ORDER 依次探测
@@ -144,64 +178,83 @@ async function browserLogin(host, type, args, timeoutMs) {
144
178
  // 换 token、撤销直接用用户给的地址拼:nginx 常把外网 /jeecgboot 转发到后端 /jeecg-boot,
145
179
  // 后端按请求算出来的 token_endpoint(.../jeecg-boot/oauth2/token)在外网访问不到
146
180
  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`;
181
+ const web = args.web ? normalizeHost(args.web) : null;
182
+ const login = {
183
+ host,
184
+ type,
185
+ web,
186
+ verifier: crypto.randomBytes(32).toString('base64url'),
187
+ state: crypto.randomBytes(16).toString('base64url'),
188
+ tokenEndpoint: `${base}/oauth2/token`,
189
+ revocationEndpoint: `${base}/oauth2/revoke`,
190
+ authorizeEndpoint: await resolveAuthorizeEndpoint(meta, host, type, web),
191
+ };
157
192
 
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);
193
+ // 2. 打不开本机浏览器又不在终端里(例如 AI 在服务器上调用):打印链接后退出,等 jeecg auth complete
194
+ if (args.manual || (!process.stdin.isTTY && !hasLocalBrowser())) {
195
+ login.redirectUri = `http://127.0.0.1:${await freePort()}/callback`;
196
+ login.expiresAt = Date.now() + LOGIN_TIMEOUT_MS;
197
+ store.savePending(login);
198
+ console.error(`请在自己电脑的浏览器里打开下面的链接,确认授权(${PRODUCTS[type].name},5 分钟内有效):\n${authorizeUrl(login, args.scope)}\n`);
199
+ console.error('授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把地址栏里的完整地址交给:jeecg auth complete "<地址>"');
200
+ process.exitCode = MANUAL_EXIT_CODE;
201
+ return null;
168
202
  }
169
- const authorizeUrl = `${meta.authorization_endpoint}?${params}`;
170
- console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl}\n`);
171
- if (args.open !== false) {
172
- openBrowser(authorizeUrl);
203
+
204
+ // 3. 本机起回调服务,打开授权页
205
+ const callback = await startCallbackServer(login.state);
206
+ login.redirectUri = `http://127.0.0.1:${callback.port}/callback`;
207
+ console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl(login, args.scope)}\n`);
208
+ const browserOpened = args.open !== false && hasLocalBrowser();
209
+ if (browserOpened) {
210
+ openBrowser(authorizeUrl(login, args.scope));
173
211
  }
212
+ // 在终端里又没自动打开浏览器(--no-open、SSH):浏览器跳回时可能打不开,也可以把地址栏的地址粘贴回来
213
+ const paste = !browserOpened && process.stdin.isTTY ? waitForPaste(login.state) : null;
174
214
 
175
- // 3. 等用户点「授权」,用授权码换 token
215
+ // 4. 等用户点「授权」,用授权码换 token
176
216
  let code;
177
217
  try {
178
- code = await callback.waitForCode(timeoutMs);
218
+ code = await (paste ? Promise.race([callback.waitForCode(timeoutMs), paste.promise]) : callback.waitForCode(timeoutMs));
179
219
  } finally {
180
220
  callback.close();
221
+ if (paste) {
222
+ paste.close();
223
+ }
181
224
  }
182
- const t = await requestToken(tokenEndpoint, {
225
+ return exchangeCode(login, code);
226
+ }
227
+
228
+ /**
229
+ * 用授权码 + code_verifier 换 token,保存凭证并设为默认地址
230
+ */
231
+ async function exchangeCode(login, code) {
232
+ const t = await requestToken(login.tokenEndpoint, {
183
233
  grant_type: 'authorization_code',
184
234
  code,
185
- code_verifier: verifier,
186
- redirect_uri: redirectUri,
235
+ code_verifier: login.verifier,
236
+ redirect_uri: login.redirectUri,
187
237
  client_id: CLIENT_ID,
188
238
  });
189
-
190
239
  const data = store.load();
191
- data.hosts[host] = toCredential(t, type, tokenEndpoint, revocationEndpoint);
192
- data.defaultHost = host;
240
+ data.hosts[login.host] = toCredential(t, login.host, login.type, login.tokenEndpoint, login.revocationEndpoint, login.web);
241
+ data.defaultHost = login.host;
193
242
  store.save(data);
194
- return data.hosts[host];
243
+ return data.hosts[login.host];
195
244
  }
196
245
 
197
246
  /**
198
- * 用 refresh_token 换新 token;登录已过期或被撤销时返回 null(由调用方重新授权)
247
+ * 用 refresh_token 换新 token;登录已过期、被撤销或 refresh_token 解不开时返回 null(由调用方重新授权)
199
248
  */
200
249
  async function refresh(host, cred) {
250
+ const refreshToken = secret.unprotect(host, cred.refreshToken);
251
+ if (!refreshToken) {
252
+ return null;
253
+ }
201
254
  try {
202
- const t = await requestToken(cred.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: cred.refreshToken, client_id: CLIENT_ID });
255
+ const t = await requestToken(cred.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID });
203
256
  const data = store.load();
204
- data.hosts[host] = toCredential(t, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint);
257
+ data.hosts[host] = toCredential(t, host, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint, cred.web);
205
258
  store.save(data);
206
259
  return data.hosts[host];
207
260
  } catch (e) {
@@ -209,9 +262,10 @@ async function refresh(host, cred) {
209
262
  throw e;
210
263
  }
211
264
  // refresh_token 每次刷新都会换新的:两个脚本同时刷新时后到的会失败,先看看另一个进程是不是已经刷新好了
265
+ // (比 accessToken:refresh_token 存进钥匙串时记录里只有 keychain: 标记,前后一样,比不出来)
212
266
  await new Promise((r) => setTimeout(r, 1000));
213
267
  const latest = store.load().hosts[host];
214
- return latest && latest.refreshToken !== cred.refreshToken ? latest : null;
268
+ return latest && latest.accessToken !== cred.accessToken ? latest : null;
215
269
  }
216
270
  }
217
271
 
@@ -219,7 +273,7 @@ async function refresh(host, cred) {
219
273
  * 地址和产品标识:--host 优先,其次上次登录的地址;都没有时在终端里提示输入(第一次使用)
220
274
  */
221
275
  async function resolveTarget(args, allowPrompt) {
222
- let type = args.type || null;
276
+ const type = args.type || null;
223
277
  if (type && !PRODUCTS[type]) {
224
278
  throw new Error(`不认识的 --type:${type},可选 ${Object.keys(PRODUCTS).join(' / ')}`);
225
279
  }
@@ -236,10 +290,10 @@ async function resolveTarget(args, allowPrompt) {
236
290
  throw new Error(`还没有登录过,请用 --host 指定地址(${HOST_EXAMPLE})`);
237
291
  }
238
292
  }
239
- // 积木地址常带着 /jmreport(例如从浏览器复制的 http://127.0.0.1:8085/jmreport),统一存应用地址,接口前缀按标识拼
240
- if (/\/jmreport$/i.test(host) && (!type || type === 'jimureport')) {
293
+ // 积木地址常带着 /jmreport(例如 skill 用的 http://127.0.0.1:8085/jmreport),统一存应用地址,接口前缀按标识拼;
294
+ // 不写 --type 时照常探测:集成在 JeecgBoot 里的积木(.../jeecg-boot/jmreport)会识别成 jeecgboot,token 积木也认
295
+ if (/\/jmreport$/i.test(host) && type !== 'jeecgboot' && type !== 'qiaoqiaoyun') {
241
296
  host = host.slice(0, -'/jmreport'.length);
242
- type = 'jimureport';
243
297
  }
244
298
  return { host, type };
245
299
  }
@@ -267,6 +321,139 @@ async function fetchMetadata(host, prefix) {
267
321
  return res.ok && meta.authorization_endpoint && meta.token_endpoint ? meta : null;
268
322
  }
269
323
 
324
+ function authorizeUrl(login, scope) {
325
+ const params = new URLSearchParams({
326
+ response_type: 'code',
327
+ client_id: CLIENT_ID,
328
+ redirect_uri: login.redirectUri,
329
+ code_challenge: crypto.createHash('sha256').update(login.verifier).digest('base64url'),
330
+ code_challenge_method: 'S256',
331
+ state: login.state,
332
+ });
333
+ if (scope) {
334
+ params.set('scope', scope);
335
+ }
336
+ return `${login.authorizeEndpoint}?${params}`;
337
+ }
338
+
339
+ /**
340
+ * 授权页地址:
341
+ * - 积木:授权页就在积木自己的地址下,直接用 --host 拼(反向代理改了路径时,后端按请求算出来的地址外网访问不到)
342
+ * - JeecgBoot / 敲敲云:授权页在前端,地址来自后端配置 jeecg.domainUrl.pc(默认 http://localhost:3100)。
343
+ * 服务器上没改这个配置时,浏览器会打开用户自己电脑上的 localhost,而不是服务器上的前端;
344
+ * 这时看 --host 同一个域名下有没有这个后端的前端,有就改用它;也可以用 --web 直接指定前端地址
345
+ */
346
+ async function resolveAuthorizeEndpoint(meta, host, type, web) {
347
+ if (type === 'jimureport') {
348
+ return `${host}${PRODUCTS.jimureport.prefix}/oauth2/authorize`;
349
+ }
350
+ if (web) {
351
+ return `${web}/oauth2/authorize`;
352
+ }
353
+ const endpoint = meta.authorization_endpoint;
354
+ if (!isLocalUrl(endpoint) || isLocalUrl(host)) {
355
+ return endpoint;
356
+ }
357
+ const origin = new URL(host).origin;
358
+ if (await isFrontendOf(origin, host)) {
359
+ console.error(`后端配置的授权页是 ${new URL(endpoint).origin}(服务器上没配 jeecg.domainUrl.pc),已改用同一域名下的前端 ${origin}\n`);
360
+ return `${origin}/oauth2/authorize`;
361
+ }
362
+ console.error(`注意:后端返回的授权页是 ${new URL(endpoint).origin},服务器上可能没配置 jeecg.domainUrl.pc(要配成前端的外网地址)。`);
363
+ console.error('可以加 --web <前端地址> 指定前端,例如 --web https://boot.example.com\n');
364
+ return endpoint;
365
+ }
366
+
367
+ /**
368
+ * 前端打包后根目录有 _app.config.js,里面记着前端调用的后端地址(VITE_GLOB_API_URL 是相对路径,例如 /jeecgboot;
369
+ * VITE_GLOB_DOMAIN_URL 是完整地址),和 --host 对得上才算是这个后端的前端,避免把用户带到别的系统
370
+ */
371
+ async function isFrontendOf(origin, host) {
372
+ try {
373
+ const res = await fetch(`${origin}/_app.config.js`);
374
+ if (!res.ok) {
375
+ return false;
376
+ }
377
+ const text = await res.text();
378
+ const apiUrl = (text.match(/"VITE_GLOB_API_URL"\s*:\s*"([^"]*)"/) || [])[1];
379
+ const domainUrl = (text.match(/"VITE_GLOB_DOMAIN_URL"\s*:\s*"([^"]*)"/) || [])[1];
380
+ const trim = (u) => u.replace(/\/+$/, '');
381
+ return (apiUrl != null && trim(origin + apiUrl) === host) || (domainUrl != null && trim(domainUrl) === host);
382
+ } catch (e) {
383
+ return false;
384
+ }
385
+ }
386
+
387
+ function isLocalUrl(url) {
388
+ return /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/i.test(url);
389
+ }
390
+
391
+ /**
392
+ * 解析浏览器地址栏里的回调地址,例如 http://127.0.0.1:52345/callback?code=xxx&state=xxx
393
+ */
394
+ function parseCallback(text, expectedState) {
395
+ let url;
396
+ try {
397
+ url = new URL(String(text).trim());
398
+ } catch (e) {
399
+ throw new Error('粘贴的不是完整地址,应该形如 http://127.0.0.1:端口/callback?code=...&state=...');
400
+ }
401
+ if (url.searchParams.get('error')) {
402
+ throw new Error('已在浏览器中取消授权');
403
+ }
404
+ if (url.searchParams.get('state') !== expectedState) {
405
+ throw new Error('地址里的 state 和这次授权对不上,可能粘贴了别的授权链接');
406
+ }
407
+ const code = url.searchParams.get('code');
408
+ if (!code) {
409
+ throw new Error('地址里没有授权码');
410
+ }
411
+ return code;
412
+ }
413
+
414
+ /**
415
+ * 在终端里等用户粘贴回调地址(和本机回调服务同时等,哪个先到用哪个)
416
+ */
417
+ function waitForPaste(expectedState) {
418
+ console.error('如果浏览器跳回时打不开页面(比如在 SSH 服务器上),把浏览器地址栏里的完整地址粘贴到这里,回车:');
419
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: false });
420
+ const promise = new Promise((resolve, reject) => {
421
+ rl.on('line', (line) => {
422
+ if (!line.trim()) {
423
+ return;
424
+ }
425
+ try {
426
+ resolve(parseCallback(line, expectedState));
427
+ } catch (e) {
428
+ reject(e);
429
+ }
430
+ });
431
+ });
432
+ promise.catch(() => {});
433
+ return { promise, close: () => rl.close() };
434
+ }
435
+
436
+ /**
437
+ * SSH 会话、没有图形界面的 Linux 打不开本机浏览器(和 claude mcp login 的判断一样)
438
+ */
439
+ function hasLocalBrowser() {
440
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
441
+ return false;
442
+ }
443
+ return !(process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY);
444
+ }
445
+
446
+ function freePort() {
447
+ return new Promise((resolve, reject) => {
448
+ const server = http.createServer();
449
+ server.on('error', reject);
450
+ server.listen(0, '127.0.0.1', () => {
451
+ const port = server.address().port;
452
+ server.close(() => resolve(port));
453
+ });
454
+ });
455
+ }
456
+
270
457
  function notEnabledText(host, type) {
271
458
  if (type === 'jimureport') {
272
459
  return `${host} 的积木报表没有开启在线授权:独立部署要宿主实现 JmReportTokenServiceI.createToken;集成在 JeecgBoot 里的积木请用 --type jeecgboot 登录 JeecgBoot 地址`;
@@ -283,18 +470,6 @@ function typeOf(cred) {
283
470
  return PRODUCTS[cred.type] ? cred.type : 'jeecgboot';
284
471
  }
285
472
 
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
473
  async function requestToken(endpoint, form) {
299
474
  const res = await fetch(endpoint, {
300
475
  method: 'POST',
@@ -311,14 +486,16 @@ async function requestToken(endpoint, form) {
311
486
  return body;
312
487
  }
313
488
 
314
- function toCredential(t, type, tokenEndpoint, revocationEndpoint) {
489
+ function toCredential(t, host, type, tokenEndpoint, revocationEndpoint, web) {
315
490
  return {
316
491
  type,
492
+ // 用 --web 指定过的前端地址,之后自动重新授权时还用它
493
+ web: web || null,
317
494
  username: t.username,
318
495
  tenantId: t.tenant_id == null ? null : t.tenant_id,
319
496
  scope: t.scope || null,
320
497
  accessToken: t.access_token,
321
- refreshToken: t.refresh_token,
498
+ refreshToken: secret.protect(host, t.refresh_token),
322
499
  expiresAt: Date.now() + Number(t.expires_in || 0) * 1000,
323
500
  tokenEndpoint,
324
501
  revocationEndpoint,
@@ -416,4 +593,4 @@ function prompt(question) {
416
593
  );
417
594
  }
418
595
 
419
- module.exports = { login, status, token, logout };
596
+ 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 CHANGED
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
  /**
3
- * 本机凭证:按后端地址存在 ~/.jeecg/credentials.json(文件权限 600)
4
- * 结构:{ defaultHost, hosts: { "<后端地址>": { username, tenantId, accessToken, refreshToken, expiresAt, ... } } }
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 分钟有效
5
7
  */
6
8
  const fs = require('fs');
7
9
  const os = require('os');
@@ -9,6 +11,7 @@ const path = require('path');
9
11
 
10
12
  const DIR = path.join(os.homedir(), '.jeecg');
11
13
  const FILE = path.join(DIR, 'credentials.json');
14
+ const PENDING_FILE = path.join(DIR, 'pending.json');
12
15
 
13
16
  function load() {
14
17
  try {
@@ -20,10 +23,34 @@ function load() {
20
23
  }
21
24
 
22
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) {
23
50
  fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
24
- fs.writeFileSync(FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
51
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
25
52
  // 文件已存在时 writeFileSync 不会改权限,这里再收紧一次(Windows 上由用户目录的 ACL 保护)
26
- fs.chmodSync(FILE, 0o600);
53
+ fs.chmodSync(file, 0o600);
27
54
  }
28
55
 
29
- module.exports = { load, save, FILE };
56
+ module.exports = { load, save, loadPending, savePending, clearPending, FILE };
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@ws1298191366/cli",
3
- "version": "0.1.0",
4
- "description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云,给 AI skills 提供 token",
5
- "publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" },
3
+ "version": "0.1.2",
4
+ "description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云 / 积木报表,给 AI skills 提供 token",
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org",
7
+ "access": "public"
8
+ },
6
9
  "bin": {
7
10
  "jeecg": "bin/jeecg.js"
8
11
  },