@lyra-ai/toolkit 0.0.1

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/.env.example ADDED
@@ -0,0 +1,8 @@
1
+ # 必需
2
+ ZENTAO_URL=https://your-zentao.example.com
3
+ ZENTAO_USERNAME=your-account
4
+ ZENTAO_PASSWORD=your-password
5
+ # 可选
6
+ ZENTAO_DEFAULT_PROJECT=1
7
+ ZENTAO_TOKEN_TTL=3600
8
+ ZENTAO_LOG_LEVEL=info
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zentao-cli contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @lyra-ai/toolkit
2
+
3
+ 面向 AI agent 的零依赖开发工具集。纯 JS(`.mjs`,Node 18+,无编译、无运行时依赖)。
4
+ 当前工具:**zentao**(禅道 REST v1)。预留 `log-query`、`skill-hub` 等扩展位。
5
+
6
+ ## 工程骨架
7
+ ```
8
+ bin/zentao.mjs # 命令入口(薄封装)→ src/zentao.mjs
9
+ src/zentao.mjs # zentao 工具逻辑
10
+ src/shared/ # 预留:多工具共享底座(config/http/output/errors)
11
+ test/*.test.mjs # node:test,零测试依赖
12
+ package.json # bin 多命令声明
13
+ .env.example # 配置模板(占位符,无真实凭证)
14
+ ```
15
+ 新增工具:在 `bin/` 加入口、`src/` 加逻辑、`package.json` 的 `bin` 加一行。
16
+
17
+ ## 安装
18
+ ```bash
19
+ npm install -g @lyra-ai/toolkit # 发布后
20
+ # 或本地开发:
21
+ cd <本目录> && npm link # 全局可用 zentao 命令
22
+ ```
23
+ 配置:复制 `.env.example` 为 `~/.zentao-cli/.env` 或 cwd `.env`,填 `ZENTAO_URL/USERNAME/PASSWORD`。
24
+ 或用 `zentao config set` 命令行管理配置,或 `zentao init` 向导式初始化。
25
+
26
+ ## 命令速查(agent 直接用)
27
+ - `zentao ping` → `{"ok":true,"url":...}`
28
+ - `zentao init` → 交互式向导(填 URL/用户/密码,末了验证连通性)
29
+ - `zentao init --url <url> --username <u> --password <p> [--project <id>] [--ping]` → 非交互式(脚本/AI)
30
+ - `zentao project list [--status active|closed|all] [--limit N] [--offset N]`
31
+ - `zentao product list [--status active|closed|all] [--limit N] [--offset N]`
32
+ - `zentao config` → 列出所有配置(密码脱敏为 `***`)
33
+ - `zentao config get <key>` → 取单个值(密码需 `--raw` 才显原文)
34
+ - `zentao config set <key> <value>` → 写入 `~/.zentao-cli/.env`
35
+ - `zentao config unset <key>` → 删除配置项
36
+ - `zentao config path` → 输出配置文件路径
37
+ - `zentao bug list [--title <kw>] [--status S] [--severity 1-4] [--priority 1-4] [--assigned-to U] [--opened-by U] [--mine] [--product <ID|名称前缀>] [--project ID] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--order id_desc|...] [--limit N] [--offset N]` → 本地缓存检索(需先 sync);`bug search` 等价别名
38
+ - `zentao bug get <id>` → Bug 详情 JSON(live)
39
+ - `zentao bug create --title T [--product ID --severity 1 --priority 2 --assigned-to U]`(`--project` 为兼容别名,作用域为产品)
40
+ - `zentao bug resolve <id> --resolution fixed [--comment ...]`
41
+ - `zentao bug assign <id> --to <user>`
42
+
43
+ ### 本地 bug 索引(先 sync 再 list/search,统一为缓存检索)
44
+ - `zentao bug sync [--product ID] [--status <browseType>] [--mine]` → 一次性同步某产品 bug 到本地(默认 `unclosed`);`--mine` 只缓存指派给我的
45
+ - `zentao bug browse-types` → 列出 `sync --status` 的合法 browseType(`all`/`unclosed`/`assigntome`/...)
46
+
47
+ > 缓存位于 `~/.zentao-cli/cache/bugs-<productId>.json`;关键判断仍走 `bug get`(live)。
48
+ > `bug list` 的 `--product` 支持产品 ID 或**产品名前缀**(如 `--product 财司`,内部调用 products API 解析)。
49
+ > `--mine` 一律本地按 `ZENTAO_USERNAME` 过滤,可与任意过滤叠加。
50
+ > 缓存 >1h(`ZENTAO_CACHE_STALE_MS` 可配)时 search 会在 stderr 提示过期。
51
+
52
+ 默认输出 JSON(stdout),错误走 stderr。退出码:0 成功 / 2 参数配置 / 3 认证 / 4 网络 / 5 API。
53
+
54
+ ## JSON 示例
55
+ ```json
56
+ {"total":1,"count":1,"bugs":[{"id":"9","title":"t","severity":"1","priority":"2","status":"active","assignedTo":"a"}]}
57
+ ```
package/bin/zentao.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // zentao 命令入口(薄封装)—— 逻辑在 src/zentao.mjs。
3
+ // 后续扩展其它工具时,在 package.json 的 bin 增加新入口(如 log-query、skill-hub)。
4
+ import { main } from '../src/zentao.mjs';
5
+
6
+ const code = main();
7
+ if (code != null) process.exit(code);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@lyra-ai/toolkit",
3
+ "version": "0.0.1",
4
+ "description": "面向 AI agent 的零依赖开发工具集",
5
+ "type": "module",
6
+ "bin": {
7
+ "zentao": "./bin/zentao.mjs"
8
+ },
9
+ "engines": { "node": ">=18" },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE",
15
+ ".env.example"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test \"test/*.test.mjs\"",
19
+ "publish:check": "node scripts/prepublish-check.mjs",
20
+ "prepublishOnly": "node scripts/prepublish-check.mjs"
21
+ },
22
+ "dependencies": {},
23
+ "devDependencies": {},
24
+ "license": "MIT",
25
+ "keywords": ["zentao", "cli", "ai-agent", "zero-dependency"]
26
+ }
File without changes
package/src/zentao.mjs ADDED
@@ -0,0 +1,985 @@
1
+ #!/usr/bin/env node
2
+ // 禅道 CLI —— 面向 AI agent,零依赖单文件
3
+ import { fileURLToPath } from 'node:url';
4
+ import process from 'node:process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import readline from 'node:readline';
9
+ // undici 随 Node 内置但未暴露为 bare specifier,按需动态 import;不可用时回退到全局 fetch 默认 dispatcher
10
+ let UndiciAgent = null;
11
+ async function loadUndiciAgent() {
12
+ if (UndiciAgent === null) {
13
+ try {
14
+ const mod = await import('undici');
15
+ UndiciAgent = mod.Agent;
16
+ } catch {
17
+ UndiciAgent = undefined; // 环境无 undici 包:退化为全局 fetch 默认 dispatcher
18
+ }
19
+ }
20
+ return UndiciAgent;
21
+ }
22
+
23
+ // 统一错误类型:kind 用于映射退出码与上层分支
24
+ export class ZentaoError extends Error {
25
+ constructor(kind, message) { super(message); this.name = 'ZentaoError'; this.kind = kind; }
26
+ }
27
+ // kind -> 进程退出码
28
+ export function exitCodeFor(kind) {
29
+ switch (kind) {
30
+ case 'CONFIG_ERROR':
31
+ case 'INVALID_PARAMETER': return 2;
32
+ case 'AUTH_ERROR': return 3;
33
+ case 'NETWORK_ERROR': return 4;
34
+ case 'API_ERROR': return 5;
35
+ default: return 5;
36
+ }
37
+ }
38
+
39
+ // Bug 解决方案枚举(依据 禅道APIv1.0.md §解决Bug:bydesign|duplicate|external|fixed|notrepro|postponed|willnotfix|tostory)
40
+ // 注:brief 原始列表缺 tostory、external 写作 externalbug,按 API 文档修正
41
+ // 放在 HELP 之前,使 HELP 能从常量插值,避免两处列表漂移
42
+ const RESOLUTIONS = ['bydesign', 'duplicate', 'external', 'fixed', 'notrepro', 'postponed', 'willnotfix', 'tostory'];
43
+
44
+ const HELP = `zentao-cli —— 面向 AI agent 的禅道 CLI
45
+
46
+ 命令:
47
+ project list [--status active|closed|all] [--limit N] [--offset N] [--pretty]
48
+ product list [--status active|closed|all] [--limit N] [--offset N] [--pretty]
49
+ bug list [--title <kw>] [--status S] [--severity 1-4] [--priority 1-4]
50
+ [--assigned-to U] [--opened-by U] [--mine]
51
+ [--product <ID|名称前缀>] [--project ID] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
52
+ [--order id_desc|openedDate_desc|severity_desc|priority_desc|...]
53
+ [--limit N] [--offset N] [--pretty]
54
+ # 本地缓存检索(需先 bug sync);bug search 等价别名;--product 支持名称前缀
55
+ bug get <bugId> [--pretty|--raw]
56
+ bug create --title T [--product ID] [--severity 1-4] [--priority 1-4]
57
+ [--assigned-to U] [--type code] [--steps ...] [--pretty]
58
+ bug resolve <bugId> --resolution <r> [--build B] [--comment ...]
59
+ r ∈ {${RESOLUTIONS.join(',')}}
60
+ bug assign <bugId> --to <user>
61
+ bug sync [--product <ID|名称前缀>] [--status <browseType>] [--mine] # 同步产品 bug 到本地索引(默认 unclosed)
62
+ bug browse-types # 列出 sync --status 的合法 browseType
63
+ config 列出所有配置(密码脱敏)
64
+ config get <key> 取单个配置值(ZENTAO_PASSWORD 需 --raw 才显原文)
65
+ config set <key> <v> 写入 ~/.zentao-cli/.env
66
+ config unset <key> 删除配置项
67
+ config path 输出配置文件路径
68
+ init 向导式初始化配置(交互提示填 URL/用户/密码等)
69
+ init --url <u> --username <u> --password <p> [--project <id>]
70
+ 非交互式初始化(适合脚本/AI agent)
71
+ ping 验证连通性 + 凭据
72
+
73
+ 通用: --pretty(人读) --raw(原始 JSON) 默认输出规整 JSON
74
+ 配置: ZENTAO_URL / ZENTAO_USERNAME / ZENTAO_PASSWORD(必需),见 .env.example
75
+ 支持: ZENTAO_DEFAULT_PROJECT / ZENTAO_TOKEN_TTL / ZENTAO_LOG_LEVEL / ZENTAO_CACHE_STALE_MS
76
+ `;
77
+
78
+ function fileTokenStore(cachePath) {
79
+ return {
80
+ read: () => readTokenCache(cachePath, fs),
81
+ write: (c) => writeTokenCache(cachePath, c, fs),
82
+ clear: () => { try { fs.unlinkSync(cachePath); } catch { /* ignore */ } },
83
+ };
84
+ }
85
+
86
+ function loadFilesText() {
87
+ const userEnv = path.join(os.homedir(), '.zentao-cli', '.env');
88
+ const cwdEnv = path.join(process.cwd(), '.env');
89
+ const files = [];
90
+ for (const p of [cwdEnv, userEnv]) { try { files.push(fs.readFileSync(p, 'utf8')); } catch { /* ignore */ } }
91
+ return files; // 项目级在前(优先级高)
92
+ }
93
+
94
+ function main() {
95
+ const argv = process.argv.slice(2);
96
+ const parsed = parseArgs(argv);
97
+ if (parsed.flags.help || parsed.command == null) {
98
+ process.stdout.write(HELP);
99
+ return 0;
100
+ }
101
+ (async () => {
102
+ const isBootstrapCmd = parsed.command === 'config' || parsed.command === 'init';
103
+ let config;
104
+ try {
105
+ config = loadConfig({ env: process.env, filesText: loadFilesText() }, { required: !isBootstrapCmd });
106
+ } catch (e) {
107
+ if (e instanceof ZentaoError) { process.stderr.write(`${e.kind}: ${e.message}\n`); process.exit(exitCodeFor(e.kind)); }
108
+ process.stderr.write('CONFIG_ERROR: ' + (e?.message || e) + '\n'); process.exit(2);
109
+ }
110
+ const cachePath = path.join(os.homedir(), '.zentao-cli', 'token.cache');
111
+ const cacheDir = path.join(os.homedir(), '.zentao-cli', 'cache');
112
+ const deps = { tokenStore: fileTokenStore(cachePath), cacheDir, fs, now: Date.now, isBootstrapCmd };
113
+ const r = await runCommand(parsed, { config, deps });
114
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : r.stdout + '\n');
115
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : r.stderr + '\n');
116
+ process.exit(r.exitCode ?? 0);
117
+ })().catch((e) => { process.stderr.write('NETWORK_ERROR: ' + (e?.message || e) + '\n'); process.exit(4);
118
+ });
119
+ }
120
+
121
+ // 手写 .env parser:逐行 KEY=VALUE,忽略空行与 # 注释,去两端引号
122
+ export function parseEnvFile(text) {
123
+ const out = {};
124
+ for (const raw of text.split(/\r?\n/)) {
125
+ const line = raw.trim();
126
+ if (!line || line.startsWith('#')) continue;
127
+ const eq = line.indexOf('=');
128
+ if (eq < 0) continue;
129
+ const key = line.slice(0, eq).trim();
130
+ let val = line.slice(eq + 1).trim();
131
+ if ((val.startsWith('"') && val.endsWith('"')) ||
132
+ (val.startsWith("'") && val.endsWith("'"))) {
133
+ val = val.slice(1, -1);
134
+ }
135
+ out[key] = val;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ const REQUIRED = ['ZENTAO_URL', 'ZENTAO_USERNAME', 'ZENTAO_PASSWORD'];
141
+
142
+ export function loadConfig({ env, filesText }, { required } = {}) {
143
+ // filesText: 数组,靠前优先级高(项目级 .env 在前,用户级在后)
144
+ const merged = {};
145
+ for (let i = filesText.length - 1; i >= 0; i--) {
146
+ Object.assign(merged, parseEnvFile(filesText[i]));
147
+ }
148
+ Object.assign(merged, env); // env 优先级最高
149
+
150
+ const requiredKeys = required !== false ? REQUIRED : [];
151
+ const missing = requiredKeys.filter((k) => !merged[k]);
152
+ if (missing.length) {
153
+ throw new ZentaoError('CONFIG_ERROR', `缺少必需环境变量: ${missing.join(', ')}(见 .env.example)`);
154
+ }
155
+ return {
156
+ zentaoUrl: String(merged.ZENTAO_URL || '').replace(/\/+$/, ''),
157
+ username: merged.ZENTAO_USERNAME || '',
158
+ password: merged.ZENTAO_PASSWORD || '',
159
+ defaultProject: merged.ZENTAO_DEFAULT_PROJECT || undefined,
160
+ tokenTtl: Number(merged.ZENTAO_TOKEN_TTL) || 3600,
161
+ logLevel: merged.ZENTAO_LOG_LEVEL || 'info',
162
+ };
163
+ }
164
+
165
+ // ---- config 读写(操作 ~/.zentao-cli/.env)----
166
+
167
+ export function configFilePath() {
168
+ return path.join(os.homedir(), '.zentao-cli', '.env');
169
+ }
170
+
171
+ // 已知配置键(用于 list 展示顺序与 set 校验提示)
172
+ const KNOWN_KEYS = ['ZENTAO_URL', 'ZENTAO_USERNAME', 'ZENTAO_PASSWORD', 'ZENTAO_DEFAULT_PROJECT', 'ZENTAO_TOKEN_TTL', 'ZENTAO_LOG_LEVEL', 'ZENTAO_CACHE_STALE_MS'];
173
+
174
+ // 敏感键:list/get 默认脱敏
175
+ const SENSITIVE_KEYS = new Set(['ZENTAO_PASSWORD']);
176
+
177
+ export function readConfigFile(filesystem) {
178
+ const p = configFilePath();
179
+ try { return filesystem.readFileSync(p, 'utf8'); } catch { return ''; }
180
+ }
181
+
182
+ export function writeConfigFile(content, filesystem) {
183
+ const p = configFilePath();
184
+ const dir = path.dirname(p);
185
+ if (!filesystem.existsSync(dir)) filesystem.mkdirSync(dir, { recursive: true });
186
+ filesystem.writeFileSync(p, content, 'utf8');
187
+ }
188
+
189
+ // 更新(或追加)一行 KEY=VALUE;保留注释与顺序
190
+ export function setConfigKey(key, value, filesystem) {
191
+ const content = readConfigFile(filesystem);
192
+ const lines = content ? content.split(/\r?\n/) : [];
193
+ let found = false;
194
+ for (let i = 0; i < lines.length; i++) {
195
+ const trimmed = lines[i].trim();
196
+ if (!trimmed || trimmed.startsWith('#')) continue;
197
+ const eq = trimmed.indexOf('=');
198
+ if (eq < 0) continue;
199
+ if (trimmed.slice(0, eq).trim() === key) {
200
+ lines[i] = `${key}=${value}`;
201
+ found = true;
202
+ break;
203
+ }
204
+ }
205
+ if (!found) lines.push(`${key}=${value}`);
206
+ writeConfigFile(lines.join('\n'), filesystem);
207
+ }
208
+
209
+ // 删除一行 KEY=VALUE
210
+ export function unsetConfigKey(key, filesystem) {
211
+ const content = readConfigFile(filesystem);
212
+ if (!content) return;
213
+ const lines = content.split(/\r?\n/);
214
+ const out = lines.filter((line) => {
215
+ const trimmed = line.trim();
216
+ if (!trimmed || trimmed.startsWith('#')) return true;
217
+ const eq = trimmed.indexOf('=');
218
+ if (eq < 0) return true;
219
+ return trimmed.slice(0, eq).trim() !== key;
220
+ });
221
+ writeConfigFile(out.join('\n'), filesystem);
222
+ }
223
+
224
+ // ---- 命令实现 ----
225
+ const BOOL_FLAGS = new Set(['pretty', 'raw', 'help']);
226
+
227
+ export function parseArgs(argv) {
228
+ const flags = {};
229
+ const positional = [];
230
+ let command = null, sub = null;
231
+ for (let i = 0; i < argv.length; i++) {
232
+ const a = argv[i];
233
+ if (a.startsWith('--')) {
234
+ const name = a.slice(2);
235
+ const eq = name.indexOf('=');
236
+ if (eq >= 0) { flags[name.slice(0, eq)] = name.slice(eq + 1); continue; }
237
+ if (BOOL_FLAGS.has(name)) { flags[name] = true; continue; }
238
+ // --flag value
239
+ const next = argv[i + 1];
240
+ flags[name] = next === undefined || next.startsWith('--') ? true : (i++, next);
241
+ } else {
242
+ if (command === null) command = a;
243
+ else if (sub === null) sub = a;
244
+ else positional.push(a);
245
+ }
246
+ }
247
+ return { command, sub, flags, positional };
248
+ }
249
+
250
+ // 禅道 v1 用户字段可能为字符串(account)或嵌套对象 {account,realname,...},统一取 account 字符串
251
+ function userToAccount(v) {
252
+ if (v == null) return '';
253
+ if (typeof v === 'string') return v;
254
+ // account 优先于 realname,因为 account 是稳定登录标识
255
+ if (typeof v === 'object') return v.account ?? v.realname ?? '';
256
+ return String(v);
257
+ }
258
+
259
+ // 输出整形:保留 agent 关心的字段,统一命名(pri -> priority)
260
+ export function shapeProject(p) {
261
+ return {
262
+ id: p.id, name: p.name, status: p.status,
263
+ begin: p.begin ?? '', end: p.end ?? '',
264
+ openedBy: userToAccount(p.openedBy), openedDate: p.openedDate ?? '',
265
+ };
266
+ }
267
+
268
+ export function shapeProduct(p) {
269
+ return {
270
+ id: p.id, name: p.name, code: p.code ?? '',
271
+ status: p.status, type: p.type ?? '',
272
+ desc: p.desc ?? '',
273
+ };
274
+ }
275
+
276
+ export function shapeBugListItem(b) {
277
+ return {
278
+ id: b.id, title: b.title, severity: b.severity, priority: b.pri,
279
+ status: b.status, openedBy: userToAccount(b.openedBy), openedDate: b.openedDate ?? '',
280
+ assignedTo: userToAccount(b.assignedTo), projectId: b.project ?? '', productId: b.product ?? '',
281
+ };
282
+ }
283
+
284
+ export function shapeBugDetail(b) {
285
+ return {
286
+ ...shapeBugListItem(b),
287
+ type: b.type ?? '', os: b.os ?? '', browser: b.browser ?? '',
288
+ steps: b.steps ?? '', deadline: b.deadline ?? '',
289
+ resolution: b.resolution ?? '', resolvedBy: userToAccount(b.resolvedBy),
290
+ resolvedDate: b.resolvedDate ?? '', resolvedBuild: b.resolvedBuild ?? '',
291
+ closedBy: userToAccount(b.closedBy), closedDate: b.closedDate ?? '',
292
+ lastEditedBy: userToAccount(b.lastEditedBy), lastEditedDate: b.lastEditedDate ?? '',
293
+ };
294
+ }
295
+
296
+ export function projectsToMarkdown({ total, count, projects }) {
297
+ const lines = ['# Projects List', '', `Found ${total} projects (showing ${count})`, '',
298
+ '| ID | Name | Status | Begin | End | OpenedBy | OpenedDate |', '|---|---|---|---|---|---|---|'];
299
+ for (const p of projects) {
300
+ lines.push(`| ${p.id} | ${p.name} | ${p.status} | ${p.begin} | ${p.end} | ${p.openedBy} | ${p.openedDate} |`);
301
+ }
302
+ return lines.join('\n');
303
+ }
304
+
305
+ export function productsToMarkdown({ total, count, products }) {
306
+ const lines = ['# Products List', '', `Found ${total} products (showing ${count})`, '',
307
+ '| ID | Name | Code | Status | Type | Desc |', '|---|---|---|---|---|---|'];
308
+ for (const p of products) {
309
+ lines.push(`| ${p.id} | ${p.name} | ${p.code} | ${p.status} | ${p.type} | ${p.desc} |`);
310
+ }
311
+ return lines.join('\n');
312
+ }
313
+
314
+ export function bugListToMarkdown({ total, count, bugs }) {
315
+ const lines = ['# Bug List', '', `Found ${total} bugs (showing ${count})`, '',
316
+ '| ID | Title | Sev | Pri | Status | AssignedTo | OpenedBy | OpenedDate |', '|---|---|---|---|---|---|---|---|'];
317
+ for (const b of bugs) {
318
+ lines.push(`| ${b.id} | ${b.title} | ${b.severity} | ${b.priority} | ${b.status} | ${b.assignedTo} | ${b.openedBy} | ${b.openedDate} |`);
319
+ }
320
+ return lines.join('\n');
321
+ }
322
+
323
+ export function bugDetailToMarkdown(b) {
324
+ const f = (k) => b[k] === '' || b[k] == null ? '-' : b[k];
325
+ return [
326
+ `# Bug #${b.id}: ${b.title}`, '',
327
+ `- **Severity**: ${b.severity} | **Priority**: ${b.priority} | **Status**: ${b.status}`,
328
+ `- **Project**: ${b.projectId} | **Product**: ${b.productId}`,
329
+ `- **OpenedBy**: ${b.openedBy} ${b.openedDate} | **AssignedTo**: ${f('assignedTo')}`,
330
+ `- **Resolution**: ${f('resolution')} by ${f('resolvedBy')} ${f('resolvedDate')}`,
331
+ '', '## Steps', '', b.steps || '(empty)',
332
+ ].join('\n');
333
+ }
334
+
335
+ // HTTP 状态码 -> 统一错误 kind/message
336
+ export function mapHttpError(status) {
337
+ switch (status) {
338
+ case 401: case 403: return { kind: 'AUTH_ERROR', message: '认证失败:检查用户名/密码与 ZENTAO_URL' };
339
+ case 404: return { kind: 'INVALID_PARAMETER', message: '资源不存在:检查 ID 是否正确' };
340
+ case 429: return { kind: 'API_ERROR', message: '请求过于频繁,请稍后重试' };
341
+ default: return { kind: 'API_ERROR', message: `API 请求失败,状态码 ${status}` };
342
+ }
343
+ }
344
+
345
+ // Token 缓存:文件读写 + 过期判断(fs 注入便于测试)
346
+ export function readTokenCache(path, fs) {
347
+ try {
348
+ const raw = fs.readFileSync(path, 'utf8');
349
+ const obj = JSON.parse(raw);
350
+ if (obj && typeof obj.token === 'string') return { token: obj.token, expiresAt: Number(obj.expiresAt) };
351
+ return null;
352
+ } catch (e) {
353
+ if (e && e.code === 'ENOENT') return null;
354
+ return null; // 损坏的缓存当作无缓存
355
+ }
356
+ }
357
+
358
+ export function writeTokenCache(path, data, fs) {
359
+ // 确保父目录存在(首次运行 ~/.zentao-cli 不存在)
360
+ try {
361
+ if (typeof fs.mkdirSync === 'function') {
362
+ const dir = path.replace(/[\\/][^\\/]+$/, '');
363
+ if (dir && dir !== path) fs.mkdirSync(dir, { recursive: true });
364
+ }
365
+ } catch { /* ignore mkdir errors(测试用内存 fs 可能无 mkdirSync) */ }
366
+ fs.writeFileSync(path, JSON.stringify(data), { mode: 0o600 });
367
+ try { if (typeof fs.chmodSync === 'function') fs.chmodSync(path, 0o600); } catch { /* Windows no-op */ }
368
+ }
369
+
370
+ export function isExpired(cache, nowMs) {
371
+ if (!cache) return true;
372
+ return nowMs >= cache.expiresAt;
373
+ }
374
+
375
+ export function createClient(config, { fetch = globalThis.fetch, tokenStore, agent } = {}) {
376
+ let _dispatcher;
377
+ async function getDispatcher() {
378
+ if (agent) return agent;
379
+ if (!_dispatcher) {
380
+ const UndiciAgentCls = await loadUndiciAgent();
381
+ if (UndiciAgentCls) _dispatcher = new UndiciAgentCls({ connect: { rejectUnauthorized: false } });
382
+ }
383
+ return _dispatcher;
384
+ }
385
+
386
+ async function rawFetch(url, opts) {
387
+ const dispatcher = await getDispatcher();
388
+ return fetch(url, { ...opts, ...(dispatcher ? { dispatcher } : {}) }); // 测试用 fake fetch 会忽略 dispatcher
389
+ }
390
+
391
+ async function login() {
392
+ const res = await rawFetch(`${config.zentaoUrl}/api.php/v1/tokens`, {
393
+ method: 'POST',
394
+ headers: { 'Content-Type': 'application/json' },
395
+ body: JSON.stringify({ account: config.username, password: config.password }),
396
+ });
397
+ if (!res.ok) {
398
+ const m = mapHttpError(res.status);
399
+ throw new ZentaoError(m.kind, '认证失败:' + m.message);
400
+ }
401
+ const data = await res.json();
402
+ if (!data.token) throw new ZentaoError('AUTH_ERROR', '登录响应缺少 token');
403
+ const cache = { token: data.token, expiresAt: Date.now() + config.tokenTtl * 1000 };
404
+ tokenStore.write(cache);
405
+ return cache.token;
406
+ }
407
+
408
+ async function getToken() {
409
+ const cache = tokenStore.read();
410
+ if (cache && !isExpired(cache, Date.now())) return cache.token;
411
+ return login();
412
+ }
413
+
414
+ async function request(method, path, { params, body, _retried = false } = {}) {
415
+ const token = await getToken();
416
+ const url = new URL(config.zentaoUrl + path);
417
+ if (params) for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, v);
418
+ let res;
419
+ try {
420
+ res = await rawFetch(url, {
421
+ method,
422
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Token: token },
423
+ body: body ? JSON.stringify(body) : undefined,
424
+ });
425
+ } catch (e) {
426
+ throw new ZentaoError('NETWORK_ERROR', e && e.message ? e.message : '网络请求失败');
427
+ }
428
+ if (res.status === 401 || res.status === 403) {
429
+ if (!_retried) { tokenStore.clear(); return request(method, path, { params, body, _retried: true }); }
430
+ throw new ZentaoError('AUTH_ERROR', '认证失败:检查凭据');
431
+ }
432
+ if (!res.ok) {
433
+ const m = mapHttpError(res.status);
434
+ let detail = '';
435
+ try { detail = (await res.json())?.message || ''; } catch { /* ignore */ }
436
+ throw new ZentaoError(m.kind, detail ? `${m.message}(${detail})` : m.message);
437
+ }
438
+ // 防御空响应体/204:先读文本,非空再解析,保持现有 JSON 端点行为不变
439
+ const text = await res.text();
440
+ return text ? JSON.parse(text) : {};
441
+ }
442
+
443
+ return { getToken, request, login };
444
+ }
445
+
446
+ // 分页辅助:在内存数组上切片,返回统一分页结构
447
+ function paginate(items, limit, offset) {
448
+ const start = Math.min(offset, items.length);
449
+ const end = Math.min(start + limit, items.length);
450
+ const slice = items.slice(start, end);
451
+ return { total: items.length, count: slice.length, offset, has_more: end < items.length, next_offset: end < items.length ? end : undefined, items: slice };
452
+ }
453
+
454
+ async function cmdPing(parsed, client, config) {
455
+ await client.request('GET', '/api.php/v1/projects', { params: { limit: 1 } });
456
+ return { stdout: JSON.stringify({ ok: true, url: config.zentaoUrl, defaultProject: config.defaultProject ?? null }, null, 2) };
457
+ }
458
+
459
+ // ---- config 子命令 ----
460
+
461
+ function maskIfSensitive(key, value) {
462
+ if (SENSITIVE_KEYS.has(key) && value) return '***';
463
+ return value ?? '';
464
+ }
465
+
466
+ function configToObj(config) {
467
+ return {
468
+ ZENTAO_URL: config.zentaoUrl,
469
+ ZENTAO_USERNAME: config.username,
470
+ ZENTAO_PASSWORD: config.password,
471
+ ZENTAO_DEFAULT_PROJECT: config.defaultProject ?? '',
472
+ ZENTAO_TOKEN_TTL: String(config.tokenTtl),
473
+ ZENTAO_LOG_LEVEL: config.logLevel,
474
+ ZENTAO_CACHE_STALE_MS: process.env.ZENTAO_CACHE_STALE_MS || '',
475
+ };
476
+ }
477
+
478
+ function cmdConfigList(config) {
479
+ const obj = configToObj(config);
480
+ const out = {};
481
+ for (const k of KNOWN_KEYS) {
482
+ if (obj[k]) out[k] = maskIfSensitive(k, obj[k]);
483
+ }
484
+ return { stdout: JSON.stringify(out, null, 2) };
485
+ }
486
+
487
+ function cmdConfigGet(parsed, config) {
488
+ const key = parsed.positional[0];
489
+ if (!key) return { stderr: '用法:zentao config get <key>', exitCode: 2 };
490
+ const obj = configToObj(config);
491
+ const keyUpper = key.toUpperCase();
492
+ if (obj[keyUpper] === undefined) return { stderr: `未知配置项: ${key}(合法值: ${KNOWN_KEYS.join(', ')})`, exitCode: 2 };
493
+ const raw = parsed.flags.raw;
494
+ const val = raw ? obj[keyUpper] : maskIfSensitive(keyUpper, obj[keyUpper]);
495
+ return { stdout: val };
496
+ }
497
+
498
+ function cmdConfigSet(parsed, filesystem) {
499
+ const key = parsed.positional[0];
500
+ const value = parsed.positional[1];
501
+ if (!key || value === undefined) return { stderr: '用法:zentao config set <key> <value>', exitCode: 2 };
502
+ const keyUpper = key.toUpperCase();
503
+ if (!KNOWN_KEYS.includes(keyUpper)) {
504
+ return { stderr: `未知配置项: ${key}(合法值: ${KNOWN_KEYS.join(', ')})。如需自定义变量请直接编辑 .env`, exitCode: 2 };
505
+ }
506
+ setConfigKey(keyUpper, value, filesystem);
507
+ return { stdout: JSON.stringify({ set: keyUpper, value: maskIfSensitive(keyUpper, value) }, null, 2) };
508
+ }
509
+
510
+ function cmdConfigUnset(parsed, filesystem) {
511
+ const key = parsed.positional[0];
512
+ if (!key) return { stderr: '用法:zentao config unset <key>', exitCode: 2 };
513
+ const keyUpper = key.toUpperCase();
514
+ unsetConfigKey(keyUpper, filesystem);
515
+ return { stdout: JSON.stringify({ unset: keyUpper }, null, 2) };
516
+ }
517
+
518
+ function cmdConfigPath() {
519
+ return { stdout: configFilePath() };
520
+ }
521
+
522
+ // ---- init 向导 ----
523
+
524
+ // 掩码密码输入(不回显),仅在 TTY 下有效
525
+ async function maskedPrompt(question) {
526
+ return new Promise((resolve) => {
527
+ process.stdout.write(question);
528
+ let buf = '';
529
+ const onData = (c) => {
530
+ const ch = c.toString();
531
+ if (ch === '\r' || ch === '\n') {
532
+ process.stdout.write('\n');
533
+ process.stdin.removeListener('data', onData);
534
+ process.stdin.setRawMode(false);
535
+ resolve(buf);
536
+ } else if (ch === '') {
537
+ process.stdout.write('\n');
538
+ process.exit(0);
539
+ } else if (ch === '\x7f' || ch === '\b') {
540
+ if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
541
+ } else {
542
+ buf += ch;
543
+ process.stdout.write('*');
544
+ }
545
+ };
546
+ process.stdin.setRawMode(true);
547
+ process.stdin.on('data', onData);
548
+ });
549
+ }
550
+
551
+ async function cmdInit(parsed, config, deps) {
552
+ const { url, username, password, project, ping: doPing } = parsed.flags;
553
+ const hasAllRequired = url && username && password;
554
+
555
+ // 非交互模式:三参数齐全直接写入
556
+ if (hasAllRequired) {
557
+ setConfigKey('ZENTAO_URL', url, deps.fs);
558
+ setConfigKey('ZENTAO_USERNAME', username, deps.fs);
559
+ setConfigKey('ZENTAO_PASSWORD', password, deps.fs);
560
+ if (project) setConfigKey('ZENTAO_DEFAULT_PROJECT', project, deps.fs);
561
+
562
+ if (doPing) {
563
+ const newCfg = loadConfig({ env: {}, filesText: [deps.fs.readFileSync(configFilePath(), 'utf8')] });
564
+ try {
565
+ const c = createClient(newCfg, deps);
566
+ await c.request('GET', '/api.php/v1/projects', { params: { limit: 1 } });
567
+ return { stdout: JSON.stringify({ ok: true, message: '已保存,连通性验证通过', pingOk: true }, null, 2) };
568
+ } catch (e) {
569
+ return { stdout: JSON.stringify({ ok: true, message: '已保存,连通性验证失败: ' + (e?.message || e), pingOk: false }, null, 2) };
570
+ }
571
+ }
572
+ return { stdout: JSON.stringify({ ok: true, message: '配置已保存' }, null, 2) };
573
+ }
574
+
575
+ // 给了部分参数但不全 → 报错
576
+ if (url || username || password) {
577
+ return { stderr: '非交互模式需同时指定 --url --username --password', exitCode: 2 };
578
+ }
579
+
580
+ // 交互模式需要 TTY
581
+ if (!process.stdin.isTTY) {
582
+ return { stderr: '非交互终端请传参:zentao init --url <url> --username <u> --password <p> [--project <id>] [--ping]', exitCode: 2 };
583
+ }
584
+
585
+ // ---- 交互式向导 ----
586
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
587
+ const ask = (q, def) => new Promise((resolve) => {
588
+ rl.question(def ? `${q} [${def}]: ` : `${q}: `, (ans) => resolve(ans.trim() || def || ''));
589
+ });
590
+
591
+ process.stdout.write('\n🧙 zentao-cli 初始化向导\n\n');
592
+
593
+ const u = await ask('禅道地址 (ZENTAO_URL)', config.zentaoUrl || '');
594
+ const un = await ask('用户名 (ZENTAO_USERNAME)', config.username || '');
595
+ const pw = await (async () => {
596
+ rl.pause();
597
+ const p = await maskedPrompt('密码 (ZENTAO_PASSWORD): ');
598
+ rl.resume();
599
+ return p;
600
+ })();
601
+
602
+ rl.close();
603
+
604
+ setConfigKey('ZENTAO_URL', u, deps.fs);
605
+ setConfigKey('ZENTAO_USERNAME', un, deps.fs);
606
+ setConfigKey('ZENTAO_PASSWORD', pw, deps.fs);
607
+
608
+ // 验证连通性
609
+ process.stdout.write('\n🔍 验证连通性...\n');
610
+ let pingOk = false;
611
+ let pingErr = '';
612
+ try {
613
+ const newCfg = loadConfig({ env: {}, filesText: [deps.fs.readFileSync(configFilePath(), 'utf8')] });
614
+ const c = createClient(newCfg, deps);
615
+ await c.request('GET', '/api.php/v1/projects', { params: { limit: 1 } });
616
+ pingOk = true;
617
+ } catch (e) {
618
+ pingErr = e?.message || String(e);
619
+ }
620
+
621
+ return {
622
+ stdout: JSON.stringify({
623
+ ok: true,
624
+ message: pingOk ? '配置已保存,连通性验证通过 ✓' : `配置已保存,但连通性验证失败(${pingErr})。请检查 ZENTAO_URL 和凭据。`,
625
+ pingOk,
626
+ hint: pingOk ? '下一步:zentao project list 查看项目 → zentao config set ZENTAO_DEFAULT_PROJECT <id> 设默认项目' : null,
627
+ }, null, 2),
628
+ };
629
+ }
630
+
631
+ async function cmdProjectList(parsed, client) {
632
+ const data = await client.request('GET', '/api.php/v1/projects');
633
+ let projects = data.projects || [];
634
+ if (parsed.flags.status && parsed.flags.status !== 'all') {
635
+ projects = projects.filter((p) => parsed.flags.status === 'closed' ? p.status === 'closed' : p.status !== 'closed');
636
+ }
637
+ const limit = Number(parsed.flags.limit) || 20;
638
+ const offset = Number(parsed.flags.offset) || 0;
639
+ const page = paginate(projects, limit, offset);
640
+ const out = { total: page.total, count: page.count, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, projects: page.items.map(shapeProject) };
641
+ if (parsed.flags.pretty) return { stdout: projectsToMarkdown({ ...out, projects: page.items.map(shapeProject) }) };
642
+ return { stdout: JSON.stringify(out, null, 2) };
643
+ }
644
+
645
+ async function cmdProductList(parsed, client) {
646
+ const data = await client.request('GET', '/api.php/v1/products');
647
+ let products = data.products || [];
648
+ if (parsed.flags.status && parsed.flags.status !== 'all') {
649
+ products = products.filter((p) => parsed.flags.status === 'closed' ? p.status === 'closed' : p.status !== 'closed');
650
+ }
651
+ const limit = Number(parsed.flags.limit) || 20;
652
+ const offset = Number(parsed.flags.offset) || 0;
653
+ const page = paginate(products, limit, offset);
654
+ const out = { total: page.total, count: page.count, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, products: page.items.map(shapeProduct) };
655
+ if (parsed.flags.pretty) return { stdout: productsToMarkdown({ ...out, products: page.items.map(shapeProduct) }) };
656
+ return { stdout: JSON.stringify(out, null, 2) };
657
+ }
658
+
659
+ // 统一 bug 列表 → 全走本地缓存(先 sync 后查)
660
+ // bug list / bug search 两者等价,search 为隐藏别名
661
+ async function cmdBugList(parsed, config, deps) {
662
+ const { cacheDir, fs, now } = deps;
663
+ // 解析 --product:非数字按产品名前缀匹配
664
+ let wantProduct = parsed.flags.product;
665
+ if (wantProduct && !/^\d+$/.test(String(wantProduct))) {
666
+ const client = deps.client || createClient(config, deps);
667
+ const data = await client.request('GET', '/api.php/v1/products');
668
+ const matches = (data.products || []).filter((p) => p.name && String(p.name).startsWith(wantProduct));
669
+ if (!matches.length) {
670
+ const names = (data.products || []).slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
671
+ return { stderr: `未找到名前缀匹配 "${wantProduct}" 的产品。已知产品: ${names || '(无)'}`, exitCode: 2 };
672
+ }
673
+ wantProduct = matches[0].id; // 优先取第一个匹配
674
+ }
675
+ const products = wantProduct ? [String(wantProduct)] : listCacheProducts(fs, cacheDir);
676
+ if (!products.length) {
677
+ return { stderr: '无本地缓存,请先:zentao bug sync --product <ID>', exitCode: 2 };
678
+ }
679
+ const nowMs = typeof now === 'function' ? now() : (now ?? Date.now());
680
+ let merged = [];
681
+ let oldestSync = Infinity;
682
+ let missing = false;
683
+ for (const pid of products) {
684
+ const cache = readBugCache(cacheDir, pid, fs);
685
+ if (!cache) { missing = true; continue; }
686
+ oldestSync = Math.min(oldestSync, cache.syncedAt);
687
+ for (const b of cache.bugs) merged.push({ ...b, productId: cache.product });
688
+ }
689
+ if (!merged.length && missing) {
690
+ return { stderr: `产品 ${wantProduct} 无本地缓存,请先:zentao bug sync --product ${wantProduct}`, exitCode: 2 };
691
+ }
692
+
693
+ // ---- 本地过滤 ----
694
+ let out = merged;
695
+ if (parsed.flags.mine) out = out.filter((b) => b.assignedTo === config.username);
696
+ if (parsed.flags.title) {
697
+ const kw = String(parsed.flags.title).toLowerCase();
698
+ out = out.filter((b) => String(b.title || '').toLowerCase().includes(kw));
699
+ }
700
+ if (parsed.flags.status && parsed.flags.status !== 'all') out = out.filter((b) => b.status === parsed.flags.status);
701
+ if (parsed.flags.severity) out = out.filter((b) => String(b.severity) === String(parsed.flags.severity));
702
+ if (parsed.flags.priority) out = out.filter((b) => String(b.priority) === String(parsed.flags.priority));
703
+ if (parsed.flags['assigned-to']) out = out.filter((b) => b.assignedTo === parsed.flags['assigned-to']);
704
+ if (parsed.flags['opened-by']) out = out.filter((b) => b.openedBy === parsed.flags['opened-by']);
705
+ if (parsed.flags.project) out = out.filter((b) => String(b.projectId) === String(parsed.flags.project));
706
+ if (parsed.flags.start) out = out.filter((b) => String(b.openedDate || '') >= parsed.flags.start);
707
+ if (parsed.flags.end) out = out.filter((b) => String(b.openedDate || '') <= parsed.flags.end);
708
+
709
+ // ---- 本地排序 ----
710
+ if (parsed.flags.order) {
711
+ const ORDERS = { id_desc: ['id', -1], id_asc: ['id', 1], openedDate_desc: ['openedDate', -1], openedDate_asc: ['openedDate', 1], severity_desc: ['severity', -1], severity_asc: ['severity', 1], priority_desc: ['priority', -1], priority_asc: ['priority', 1], status_desc: ['status', -1], status_asc: ['status', 1], title_asc: ['title', 1], title_desc: ['title', -1] };
712
+ const [field, dir] = ORDERS[parsed.flags.order] || [null, 0];
713
+ if (field) {
714
+ out = [...out].sort((a, b) => {
715
+ const va = (a[field] ?? ''), vb = (b[field] ?? '');
716
+ return dir * String(va).localeCompare(String(vb), undefined, { numeric: true });
717
+ });
718
+ }
719
+ }
720
+
721
+ // ---- 分页 ----
722
+ const limit = Number(parsed.flags.limit) || 20;
723
+ const offset = Number(parsed.flags.offset) || 0;
724
+ const start = Math.min(offset, out.length);
725
+ const end = Math.min(start + limit, out.length);
726
+ const page = out.slice(start, end);
727
+
728
+ // staleness 提示
729
+ let stderr;
730
+ if (isFinite(oldestSync) && nowMs - oldestSync > CACHE_STALE_MS) {
731
+ const hrs = ((nowMs - oldestSync) / 3600000).toFixed(1);
732
+ stderr = `⚠ 缓存可能过期(${hrs}h 前同步),关键判断请 'bug get <id>' 或重新 'bug sync'`;
733
+ }
734
+ if (parsed.flags.pretty) {
735
+ return { stdout: bugListToMarkdown({ total: out.length, count: page.length, bugs: page }), stderr };
736
+ }
737
+ const body = { syncedAt: isFinite(oldestSync) ? oldestSync : null, total: out.length, count: page.length, offset, has_more: end < out.length, bugs: page };
738
+ return { stdout: JSON.stringify(body, null, 2), stderr };
739
+ }
740
+
741
+ async function cmdBugGet(parsed, client) {
742
+ const bugId = parsed.positional[0];
743
+ if (!bugId) return { stderr: '错误:缺少 bugId(用法:zentao bug get <bugId>)', exitCode: 2 };
744
+ const data = await client.request('GET', `/api.php/v1/bugs/${bugId}`);
745
+ if (parsed.flags.raw) return { stdout: JSON.stringify(data, null, 2) };
746
+ const shaped = shapeBugDetail(data);
747
+ if (parsed.flags.pretty) return { stdout: bugDetailToMarkdown(shaped) };
748
+ return { stdout: JSON.stringify(shaped, null, 2) };
749
+ }
750
+
751
+ // 创建 Bug
752
+ // 依据 禅道APIv1.0.md §创建Bug:POST /products/id/bugs(brief 原写 /projects/id/bugs 为误,文档以产品为作用域)
753
+ // TODO 依据 禅道APIv1.0.md §创建Bug,实测受限(只读环境)未做活体验证
754
+ async function cmdBugCreate(parsed, client, defaultProject) {
755
+ // 文档作用域为 product;保留 --project 旗标与 defaultProject 配置以兼容现有 cfg,语义等同 productId
756
+ const productId = parsed.flags.project || parsed.flags.product || defaultProject;
757
+ if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project 且无 ZENTAO_DEFAULT_PROJECT');
758
+ if (!parsed.flags.title) return { stderr: '错误:缺少 --title', exitCode: 2 };
759
+ const body = {
760
+ title: parsed.flags.title,
761
+ // severity/pri/type 文档标为必填;这里仅透传,缺失时由后端校验
762
+ ...(parsed.flags.severity ? { severity: parsed.flags.severity } : {}),
763
+ ...(parsed.flags.priority ? { pri: parsed.flags.priority } : {}),
764
+ ...(parsed.flags['assigned-to'] ? { assignedTo: parsed.flags['assigned-to'] } : {}),
765
+ ...(parsed.flags.type ? { type: parsed.flags.type } : {}),
766
+ ...(parsed.flags.steps ? { steps: parsed.flags.steps } : {}),
767
+ // openedBuild 在部分版本必填,留 TODO:实测后补默认值
768
+ };
769
+ const data = await client.request('POST', `/api.php/v1/products/${productId}/bugs`, { body });
770
+ return { stdout: JSON.stringify({ id: data.id ?? data.bugId ?? null }, null, 2) };
771
+ }
772
+
773
+ // 解决 Bug
774
+ // 依据 禅道APIv1.0.md §解决Bug:POST /bugs/id/resolve,body resolution 必填
775
+ async function cmdBugResolve(parsed, client, deps) {
776
+ const bugId = parsed.positional[0];
777
+ if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
778
+ if (!parsed.flags.resolution) return { stderr: `错误:缺少 --resolution(可选:${RESOLUTIONS.join(',')})`, exitCode: 2 };
779
+ const body = {
780
+ resolution: parsed.flags.resolution,
781
+ ...(parsed.flags.build ? { resolvedBuild: parsed.flags.build } : {}),
782
+ ...(parsed.flags.comment ? { comment: parsed.flags.comment } : {}),
783
+ };
784
+ const data = await client.request('POST', `/api.php/v1/bugs/${bugId}/resolve`, { body });
785
+ // best-effort 更新本地索引(不阻断)
786
+ if (deps?.cacheDir && deps?.fs) {
787
+ updateCachedBugAnywhere(deps.cacheDir, bugId, { status: 'resolved', resolution: parsed.flags.resolution }, deps.fs);
788
+ }
789
+ return { stdout: JSON.stringify({ id: bugId, resolved: true, raw: data?.id ?? null }, null, 2) };
790
+ }
791
+
792
+ // 指派 Bug
793
+ // 依据 禅道APIv1.0.md §2.14.4 修改Bug:PUT /bugs/id(禅道 REST v1 无独立 assignTo 端点,
794
+ // 亦无独立 comment 端点)。指派复用「修改 Bug」通道,body 仅传 assignedTo 字段。
795
+ // 字段名 assignedTo 为禅道各动作体(confirm/active/task 等)通用指派字段,与文档一致。
796
+ // TODO 实测受限(只读环境)未做活体验证。
797
+ async function cmdBugAssign(parsed, client, deps) {
798
+ const bugId = parsed.positional[0];
799
+ if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
800
+ if (!parsed.flags.to) return { stderr: '错误:缺少 --to <user>', exitCode: 2 };
801
+ const body = { assignedTo: parsed.flags.to };
802
+ const data = await client.request('PUT', `/api.php/v1/bugs/${bugId}`, { body });
803
+ // best-effort 更新本地索引(不阻断)
804
+ if (deps?.cacheDir && deps?.fs) {
805
+ updateCachedBugAnywhere(deps.cacheDir, bugId, { assignedTo: parsed.flags.to }, deps.fs);
806
+ }
807
+ return { stdout: JSON.stringify({ id: bugId, assignedTo: parsed.flags.to, raw: data?.id ?? null }, null, 2) };
808
+ }
809
+
810
+ // ============ 本地 Bug 索引(sync / search) ============
811
+
812
+ // bug 列表服务端 browseType 合法值(源码 module/bug/model.php getBugs 398-414)
813
+ // bysearch(queryID 写死0)/bymodule(需moduleID)/bybranch 经 REST 不可用,故不列。
814
+ export const BUG_BROWSE_TYPES = [
815
+ { value: 'all', desc: '全部' },
816
+ { value: 'unclosed', desc: '未关闭(sync 默认)' },
817
+ { value: 'unresolved', desc: '未解决' },
818
+ { value: 'toclosed', desc: '待关闭' },
819
+ { value: 'assigntome', desc: '指派给我' },
820
+ { value: 'openedbyme', desc: '我创建的' },
821
+ { value: 'resolvedbyme', desc: '我解决的' },
822
+ { value: 'assignedbyme', desc: '我指派的' },
823
+ { value: 'assigntonull', desc: '未指派' },
824
+ { value: 'unconfirmed', desc: '未确认' },
825
+ { value: 'longlifebugs', desc: '长期未解决' },
826
+ { value: 'postponedbugs', desc: '延期处理' },
827
+ { value: 'needconfirm', desc: '需确认' },
828
+ { value: 'overduebugs', desc: '已过期' },
829
+ { value: 'review', desc: '待评审' },
830
+ ];
831
+
832
+ // 缓存过期阈值(ms),默认 1h,可由 ZENTAO_CACHE_STALE_MS 覆盖
833
+ const CACHE_STALE_MS = Number(process.env.ZENTAO_CACHE_STALE_MS) || 3600 * 1000;
834
+
835
+ // 本地 bug 索引缓存 I/O(注入 fs 便于测试)
836
+ export function cacheFilePath(dir, productId) {
837
+ return `${dir}/bugs-${productId}.json`;
838
+ }
839
+
840
+ export function readBugCache(dir, productId, fs) {
841
+ try {
842
+ const raw = fs.readFileSync(cacheFilePath(dir, productId), 'utf8');
843
+ const obj = JSON.parse(raw);
844
+ if (obj && Array.isArray(obj.bugs)) return obj;
845
+ return null; // 结构不符当作无缓存
846
+ } catch (e) {
847
+ if (e && e.code === 'ENOENT') return null;
848
+ return null; // 损坏缓存当作无缓存
849
+ }
850
+ }
851
+
852
+ // 原子写:先写 .tmp 再 rename,避免崩溃损坏;目录自动建;权限收紧
853
+ export function writeBugCache(dir, productId, data, fs) {
854
+ const final = cacheFilePath(dir, productId);
855
+ const tmp = `${final}.tmp`;
856
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* 已存在或不可建 */ }
857
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
858
+ try { if (typeof fs.chmodSync === 'function') fs.chmodSync(tmp, 0o600); } catch { /* Windows no-op */ }
859
+ fs.renameSync(tmp, final);
860
+ }
861
+
862
+ // 列出已缓存的产品 id(扫描 cacheDir 下 bugs-<id>.json)
863
+ export function listCacheProducts(fs, cacheDir) {
864
+ let entries = [];
865
+ try { entries = fs.readdirSync(cacheDir); } catch { return []; }
866
+ const out = [];
867
+ for (const name of entries) {
868
+ const m = name.match(/^bugs-(.+)\.json$/);
869
+ if (m) out.push(m[1]);
870
+ }
871
+ return out;
872
+ }
873
+
874
+ // best-effort 更新某产品缓存中指定 bug;命中并写入返回 true,否则 false;失败静默
875
+ export function updateCachedBug(cacheDir, productId, bugId, patch, fs) {
876
+ try {
877
+ const cache = readBugCache(cacheDir, productId, fs);
878
+ if (!cache) return false;
879
+ let hit = false;
880
+ cache.bugs = cache.bugs.map((b) => {
881
+ if (String(b.id) === String(bugId)) { hit = true; return { ...b, ...patch }; }
882
+ return b;
883
+ });
884
+ if (hit) writeBugCache(cacheDir, productId, cache, fs);
885
+ return hit;
886
+ } catch {
887
+ return false;
888
+ }
889
+ }
890
+
891
+ // 扫描所有已缓存产品,更新命中 bug(写命令后 best-effort)
892
+ export function updateCachedBugAnywhere(cacheDir, bugId, patch, fs) {
893
+ let hit = false;
894
+ for (const pid of listCacheProducts(fs, cacheDir)) {
895
+ if (updateCachedBug(cacheDir, pid, bugId, patch, fs)) hit = true;
896
+ }
897
+ return hit;
898
+ }
899
+
900
+ // 输出 browseType 合法值
901
+ function cmdBugBrowseTypes(parsed) {
902
+ const rows = BUG_BROWSE_TYPES;
903
+ if (parsed.flags.pretty) {
904
+ const lines = ['# Bug browseTypes(sync --status 取值)', '', '| value | desc |', '|---|---|'];
905
+ for (const r of rows) lines.push(`| ${r.value} | ${r.desc} |`);
906
+ return { stdout: lines.join('\n') };
907
+ }
908
+ return { stdout: JSON.stringify({ browseTypes: rows.map((r) => r.value) }, null, 2) };
909
+ }
910
+
911
+ // 一次性同步某产品 bug 到本地索引
912
+ async function cmdBugSync(parsed, client, config, { cacheDir, fs }) {
913
+ let productId = parsed.flags.product || config.defaultProject;
914
+ // --product 非数字 → 产品名前缀匹配
915
+ if (productId && !/^\d+$/.test(String(productId))) {
916
+ const data = await client.request('GET', '/api.php/v1/products');
917
+ const matches = (data.products || []).filter((p) => p.name && String(p.name).startsWith(productId));
918
+ if (!matches.length) {
919
+ const names = (data.products || []).slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
920
+ throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${productId}" 的产品。已知产品: ${names || '(无)'}`);
921
+ }
922
+ productId = matches[0].id;
923
+ }
924
+ if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --product 且无 ZENTAO_DEFAULT_PROJECT');
925
+ const browseType = parsed.flags.status || 'unclosed'; // sync 的 --status = 服务端 browseType,默认 unclosed
926
+ const limit = 100;
927
+ const all = [];
928
+ for (let page = 1; ; page++) {
929
+ const data = await client.request('GET', `/api.php/v1/products/${productId}/bugs`, { params: { status: browseType, limit, page } });
930
+ const bugs = (data.bugs || []).map(shapeBugListItem);
931
+ all.push(...bugs);
932
+ if (!data.bugs || data.bugs.length < limit) break; // 不足一页 = 取完
933
+ }
934
+ // --mine:本地按 assignedTo === 当前账号 过滤(可与任意 browseType 叠加)
935
+ const final = parsed.flags.mine ? all.filter((b) => b.assignedTo === config.username) : all;
936
+ const cache = { product: String(productId), status: browseType, syncedAt: Date.now(), count: final.length, bugs: final };
937
+ writeBugCache(cacheDir, productId, cache, fs); // 仅成功后写,失败不破坏旧缓存
938
+ return { stdout: JSON.stringify({ product: String(productId), status: browseType, syncedAt: cache.syncedAt, count: final.length, cached: final.length }, null, 2) };
939
+ }
940
+
941
+ // 命令分发:根据 parsed 派发到对应 cmd,捕获 ZentaoError 转 exitCode
942
+ export async function runCommand(parsed, { config, deps }) {
943
+ const client = deps.client ?? (deps.isBootstrapCmd ? null : createClient(config, deps));
944
+ try {
945
+ let res;
946
+ if (parsed.command === 'config' && parsed.sub == null) res = cmdConfigList(config);
947
+ else if (parsed.command === 'config' && parsed.sub === 'get') res = cmdConfigGet(parsed, config);
948
+ else if (parsed.command === 'config' && parsed.sub === 'set') res = cmdConfigSet(parsed, deps.fs);
949
+ else if (parsed.command === 'config' && parsed.sub === 'unset') res = cmdConfigUnset(parsed, deps.fs);
950
+ else if (parsed.command === 'config' && parsed.sub === 'path') res = cmdConfigPath();
951
+ else if (parsed.command === 'init') res = await cmdInit(parsed, config, deps);
952
+ else if (parsed.command === 'project' && parsed.sub === 'list') res = await cmdProjectList(parsed, client);
953
+ else if (parsed.command === 'product' && parsed.sub === 'list') res = await cmdProductList(parsed, client);
954
+ else if (parsed.command === 'bug' && (parsed.sub === 'list' || parsed.sub === 'search')) res = await cmdBugList(parsed, config, deps);
955
+ else if (parsed.command === 'bug' && parsed.sub === 'get') res = await cmdBugGet(parsed, client);
956
+ else if (parsed.command === 'bug' && parsed.sub === 'create') res = await cmdBugCreate(parsed, client, config.defaultProject);
957
+ else if (parsed.command === 'bug' && parsed.sub === 'resolve') res = await cmdBugResolve(parsed, client, deps);
958
+ else if (parsed.command === 'bug' && parsed.sub === 'assign') res = await cmdBugAssign(parsed, client, deps);
959
+ else if (parsed.command === 'bug' && parsed.sub === 'sync') res = await cmdBugSync(parsed, client, config, deps);
960
+ else if (parsed.command === 'bug' && parsed.sub === 'browse-types') res = cmdBugBrowseTypes(parsed);
961
+ else if (parsed.command === 'ping') res = await cmdPing(parsed, client, config);
962
+ else return { stderr: `未知命令:${[parsed.command, parsed.sub].filter(Boolean).join(' ')}(见 --help)`, exitCode: 2 };
963
+ return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', exitCode: res.exitCode ?? 0 };
964
+ } catch (e) {
965
+ if (e instanceof ZentaoError) return { stderr: `${e.kind}: ${e.message}`, exitCode: exitCodeFor(e.kind) };
966
+ return { stderr: 'NETWORK_ERROR: ' + (e?.message || String(e)), exitCode: 4 };
967
+ }
968
+ }
969
+
970
+ export { main };
971
+
972
+ // 仅在直接执行时跑 main(被测试 import 时不跑)—— 放在文件末尾,确保所有 const 已初始化
973
+ // 用 realpath 比较:npm 全局安装时 node_modules/zentao-cli 是软链,argv[1] 软链路径 ≠ import.meta.url 真实路径,
974
+ // 直接字符串比较会导致 main 不触发(全局命令无输出)。realpath 把两者都解析到真实文件再比较。
975
+ const __isMain = (() => {
976
+ try {
977
+ return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
978
+ } catch {
979
+ return false;
980
+ }
981
+ })();
982
+ if (__isMain) {
983
+ const code = main();
984
+ if (code != null) process.exit(code);
985
+ }