@dreamor/atlas-cli 0.7.21 → 0.7.23
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 +5 -1
- package/dist/adapters/atlas/auth/index.js +2 -1
- package/dist/adapters/atlas/auth/login.js +44 -4
- package/dist/adapters/atlas/auth/portable.js +193 -0
- package/dist/adapters/atlas/auth/session.js +62 -10
- package/dist/adapters/atlas/cli.js +55 -5
- package/dist/adapters/atlas/commands/actual/_logic.js +62 -27
- package/dist/adapters/atlas/commands/actual/index.js +13 -10
- package/dist/adapters/atlas/commands/auth.js +1 -1
- package/dist/adapters/atlas/commands/baseline/index.js +30 -33
- package/dist/adapters/atlas/commands/compare/index.js +23 -21
- package/dist/adapters/atlas/daemon/index.js +95 -14
- package/dist/adapters/atlas/util/cidr.js +114 -0
- package/dist/adapters/atlas/util/environment.js +152 -0
- package/dist/adapters/atlas/util/portable-store.js +153 -0
- package/dist/adapters/atlas/util/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ atlas <cmd> --describe
|
|
|
36
36
|
### 3. 鉴权
|
|
37
37
|
|
|
38
38
|
- `atlas auth status` 返回 `data.loggedIn: boolean`
|
|
39
|
+
- `atlas auth refresh` — 无头模式静默刷新 access_token(依赖 SSO_REFRESH_TOKEN);session 过期时 agent 先试此命令
|
|
39
40
|
- agent **不能自动执行** `atlas auth login`(弹浏览器做 SSO,非终端环境报 exit 64)
|
|
40
41
|
|
|
41
42
|
### 4. 退出码速查
|
|
@@ -43,10 +44,13 @@ atlas <cmd> --describe
|
|
|
43
44
|
| 码 | 含义 | agent 应对 |
|
|
44
45
|
|----|------|-----------|
|
|
45
46
|
| 0 | 成功 | 继续 |
|
|
46
|
-
| 2 | 会话过期 |
|
|
47
|
+
| 2 | 会话过期 | 先试 `atlas auth refresh`,失败再让用户手动 login |
|
|
47
48
|
| 3 | API 错误 | 不重试 |
|
|
49
|
+
| 4 | 项目匹配歧义 | 用 `atlas find project` 让用户挑 |
|
|
50
|
+
| 5 | 项目未找到 | 改关键词重试 1 次后报告 |
|
|
48
51
|
| 6 | 限流 | 退避 10s 重试 ≤1 次 |
|
|
49
52
|
| 7 | 网络错误 | 退避 5s 重试 ≤1 次 |
|
|
53
|
+
| 8 | 升级异常 | 忽略 |
|
|
50
54
|
| 64 | 配置/未实现 | 不重试 |
|
|
51
55
|
| 65 | 输出超限 | 改用 `--out <file>` |
|
|
52
56
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { loginCmd, statusCmd } from './login.js';
|
|
1
|
+
export { loginCmd, statusCmd, sandboxLoginGuide } from './login.js';
|
|
2
2
|
export { refreshCmd, refreshSession } from './refresh.js';
|
|
3
3
|
export { getSessionToken, getBanmaIdentity, readBanmaIdentity } from './session.js';
|
|
4
|
+
export { exportCmd, importCmd, doctorCmd, recommendPath } from './portable.js';
|
|
@@ -111,8 +111,35 @@ export async function loginStatus() {
|
|
|
111
111
|
}
|
|
112
112
|
return { loggedIn: false };
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* 沙盒登录降级文案(对齐 dws 三条路径)。
|
|
116
|
+
* 纯函数,便于测试。
|
|
117
|
+
*/
|
|
118
|
+
export function sandboxLoginGuide(agentCode, reason) {
|
|
119
|
+
const head = reason ?? '无法拉起浏览器完成 SSO';
|
|
120
|
+
const agent = agentCode ? `(agent=${agentCode}, no DISPLAY/TTY)` : '(无浏览器环境)';
|
|
121
|
+
return `检测到当前是沙盒环境${agent},${head}。请任选一种方式完成登录:
|
|
122
|
+
|
|
123
|
+
[推荐] 从本地宿主机导出登录态:
|
|
124
|
+
(本地) atlas auth export --base64
|
|
125
|
+
(沙盒) export ATLAS_COOKIES_B64=<粘贴上一步输出>
|
|
126
|
+
atlas auth doctor # 确认识别成功
|
|
127
|
+
|
|
128
|
+
[网络可达宿主机] 复用 daemon:
|
|
129
|
+
(本地) atlas daemon
|
|
130
|
+
(沙盒) export ATLAS_DAEMON_URL=http://host.docker.internal:8765
|
|
131
|
+
export ATLAS_DAEMON_TOKEN=<~/.atlas/daemon.token 内容>
|
|
132
|
+
atlas auth status # 走 daemon 透明鉴权
|
|
133
|
+
|
|
134
|
+
[文件传输] 直接拷贝 bundle:
|
|
135
|
+
(本地) atlas auth export -o /tmp/atlas-auth.tar.gz
|
|
136
|
+
(沙盒) atlas auth import -i /tmp/atlas-auth.tar.gz
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
114
139
|
export async function loginCmd(opts) {
|
|
115
|
-
|
|
140
|
+
const { detectEnvironment } = await import('../util/environment.js');
|
|
141
|
+
const env = detectEnvironment();
|
|
142
|
+
// 优先复用 daemon(已存在逻辑)
|
|
116
143
|
const daemonCookies = await fetchCookiesFromDaemon();
|
|
117
144
|
if (daemonCookies && daemonCookies.length > 0) {
|
|
118
145
|
await writeCookies(daemonCookies);
|
|
@@ -122,10 +149,23 @@ export async function loginCmd(opts) {
|
|
|
122
149
|
}
|
|
123
150
|
return;
|
|
124
151
|
}
|
|
125
|
-
|
|
126
|
-
|
|
152
|
+
// 沙盒环境:直接走引导路径,不尝试拉起 Firefox
|
|
153
|
+
if (!env.canLaunchBrowser) {
|
|
154
|
+
throw new AtlasError(sandboxLoginGuide(env.agentCode, env.isContainer ? '容器环境无法拉起 GUI' : '无 TTY/DISPLAY'), 'INTERACTIVE_REQUIRED');
|
|
155
|
+
}
|
|
156
|
+
// 本地环境:尝试拉起浏览器;失败降级为引导提示而非硬崩
|
|
157
|
+
try {
|
|
158
|
+
await login();
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
log(sandboxLoginGuide(env.agentCode, '浏览器拉起失败'));
|
|
162
|
+
if (opts.json || isJsonMode()) {
|
|
163
|
+
// 已在 stderr 给出引导,仍抛出错误让上层以 INTERACTIVE_REQUIRED 退出
|
|
164
|
+
}
|
|
165
|
+
throw e instanceof Error
|
|
166
|
+
? new AtlasError(`浏览器登录失败:${e.message}\n${sandboxLoginGuide(env.agentCode, '浏览器拉起失败')}`, 'INTERACTIVE_REQUIRED')
|
|
167
|
+
: e;
|
|
127
168
|
}
|
|
128
|
-
await login();
|
|
129
169
|
if (opts.json || isJsonMode()) {
|
|
130
170
|
jsonOk({ status: 'logged_in' });
|
|
131
171
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atlas auth export / import / doctor 命令。
|
|
3
|
+
*
|
|
4
|
+
* 完全对齐 dws 参数:
|
|
5
|
+
* atlas auth export [-o <path>] [--base64] [--refresh-only] [--include-access-token]
|
|
6
|
+
* atlas auth import [-i <path>] [--base64] [--force] [--persist]
|
|
7
|
+
* atlas auth doctor
|
|
8
|
+
*
|
|
9
|
+
* 安全护栏详见 docs/sandbox-login-plan.md §4.3/§4.4 与 §8。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { buildBundle, serializeBundleBase64, writeBundleToFile, parseBundleBase64, parseBundleFile, } from '../util/portable-store.js';
|
|
13
|
+
import { detectEnvironment, resolveDaemonConfig, probeDaemonReachable, } from '../util/environment.js';
|
|
14
|
+
import { readCookies, writeCookies } from './session.js';
|
|
15
|
+
import { isJsonMode, jsonOk, log, printError } from '../util/output.js';
|
|
16
|
+
import { ConfigError } from '../util/errors.js';
|
|
17
|
+
import { getCookieFile } from '../util/paths.js';
|
|
18
|
+
const REFRESH_TOKEN_WARNING = '⚠️ 即将输出的 refresh token 是长期凭证,请仅在受信任的环境粘贴,且仅用于一次性导入。';
|
|
19
|
+
/** 当前 ISO 时间戳(命令层调用,纯逻辑层不依赖) */
|
|
20
|
+
function nowIso() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
export async function exportCmd(opts) {
|
|
24
|
+
// refresh-only 默认开启;--include-access-token 显式关闭
|
|
25
|
+
const refreshOnly = opts.includeAccessToken ? false : opts.refreshOnly !== false;
|
|
26
|
+
const cookies = await readCookies();
|
|
27
|
+
if (!cookies || cookies.length === 0) {
|
|
28
|
+
throw new ConfigError('本地无登录态,无法导出。请先执行:atlas auth login');
|
|
29
|
+
}
|
|
30
|
+
const bundle = buildBundle(cookies, {
|
|
31
|
+
refreshOnly,
|
|
32
|
+
exportedAt: nowIso(),
|
|
33
|
+
});
|
|
34
|
+
// 输出到 stdout(base64 模式无 -o,明显是给 agent 管道用的,先打 stderr 警告)
|
|
35
|
+
if (opts.base64 && !opts.out) {
|
|
36
|
+
log(REFRESH_TOKEN_WARNING);
|
|
37
|
+
const b64 = await serializeBundleBase64(bundle);
|
|
38
|
+
process.stdout.write(b64 + '\n');
|
|
39
|
+
if (opts.json || isJsonMode()) {
|
|
40
|
+
// JSON 模式下也输出信封,但 stdout 已被 b64 占用——只在 stderr 给提示
|
|
41
|
+
log('(JSON 模式下 base64 优先写 stdout;信封省略)');
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// base64 模式 + -o:写到文件
|
|
46
|
+
if (opts.base64 && opts.out) {
|
|
47
|
+
const resolved = await writeBundleToFile(bundle, opts.out, true);
|
|
48
|
+
if (refreshOnly)
|
|
49
|
+
log(REFRESH_TOKEN_WARNING);
|
|
50
|
+
log(`已导出到 ${resolved}(refresh-only=${refreshOnly},base64 文本)`);
|
|
51
|
+
if (opts.json || isJsonMode()) {
|
|
52
|
+
jsonOk({ exported: true, path: resolved, refreshOnly, base64: true, count: bundle.cookies.length });
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// 无 base64 写二进制 gzip 文件(需 -o)
|
|
57
|
+
if (opts.out) {
|
|
58
|
+
const resolved = await writeBundleToFile(bundle, opts.out, false);
|
|
59
|
+
if (refreshOnly)
|
|
60
|
+
log(REFRESH_TOKEN_WARNING);
|
|
61
|
+
log(`已导出到 ${resolved}(refresh-only=${refreshOnly},gzip 二进制)`);
|
|
62
|
+
if (opts.json || isJsonMode()) {
|
|
63
|
+
jsonOk({ exported: true, path: resolved, refreshOnly, base64: false, count: bundle.cookies.length });
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// 既无 base64 也无 -o:默认走 base64 stdout(对齐 dws `export` 无参行为)
|
|
68
|
+
log(REFRESH_TOKEN_WARNING);
|
|
69
|
+
const b64 = await serializeBundleBase64(bundle);
|
|
70
|
+
process.stdout.write(b64 + '\n');
|
|
71
|
+
}
|
|
72
|
+
export async function importCmd(opts) {
|
|
73
|
+
const persist = opts.persist !== false; // 默认落盘
|
|
74
|
+
let bundle;
|
|
75
|
+
let osMismatch = false;
|
|
76
|
+
// 从 stdin 读 base64:--base64 且无 -i
|
|
77
|
+
if (opts.base64 && !opts.input) {
|
|
78
|
+
const b64 = readFileSync(0, 'utf-8').trim(); // fd 0 = stdin
|
|
79
|
+
const result = await parseBundleBase64(b64);
|
|
80
|
+
bundle = result.bundle;
|
|
81
|
+
osMismatch = result.warning.osMismatch;
|
|
82
|
+
}
|
|
83
|
+
else if (opts.input) {
|
|
84
|
+
const result = await parseBundleFile(opts.input);
|
|
85
|
+
bundle = result.bundle;
|
|
86
|
+
osMismatch = result.warning.osMismatch;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw new ConfigError('请指定导入源:-i <文件> 或 --base64 从 stdin 读取(如 ATLAS_COOKIES_B64 环境变量自动导入请使用环境变量方式)');
|
|
90
|
+
}
|
|
91
|
+
if (bundle.cookies.length === 0) {
|
|
92
|
+
throw new ConfigError('导入的 bundle 不含任何 cookies,请确认导出源已登录');
|
|
93
|
+
}
|
|
94
|
+
// 跨 OS 警告(不阻断)
|
|
95
|
+
if (osMismatch) {
|
|
96
|
+
log(`⚠️ bundle 在 ${bundle.manifest.os} 导出,当前为 ${process.platform},跨平台 cookies 可能失效`);
|
|
97
|
+
}
|
|
98
|
+
// 已有 cookies 且未 --force 时拒绝覆盖
|
|
99
|
+
const existing = await readCookies();
|
|
100
|
+
if (existing && existing.length > 0 && !opts.force) {
|
|
101
|
+
throw new ConfigError('本地已存在登录态,拒绝覆盖。使用 --force 强制覆盖(将丢失旧 cookies)');
|
|
102
|
+
}
|
|
103
|
+
if (persist) {
|
|
104
|
+
await writeCookies(bundle.cookies);
|
|
105
|
+
log(`已导入 ${bundle.cookies.length} 个 cookies 到 ${getCookieFile()}`);
|
|
106
|
+
if (opts.json || isJsonMode()) {
|
|
107
|
+
jsonOk({
|
|
108
|
+
imported: true,
|
|
109
|
+
count: bundle.cookies.length,
|
|
110
|
+
osMismatch,
|
|
111
|
+
refreshOnly: bundle.manifest.refreshOnly,
|
|
112
|
+
persisted: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// 仅内存缓存(不落盘),用于一次性会话
|
|
118
|
+
// readCookies 走 cachedCookies,需手动注入缓存
|
|
119
|
+
const { __setCachedCookiesForImport } = await import('./session.js');
|
|
120
|
+
__setCachedCookiesForImport(bundle.cookies);
|
|
121
|
+
log(`已内存导入 ${bundle.cookies.length} 个 cookies(未落盘)`);
|
|
122
|
+
if (opts.json || isJsonMode()) {
|
|
123
|
+
jsonOk({
|
|
124
|
+
imported: true,
|
|
125
|
+
count: bundle.cookies.length,
|
|
126
|
+
osMismatch,
|
|
127
|
+
refreshOnly: bundle.manifest.refreshOnly,
|
|
128
|
+
persisted: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** 推导 recommendedPath(纯函数,便于测试) */
|
|
134
|
+
export function recommendPath(opts) {
|
|
135
|
+
if (opts.canLaunchBrowser)
|
|
136
|
+
return 'browser';
|
|
137
|
+
if (opts.daemonReachable)
|
|
138
|
+
return 'daemon';
|
|
139
|
+
if (opts.cookiesReady)
|
|
140
|
+
return 'manual_export_hint';
|
|
141
|
+
return 'import';
|
|
142
|
+
}
|
|
143
|
+
export async function doctorCmd(opts) {
|
|
144
|
+
const env = detectEnvironment();
|
|
145
|
+
const daemonCfg = resolveDaemonConfig();
|
|
146
|
+
const daemonReachable = await probeDaemonReachable(daemonCfg.url ?? undefined, daemonCfg.token ?? undefined);
|
|
147
|
+
const recommendedPath = recommendPath({
|
|
148
|
+
canLaunchBrowser: env.canLaunchBrowser,
|
|
149
|
+
cookiesReady: env.cookiesReady,
|
|
150
|
+
daemonReachable,
|
|
151
|
+
daemonVia: daemonCfg.via,
|
|
152
|
+
});
|
|
153
|
+
const result = {
|
|
154
|
+
agentCode: env.agentCode,
|
|
155
|
+
agentSignal: env.agentSignal,
|
|
156
|
+
sandbox: !env.canLaunchBrowser,
|
|
157
|
+
canLaunchBrowser: env.canLaunchBrowser,
|
|
158
|
+
cookies: { path: getCookieFile(), present: env.cookiesReady, expired: null },
|
|
159
|
+
daemon: {
|
|
160
|
+
url: daemonCfg.url,
|
|
161
|
+
reachable: daemonReachable,
|
|
162
|
+
via: daemonCfg.via,
|
|
163
|
+
},
|
|
164
|
+
recommendedPath,
|
|
165
|
+
};
|
|
166
|
+
if (opts.json || isJsonMode()) {
|
|
167
|
+
jsonOk(result);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// 人类可读输出
|
|
171
|
+
log('Atlas auth 诊断');
|
|
172
|
+
log(` agent: ${env.agentCode || '(未识别)'} ${env.agentSignal ? `[${env.agentSignal}]` : ''}`);
|
|
173
|
+
log(` 沙盒环境: ${result.sandbox ? '是' : '否'}`);
|
|
174
|
+
log(` 可拉起浏览器: ${env.canLaunchBrowser ? '是' : '否'} (TTY=${env.hasTTY}, DISPLAY=${env.hasDisplay}, container=${env.isContainer})`);
|
|
175
|
+
log(` 本地 cookies: ${env.cookiesReady ? '已就绪' : '缺失'} (${getCookieFile()})`);
|
|
176
|
+
log(` Daemon: ${daemonCfg.url ? `${daemonCfg.url} (${daemonCfg.via})` : '未配置'} - ${daemonReachable ? '可达' : '不可达'}`);
|
|
177
|
+
log(` 推荐路径: ${recommendedPath}`);
|
|
178
|
+
if (result.sandbox && !env.cookiesReady && !daemonReachable) {
|
|
179
|
+
log('');
|
|
180
|
+
log('沙盒登录引导:');
|
|
181
|
+
log(' [推荐] 从本地导出 → 沙盒导入:');
|
|
182
|
+
log(' (本地) atlas auth export --base64');
|
|
183
|
+
log(' (沙盒) export ATLAS_COOKIES_B64=<粘贴上一步输出> && atlas auth doctor');
|
|
184
|
+
log(' [或] 复用宿主机 daemon:');
|
|
185
|
+
log(' (本地) atlas daemon');
|
|
186
|
+
log(' (沙盒) export ATLAS_DAEMON_URL=http://host.docker.internal:8765');
|
|
187
|
+
log(' export ATLAS_DAEMON_TOKEN=<~/.atlas/daemon.token 内容>');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** 兼容错误打印 helper(cli.ts handleError 用不到,保留给子命令内部用) */
|
|
191
|
+
export function reportError(err, json) {
|
|
192
|
+
printError(err, { json });
|
|
193
|
+
}
|
|
@@ -8,13 +8,27 @@ let cachedCookies = null;
|
|
|
8
8
|
/**
|
|
9
9
|
* 读取持久化的 cookies(从本地文件)
|
|
10
10
|
* Playwright SSO 登录后将 cookies 写入此文件
|
|
11
|
+
*
|
|
12
|
+
* 沙盒免命令登录:本地文件缺失时若设置了 ATLAS_COOKIES_B64,则内存中一次性
|
|
13
|
+
* import(不落盘),让 agent 只注入 secret 就能用上。这条路径在 startAuthRefresh
|
|
14
|
+
* 之外的成本几乎为零,避免每次调用都重新解 base64/gzip。
|
|
11
15
|
*/
|
|
12
16
|
export async function readCookies() {
|
|
13
17
|
if (cachedCookies)
|
|
14
18
|
return cachedCookies;
|
|
15
19
|
try {
|
|
16
|
-
if (!existsSync(COOKIE_FILE))
|
|
20
|
+
if (!existsSync(COOKIE_FILE)) {
|
|
21
|
+
// 沙盒自动导入:ATLAS_COOKIES_B64 内存注入(不落盘)
|
|
22
|
+
const b64 = process.env.ATLAS_COOKIES_B64;
|
|
23
|
+
if (b64 && b64.trim().length > 0) {
|
|
24
|
+
const imported = await importFromEnvBase64(b64);
|
|
25
|
+
if (imported && imported.length > 0) {
|
|
26
|
+
cachedCookies = imported;
|
|
27
|
+
return imported;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
17
30
|
return null;
|
|
31
|
+
}
|
|
18
32
|
const data = await readFile(COOKIE_FILE, 'utf-8');
|
|
19
33
|
const cookies = JSON.parse(data);
|
|
20
34
|
cachedCookies = cookies;
|
|
@@ -24,6 +38,34 @@ export async function readCookies() {
|
|
|
24
38
|
return null;
|
|
25
39
|
}
|
|
26
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* 解 ATLAS_COOKIES_B64 环境变量为新版 portable bundle(gzip+base64 JSON)。
|
|
43
|
+
* 兼容旧格式:纯 JSON 数组 cookies(无 manifest 包装)。
|
|
44
|
+
* 失败返回 null,绝不抛——避免阻塞主流程。
|
|
45
|
+
*/
|
|
46
|
+
async function importFromEnvBase64(b64) {
|
|
47
|
+
try {
|
|
48
|
+
const { parseBundleBase64 } = await import('../util/portable-store.js');
|
|
49
|
+
const { bundle } = await parseBundleBase64(b64);
|
|
50
|
+
return bundle.cookies;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 兼容:直接是纯 JSON 数组字符串(非 gzip)
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(b64);
|
|
56
|
+
if (Array.isArray(parsed))
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// fallthrough
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 仅内存导入(importCmd --persist=false 用),不落盘 */
|
|
66
|
+
export function __setCachedCookiesForImport(cookies) {
|
|
67
|
+
cachedCookies = cookies;
|
|
68
|
+
}
|
|
27
69
|
export async function writeCookies(cookies) {
|
|
28
70
|
cachedCookies = cookies;
|
|
29
71
|
await secureMkdir(SESSION_DIR, { recursive: true });
|
|
@@ -39,22 +81,32 @@ export async function clearCookies() {
|
|
|
39
81
|
}
|
|
40
82
|
}
|
|
41
83
|
/**
|
|
42
|
-
* 从 daemon 获取 cookies(附带
|
|
84
|
+
* 从 daemon 获取 cookies(附带 Bearer token 鉴权)。
|
|
85
|
+
*
|
|
86
|
+
* 连接配置走 resolveDaemonConfig():优先 ATLAS_DAEMON_URL + ATLAS_DAEMON_TOKEN
|
|
87
|
+
* (沙盒连宿主机 daemon),回退 localhost + 本机 ~/.atlas/daemon.token(本机复用)。
|
|
88
|
+
* 失败返回 null,绝不抛——调用方据此降级到本地 cookies。
|
|
43
89
|
*/
|
|
44
|
-
export async function fetchCookiesFromDaemon(
|
|
90
|
+
export async function fetchCookiesFromDaemon() {
|
|
45
91
|
try {
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
const { resolveDaemonConfig } = await import('../util/environment.js');
|
|
93
|
+
const cfg = resolveDaemonConfig();
|
|
94
|
+
if (!cfg.url)
|
|
95
|
+
return null;
|
|
96
|
+
// token:env 优先(ATLAS_DAEMON_TOKEN),回退本地 token 文件(default 分支)
|
|
97
|
+
let token = cfg.token ?? '';
|
|
98
|
+
if (!token) {
|
|
99
|
+
const { getDaemonTokenFile } = await import('../util/paths.js');
|
|
100
|
+
const tokenFile = getDaemonTokenFile();
|
|
101
|
+
if (existsSync(tokenFile)) {
|
|
102
|
+
token = (await readFile(tokenFile, 'utf-8')).trim();
|
|
103
|
+
}
|
|
52
104
|
}
|
|
53
105
|
const { request } = await import('undici');
|
|
54
106
|
const headers = {};
|
|
55
107
|
if (token)
|
|
56
108
|
headers['Authorization'] = `Bearer ${token}`;
|
|
57
|
-
const resp = await request(
|
|
109
|
+
const resp = await request(`${cfg.url.replace(/\/$/, '')}/api/cookies`, {
|
|
58
110
|
headers,
|
|
59
111
|
headersTimeout: 5000,
|
|
60
112
|
bodyTimeout: 5000,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
// Auth
|
|
4
|
-
import { authLoginCmd, authStatusCmd, authRefreshCmd } from './commands/auth.js';
|
|
4
|
+
import { authLoginCmd, authStatusCmd, authRefreshCmd, authExportCmd, authImportCmd, authDoctorCmd } 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)
|
|
@@ -143,6 +143,50 @@ function registerAuthCommands(program) {
|
|
|
143
143
|
handleError(e);
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
|
+
auth
|
|
147
|
+
.command('export')
|
|
148
|
+
.description('导出登录态为便携 bundle(默认 --refresh-only 剥离短命 token)')
|
|
149
|
+
.option('-o, --out <path>', '输出文件路径(省略时 --base64 写 stdout)')
|
|
150
|
+
.option('--base64', 'base64 文本格式(gzip+JSON),方便管道/env 传输')
|
|
151
|
+
.option('--refresh-only', '仅长期凭证(默认开启)', true)
|
|
152
|
+
.option('--include-access-token', '包含短命 access_token(默认剥离)')
|
|
153
|
+
.option('--json', '输出 JSON 信封')
|
|
154
|
+
.action(async (opts) => {
|
|
155
|
+
try {
|
|
156
|
+
await authExportCmd(opts);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
handleError(e);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
auth
|
|
163
|
+
.command('import')
|
|
164
|
+
.description('从便携 bundle 导入登录态(覆盖本地 cookies)')
|
|
165
|
+
.option('-i, --input <path>', '导入文件路径(省略 --base64 时从 stdin 读)')
|
|
166
|
+
.option('--base64', '输入为 base64 文本格式')
|
|
167
|
+
.option('--force', '本地已有登录态时强制覆盖')
|
|
168
|
+
.option('--persist', '落盘到 ~/.atlas/cookies.json(默认开启)', true)
|
|
169
|
+
.option('--json', '输出 JSON 信封')
|
|
170
|
+
.action(async (opts) => {
|
|
171
|
+
try {
|
|
172
|
+
await authImportCmd(opts);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
handleError(e);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
auth
|
|
179
|
+
.command('doctor')
|
|
180
|
+
.description('诊断当前环境的登录路径(沙盒 agent 自省用)')
|
|
181
|
+
.option('--json', '输出 JSON 信封')
|
|
182
|
+
.action(async (opts) => {
|
|
183
|
+
try {
|
|
184
|
+
await authDoctorCmd(opts);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
handleError(e);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
146
190
|
}
|
|
147
191
|
function registerProjectCommands(program) {
|
|
148
192
|
// atlas find
|
|
@@ -291,6 +335,7 @@ function registerActualCommands(program) {
|
|
|
291
335
|
.command('show <staffId>')
|
|
292
336
|
.description('查看单个人员的实际工时明细'))
|
|
293
337
|
.option('--month <yyyymm>', '查询月份(YYYY-MM,默认当前月)')
|
|
338
|
+
.option('--status <status>', '筛选审批状态: approved | pending | all', 'approved')
|
|
294
339
|
.option('--json', '输出 JSON 信封')
|
|
295
340
|
.action(async (staffId, opts) => {
|
|
296
341
|
try {
|
|
@@ -307,7 +352,7 @@ function registerActualCommands(program) {
|
|
|
307
352
|
.option('--month <yyyymm>', '查询月份(YYYY-MM,与 --from/--to 互斥)')
|
|
308
353
|
.option('--from <yyyymm>', '起始月份(YYYY-MM,包含,与 --month 互斥)')
|
|
309
354
|
.option('--to <yyyymm>', '结束月份(YYYY-MM,包含,与 --month 互斥)')
|
|
310
|
-
.option('--status <status>', '筛选审批状态:
|
|
355
|
+
.option('--status <status>', '筛选审批状态: approved | pending | all', 'approved')
|
|
311
356
|
.option('--department <name>', '按团队负责人/部门筛选(子串,不区分大小写)')
|
|
312
357
|
.option('--role <name>', '按角色/备注筛选(子串,不区分大小写)')
|
|
313
358
|
.option('--staff-name <name>', '按姓名/工号筛选(子串,不区分大小写)')
|
|
@@ -326,7 +371,7 @@ function registerActualCommands(program) {
|
|
|
326
371
|
.description('按月/部门/角色汇总实际工时'))
|
|
327
372
|
.option('--by <axis>', 'month | department | role', 'month')
|
|
328
373
|
.option('--month <yyyymm>', '查询月份')
|
|
329
|
-
.option('--status <status>', '
|
|
374
|
+
.option('--status <status>', 'approved | pending | all', 'approved')
|
|
330
375
|
.option('--department <name>', '按部门筛选')
|
|
331
376
|
.option('--role <name>', '按角色筛选')
|
|
332
377
|
.option('--from <yyyymm>', '起始月份')
|
|
@@ -347,7 +392,7 @@ function registerActualCommands(program) {
|
|
|
347
392
|
.requiredOption('--format <fmt>', 'csv | json')
|
|
348
393
|
.requiredOption('--out <path>', '输出文件路径')
|
|
349
394
|
.option('--by <axis>', 'month | department | role', 'month')
|
|
350
|
-
.option('--status <status>', '
|
|
395
|
+
.option('--status <status>', 'approved | pending | all', 'approved')
|
|
351
396
|
.option('--department <name>', '按部门筛选')
|
|
352
397
|
.option('--role <name>', '按角色筛选')
|
|
353
398
|
.option('--from <yyyymm>', '起始月份')
|
|
@@ -372,7 +417,7 @@ function registerCompareCommands(program) {
|
|
|
372
417
|
.option('--month <yyyymm>', '查询月份(YYYY-MM,优先级高于 from/to 用于实际数据 API)')
|
|
373
418
|
.option('--department <name>', '按部门名称/ID 筛选(子串,不区分大小写)')
|
|
374
419
|
.option('--role <name>', '按角色/备注筛选(子串,不区分大小写)')
|
|
375
|
-
.option('--status <status>', '筛选审批状态:
|
|
420
|
+
.option('--status <status>', '筛选审批状态: approved | pending | all', 'approved')
|
|
376
421
|
.option('--threshold <n>', '差异绝对值阈值(人月),低于此值不标记', '0')
|
|
377
422
|
.option('--flag-overrun', '用 ⚠️ 标记实际 > 基线的情况')
|
|
378
423
|
.option('--page <n>', '页码(从 1 开始)')
|
|
@@ -393,6 +438,11 @@ function registerUtilityCommands(program) {
|
|
|
393
438
|
.command('daemon')
|
|
394
439
|
.description('启动本地守护进程(沙盒环境使用,保持浏览器会话)')
|
|
395
440
|
.option('--port <n>', '监听端口(默认 8765,也可用 ATLAS_DAEMON_PORT 环境变量)')
|
|
441
|
+
.option('--host <host>', '监听地址(默认 127.0.0.1;非 loopback 需配 --allow-ip 或 --insecure)')
|
|
442
|
+
.option('--allow-ip <cidr>', 'IP 白名单(CIDR 或单 IP,可多次传)', (v, p) => [...p, v], [])
|
|
443
|
+
.option('--tls-cert <path>', 'TLS 证书文件路径(需与 --tls-key 同时指定)')
|
|
444
|
+
.option('--tls-key <path>', 'TLS 私钥文件路径')
|
|
445
|
+
.option('--insecure', '显式允许明文监听非 loopback 接口(仅本机开发)')
|
|
396
446
|
.option('--json', '输出 JSON 信封')
|
|
397
447
|
.action(async (opts) => {
|
|
398
448
|
try {
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* 与命令解析层(index.ts)分离:这里不做 IO、不调 API、不输出,
|
|
5
5
|
* 只做数据变换。便于单元测试(参见 tests/actual_logic.test.ts)。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 单位约定(关键):
|
|
8
|
+
* - API detail.manpower 字段单位为**人天**,不是人月。
|
|
9
|
+
* - 网页"人力汇总"页显示的人月 = Σ(叶子节点 detail.manpower) ÷ WORK_DAYS_PER_MONTH。
|
|
10
|
+
* - 经 2026-07-01 实测验证:BMW(2548) 2026-03 叶子节点合计 321.9 人天 ÷ 22 = 14.63 人月,
|
|
11
|
+
* 与网页"各项目显示值:已确认"完全一致。
|
|
8
12
|
*/
|
|
13
|
+
/** 每月工作日,用于把 API 返回的人天换算为人月(与网页口径一致) */
|
|
14
|
+
export const WORK_DAYS_PER_MONTH = 22;
|
|
9
15
|
/**
|
|
10
16
|
* expandMonths 已移至 util/months.ts,此处不再导出。
|
|
11
17
|
*/
|
|
@@ -13,6 +19,21 @@
|
|
|
13
19
|
export function annotateWithMonth(entries, month) {
|
|
14
20
|
return entries.map((p) => ({ ...p, month }));
|
|
15
21
|
}
|
|
22
|
+
/** 将 CLI 字符串选项规范化为 StatusFilter,默认 approved(与网站默认视图对齐) */
|
|
23
|
+
export function normalizeStatusFilter(raw) {
|
|
24
|
+
if (raw === 'all' || raw === 'pending' || raw === 'approved')
|
|
25
|
+
return raw;
|
|
26
|
+
return 'approved';
|
|
27
|
+
}
|
|
28
|
+
/** 判断单条 detail 记录是否通过状态筛选 */
|
|
29
|
+
function passesStatusFilter(statusRaw, filter) {
|
|
30
|
+
const status = Number(statusRaw);
|
|
31
|
+
if (filter === 'approved')
|
|
32
|
+
return status === 2;
|
|
33
|
+
if (filter === 'pending')
|
|
34
|
+
return status !== 2;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
16
37
|
/**
|
|
17
38
|
* 按轴聚合人月。
|
|
18
39
|
* - month:按月份分组(需先用 annotateWithMonth 打标)
|
|
@@ -40,42 +61,56 @@ export function filterByStaff(entries, staffId) {
|
|
|
40
61
|
return entries.filter((p) => p.staffId === staffId || p.staffName?.includes(staffId));
|
|
41
62
|
}
|
|
42
63
|
/**
|
|
43
|
-
* 走查
|
|
64
|
+
* 走查 summaryByProject 返回的树结构,提取指定项目的实际工时条目(人月)。
|
|
65
|
+
*
|
|
66
|
+
* 关键口径(与网页"人力汇总"页一致,2026-07-01 实测验证):
|
|
67
|
+
* 1. **只在叶子节点累加**:manager 节点的 detail 是下属汇总的副本,
|
|
68
|
+
* 若一并计入会把同一笔工时重复累加(实测会把 321.9 人天虚增到 440.6)。
|
|
69
|
+
* 叶子 = children 为 null 或空数组的节点。
|
|
70
|
+
* 2. **人天 ÷ 工作日 → 人月**:API detail.manpower 单位是人天,
|
|
71
|
+
* 需除以 WORK_DAYS_PER_MONTH(22) 才是网页显示的人月。
|
|
72
|
+
* 3. **状态筛选**:默认 approved 只统计 status===2(已确认),与网页默认视图对齐。
|
|
44
73
|
*
|
|
45
|
-
*
|
|
46
|
-
* 采取走最深层的策略:越深的节点 detail 越精确。
|
|
74
|
+
* 返回按员工去重的条目,manpower 为人月(四舍五入 2 位)。
|
|
47
75
|
*/
|
|
48
|
-
export function flattenTree(nodes, targetPid) {
|
|
76
|
+
export function flattenTree(nodes, targetPid, statusFilter = 'approved') {
|
|
49
77
|
const byStaffId = new Map();
|
|
50
|
-
function
|
|
78
|
+
function isLeaf(node) {
|
|
79
|
+
const kids = node.children;
|
|
80
|
+
return !Array.isArray(kids) || kids.length === 0;
|
|
81
|
+
}
|
|
82
|
+
function walk(items) {
|
|
51
83
|
for (const raw of items ?? []) {
|
|
52
84
|
const node = raw;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
// 只在叶子节点累加 detail,避免 manager 层重复计入
|
|
86
|
+
if (isLeaf(node)) {
|
|
87
|
+
const details = (node.detail ?? []);
|
|
88
|
+
let personDays = 0;
|
|
89
|
+
for (const d of details) {
|
|
90
|
+
if (String(d.projectId) === targetPid && passesStatusFilter(d.status, statusFilter)) {
|
|
91
|
+
personDays += Number(d.manpower ?? 0);
|
|
92
|
+
}
|
|
58
93
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
if (personDays > 0) {
|
|
95
|
+
const sid = String(node.staffId ?? '');
|
|
96
|
+
const manMonths = personDays / WORK_DAYS_PER_MONTH;
|
|
97
|
+
// 同一叶子节点不可能重复出现;若员工跨多节点,按 manpower 累加
|
|
98
|
+
const existing = byStaffId.get(sid);
|
|
99
|
+
const total = (existing?.manpower ?? 0) + manMonths;
|
|
64
100
|
byStaffId.set(sid, {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
departmentName: String(node.department ?? ''),
|
|
71
|
-
role: String(node.role ?? ''),
|
|
72
|
-
},
|
|
101
|
+
staffId: sid,
|
|
102
|
+
staffName: String(node.realname ?? ''),
|
|
103
|
+
manpower: Math.round(total * 100) / 100,
|
|
104
|
+
departmentName: String(node.department ?? ''),
|
|
105
|
+
role: String(node.role ?? ''),
|
|
73
106
|
});
|
|
74
107
|
}
|
|
75
108
|
}
|
|
76
|
-
|
|
109
|
+
else {
|
|
110
|
+
walk(node.children);
|
|
111
|
+
}
|
|
77
112
|
}
|
|
78
113
|
}
|
|
79
|
-
walk(nodes
|
|
80
|
-
return [...byStaffId.values()]
|
|
114
|
+
walk(nodes);
|
|
115
|
+
return [...byStaffId.values()];
|
|
81
116
|
}
|