@dreamor/atlas-cli 0.7.0
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 +84 -0
- package/bin/atlas.js +5 -0
- package/dist/adapters/atlas/auth/index.js +2 -0
- package/dist/adapters/atlas/auth/login.js +107 -0
- package/dist/adapters/atlas/auth/session.js +154 -0
- package/dist/adapters/atlas/cli.js +502 -0
- package/dist/adapters/atlas/commands/_output_schema.js +100 -0
- package/dist/adapters/atlas/commands/actual/_logic.js +41 -0
- package/dist/adapters/atlas/commands/actual/index.js +117 -0
- package/dist/adapters/atlas/commands/auth.js +1 -0
- package/dist/adapters/atlas/commands/baseline/index.js +122 -0
- package/dist/adapters/atlas/commands/compare/_logic.js +39 -0
- package/dist/adapters/atlas/commands/compare/index.js +72 -0
- package/dist/adapters/atlas/commands/exec.js +58 -0
- package/dist/adapters/atlas/commands/project/index.js +179 -0
- package/dist/adapters/atlas/commands/schema.js +30 -0
- package/dist/adapters/atlas/commands/suggest.js +56 -0
- package/dist/adapters/atlas/commands/update.js +106 -0
- package/dist/adapters/atlas/daemon/index.js +64 -0
- package/dist/adapters/atlas/dict/index.js +41 -0
- package/dist/adapters/atlas/http/client.js +151 -0
- package/dist/adapters/atlas/http/index.js +1 -0
- package/dist/adapters/atlas/schema/actual.js +16 -0
- package/dist/adapters/atlas/schema/baseline.js +34 -0
- package/dist/adapters/atlas/schema/department.js +11 -0
- package/dist/adapters/atlas/schema/index.js +4 -0
- package/dist/adapters/atlas/schema/project.js +13 -0
- package/dist/adapters/atlas/util/constants.js +4 -0
- package/dist/adapters/atlas/util/env.js +8 -0
- package/dist/adapters/atlas/util/errors.js +45 -0
- package/dist/adapters/atlas/util/helpers.js +17 -0
- package/dist/adapters/atlas/util/months.js +41 -0
- package/dist/adapters/atlas/util/output-limit.js +20 -0
- package/dist/adapters/atlas/util/output.js +67 -0
- package/dist/adapters/atlas/util/paths.js +40 -0
- package/dist/adapters/atlas/util/secure-fs.js +41 -0
- package/dist/adapters/atlas/util/time.js +17 -0
- package/dist/adapters/atlas/util/version.js +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
log('');
|
|
78
|
+
log('指定的 NPM_TOKEN 拥有 Publish 权限时,npm 会从 registry 自动拉取最新版。');
|
|
79
|
+
if (opts.json || isJsonMode()) {
|
|
80
|
+
jsonOk({
|
|
81
|
+
mode: 'npm',
|
|
82
|
+
current: ATLAS_VERSION,
|
|
83
|
+
updateCommand: npmCmd,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// 非 npm 安装(独立二进制/Bun/源码开发模式)
|
|
89
|
+
log(`当前版本 ${ATLAS_VERSION}`);
|
|
90
|
+
log('当前以非 npm 方式运行,自更新已禁用。');
|
|
91
|
+
log('推荐迁移到 npm 安装:');
|
|
92
|
+
log(' npm i -g @dreamor/atlas-cli');
|
|
93
|
+
log('随后通过 `npm update -g @dreamor/atlas-cli` 升级。');
|
|
94
|
+
if (opts.json || isJsonMode()) {
|
|
95
|
+
jsonOk({
|
|
96
|
+
mode: 'other',
|
|
97
|
+
current: ATLAS_VERSION,
|
|
98
|
+
migrationCommand: 'npm i -g @dreamor/atlas-cli',
|
|
99
|
+
updateCommand: npmCmd,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 保留 throw 以便旧调用点不会报错(改造后独立函数已无调用者)
|
|
104
|
+
export function _unreachableCheck() {
|
|
105
|
+
throw new AtlasError('update.ts 内部: 不应到达', 'UPDATE_ERROR');
|
|
106
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
import { log } from '../util/output.js';
|
|
5
|
+
import { getDaemonTokenFile } from '../util/paths.js';
|
|
6
|
+
import { secureMkdir, secureWriteFile } from '../util/secure-fs.js';
|
|
7
|
+
import { AtlasError } from '../util/errors.js';
|
|
8
|
+
/** 生成 32 字节 hex token */
|
|
9
|
+
function generateToken() {
|
|
10
|
+
return randomBytes(32).toString('hex');
|
|
11
|
+
}
|
|
12
|
+
/** 校验 Authorization: Bearer <token>(timingSafeEqual 防时序侧信道) */
|
|
13
|
+
function isAuthorized(req, token) {
|
|
14
|
+
const auth = req.headers.authorization ?? '';
|
|
15
|
+
const expected = `Bearer ${token}`;
|
|
16
|
+
const a = Buffer.from(auth);
|
|
17
|
+
const b = Buffer.from(expected);
|
|
18
|
+
// timingSafeEqual 要求两 Buffer 等长;不等长时先比后再返回 false 防止时序泄漏
|
|
19
|
+
if (a.length !== b.length) {
|
|
20
|
+
timingSafeEqual(Buffer.alloc(1), Buffer.alloc(1));
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return timingSafeEqual(a, b);
|
|
24
|
+
}
|
|
25
|
+
export async function daemonCmd(opts) {
|
|
26
|
+
const envPort = Number(process.env.ATLAS_DAEMON_PORT);
|
|
27
|
+
const optsPort = opts.port !== undefined ? Number(opts.port) : NaN;
|
|
28
|
+
const port = Number.isFinite(optsPort) && optsPort > 0
|
|
29
|
+
? optsPort
|
|
30
|
+
: Number.isFinite(envPort) && envPort > 0
|
|
31
|
+
? envPort
|
|
32
|
+
: 8765;
|
|
33
|
+
// 生成一次性 token,写入 ~/.atlas/daemon.token(0600)
|
|
34
|
+
const token = generateToken();
|
|
35
|
+
const tokenFile = getDaemonTokenFile();
|
|
36
|
+
await secureMkdir(dirname(tokenFile), { recursive: true });
|
|
37
|
+
await secureWriteFile(tokenFile, token);
|
|
38
|
+
log(`Daemon token 已写入 ${tokenFile}`);
|
|
39
|
+
const server = createServer((req, res) => {
|
|
40
|
+
// 除 /api/health 外所有端点均需鉴权
|
|
41
|
+
const isHealth = req.url === '/api/health';
|
|
42
|
+
if (!isHealth && !isAuthorized(req, token)) {
|
|
43
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.on('error', (e) => {
|
|
52
|
+
if (e.code === 'EADDRINUSE') {
|
|
53
|
+
reject(new AtlasError(`端口 ${port} 已被占用`, 'CONFIG_ERROR'));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
reject(e);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
60
|
+
});
|
|
61
|
+
log(`守护进程已启动,监听 127.0.0.1:${port}`);
|
|
62
|
+
// Keep running
|
|
63
|
+
await new Promise(() => { });
|
|
64
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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 已失效,请重新登录'),
|
|
17
|
+
'TOKEN_EXPIRED': () => new SessionExpiredError('会话 token 已过期,请重新登录'),
|
|
18
|
+
'SESSION_EXPIRED': () => new SessionExpiredError('会话已过期,请重新登录'),
|
|
19
|
+
};
|
|
20
|
+
const BANMA_BASE = 'https://banma-yuntu.alibaba-inc.com';
|
|
21
|
+
/**
|
|
22
|
+
* Banma API HTTP 客户端
|
|
23
|
+
*
|
|
24
|
+
* 真实 API 格式: { status, code, errCode, errorMsg, detail, success, data }
|
|
25
|
+
* - success=true 或 status=1 表示成功
|
|
26
|
+
* - data 可能为数组或对象
|
|
27
|
+
* - 200 不一定成功;需检查 success 字段
|
|
28
|
+
*/
|
|
29
|
+
export class HttpClient {
|
|
30
|
+
baseUrl;
|
|
31
|
+
baseOrigin;
|
|
32
|
+
timeout;
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
this.baseUrl = (opts.baseUrl ?? BANMA_BASE).replace(/\/+$/, '');
|
|
35
|
+
this.baseOrigin = new URL(this.baseUrl).origin;
|
|
36
|
+
this.timeout = opts.timeout ?? 30000;
|
|
37
|
+
}
|
|
38
|
+
async buildHeaders() {
|
|
39
|
+
const cookies = await readCookies();
|
|
40
|
+
const headers = {
|
|
41
|
+
'Accept': 'application/json, text/plain, */*',
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) atlas-cli',
|
|
44
|
+
'Referer': 'https://banma-yuntu.alibaba-inc.com/',
|
|
45
|
+
'Origin': 'https://banma-yuntu.alibaba-inc.com',
|
|
46
|
+
};
|
|
47
|
+
if (cookies && cookies.length > 0) {
|
|
48
|
+
headers['Cookie'] = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
49
|
+
}
|
|
50
|
+
// 斑马的项目权限类 API 强制校验 x-banma-* 头,缺失则判定无身份返回空数据。
|
|
51
|
+
// token/user/staffId 仅在有有效值时设置(B5);company-id 始终发送(空串亦可)。
|
|
52
|
+
const identity = await readBanmaIdentity();
|
|
53
|
+
if (identity) {
|
|
54
|
+
if (identity.token) {
|
|
55
|
+
headers['token'] = identity.token;
|
|
56
|
+
headers['x-banma-token'] = identity.token;
|
|
57
|
+
}
|
|
58
|
+
if (identity.user)
|
|
59
|
+
headers['x-banma-user'] = identity.user;
|
|
60
|
+
if (identity.staffId)
|
|
61
|
+
headers['x-banma-staff-id'] = identity.staffId;
|
|
62
|
+
headers['x-banma-company-id'] = '';
|
|
63
|
+
}
|
|
64
|
+
return headers;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* POST 请求。
|
|
68
|
+
*
|
|
69
|
+
* 可传可选 schema(ZodSchema),在 API 返回后对 json.data 做运行时校验(D3)。
|
|
70
|
+
*/
|
|
71
|
+
async post(path, body, schema) {
|
|
72
|
+
// A4: 校验 path 必须以 / 开头,防止 http://evil.example/x 绕过 baseUrl
|
|
73
|
+
if (!path.startsWith('/'))
|
|
74
|
+
throw new AtlasError('path must be relative (start with /)', 'CONFIG_ERROR');
|
|
75
|
+
const url = new URL(path, this.baseUrl + '/');
|
|
76
|
+
if (url.origin !== this.baseOrigin)
|
|
77
|
+
throw new AtlasError('origin mismatch — possible SSRF', 'CONFIG_ERROR');
|
|
78
|
+
const headers = await this.buildHeaders();
|
|
79
|
+
const response = await request(url.toString(), {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers,
|
|
82
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
83
|
+
headersTimeout: this.timeout,
|
|
84
|
+
bodyTimeout: this.timeout,
|
|
85
|
+
});
|
|
86
|
+
return this.parseResponse(response, schema);
|
|
87
|
+
}
|
|
88
|
+
async multipartUpload(path, fields, file) {
|
|
89
|
+
// A4: 校验 path
|
|
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 (url.origin !== this.baseOrigin)
|
|
94
|
+
throw new AtlasError('origin mismatch — possible SSRF', 'CONFIG_ERROR');
|
|
95
|
+
const headers = await this.buildHeaders();
|
|
96
|
+
const boundary = `----FormBoundary${Math.random().toString(36).slice(2)}`;
|
|
97
|
+
const CRLF = '\r\n';
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const f of fields) {
|
|
100
|
+
parts.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.name}"${CRLF}${CRLF}${f.value}${CRLF}`));
|
|
101
|
+
}
|
|
102
|
+
parts.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${file.name}"; filename="${file.filename}"${CRLF}Content-Type: application/octet-stream${CRLF}${CRLF}`));
|
|
103
|
+
parts.push(file.buffer);
|
|
104
|
+
parts.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`));
|
|
105
|
+
const response = await request(url.toString(), {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
108
|
+
body: Buffer.concat(parts),
|
|
109
|
+
headersTimeout: this.timeout * 2,
|
|
110
|
+
bodyTimeout: this.timeout * 2,
|
|
111
|
+
});
|
|
112
|
+
return this.parseResponse(response);
|
|
113
|
+
}
|
|
114
|
+
async parseResponse(response, schema) {
|
|
115
|
+
const status = response.statusCode;
|
|
116
|
+
const text = await response.body.text();
|
|
117
|
+
if (status === 401 || status === 403) {
|
|
118
|
+
throw new SessionExpiredError();
|
|
119
|
+
}
|
|
120
|
+
// B2: 识别 429 限流
|
|
121
|
+
if (status === 429) {
|
|
122
|
+
throw new AtlasError('请求过于频繁(429 限流),请稍后重试', 'RATE_LIMITED');
|
|
123
|
+
}
|
|
124
|
+
let json;
|
|
125
|
+
try {
|
|
126
|
+
json = JSON.parse(text);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new BanmaApiError('PARSE_ERROR', `Failed to parse: ${text.slice(0, 200)}`);
|
|
130
|
+
}
|
|
131
|
+
// D10: 识别业务错误码 → 映射到特定异常
|
|
132
|
+
if (json.errCode && json.errCode !== 'SUCCESS') {
|
|
133
|
+
const knownError = KNOWN_BUSINESS_ERRORS[json.errCode];
|
|
134
|
+
if (knownError)
|
|
135
|
+
throw knownError();
|
|
136
|
+
}
|
|
137
|
+
// Banma API 的 success/status 字段不可靠(空结果也返回 success: false)
|
|
138
|
+
// 只要 HTTP 2xx 就返回 data,由调用方处理 null
|
|
139
|
+
if (status >= 200 && status < 300) {
|
|
140
|
+
if (schema)
|
|
141
|
+
return schema.parse(json.data);
|
|
142
|
+
return json.data;
|
|
143
|
+
}
|
|
144
|
+
const errCode = json.errCode ?? String(json.code) ?? `HTTP_${status}`;
|
|
145
|
+
const errMsg = json.errorMsg ?? json.detail ?? `HTTP ${status}`;
|
|
146
|
+
throw new BanmaApiError(errCode, errMsg, json.detail ?? undefined);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function createClient() {
|
|
150
|
+
return new HttpClient();
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HttpClient } from './client.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
export class BanmaApiError extends Error {
|
|
3
|
+
errCode;
|
|
4
|
+
errorMsg;
|
|
5
|
+
requestId;
|
|
6
|
+
constructor(errCode, errorMsg, requestId) {
|
|
7
|
+
super(`Banma API error [${errCode}] ${errorMsg}`);
|
|
8
|
+
this.errCode = errCode;
|
|
9
|
+
this.errorMsg = errorMsg;
|
|
10
|
+
this.requestId = requestId;
|
|
11
|
+
this.name = 'BanmaApiError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class ConfigError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ConfigError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class SessionExpiredError extends Error {
|
|
21
|
+
constructor(message = '会话已过期,请重新登录:atlas auth login') {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'SessionExpiredError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class NotImplementedError extends Error {
|
|
27
|
+
constructor(message = '该功能尚未实现') {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'NotImplementedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class AtlasError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(message, code) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = 'AtlasError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isAtlasError(err) {
|
|
41
|
+
return err instanceof AtlasError;
|
|
42
|
+
}
|
|
43
|
+
export function isZodError(err) {
|
|
44
|
+
return err instanceof ZodError;
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 将 人天 ÷ 22 转换为 人月 */
|
|
2
|
+
export function manhoursToManMonth(hours) {
|
|
3
|
+
const v = typeof hours === 'string' ? parseFloat(hours) : hours;
|
|
4
|
+
if (isNaN(v) || v < 0)
|
|
5
|
+
return 0;
|
|
6
|
+
return Math.round((v / 22) * 100) / 100;
|
|
7
|
+
}
|
|
8
|
+
/** 格式化人力数字 */
|
|
9
|
+
export function fmtManpower(v) {
|
|
10
|
+
if (v == null || isNaN(v))
|
|
11
|
+
return '-';
|
|
12
|
+
return v.toFixed(2);
|
|
13
|
+
}
|
|
14
|
+
/** 安全的 sum */
|
|
15
|
+
export function safeSum(values) {
|
|
16
|
+
return values.reduce((acc, v) => acc + (v ?? 0), 0);
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 月份工具:展开月份范围、格式校验、上限保护。
|
|
3
|
+
*/
|
|
4
|
+
import { ConfigError } from './errors.js';
|
|
5
|
+
import { currentMonthKey } from './time.js';
|
|
6
|
+
/** YYYY-MM 格式正则 */
|
|
7
|
+
const MONTH_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
|
8
|
+
/** 单次查询最大月份数 */
|
|
9
|
+
const MAX_MONTHS = 36;
|
|
10
|
+
/**
|
|
11
|
+
* 展开月份范围 → YYYY-MM 数组。
|
|
12
|
+
* - 同时给 from/to:返回闭区间内的所有月份。
|
|
13
|
+
* - 都不给:返回当前月(单元素数组)。
|
|
14
|
+
* - 只给一个:按"都给"处理,缺失的那个用当前月补齐的语义由调用方决定(此处不单独支持)。
|
|
15
|
+
*
|
|
16
|
+
* 格式校验通过 MONTH_RE;无效格式或范围超过 MAX_MONTHS 抛 ConfigError。
|
|
17
|
+
*/
|
|
18
|
+
export function expandMonths(from, to) {
|
|
19
|
+
if (!from || !to)
|
|
20
|
+
return [currentMonthKey()];
|
|
21
|
+
if (!MONTH_RE.test(from))
|
|
22
|
+
throw new ConfigError(`月份格式应为 YYYY-MM(如 2026-03),实际: "${from}"`);
|
|
23
|
+
if (!MONTH_RE.test(to))
|
|
24
|
+
throw new ConfigError(`月份格式应为 YYYY-MM(如 2026-03),实际: "${to}"`);
|
|
25
|
+
const [fy, fm] = from.split('-').map(Number);
|
|
26
|
+
const [ty, tm] = to.split('-').map(Number);
|
|
27
|
+
const months = [];
|
|
28
|
+
let y = fy, m = fm;
|
|
29
|
+
while (y < ty || (y === ty && m <= tm)) {
|
|
30
|
+
months.push(`${y}-${String(m).padStart(2, '0')}`);
|
|
31
|
+
m++;
|
|
32
|
+
if (m > 12) {
|
|
33
|
+
m = 1;
|
|
34
|
+
y++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (months.length > MAX_MONTHS) {
|
|
38
|
+
throw new ConfigError(`月份范围过大: ${months.length} 个月(上限 ${MAX_MONTHS} 个月),请缩小查询范围`);
|
|
39
|
+
}
|
|
40
|
+
return months;
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
2
|
+
const MAX_OUTPUT_BYTES_ENV = 'ATLAS_MAX_OUTPUT_BYTES';
|
|
3
|
+
/**
|
|
4
|
+
* 在写出文件前校验输出字节数上限。
|
|
5
|
+
*
|
|
6
|
+
* 读取 ATLAS_MAX_OUTPUT_BYTES(字节数,需为正数)。未设置或非法值时放行;
|
|
7
|
+
* 超限时抛 ConfigError(退出码 64),供 agent 场景防止生成超大 CSV/JSON 占满磁盘。
|
|
8
|
+
*/
|
|
9
|
+
export function enforceOutputLimit(content) {
|
|
10
|
+
const raw = process.env[MAX_OUTPUT_BYTES_ENV];
|
|
11
|
+
if (!raw)
|
|
12
|
+
return;
|
|
13
|
+
const limit = Number(raw);
|
|
14
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
15
|
+
return;
|
|
16
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
17
|
+
if (bytes > limit) {
|
|
18
|
+
throw new ConfigError(`输出 ${bytes} 字节超过 ATLAS_MAX_OUTPUT_BYTES 上限 ${limit} 字节`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { BanmaApiError, AtlasError, ConfigError, NotImplementedError, SessionExpiredError } from './errors.js';
|
|
2
|
+
export function isJsonMode() {
|
|
3
|
+
return process.env.ATLAS_OUTPUT === 'json';
|
|
4
|
+
}
|
|
5
|
+
export function isQuietMode() {
|
|
6
|
+
return process.env.ATLAS_QUIET === '1';
|
|
7
|
+
}
|
|
8
|
+
export function jsonOk(data, meta) {
|
|
9
|
+
const envelope = { ok: true, data };
|
|
10
|
+
if (meta)
|
|
11
|
+
envelope.meta = meta;
|
|
12
|
+
process.stdout.write(JSON.stringify(envelope) + '\n');
|
|
13
|
+
}
|
|
14
|
+
export function jsonError(code, message) {
|
|
15
|
+
const envelope = { ok: false, error: { code, message } };
|
|
16
|
+
process.stdout.write(JSON.stringify(envelope) + '\n');
|
|
17
|
+
}
|
|
18
|
+
export function printError(err, opts) {
|
|
19
|
+
if (opts?.json && isJsonMode()) {
|
|
20
|
+
if (err instanceof BanmaApiError) {
|
|
21
|
+
jsonError(err.errCode, err.errorMsg);
|
|
22
|
+
}
|
|
23
|
+
else if (err instanceof AtlasError) {
|
|
24
|
+
jsonError(err.code, err.message);
|
|
25
|
+
}
|
|
26
|
+
else if (err instanceof Error) {
|
|
27
|
+
jsonError('ERROR', err.message);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
jsonError('ERROR', String(err));
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Non-JSON error printing
|
|
35
|
+
if (err instanceof BanmaApiError) {
|
|
36
|
+
console.error(`Banma API error [${err.errCode}] ${err.errorMsg}`);
|
|
37
|
+
}
|
|
38
|
+
else if (err instanceof SessionExpiredError) {
|
|
39
|
+
console.error(err.message);
|
|
40
|
+
}
|
|
41
|
+
else if (err instanceof ConfigError) {
|
|
42
|
+
console.error(`Config error: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
else if (err instanceof NotImplementedError) {
|
|
45
|
+
console.error(err.message);
|
|
46
|
+
}
|
|
47
|
+
else if (err instanceof AtlasError) {
|
|
48
|
+
console.error(`[${err.code}] ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
else if (err instanceof Error) {
|
|
51
|
+
const debug = process.env.DEBUG === '1';
|
|
52
|
+
console.error(debug ? err.stack ?? err.message : err.message);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.error(String(err));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function log(msg) {
|
|
59
|
+
if (!isQuietMode()) {
|
|
60
|
+
console.error(msg);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function table(data) {
|
|
64
|
+
if (!isJsonMode()) {
|
|
65
|
+
console.table(data);
|
|
66
|
+
}
|
|
67
|
+
}
|