@dreamor/atlas-cli 0.7.17 → 0.7.19

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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Playwright 加载与 Firefox 二进制安装 —— login 和 refresh 命令共用。
3
+ *
4
+ * 保持动态 import + try/catch 降级:顶层静态 import 会让单文件 bundle
5
+ * 在未安装 playwright 的环境下无法启动(连 --version 都 require 失败)。
6
+ * 只在真正调用 auth login / auth refresh 时才尝试加载 playwright。
7
+ */
8
+ import { execSync } from 'child_process';
9
+ import { createRequire } from 'module';
10
+ import { log } from '../util/output.js';
11
+ const _require = createRequire(import.meta.url);
12
+ export async function loadFirefox() {
13
+ try {
14
+ const pw = await import('playwright');
15
+ return pw.firefox;
16
+ }
17
+ catch {
18
+ throw new Error("未安装 playwright。请执行: npm i -g playwright && npx playwright install");
19
+ }
20
+ }
21
+ /**
22
+ * 检测 launch 错误是否为"浏览器二进制未安装",自动用内置 playwright 下载。
23
+ */
24
+ export async function ensureFirefoxInstalled() {
25
+ const pwPath = _require.resolve('playwright/package.json');
26
+ const cliJs = pwPath.replace(/package\.json$/, 'cli.js');
27
+ log('首次运行,正在自动安装 Firefox 浏览器...');
28
+ try {
29
+ execSync(`node "${cliJs}" install firefox`, {
30
+ stdio: 'inherit',
31
+ timeout: 300_000,
32
+ });
33
+ }
34
+ catch {
35
+ throw new Error('Firefox 浏览器自动安装失败。请手动执行: npx playwright install firefox');
36
+ }
37
+ }
@@ -1,2 +1,3 @@
1
1
  export { loginCmd, statusCmd } from './login.js';
2
+ export { refreshCmd, refreshSession } from './refresh.js';
2
3
  export { getSessionToken, getBanmaIdentity, readBanmaIdentity } from './session.js';
@@ -1,41 +1,8 @@
1
- import { execSync } from 'child_process';
2
- import { createRequire } from 'module';
3
1
  import { writeCookies, readCookies, fetchCookiesFromDaemon } from './session.js';
2
+ import { loadFirefox, ensureFirefoxInstalled } from './browser.js';
4
3
  import { isJsonMode, jsonOk, log } from '../util/output.js';
5
4
  import { AtlasError } from '../util/errors.js';
6
- const _require = createRequire(import.meta.url);
7
5
  const BANMA_HOST = 'banma-yuntu.alibaba-inc.com';
8
- /**
9
- * 动态加载 playwright。顶层静态 import 会让单文件 bundle 在未安装 playwright 的
10
- * 环境下无法启动(连 --version 都 require 失败),故改为运行时动态 import + try/catch
11
- * 降级:只在真正执行 `auth login` 时才需要 playwright,未安装则给出清晰提示。
12
- */
13
- async function loadFirefox() {
14
- try {
15
- const pw = await import('playwright');
16
- return pw.firefox;
17
- }
18
- catch {
19
- throw new Error("未安装 playwright。请执行: npm i -g playwright && npx playwright install");
20
- }
21
- }
22
- /**
23
- * 检测 launch 错误是否为"浏览器二进制未安装",自动用内置 playwright 下载。
24
- */
25
- async function ensureBrowserInstalled() {
26
- const pwPath = _require.resolve('playwright/package.json');
27
- const cliJs = pwPath.replace(/package\.json$/, 'cli.js');
28
- log('首次运行,正在自动安装 Firefox 浏览器...');
29
- try {
30
- execSync(`node "${cliJs}" install firefox`, {
31
- stdio: 'inherit',
32
- timeout: 300_000,
33
- });
34
- }
35
- catch {
36
- throw new Error('Firefox 浏览器自动安装失败。请手动执行: npx playwright install firefox');
37
- }
38
- }
39
6
  export async function login(_port) {
40
7
  log('正在打开浏览器进行 Banma SSO 登录...');
41
8
  const firefox = await loadFirefox();
@@ -47,7 +14,7 @@ export async function login(_port) {
47
14
  }
48
15
  catch (e) {
49
16
  if (e instanceof Error && e.message.includes('Executable doesn\'t exist')) {
50
- await ensureBrowserInstalled();
17
+ await ensureFirefoxInstalled();
51
18
  browser = await firefox.launch({ headless: false });
52
19
  }
53
20
  else {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * atlas auth refresh —— headless 静默换发 access_token。
3
+ *
4
+ * 原理:斑马 banma-yuntu 首页在 access_token 缺失/过期时会被 SSO
5
+ * 拦截并走 302 重定向链换发新 token。已通过 spike 验证 headless 模式下
6
+ * SSO_REFRESH_TOKEN 足以完成整个链路,无需人工交互。
7
+ *
8
+ * 失败降级:抛 SessionExpiredError 引导用户 atlas auth login。
9
+ */
10
+ import { readCookies, writeCookies } from './session.js';
11
+ import { loadFirefox, ensureFirefoxInstalled } from './browser.js';
12
+ import { isJsonMode, jsonOk, log } from '../util/output.js';
13
+ import { AtlasError, SessionExpiredError } from '../util/errors.js';
14
+ const BANMA_HOST = 'banma-yuntu.alibaba-inc.com';
15
+ const REFRESH_TIMEOUT_MS = 15_000;
16
+ const POLL_INTERVAL_MS = 500;
17
+ /**
18
+ * 执行一次静默 SSO 刷新。
19
+ *
20
+ * 前置条件:本地存在 cookies.json 且含有 SSO_REFRESH_TOKEN 等长期凭证。
21
+ * 成功:新 access_token 落库;返回 { refreshed: true, account }。
22
+ * 失败:抛 SessionExpiredError 引导 atlas auth login。
23
+ */
24
+ export async function refreshSession(opts = {}) {
25
+ const timeoutMs = opts.timeoutMs ?? REFRESH_TIMEOUT_MS;
26
+ const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
27
+ const existing = await readCookies();
28
+ if (!existing || existing.length === 0) {
29
+ throw new AtlasError('未检测到本地登录态,请先执行:atlas auth login', 'INTERACTIVE_REQUIRED');
30
+ }
31
+ const originalAccessToken = existing.find((c) => c.name === 'access_token')?.value ?? '';
32
+ const firefox = await loadFirefox();
33
+ let browser;
34
+ try {
35
+ browser = await firefox.launch({ headless: true });
36
+ }
37
+ catch (e) {
38
+ if (e instanceof Error && e.message.includes("Executable doesn't exist")) {
39
+ await ensureFirefoxInstalled();
40
+ browser = await firefox.launch({ headless: true });
41
+ }
42
+ else {
43
+ throw e;
44
+ }
45
+ }
46
+ try {
47
+ const ctx = await browser.newContext();
48
+ // 灌入已有 cookie,但剥离失效的 access_token —— 强制 SSO 用
49
+ // SSO_REFRESH_TOKEN 换发全新 token,防止 still_valid 假阳性。
50
+ // playwright 严格校验 cookie 字段,需要补齐 path/secure 等。
51
+ const cookiesForContext = existing
52
+ .filter((c) => c.name !== 'access_token')
53
+ .map((c) => ({
54
+ name: c.name,
55
+ value: c.value,
56
+ domain: c.domain ?? '.alibaba-inc.com',
57
+ path: '/',
58
+ httpOnly: false,
59
+ secure: true,
60
+ sameSite: 'Lax',
61
+ }));
62
+ await ctx.addCookies(cookiesForContext);
63
+ const page = await ctx.newPage();
64
+ await page.goto(`https://${BANMA_HOST}/`, {
65
+ waitUntil: 'domcontentloaded',
66
+ timeout: timeoutMs,
67
+ });
68
+ // 轮询 access_token:goto 完成后如果 SSO 302 链跑通,浏览器 context 里
69
+ // 就会有 access_token(可能是原值——本来就没过期;也可能是新值——SSO 补发)。
70
+ // 两种情况都视为"session 依然可用",回写 cookies 即可;只有拿不到才算失败。
71
+ let finalToken = null;
72
+ let tokenChanged = false;
73
+ const startedAt = Date.now();
74
+ while (Date.now() - startedAt < timeoutMs) {
75
+ const all = await ctx.cookies();
76
+ const cur = all.find((c) => c.name === 'access_token');
77
+ if (cur && cur.value) {
78
+ finalToken = cur.value;
79
+ tokenChanged = cur.value !== originalAccessToken;
80
+ // 如果 token 变了(真的刷新过),马上确认,避免过早退出
81
+ if (tokenChanged || !originalAccessToken)
82
+ break;
83
+ // 如果 token 未变,多轮询几次给 SSO 一点补发机会,但 goto 完成后
84
+ // 通常几百 ms 内就有结论;等一轮就够了。
85
+ await page.waitForTimeout(pollIntervalMs);
86
+ const again = (await ctx.cookies()).find((c) => c.name === 'access_token');
87
+ if (again && again.value !== originalAccessToken) {
88
+ finalToken = again.value;
89
+ tokenChanged = true;
90
+ }
91
+ break;
92
+ }
93
+ await page.waitForTimeout(pollIntervalMs);
94
+ }
95
+ if (!finalToken) {
96
+ throw new SessionExpiredError('自动刷新失败(未获取到 access_token),请重新登录:atlas auth login');
97
+ }
98
+ // 抓 banma-yuntu / alibaba-inc 域下所有 cookies,回写持久化
99
+ const all = await ctx.cookies();
100
+ const filtered = all
101
+ .filter((c) => c.domain?.includes('banma-yuntu') || c.domain?.includes('alibaba-inc'))
102
+ .map((c) => ({ name: c.name, value: c.value, domain: c.domain }));
103
+ await writeCookies(filtered);
104
+ const account = deriveAccountName(filtered);
105
+ return { refreshed: tokenChanged, account };
106
+ }
107
+ finally {
108
+ await browser.close();
109
+ }
110
+ }
111
+ function deriveAccountName(cookies) {
112
+ const userinfo = cookies.find((c) => c.name === 'buc_userinfo');
113
+ if (userinfo) {
114
+ try {
115
+ const decoded = JSON.parse(Buffer.from(userinfo.value, 'base64').toString('utf-8'));
116
+ return decoded.name || decoded.account || (decoded.emp_id ? `工号 ${decoded.emp_id}` : undefined);
117
+ }
118
+ catch {
119
+ // 解码失败降级到 buc_username
120
+ }
121
+ }
122
+ return cookies.find((c) => c.name === 'buc_username')?.value;
123
+ }
124
+ export async function refreshCmd(opts) {
125
+ const result = await refreshSession();
126
+ const status = result.refreshed ? 'refreshed' : 'still_valid';
127
+ if (opts.json || isJsonMode()) {
128
+ jsonOk({ status, account: result.account });
129
+ return;
130
+ }
131
+ log(result.refreshed ? '会话已刷新' : '会话仍然有效,无需刷新');
132
+ if (result.account)
133
+ log(`账号: ${result.account}`);
134
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  // Auth
4
- import { authLoginCmd, authStatusCmd } from './commands/auth.js';
4
+ import { authLoginCmd, authStatusCmd, authRefreshCmd } from './commands/auth.js';
5
5
  // Project commands (find, projects, link, unlink)
6
6
  import { findCmd, projectsCmd, linkCmd, linkStatusCmd, unlinkCmd, } from './commands/project/index.js';
7
7
  // Baseline commands (month, summary, export)
@@ -131,6 +131,18 @@ function registerAuthCommands(program) {
131
131
  handleError(e);
132
132
  }
133
133
  });
134
+ auth
135
+ .command('refresh')
136
+ .description('无头模式静默刷新 access_token(依赖 SSO_REFRESH_TOKEN,失败降级到 atlas auth login)')
137
+ .option('--json', '输出 JSON 信封')
138
+ .action(async (opts) => {
139
+ try {
140
+ await authRefreshCmd(opts);
141
+ }
142
+ catch (e) {
143
+ handleError(e);
144
+ }
145
+ });
134
146
  }
135
147
  function registerProjectCommands(program) {
136
148
  // atlas find
@@ -29,6 +29,21 @@ const schemas = {
29
29
  },
30
30
  },
31
31
  },
32
+ 'atlas auth refresh': {
33
+ jsonSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ ok: { type: 'boolean', const: true },
37
+ data: {
38
+ type: 'object',
39
+ properties: {
40
+ status: { type: 'string', enum: ['refreshed', 'still_valid'] },
41
+ account: { type: 'string' },
42
+ },
43
+ },
44
+ },
45
+ },
46
+ },
32
47
  'atlas projects': {
33
48
  jsonSchema: {
34
49
  type: 'object',
@@ -1 +1 @@
1
- export { loginCmd as authLoginCmd, statusCmd as authStatusCmd } from '../auth/index.js';
1
+ export { loginCmd as authLoginCmd, statusCmd as authStatusCmd, refreshCmd as authRefreshCmd, } from '../auth/index.js';
@@ -13,10 +13,26 @@ import { BanmaApiError, SessionExpiredError, AtlasError } from '../util/errors.j
13
13
  import { readCookies, readBanmaIdentity } from '../auth/session.js';
14
14
  /** D10:已知业务错误码 → 异常映射(工厂函数避免单例 stack trace 失准) */
15
15
  const KNOWN_BUSINESS_ERRORS = {
16
- 'TOKEN_INVALID': () => new SessionExpiredError('会话 token 已失效,请重新登录'),
17
- 'TOKEN_EXPIRED': () => new SessionExpiredError('会话 token 已过期,请重新登录'),
18
- 'SESSION_EXPIRED': () => new SessionExpiredError('会话已过期,请重新登录'),
16
+ 'TOKEN_INVALID': () => new SessionExpiredError('会话 token 已失效,请先执行:atlas auth refresh;失败再执行:atlas auth login'),
17
+ 'TOKEN_EXPIRED': () => new SessionExpiredError('会话 token 已过期,请先执行:atlas auth refresh;失败再执行:atlas auth login'),
18
+ 'SESSION_EXPIRED': () => new SessionExpiredError('会话已过期,请先执行:atlas auth refresh;失败再执行:atlas auth login'),
19
+ // 斑马 API 会用 errCode=501 + errorMsg="当前token已失效" 表达 token 过期;
20
+ // 这是文档中反复踩过的坑,务必显式映射。
21
+ '501': () => new SessionExpiredError('会话 token 已失效,请先执行:atlas auth refresh;失败再执行:atlas auth login'),
19
22
  };
23
+ /**
24
+ * 关键词兜底:识别 errorMsg 是否描述 token 失效/过期。
25
+ * 用途:防止后续 API 又换一个 errCode 编号(例如 502/503)继续绕过我们的显式映射。
26
+ * 规则:errorMsg 同时包含 "token"(大小写不敏感)且包含 "失效" 或 "过期" 之一。
27
+ */
28
+ function looksLikeTokenExpired(errorMsg) {
29
+ if (!errorMsg)
30
+ return false;
31
+ const lower = errorMsg.toLowerCase();
32
+ const hasToken = lower.includes('token');
33
+ const hasExpiry = errorMsg.includes('失效') || errorMsg.includes('过期');
34
+ return hasToken && hasExpiry;
35
+ }
20
36
  const BANMA_BASE = 'https://banma-yuntu.alibaba-inc.com';
21
37
  /**
22
38
  * Banma API HTTP 客户端
@@ -162,6 +178,10 @@ export class HttpClient {
162
178
  const knownError = KNOWN_BUSINESS_ERRORS[json.errCode];
163
179
  if (knownError)
164
180
  throw knownError();
181
+ // 兜底:显式映射漏掉时,用 errorMsg 关键词识别 token 失效
182
+ if (looksLikeTokenExpired(json.errorMsg)) {
183
+ throw new SessionExpiredError('会话 token 已失效,请先执行:atlas auth refresh;失败再执行:atlas auth login');
184
+ }
165
185
  }
166
186
  // Banma API 的 success/status 字段不可靠(空结果也返回 success: false)
167
187
  // 只要 HTTP 2xx 就返回 data,由调用方处理 null
@@ -1 +1 @@
1
- export const ATLAS_VERSION = '0.7.17';
1
+ export const ATLAS_VERSION = '0.7.19';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamor/atlas-cli",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "Atlas CLI - 斑马云图人力基线管理工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,6 +49,6 @@
49
49
  "esbuild": "^0.28.1",
50
50
  "tsx": "^4.19.0",
51
51
  "typescript": "^5.6.0",
52
- "vitest": "^2.1.0"
52
+ "vitest": "^4.1.9"
53
53
  }
54
54
  }