@ws1298191366/cli 0.1.1 → 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
@@ -50,10 +50,21 @@ jeecg auth logout [--host <地址>] # 撤销授权并删除本
50
50
  - 被 AI 调用(不在终端里):命令打印授权链接后退出(退出码 2),用户授权后把地址栏的地址交给 `jeecg auth complete "<地址>"`(5 分钟内有效);也可以加 `--manual` 主动这样做
51
51
  - 本机有浏览器但不想自动打开:加 `--no-open`
52
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)` 不受影响。
62
+
53
63
  ## 服务器上的地址
54
64
 
55
65
  - `--host` 填前端调接口用的后端地址(nginx 转发后的外网地址,例如 `https://boot.example.com/jeecgboot`),换 token 也走这个地址
56
- - 后端要把 `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` 拼,经过反向代理改了路径也没问题
57
68
  - 授权后浏览器跳回的是你电脑上的 `127.0.0.1`,服务器不需要访问你的电脑,也不用开端口
58
69
  - 多个环境各登录一次,用 `--host` 切换,最后登录的是默认地址
59
70
 
package/bin/jeecg.js CHANGED
@@ -5,10 +5,10 @@ const auth = require('../lib/auth');
5
5
  const USAGE = `jeecg 命令行工具
6
6
 
7
7
  用法:
8
- jeecg auth token [--host <地址>] [--type <产品>] [--scope <权限>] [--no-open] [--manual]
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] [--manual]
11
+ jeecg auth login [--host <地址>] [--type <产品>] [--web <前端地址>] [--scope <权限>] [--no-open] [--manual]
12
12
  只登录(最多等 5 分钟),不输出 token
13
13
  jeecg auth complete "<浏览器地址栏里的地址>"
14
14
  打不开浏览器时用:授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把它交给这条命令完成登录
@@ -23,11 +23,15 @@ const USAGE = `jeecg 命令行工具
23
23
  jimureport 积木报表,地址填应用地址,例如 http://127.0.0.1:8085(带不带 /jmreport 都行)
24
24
  不填时按 jeecgboot、jimureport 的顺序自动探测;敲敲云要明确写 --type qiaoqiaoyun
25
25
 
26
+ --web 前端地址(JeecgBoot / 敲敲云的授权页在前端):
27
+ 默认用后端配置 jeecg.domainUrl.pc;它还是 localhost 而 --host 是服务器时,自动改用同一域名下的前端;
28
+ 前端和后端不在同一个域名时用 --web 指定,例如 --web https://boot.example.com
29
+
26
30
  打不开浏览器时(SSH、没有图形界面的 Linux):
27
31
  在终端里:把浏览器地址栏的地址粘贴回终端即可
28
32
  被 AI 调用:命令打印授权链接后退出(退出码 2),用户授权后执行 jeecg auth complete "<地址>";也可以加 --manual 主动这样做`;
29
33
 
30
- const VALUE_OPTIONS = ['host', 'scope', 'type'];
34
+ const VALUE_OPTIONS = ['host', 'scope', 'type', 'web'];
31
35
 
32
36
  function parseArgs(argv) {
33
37
  const args = { _: [] };
package/lib/auth.js CHANGED
@@ -74,7 +74,7 @@ async function token(args) {
74
74
  return;
75
75
  }
76
76
  console.error(cred ? `登录已过期或已被撤销(${host}),重新授权:` : `还没有登录 ${host},开始授权:`);
77
- 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
78
  if (!fresh) {
79
79
  // 打不开浏览器:已经打印了授权链接,等 jeecg auth complete,这次不输出 token
80
80
  return;
@@ -178,22 +178,24 @@ async function browserLogin(host, type, args, timeoutMs) {
178
178
  // 换 token、撤销直接用用户给的地址拼:nginx 常把外网 /jeecgboot 转发到后端 /jeecg-boot,
179
179
  // 后端按请求算出来的 token_endpoint(.../jeecg-boot/oauth2/token)在外网访问不到
180
180
  const base = host + PRODUCTS[type].prefix;
181
+ const web = args.web ? normalizeHost(args.web) : null;
181
182
  const login = {
182
183
  host,
183
184
  type,
185
+ web,
184
186
  verifier: crypto.randomBytes(32).toString('base64url'),
185
187
  state: crypto.randomBytes(16).toString('base64url'),
186
188
  tokenEndpoint: `${base}/oauth2/token`,
187
189
  revocationEndpoint: `${base}/oauth2/revoke`,
190
+ authorizeEndpoint: await resolveAuthorizeEndpoint(meta, host, type, web),
188
191
  };
189
- warnLocalAuthorizePage(meta.authorization_endpoint, host);
190
192
 
191
193
  // 2. 打不开本机浏览器又不在终端里(例如 AI 在服务器上调用):打印链接后退出,等 jeecg auth complete
192
194
  if (args.manual || (!process.stdin.isTTY && !hasLocalBrowser())) {
193
195
  login.redirectUri = `http://127.0.0.1:${await freePort()}/callback`;
194
196
  login.expiresAt = Date.now() + LOGIN_TIMEOUT_MS;
195
197
  store.savePending(login);
196
- console.error(`请在自己电脑的浏览器里打开下面的链接,确认授权(${PRODUCTS[type].name},5 分钟内有效):\n${authorizeUrl(meta, login, args.scope)}\n`);
198
+ console.error(`请在自己电脑的浏览器里打开下面的链接,确认授权(${PRODUCTS[type].name},5 分钟内有效):\n${authorizeUrl(login, args.scope)}\n`);
197
199
  console.error('授权后浏览器会跳到一个打不开的 127.0.0.1 地址,把地址栏里的完整地址交给:jeecg auth complete "<地址>"');
198
200
  process.exitCode = MANUAL_EXIT_CODE;
199
201
  return null;
@@ -202,10 +204,10 @@ async function browserLogin(host, type, args, timeoutMs) {
202
204
  // 3. 本机起回调服务,打开授权页
203
205
  const callback = await startCallbackServer(login.state);
204
206
  login.redirectUri = `http://127.0.0.1:${callback.port}/callback`;
205
- console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl(meta, login, args.scope)}\n`);
207
+ console.error(`请在浏览器中确认授权(${PRODUCTS[type].name}):\n${authorizeUrl(login, args.scope)}\n`);
206
208
  const browserOpened = args.open !== false && hasLocalBrowser();
207
209
  if (browserOpened) {
208
- openBrowser(authorizeUrl(meta, login, args.scope));
210
+ openBrowser(authorizeUrl(login, args.scope));
209
211
  }
210
212
  // 在终端里又没自动打开浏览器(--no-open、SSH):浏览器跳回时可能打不开,也可以把地址栏的地址粘贴回来
211
213
  const paste = !browserOpened && process.stdin.isTTY ? waitForPaste(login.state) : null;
@@ -235,7 +237,7 @@ async function exchangeCode(login, code) {
235
237
  client_id: CLIENT_ID,
236
238
  });
237
239
  const data = store.load();
238
- data.hosts[login.host] = toCredential(t, login.host, login.type, login.tokenEndpoint, login.revocationEndpoint);
240
+ data.hosts[login.host] = toCredential(t, login.host, login.type, login.tokenEndpoint, login.revocationEndpoint, login.web);
239
241
  data.defaultHost = login.host;
240
242
  store.save(data);
241
243
  return data.hosts[login.host];
@@ -252,7 +254,7 @@ async function refresh(host, cred) {
252
254
  try {
253
255
  const t = await requestToken(cred.tokenEndpoint, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID });
254
256
  const data = store.load();
255
- data.hosts[host] = toCredential(t, host, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint);
257
+ data.hosts[host] = toCredential(t, host, typeOf(cred), cred.tokenEndpoint, cred.revocationEndpoint, cred.web);
256
258
  store.save(data);
257
259
  return data.hosts[host];
258
260
  } catch (e) {
@@ -319,7 +321,7 @@ async function fetchMetadata(host, prefix) {
319
321
  return res.ok && meta.authorization_endpoint && meta.token_endpoint ? meta : null;
320
322
  }
321
323
 
322
- function authorizeUrl(meta, login, scope) {
324
+ function authorizeUrl(login, scope) {
323
325
  const params = new URLSearchParams({
324
326
  response_type: 'code',
325
327
  client_id: CLIENT_ID,
@@ -331,7 +333,59 @@ function authorizeUrl(meta, login, scope) {
331
333
  if (scope) {
332
334
  params.set('scope', scope);
333
335
  }
334
- return `${meta.authorization_endpoint}?${params}`;
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);
335
389
  }
336
390
 
337
391
  /**
@@ -416,18 +470,6 @@ function typeOf(cred) {
416
470
  return PRODUCTS[cred.type] ? cred.type : 'jeecgboot';
417
471
  }
418
472
 
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
473
  async function requestToken(endpoint, form) {
432
474
  const res = await fetch(endpoint, {
433
475
  method: 'POST',
@@ -444,9 +486,11 @@ async function requestToken(endpoint, form) {
444
486
  return body;
445
487
  }
446
488
 
447
- function toCredential(t, host, type, tokenEndpoint, revocationEndpoint) {
489
+ function toCredential(t, host, type, tokenEndpoint, revocationEndpoint, web) {
448
490
  return {
449
491
  type,
492
+ // 用 --web 指定过的前端地址,之后自动重新授权时还用它
493
+ web: web || null,
450
494
  username: t.username,
451
495
  tenantId: t.tenant_id == null ? null : t.tenant_id,
452
496
  scope: t.scope || null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ws1298191366/cli",
3
- "version": "0.1.1",
4
- "description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云,给 AI skills 提供 token",
3
+ "version": "0.1.2",
4
+ "description": "JEECG 命令行工具:在浏览器里授权登录 JeecgBoot / 敲敲云 / 积木报表,给 AI skills 提供 token",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"