@dreamor/atlas-cli 0.7.23 → 0.7.24
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.github.md +230 -0
- package/README.md +1 -59
- package/atlas.cjs +226 -0
- package/package.json +7 -9
- package/atlas.js +0 -5
- package/dist/adapters/atlas/auth/browser.js +0 -37
- package/dist/adapters/atlas/auth/index.js +0 -4
- package/dist/adapters/atlas/auth/login.js +0 -187
- package/dist/adapters/atlas/auth/portable.js +0 -193
- package/dist/adapters/atlas/auth/refresh.js +0 -138
- package/dist/adapters/atlas/auth/session.js +0 -167
- package/dist/adapters/atlas/cli.js +0 -561
- package/dist/adapters/atlas/commands/_output_schema.js +0 -379
- package/dist/adapters/atlas/commands/actual/_logic.js +0 -116
- package/dist/adapters/atlas/commands/actual/index.js +0 -138
- package/dist/adapters/atlas/commands/auth.js +0 -1
- package/dist/adapters/atlas/commands/baseline/index.js +0 -137
- package/dist/adapters/atlas/commands/compare/_logic.js +0 -39
- package/dist/adapters/atlas/commands/compare/index.js +0 -89
- package/dist/adapters/atlas/commands/exec.js +0 -81
- package/dist/adapters/atlas/commands/project/index.js +0 -218
- package/dist/adapters/atlas/commands/schema.js +0 -25
- package/dist/adapters/atlas/commands/suggest.js +0 -83
- package/dist/adapters/atlas/commands/update.js +0 -104
- package/dist/adapters/atlas/daemon/index.js +0 -145
- package/dist/adapters/atlas/dict/index.js +0 -41
- package/dist/adapters/atlas/http/client.js +0 -200
- package/dist/adapters/atlas/http/index.js +0 -1
- package/dist/adapters/atlas/schema/actual.js +0 -16
- package/dist/adapters/atlas/schema/baseline.js +0 -34
- package/dist/adapters/atlas/schema/department.js +0 -11
- package/dist/adapters/atlas/schema/index.js +0 -4
- package/dist/adapters/atlas/schema/project.js +0 -13
- package/dist/adapters/atlas/util/cidr.js +0 -114
- package/dist/adapters/atlas/util/constants.js +0 -4
- package/dist/adapters/atlas/util/env.js +0 -56
- package/dist/adapters/atlas/util/environment.js +0 -152
- package/dist/adapters/atlas/util/errors.js +0 -49
- package/dist/adapters/atlas/util/helpers.js +0 -17
- package/dist/adapters/atlas/util/months.js +0 -65
- package/dist/adapters/atlas/util/output-limit.js +0 -21
- package/dist/adapters/atlas/util/output.js +0 -70
- package/dist/adapters/atlas/util/paths.js +0 -40
- package/dist/adapters/atlas/util/portable-store.js +0 -153
- package/dist/adapters/atlas/util/secure-fs.js +0 -41
- package/dist/adapters/atlas/util/time.js +0 -17
- package/dist/adapters/atlas/util/version.js +0 -1
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { isJsonMode, jsonOk, log } from '../util/output.js';
|
|
2
|
-
const RULES = [
|
|
3
|
-
// ── 鉴权类 ─────────────────────────────
|
|
4
|
-
{ pattern: /登录|login|sso|auth/i, command: 'atlas auth login', description: 'SSO 登录(需终端交互)', weight: 10, requiresAuth: false, exitCodes: [0, 1, 64] },
|
|
5
|
-
{ pattern: /状态|status|会话/i, command: 'atlas auth status', description: '查看会话状态', weight: 10, requiresAuth: false, exitCodes: [0] },
|
|
6
|
-
// ── 项目类 ─────────────────────────────
|
|
7
|
-
{ pattern: /项目.*列表|列出.*项目|projects/i, command: 'atlas projects', description: '列出所有项目', weight: 10, requiresAuth: true, exitCodes: [0, 2] },
|
|
8
|
-
{ pattern: /绑定|link|关联/i, command: 'atlas link <project>', description: '绑定项目(可省后续 --project-id)', weight: 10, requiresAuth: true, exitCodes: [0, 4, 5] },
|
|
9
|
-
{ pattern: /解绑|unlink/i, command: 'atlas unlink', description: '解绑项目', weight: 10, requiresAuth: true, exitCodes: [0] },
|
|
10
|
-
{ pattern: /搜索|找|find|查询项目/i, command: 'atlas find project <query>', description: '搜索项目(也支持 department/manpower-type/role/area)', weight: 10, requiresAuth: true, exitCodes: [0, 2] },
|
|
11
|
-
{ pattern: /部门|department/i, command: 'atlas find department <query>', description: '搜索部门', weight: 7, requiresAuth: true, exitCodes: [0, 2] },
|
|
12
|
-
{ pattern: /字典|类型|mp.type|line.plan/i, command: 'atlas find manpower-type <query>', description: '搜索 MP 类型字典值', weight: 7, requiresAuth: true, exitCodes: [0, 2] },
|
|
13
|
-
// ── 基线人力 ────────────────────────────
|
|
14
|
-
{ pattern: /基线|计划|baseline|人月.*计划/i, command: 'atlas baseline month --month YYYY-MM', description: '查看指定月份基线人力', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3, 4, 5] },
|
|
15
|
-
{ pattern: /基线.*汇总|baseline.*summar/i, command: 'atlas baseline summary [--by month|department|role]', description: '按月/部门/角色汇总基线人力', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3] },
|
|
16
|
-
{ pattern: /基线.*导出|baseline.*export/i, command: 'atlas baseline export --from YYYY-MM --to YYYY-MM [--format csv|json] [--out <path>]', description: '导出基线条目', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3, 65] },
|
|
17
|
-
// ── 实际工时 ────────────────────────────
|
|
18
|
-
{ pattern: /实际|actual|工时|人月.*实际/i, command: 'atlas actual month --month YYYY-MM', description: '查看指定月份实际工时(人月)', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3, 4, 5] },
|
|
19
|
-
{ pattern: /实际.*汇总|actual.*summar/i, command: 'atlas actual summary [--by month|department|role]', description: '汇总实际工时', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3] },
|
|
20
|
-
{ pattern: /实际.*导出|actual.*export/i, command: 'atlas actual export --from YYYY-MM --to YYYY-MM [--format csv|json] [--out <path>]', description: '导出实际工时', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3, 65] },
|
|
21
|
-
{ pattern: /实际.*人员|人员.*明细|staff|成员.*工时/i, command: 'atlas actual show <staffId> --month YYYY-MM', description: '查看单人员工实际工时明细', weight: 5, requiresAuth: true, exitCodes: [0, 2, 3] },
|
|
22
|
-
// ── 对比 ───────────────────────────────
|
|
23
|
-
{ pattern: /对比|比较|compare|差异|偏差/i, command: 'atlas compare --from YYYY-MM --to YYYY-MM', description: '基线 vs 实际对比(diff = 实际 - 基线,人月)', weight: 10, requiresAuth: true, exitCodes: [0, 2, 3, 4, 5] },
|
|
24
|
-
// ── 批量执行 ────────────────────────────
|
|
25
|
-
{ pattern: /批量|batch|exec|编排/i, command: 'atlas exec --plan-file <path>', description: '按 plan-file 顺序执行多条 atlas 命令', weight: 7, requiresAuth: false, exitCodes: [0, 1] },
|
|
26
|
-
// ── 自省 / 帮助 ─────────────────────────
|
|
27
|
-
{ pattern: /命令.*列表|命令树|帮助|自省|schema/i, command: 'atlas schema commands [--describe]', description: '列出所有命令 + 输出 schema(agent 自省入口)', weight: 8, requiresAuth: false, exitCodes: [0] },
|
|
28
|
-
{ pattern: /升级|更新|update/i, command: 'atlas update', description: '升级到最新版本', weight: 10, requiresAuth: false, exitCodes: [0] },
|
|
29
|
-
{ pattern: /守护|daemon/i, command: 'atlas daemon', description: '启动本地守护进程(保持浏览器会话)', weight: 5, requiresAuth: false, exitCodes: [0] },
|
|
30
|
-
// ── 模糊匹配:问"某项目" ────────────
|
|
31
|
-
{ pattern: /项目\S+|(\S+项目)/i, command: 'atlas find project <query>', description: '搜索项目(含模糊匹配参数)', weight: 3, requiresAuth: true, exitCodes: [0, 2] },
|
|
32
|
-
// ── 月份推算 ────────────────────────────
|
|
33
|
-
{ pattern: /上个月|本月|上月|这个月|当月|当前月/i, command: 'atlas baseline month --month $(date +%Y-%m)', description: '查看当前/上个月基线(月份参数可推算)', weight: 3, requiresAuth: true, exitCodes: [0, 2, 3] },
|
|
34
|
-
{ pattern: /上季度|上个季度|本季度/i, command: 'atlas compare --from $(date -d "$(date +%Y-%m-01) -3 months" +%Y-%m) --to $(date +%Y-%m)', description: '按季度范围对比(月份参数自动推算)', weight: 3, requiresAuth: true, exitCodes: [0, 2, 3] },
|
|
35
|
-
];
|
|
36
|
-
export function suggestCmd(query, opts) {
|
|
37
|
-
const suggestions = [];
|
|
38
|
-
for (const rule of RULES) {
|
|
39
|
-
if (rule.pattern.test(query)) {
|
|
40
|
-
suggestions.push({
|
|
41
|
-
command: rule.command,
|
|
42
|
-
description: rule.description,
|
|
43
|
-
score: rule.weight,
|
|
44
|
-
requiresAuth: rule.requiresAuth,
|
|
45
|
-
exitCodes: rule.exitCodes,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
// 模糊分提升:查询词在命令中出现越多加分
|
|
50
|
-
const queryLower = query.toLowerCase();
|
|
51
|
-
for (const s of suggestions) {
|
|
52
|
-
const cmdLower = s.command.toLowerCase();
|
|
53
|
-
let score = 0;
|
|
54
|
-
for (const word of queryLower.split(/\s+/)) {
|
|
55
|
-
if (word.length >= 2 && cmdLower.includes(word))
|
|
56
|
-
score += 0.5;
|
|
57
|
-
// 月份/具体数值命中额外加分
|
|
58
|
-
if (/^\d{4}[-\/]\d{2}$/.test(word))
|
|
59
|
-
score += 1;
|
|
60
|
-
if (/^\d+$/.test(word) && word.length >= 2 && cmdLower.includes(word))
|
|
61
|
-
score += 1;
|
|
62
|
-
}
|
|
63
|
-
s.score += score;
|
|
64
|
-
}
|
|
65
|
-
suggestions.sort((a, b) => b.score - a.score);
|
|
66
|
-
if (opts.json || isJsonMode()) {
|
|
67
|
-
jsonOk({ query, suggestions: suggestions.slice(0, 5) });
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (suggestions.length === 0) {
|
|
71
|
-
log(`未找到匹配的 atlas 命令: "${query}"`);
|
|
72
|
-
log('提示: 试试 atlas --help 查看所有命令');
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
log(`自然语言查询: "${query}"`);
|
|
76
|
-
log('建议命令:');
|
|
77
|
-
for (const s of suggestions.slice(0, 5)) {
|
|
78
|
-
const authMark = s.requiresAuth ? '🔑' : ' ';
|
|
79
|
-
log(` ${authMark} ${s.command} — ${s.description}`);
|
|
80
|
-
}
|
|
81
|
-
log('');
|
|
82
|
-
log('标记 🔑=需要登录,无标记=无需鉴权');
|
|
83
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from 'url';
|
|
2
|
-
import { isJsonMode, jsonOk, log } from '../util/output.js';
|
|
3
|
-
import { AtlasError } from '../util/errors.js';
|
|
4
|
-
import { ATLAS_VERSION } from '../util/version.js';
|
|
5
|
-
/**
|
|
6
|
-
* 检测当前是否通过 npm 全局包安装运行。
|
|
7
|
-
* 判断依据:import.meta.url 解析后的真实路径是否在 node_modules/@dreamor/atlas-cli 下。
|
|
8
|
-
* - 命中 → 提示用户用 `npm update -g @dreamor/atlas-cli` 升级
|
|
9
|
-
* - 未命中 → 独立二进制/Bun/开发模式,提示去 GitHub Releases 升级
|
|
10
|
-
*/
|
|
11
|
-
function detectInstallMode() {
|
|
12
|
-
try {
|
|
13
|
-
const url = new URL(import.meta.url);
|
|
14
|
-
const path = fileURLToPath(url);
|
|
15
|
-
return /node_modules[\\/]@dreamor[\\/]atlas-cli/.test(path) ? 'npm' : 'other';
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return 'other';
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
export function detectPlatform() {
|
|
22
|
-
const osMap = {
|
|
23
|
-
darwin: 'darwin',
|
|
24
|
-
linux: 'linux',
|
|
25
|
-
win32: 'windows',
|
|
26
|
-
};
|
|
27
|
-
const archMap = {
|
|
28
|
-
arm64: 'arm64',
|
|
29
|
-
x64: 'x64',
|
|
30
|
-
};
|
|
31
|
-
const os = osMap[process.platform];
|
|
32
|
-
const arch = archMap[process.arch];
|
|
33
|
-
if (!os || !arch)
|
|
34
|
-
return null;
|
|
35
|
-
return { os, arch };
|
|
36
|
-
}
|
|
37
|
-
export function stripV(v) {
|
|
38
|
-
return v.replace(/^v/, '');
|
|
39
|
-
}
|
|
40
|
-
export function compareVersions(a, b) {
|
|
41
|
-
// 预处理:去除 prerelease 后缀(如 -rc.1、-beta),纯 semver 比较
|
|
42
|
-
const clean = (v) => v.replace(/-.*$/, '');
|
|
43
|
-
const pa = clean(a).split('.').map(Number);
|
|
44
|
-
const pb = clean(b).split('.').map(Number);
|
|
45
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
46
|
-
const na = pa[i] ?? 0;
|
|
47
|
-
const nb = pb[i] ?? 0;
|
|
48
|
-
if (na > nb)
|
|
49
|
-
return 1;
|
|
50
|
-
if (na < nb)
|
|
51
|
-
return -1;
|
|
52
|
-
}
|
|
53
|
-
// 语义相同:a 有 prerelease 后缀(如 0.6.1-rc.1)→ a < b(跳过升级)
|
|
54
|
-
if (a !== clean(a) && b === clean(b))
|
|
55
|
-
return -1;
|
|
56
|
-
if (a === clean(a) && b !== clean(b))
|
|
57
|
-
return 1;
|
|
58
|
-
return 0;
|
|
59
|
-
}
|
|
60
|
-
export async function updateCmd(opts) {
|
|
61
|
-
if (process.env.ATLAS_DISABLE_UPDATE === '1') {
|
|
62
|
-
log('更新已被 ATLAS_DISABLE_UPDATE 禁用');
|
|
63
|
-
if (opts.json || isJsonMode()) {
|
|
64
|
-
jsonOk({ disabled: true });
|
|
65
|
-
}
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
// npm 分发后,自更新交给 npm/yarn/pnpm 管理;CLI 不再下载二进制。
|
|
69
|
-
// 说明:原 GitHub Release 下载 + SHA256 校验 + 原子写入逻辑由 npm registry 取代,
|
|
70
|
-
// 维持这个命令只是为了给已有用户一个清晰引导,而不是让他们卡在过期入口。
|
|
71
|
-
const installMode = detectInstallMode();
|
|
72
|
-
const npmCmd = 'npm update -g @dreamor/atlas-cli';
|
|
73
|
-
if (installMode === 'npm') {
|
|
74
|
-
log(`当前版本 ${ATLAS_VERSION}`);
|
|
75
|
-
log('通过 npm 安装的 atlas 由 npm registry 管理升级,请运行:');
|
|
76
|
-
log(` ${npmCmd}`);
|
|
77
|
-
if (opts.json || isJsonMode()) {
|
|
78
|
-
jsonOk({
|
|
79
|
-
mode: 'npm',
|
|
80
|
-
current: ATLAS_VERSION,
|
|
81
|
-
updateCommand: npmCmd,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
// 非 npm 安装(独立二进制/Bun/源码开发模式)
|
|
87
|
-
log(`当前版本 ${ATLAS_VERSION}`);
|
|
88
|
-
log('当前以非 npm 方式运行,自更新已禁用。');
|
|
89
|
-
log('推荐迁移到 npm 安装:');
|
|
90
|
-
log(' npm i -g @dreamor/atlas-cli');
|
|
91
|
-
log('随后通过 `npm update -g @dreamor/atlas-cli` 升级。');
|
|
92
|
-
if (opts.json || isJsonMode()) {
|
|
93
|
-
jsonOk({
|
|
94
|
-
mode: 'other',
|
|
95
|
-
current: ATLAS_VERSION,
|
|
96
|
-
migrationCommand: 'npm i -g @dreamor/atlas-cli',
|
|
97
|
-
updateCommand: npmCmd,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// 保留 throw 以便旧调用点不会报错(改造后独立函数已无调用者)
|
|
102
|
-
export function _unreachableCheck() {
|
|
103
|
-
throw new AtlasError('update.ts 内部: 不应到达', 'UPDATE_ERROR');
|
|
104
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { createServer as httpCreateServer } from 'http';
|
|
2
|
-
import { createServer as httpsCreateServer } from 'https';
|
|
3
|
-
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
4
|
-
import { readFileSync } from 'fs';
|
|
5
|
-
import { dirname } from 'path';
|
|
6
|
-
import { log } from '../util/output.js';
|
|
7
|
-
import { getDaemonTokenFile } from '../util/paths.js';
|
|
8
|
-
import { secureMkdir, secureWriteFile } from '../util/secure-fs.js';
|
|
9
|
-
import { AtlasError } from '../util/errors.js';
|
|
10
|
-
import { parseCidrList, isIpAllowed, isLoopback, } from '../util/cidr.js';
|
|
11
|
-
/** 生成 32 字节 hex token */
|
|
12
|
-
function generateToken() {
|
|
13
|
-
return randomBytes(32).toString('hex');
|
|
14
|
-
}
|
|
15
|
-
/** 校验 Authorization: Bearer <token>(timingSafeEqual 防时序侧信道) */
|
|
16
|
-
function isAuthorized(req, token) {
|
|
17
|
-
const auth = req.headers.authorization ?? '';
|
|
18
|
-
const expected = `Bearer ${token}`;
|
|
19
|
-
const a = Buffer.from(auth);
|
|
20
|
-
const b = Buffer.from(expected);
|
|
21
|
-
// timingSafeEqual 要求两 Buffer 等长;不等长时先比后再返回 false 防止时序泄漏
|
|
22
|
-
if (a.length !== b.length) {
|
|
23
|
-
timingSafeEqual(Buffer.alloc(1), Buffer.alloc(1));
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
return timingSafeEqual(a, b);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* 构造 daemon request handler。抽出为纯函数便于测试:
|
|
30
|
-
* 测试可直接用返回的 handler 起临时 http/https server。
|
|
31
|
-
*
|
|
32
|
-
* 路由:
|
|
33
|
-
* - GET /api/health → 公开,{ok, pid}
|
|
34
|
-
* - GET /api/cookies → 需 Bearer + IP 白名单,{ok, cookies}
|
|
35
|
-
* - 其他 → 404
|
|
36
|
-
*/
|
|
37
|
-
export function createDaemonHandler(deps) {
|
|
38
|
-
return (req, res) => {
|
|
39
|
-
const sendJson = (status, body) => {
|
|
40
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
41
|
-
res.end(JSON.stringify(body));
|
|
42
|
-
};
|
|
43
|
-
const url = req.url ?? '/';
|
|
44
|
-
const isHealth = url === '/api/health' || url === '/';
|
|
45
|
-
// health 端点不鉴权、不限 IP(用于探测)
|
|
46
|
-
if (isHealth) {
|
|
47
|
-
sendJson(200, { ok: true, pid: process.pid });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
// IP 白名单(非 health 端点都校验)
|
|
51
|
-
if (!isIpAllowed(req.socket.remoteAddress, deps.allowList)) {
|
|
52
|
-
sendJson(403, { ok: false, error: 'forbidden: ip not allowed' });
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
// Bearer 鉴权
|
|
56
|
-
if (!isAuthorized(req, deps.token)) {
|
|
57
|
-
sendJson(401, { ok: false, error: 'unauthorized' });
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (url === '/api/cookies') {
|
|
61
|
-
void deps
|
|
62
|
-
.readCookies()
|
|
63
|
-
.then((cookies) => sendJson(200, { ok: true, cookies: cookies ?? [] }))
|
|
64
|
-
.catch(() => sendJson(500, { ok: false, error: 'read cookies failed' }));
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
sendJson(404, { ok: false, error: 'not found' });
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
/** 默认端口号 */
|
|
71
|
-
const DEFAULT_PORT = 8765;
|
|
72
|
-
export async function daemonCmd(opts) {
|
|
73
|
-
const envPort = Number(process.env.ATLAS_DAEMON_PORT);
|
|
74
|
-
const optsPort = opts.port !== undefined ? Number(opts.port) : NaN;
|
|
75
|
-
const port = Number.isFinite(optsPort) && optsPort > 0
|
|
76
|
-
? optsPort
|
|
77
|
-
: Number.isFinite(envPort) && envPort > 0
|
|
78
|
-
? envPort
|
|
79
|
-
: DEFAULT_PORT;
|
|
80
|
-
const host = opts.host ?? '127.0.0.1';
|
|
81
|
-
const hostLoopback = isLoopback(host);
|
|
82
|
-
const hasTls = Boolean(opts.tlsCert) && Boolean(opts.tlsKey);
|
|
83
|
-
// 护栏 1:TLS cert/key 必须同时指定
|
|
84
|
-
if ((opts.tlsCert && !opts.tlsKey) || (opts.tlsKey && !opts.tlsCert)) {
|
|
85
|
-
throw new AtlasError('--tls-cert 与 --tls-key 必须同时指定', 'CONFIG_ERROR');
|
|
86
|
-
}
|
|
87
|
-
// 护栏 2:非 loopback 监听必须有 IP 白名单或显式 --insecure
|
|
88
|
-
if (!hostLoopback && !opts.insecure && (!opts.allowIp || opts.allowIp.length === 0)) {
|
|
89
|
-
throw new AtlasError('非 loopback 监听必须指定 --allow-ip 或显式 --insecure(明文传输 token 仅建议本机开发)', 'CONFIG_ERROR');
|
|
90
|
-
}
|
|
91
|
-
// 护栏 3:非 loopback + 无 TLS + 明文 → stderr 大红警告
|
|
92
|
-
if (!hostLoopback && !hasTls && opts.insecure) {
|
|
93
|
-
log('⚠⚠⚠ 明文监听非 loopback 接口,Bearer token 将以明文传输,仅建议本机开发使用 ⚠⚠⚠');
|
|
94
|
-
}
|
|
95
|
-
// 解析 IP 白名单(非法 CIDR 在此抛 ConfigError,启动期失败而非运行期)
|
|
96
|
-
const allowList = parseCidrList(opts.allowIp);
|
|
97
|
-
// 生成一次性 token,写入 ~/.atlas/daemon.token(0600)
|
|
98
|
-
const token = generateToken();
|
|
99
|
-
const tokenFile = getDaemonTokenFile();
|
|
100
|
-
await secureMkdir(dirname(tokenFile), { recursive: true });
|
|
101
|
-
await secureWriteFile(tokenFile, token);
|
|
102
|
-
log(`Daemon token 已写入 ${tokenFile}`);
|
|
103
|
-
// 动态 import readCookies 避免与 session 模块的循环依赖
|
|
104
|
-
const handlerDeps = {
|
|
105
|
-
token,
|
|
106
|
-
allowList,
|
|
107
|
-
readCookies: async () => {
|
|
108
|
-
const { readCookies } = await import('../auth/session.js');
|
|
109
|
-
return readCookies();
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
const handler = createDaemonHandler(handlerDeps);
|
|
113
|
-
// 创建 server(http 或 https)
|
|
114
|
-
let server;
|
|
115
|
-
if (hasTls) {
|
|
116
|
-
let cert;
|
|
117
|
-
let key;
|
|
118
|
-
try {
|
|
119
|
-
cert = readFileSync(opts.tlsCert);
|
|
120
|
-
key = readFileSync(opts.tlsKey);
|
|
121
|
-
}
|
|
122
|
-
catch (e) {
|
|
123
|
-
throw new AtlasError(`TLS 证书读取失败:${e.message}`, 'CONFIG_ERROR');
|
|
124
|
-
}
|
|
125
|
-
server = httpsCreateServer({ cert, key }, handler);
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
server = httpCreateServer(handler);
|
|
129
|
-
}
|
|
130
|
-
await new Promise((resolve, reject) => {
|
|
131
|
-
server.on('error', (e) => {
|
|
132
|
-
if (e.code === 'EADDRINUSE') {
|
|
133
|
-
reject(new AtlasError(`端口 ${port} 已被占用`, 'CONFIG_ERROR'));
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
reject(e);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
server.listen(port, host, resolve);
|
|
140
|
-
});
|
|
141
|
-
const scheme = hasTls ? 'https' : 'http';
|
|
142
|
-
log(`守护进程已启动,监听 ${scheme}://${host}:${port}${!hostLoopback && !hasTls && opts.insecure ? '(明文)' : ''}`);
|
|
143
|
-
// Keep running
|
|
144
|
-
await new Promise(() => { });
|
|
145
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { readFile } from 'fs/promises';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import { getCacheDir, getCacheFile } from '../util/paths.js';
|
|
4
|
-
import { secureMkdir, secureWriteFile } from '../util/secure-fs.js';
|
|
5
|
-
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24h
|
|
6
|
-
// Key 只允许字母数字和下划线,防止路径穿越(D6)
|
|
7
|
-
const CACHE_KEY_RE = /^[a-z0-9_-]+$/i;
|
|
8
|
-
export async function getCached(key) {
|
|
9
|
-
if (!CACHE_KEY_RE.test(key))
|
|
10
|
-
throw new Error(`非法 cache key: "${key}"`);
|
|
11
|
-
const filePath = getCacheFile(key);
|
|
12
|
-
if (!existsSync(filePath))
|
|
13
|
-
return null;
|
|
14
|
-
try {
|
|
15
|
-
const raw = await readFile(filePath, 'utf-8');
|
|
16
|
-
const entry = JSON.parse(raw);
|
|
17
|
-
if (Date.now() - entry.fetchedAt > CACHE_TTL)
|
|
18
|
-
return null;
|
|
19
|
-
return entry.data;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
export async function setCache(key, data) {
|
|
26
|
-
if (!CACHE_KEY_RE.test(key))
|
|
27
|
-
throw new Error(`非法 cache key: "${key}"`);
|
|
28
|
-
const filePath = getCacheFile(key);
|
|
29
|
-
const cacheDir = getCacheDir();
|
|
30
|
-
await secureMkdir(cacheDir, { recursive: true });
|
|
31
|
-
const entry = { data, fetchedAt: Date.now() };
|
|
32
|
-
await secureWriteFile(filePath, JSON.stringify(entry));
|
|
33
|
-
}
|
|
34
|
-
export async function clearCache() {
|
|
35
|
-
const { rm } = await import('fs/promises');
|
|
36
|
-
const cacheDir = getCacheDir();
|
|
37
|
-
try {
|
|
38
|
-
await rm(cacheDir, { recursive: true, force: true });
|
|
39
|
-
}
|
|
40
|
-
catch { }
|
|
41
|
-
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 斑马云图 HTTP 客户端。
|
|
3
|
-
*
|
|
4
|
-
* 变更记录(安全审计):
|
|
5
|
-
* - A4:path 校验 → 必须以 / 开头,origin 锁定,防 SSRF
|
|
6
|
-
* - B2:识别 429 状态码,抛 RATE_LIMITED AtlasError
|
|
7
|
-
* - B5:token/user/staffId 空串时不设请求头
|
|
8
|
-
* - D3:可传 ZodSchema 对 data 做运行时校验
|
|
9
|
-
* - D10:已知业务错误码 → 特定异常
|
|
10
|
-
*/
|
|
11
|
-
import { request } from 'undici';
|
|
12
|
-
import { BanmaApiError, SessionExpiredError, AtlasError } from '../util/errors.js';
|
|
13
|
-
import { readCookies, readBanmaIdentity } from '../auth/session.js';
|
|
14
|
-
/** D10:已知业务错误码 → 异常映射(工厂函数避免单例 stack trace 失准) */
|
|
15
|
-
const KNOWN_BUSINESS_ERRORS = {
|
|
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'),
|
|
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
|
-
}
|
|
36
|
-
const BANMA_BASE = 'https://banma-yuntu.alibaba-inc.com';
|
|
37
|
-
/**
|
|
38
|
-
* Banma API HTTP 客户端
|
|
39
|
-
*
|
|
40
|
-
* 真实 API 格式: { status, code, errCode, errorMsg, detail, success, data }
|
|
41
|
-
* - success=true 或 status=1 表示成功
|
|
42
|
-
* - data 可能为数组或对象
|
|
43
|
-
* - 200 不一定成功;需检查 success 字段
|
|
44
|
-
*/
|
|
45
|
-
export class HttpClient {
|
|
46
|
-
baseUrl;
|
|
47
|
-
baseOrigin;
|
|
48
|
-
timeout;
|
|
49
|
-
constructor(opts = {}) {
|
|
50
|
-
this.baseUrl = (opts.baseUrl ?? BANMA_BASE).replace(/\/+$/, '');
|
|
51
|
-
this.baseOrigin = new URL(this.baseUrl).origin;
|
|
52
|
-
this.timeout = opts.timeout ?? 30000;
|
|
53
|
-
}
|
|
54
|
-
async buildHeaders() {
|
|
55
|
-
const cookies = await readCookies();
|
|
56
|
-
const headers = {
|
|
57
|
-
'Accept': 'application/json, text/plain, */*',
|
|
58
|
-
'Content-Type': 'application/json',
|
|
59
|
-
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) atlas-cli',
|
|
60
|
-
'Referer': 'https://banma-yuntu.alibaba-inc.com/',
|
|
61
|
-
'Origin': 'https://banma-yuntu.alibaba-inc.com',
|
|
62
|
-
};
|
|
63
|
-
if (cookies && cookies.length > 0) {
|
|
64
|
-
headers['Cookie'] = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
65
|
-
}
|
|
66
|
-
// 斑马的项目权限类 API 强制校验 x-banma-* 头,缺失则判定无身份返回空数据。
|
|
67
|
-
// token/user/staffId 仅在有有效值时设置(B5);company-id 始终发送(空串亦可)。
|
|
68
|
-
const identity = await readBanmaIdentity();
|
|
69
|
-
if (identity) {
|
|
70
|
-
if (identity.token) {
|
|
71
|
-
headers['token'] = identity.token;
|
|
72
|
-
headers['x-banma-token'] = identity.token;
|
|
73
|
-
}
|
|
74
|
-
if (identity.user)
|
|
75
|
-
headers['x-banma-user'] = identity.user;
|
|
76
|
-
if (identity.staffId)
|
|
77
|
-
headers['x-banma-staff-id'] = identity.staffId;
|
|
78
|
-
headers['x-banma-company-id'] = '';
|
|
79
|
-
}
|
|
80
|
-
return headers;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* GET 请求。
|
|
84
|
-
*
|
|
85
|
-
* query 参数以键值对形式传入,自动拼接到 URL 查询字符串。
|
|
86
|
-
* 可传可选 schema(ZodSchema),在 API 返回后对 json.data 做运行时校验。
|
|
87
|
-
*/
|
|
88
|
-
async get(path, query, schema) {
|
|
89
|
-
// A4: 校验 path 必须以 / 开头,防止 http://evil.example/x 绕过 baseUrl
|
|
90
|
-
if (!path.startsWith('/'))
|
|
91
|
-
throw new AtlasError('path must be relative (start with /)', 'CONFIG_ERROR');
|
|
92
|
-
const url = new URL(path, this.baseUrl + '/');
|
|
93
|
-
if (query) {
|
|
94
|
-
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
95
|
-
}
|
|
96
|
-
if (url.origin !== this.baseOrigin)
|
|
97
|
-
throw new AtlasError('origin mismatch — possible SSRF', 'CONFIG_ERROR');
|
|
98
|
-
const headers = await this.buildHeaders();
|
|
99
|
-
const response = await request(url.toString(), {
|
|
100
|
-
method: 'GET',
|
|
101
|
-
headers,
|
|
102
|
-
headersTimeout: this.timeout,
|
|
103
|
-
bodyTimeout: this.timeout,
|
|
104
|
-
});
|
|
105
|
-
return this.parseResponse(response, schema);
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* POST 请求。
|
|
109
|
-
*
|
|
110
|
-
* 可传可选 schema(ZodSchema),在 API 返回后对 json.data 做运行时校验(D3)。
|
|
111
|
-
*/
|
|
112
|
-
async post(path, body, schema) {
|
|
113
|
-
// A4: 校验 path 必须以 / 开头,防止 http://evil.example/x 绕过 baseUrl
|
|
114
|
-
if (!path.startsWith('/'))
|
|
115
|
-
throw new AtlasError('path must be relative (start with /)', 'CONFIG_ERROR');
|
|
116
|
-
const url = new URL(path, this.baseUrl + '/');
|
|
117
|
-
if (url.origin !== this.baseOrigin)
|
|
118
|
-
throw new AtlasError('origin mismatch — possible SSRF', 'CONFIG_ERROR');
|
|
119
|
-
const headers = await this.buildHeaders();
|
|
120
|
-
const response = await request(url.toString(), {
|
|
121
|
-
method: 'POST',
|
|
122
|
-
headers,
|
|
123
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
124
|
-
headersTimeout: this.timeout,
|
|
125
|
-
bodyTimeout: this.timeout,
|
|
126
|
-
});
|
|
127
|
-
return this.parseResponse(response, schema);
|
|
128
|
-
}
|
|
129
|
-
async multipartUpload(path, fields, file) {
|
|
130
|
-
// A4: 校验 path
|
|
131
|
-
if (!path.startsWith('/'))
|
|
132
|
-
throw new AtlasError('path must be relative (start with /)', 'CONFIG_ERROR');
|
|
133
|
-
const url = new URL(path, this.baseUrl + '/');
|
|
134
|
-
if (url.origin !== this.baseOrigin)
|
|
135
|
-
throw new AtlasError('origin mismatch — possible SSRF', 'CONFIG_ERROR');
|
|
136
|
-
const headers = await this.buildHeaders();
|
|
137
|
-
const boundary = `----FormBoundary${Math.random().toString(36).slice(2)}`;
|
|
138
|
-
const CRLF = '\r\n';
|
|
139
|
-
const parts = [];
|
|
140
|
-
for (const f of fields) {
|
|
141
|
-
parts.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.name}"${CRLF}${CRLF}${f.value}${CRLF}`));
|
|
142
|
-
}
|
|
143
|
-
parts.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${file.name}"; filename="${file.filename}"${CRLF}Content-Type: application/octet-stream${CRLF}${CRLF}`));
|
|
144
|
-
parts.push(file.buffer);
|
|
145
|
-
parts.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`));
|
|
146
|
-
const response = await request(url.toString(), {
|
|
147
|
-
method: 'POST',
|
|
148
|
-
headers: { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
149
|
-
body: Buffer.concat(parts),
|
|
150
|
-
headersTimeout: this.timeout * 2,
|
|
151
|
-
bodyTimeout: this.timeout * 2,
|
|
152
|
-
});
|
|
153
|
-
return this.parseResponse(response);
|
|
154
|
-
}
|
|
155
|
-
async parseResponse(response, schema) {
|
|
156
|
-
const status = response.statusCode;
|
|
157
|
-
const text = await response.body.text();
|
|
158
|
-
if (status === 401 || status === 403) {
|
|
159
|
-
throw new SessionExpiredError();
|
|
160
|
-
}
|
|
161
|
-
// 302 一般是重定向到登录页(session 失效 / 未授权)
|
|
162
|
-
if (status === 302) {
|
|
163
|
-
throw new SessionExpiredError('会话已失效或未授权,请重新登录');
|
|
164
|
-
}
|
|
165
|
-
// B2: 识别 429 限流
|
|
166
|
-
if (status === 429) {
|
|
167
|
-
throw new AtlasError('请求过于频繁(429 限流),请稍后重试', 'RATE_LIMITED');
|
|
168
|
-
}
|
|
169
|
-
let json;
|
|
170
|
-
try {
|
|
171
|
-
json = JSON.parse(text);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
throw new BanmaApiError('PARSE_ERROR', `Failed to parse: ${text.slice(0, 200)}`);
|
|
175
|
-
}
|
|
176
|
-
// D10: 识别业务错误码 → 映射到特定异常
|
|
177
|
-
if (json.errCode && json.errCode !== 'SUCCESS') {
|
|
178
|
-
const knownError = KNOWN_BUSINESS_ERRORS[json.errCode];
|
|
179
|
-
if (knownError)
|
|
180
|
-
throw knownError();
|
|
181
|
-
// 兜底:显式映射漏掉时,用 errorMsg 关键词识别 token 失效
|
|
182
|
-
if (looksLikeTokenExpired(json.errorMsg)) {
|
|
183
|
-
throw new SessionExpiredError('会话 token 已失效,请先执行:atlas auth refresh;失败再执行:atlas auth login');
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// Banma API 的 success/status 字段不可靠(空结果也返回 success: false)
|
|
187
|
-
// 只要 HTTP 2xx 就返回 data,由调用方处理 null
|
|
188
|
-
if (status >= 200 && status < 300) {
|
|
189
|
-
if (schema)
|
|
190
|
-
return schema.parse(json.data);
|
|
191
|
-
return json.data;
|
|
192
|
-
}
|
|
193
|
-
const errCode = json.errCode ?? String(json.code) ?? `HTTP_${status}`;
|
|
194
|
-
const errMsg = json.errorMsg ?? json.detail ?? `HTTP ${status}`;
|
|
195
|
-
throw new BanmaApiError(errCode, errMsg, json.detail ?? undefined);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
export function createClient() {
|
|
199
|
-
return new HttpClient();
|
|
200
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { HttpClient } from './client.js';
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// 实际工时-按团队汇总(API: POST /yuntu-service/manpower/weekly/summaryByTeam.json)
|
|
3
|
-
export const ActualSummaryByTeamSchema = z.object({
|
|
4
|
-
staffId: z.string(),
|
|
5
|
-
staffName: z.string(),
|
|
6
|
-
department: z.string().optional(),
|
|
7
|
-
departmentName: z.string().optional(),
|
|
8
|
-
role: z.string().optional(),
|
|
9
|
-
manpower: z.number(),
|
|
10
|
-
status: z.enum(['pending', 'approved']).optional(),
|
|
11
|
-
leadName: z.string().optional(),
|
|
12
|
-
});
|
|
13
|
-
export const ActualSummaryByTeamDataSchema = z.object({
|
|
14
|
-
summaryByTeam: z.array(ActualSummaryByTeamSchema),
|
|
15
|
-
mp: z.number().optional(),
|
|
16
|
-
});
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// 基线月明细条目(API: POST /yuntu-service/line/plan/month/select.json)
|
|
3
|
-
export const LinePlanMonthDetailSchema = z.object({
|
|
4
|
-
projectId: z.string(),
|
|
5
|
-
projectName: z.string(),
|
|
6
|
-
departmentId: z.string().optional(),
|
|
7
|
-
departmentName: z.string().optional(),
|
|
8
|
-
role: z.string().optional(),
|
|
9
|
-
manpower: z.number(),
|
|
10
|
-
month: z.string(),
|
|
11
|
-
areaCode: z.string().optional(),
|
|
12
|
-
mpType: z.string().optional(),
|
|
13
|
-
leadId: z.string().optional(),
|
|
14
|
-
leadName: z.string().optional(),
|
|
15
|
-
});
|
|
16
|
-
// 实际 API 返回的 data 结构
|
|
17
|
-
export const BaselineMonthDataSchema = z.object({
|
|
18
|
-
linePlanMonthDetailList: z.array(LinePlanMonthDetailSchema),
|
|
19
|
-
mp: z.number().optional(),
|
|
20
|
-
});
|
|
21
|
-
// 基线条目列表(API: POST /yuntu-service/line/plan/select.json)
|
|
22
|
-
export const LinePlanSelectDataSchema = z.object({
|
|
23
|
-
linePlanList: z.array(z.object({
|
|
24
|
-
id: z.string().optional(),
|
|
25
|
-
projectId: z.string(),
|
|
26
|
-
projectName: z.string(),
|
|
27
|
-
departmentName: z.string().optional(),
|
|
28
|
-
role: z.string().optional(),
|
|
29
|
-
manpower: z.number(),
|
|
30
|
-
month: z.string(),
|
|
31
|
-
areaCode: z.string().optional(),
|
|
32
|
-
mpType: z.string().optional(),
|
|
33
|
-
})),
|
|
34
|
-
});
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// 部门树节点(API: POST /yuntu-service/department/tree/select.json)
|
|
3
|
-
export const DepartmentNodeSchema = z.object({
|
|
4
|
-
id: z.string(),
|
|
5
|
-
name: z.string(),
|
|
6
|
-
parentId: z.string().optional(),
|
|
7
|
-
children: z.lazy(() => z.array(DepartmentNodeSchema)).optional(),
|
|
8
|
-
});
|
|
9
|
-
export const DepartmentTreeDataSchema = z.object({
|
|
10
|
-
departmentTree: z.array(DepartmentNodeSchema).optional(),
|
|
11
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// 项目列表(API: POST /yuntu-service/project/selectHasPermisValidProject.json)
|
|
3
|
-
export const ProjectSchema = z.object({
|
|
4
|
-
id: z.string(),
|
|
5
|
-
name: z.string(),
|
|
6
|
-
code: z.string().optional(),
|
|
7
|
-
status: z.string().optional(),
|
|
8
|
-
manager: z.string().optional(),
|
|
9
|
-
mpType: z.string().optional(),
|
|
10
|
-
});
|
|
11
|
-
export const ProjectListDataSchema = z.object({
|
|
12
|
-
projectList: z.array(ProjectSchema).optional(),
|
|
13
|
-
});
|