From a58b8ea118b9c6053cef03abec51f3f5c24249bf Mon Sep 17 00:00:00 2001 From: hubert Date: Mon, 27 Apr 2026 17:25:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=94=AF=E6=8C=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=A6=85=E9=81=93=E8=BF=9E=E6=8E=A5=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `config` 子命令,支持 get / set / remove 操作 - 本地配置优先级高于环境变量,低于命令行参数 - 配置存储在 `~/.config/zentao/config.json`,权限 0600 - 交互式 `config set` 避免密码暴露在 shell 历史 - 统一 MCP Server 与 CLI 的配置读取逻辑 --- README.md | 26 +++- packages/zentao-mcp/README.md | 26 +++- packages/zentao-mcp/src/cli.ts | 201 ++++++++++++++++++++++++++++-- packages/zentao-mcp/src/config.ts | 156 +++++++++++++++++++++++ packages/zentao-mcp/src/index.ts | 26 ++-- packages/zentao-mcp/src/types.ts | 2 +- packages/zentao-mcp/src/utils.ts | 16 +-- skills/zentao-cli/SKILL.md | 2 +- 8 files changed, 424 insertions(+), 31 deletions(-) create mode 100644 packages/zentao-mcp/src/config.ts diff --git a/README.md b/README.md index 43b6353..3c568fe 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,31 @@ yarn test ## ⚙️ 连接配置 -MCP Server 和 CLI 都支持通过环境变量配置禅道连接信息: +MCP Server 和 CLI 支持通过本地配置或环境变量配置禅道连接信息。 + +推荐使用本地配置,避免密码进入普通业务命令的 shell 历史: + +```bash +zentao config set +``` + +也可以逐项设置: + +```bash +zentao config set url "https://zentao.example.com" +zentao config set account "your_account" +zentao config set password "your_password" +zentao config set version "v2" +zentao config set skipSSL "false" +zentao config get +zentao config get url +zentao config remove password +``` + +解析优先级为:命令行参数 > 本地配置 > 环境变量。 +MCP Server 和 CLI 共用同一份本地配置,因此 `config set` 后无需再为 MCP 单独配置同样的值。 + +也可以使用环境变量配置连接信息: ```bash export ZENTAO_URL="https://zentao.example.com" diff --git a/packages/zentao-mcp/README.md b/packages/zentao-mcp/README.md index abacf9d..cddb2cd 100644 --- a/packages/zentao-mcp/README.md +++ b/packages/zentao-mcp/README.md @@ -168,7 +168,29 @@ npm install -g @acehubert/zentao-mcp zentao --version ``` -推荐使用环境变量配置连接信息,避免密码进入 shell 历史: +推荐使用本地配置或环境变量配置连接信息,避免密码进入普通业务命令的 shell 历史: + +```bash +zentao config set +``` + +也可以逐项设置: + +```bash +zentao config set url "https://your-zentao-server.com" +zentao config set account "your_username" +zentao config set password "your_password" +zentao config set version "v2" +zentao config set skipSSL "false" +zentao config get +zentao config get url +zentao config remove password +``` + +解析优先级为:命令行参数 > 本地配置 > 环境变量。 +MCP Server 和 CLI 共用同一份本地配置,因此 `config set` 后无需再为 MCP 单独配置同样的值。 + +也可以使用环境变量配置连接信息: ```bash export ZENTAO_URL="https://your-zentao-server.com" @@ -185,7 +207,7 @@ zentao users me \ --url "https://your-zentao-server.com" \ --account "your_username" \ --password "your_password" \ - --zentaoVersion "v2" \ + --version "v2" \ --skipSSL ``` diff --git a/packages/zentao-mcp/src/cli.ts b/packages/zentao-mcp/src/cli.ts index 2c75916..29c170d 100644 --- a/packages/zentao-mcp/src/cli.ts +++ b/packages/zentao-mcp/src/cli.ts @@ -4,10 +4,22 @@ * 将 MCP 工具按资源和 action 平铺为可直接执行的 CLI commands。 */ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; import dotenv from "dotenv"; import yargs, { type Argv } from "yargs"; import { hideBin } from "yargs/helpers"; import { createZentaoClient } from "./zentao-clients"; +import { + inspectZentaoConfig, + readSavedZentaoConfig, + removeSavedZentaoConfig, + resolveZentaoConfig, + toConfigBoolean, + toConfigString, + updateSavedZentaoConfig, + type ZentaoConfigSpec, +} from "./config"; import { normalizeZentaoVersion, addZentaoConnectionOptions, @@ -34,26 +46,31 @@ import type { dotenv.config({ quiet: true }); type ClientFactory = (args: ZentaoCommandArgs) => IZentaoClient; +type ZentaoCliConfigKey = Extract; + +const cliConfigKeys = ["url", "account", "password", "version", "skipSSL"] as const; + +const cliConfigSpecs: readonly ZentaoConfigSpec[] = [ + { key: "url", env: "ZENTAO_URL", parse: toConfigString }, + { key: "account", env: "ZENTAO_ACCOUNT", parse: toConfigString }, + { key: "password", env: "ZENTAO_PASSWORD", parse: toConfigString }, + { key: "version", env: "ZENTAO_VERSION", parse: toConfigString }, + { key: "skipSSL", env: "ZENTAO_SKIP_SSL", parse: toConfigBoolean }, +]; export function getZentaoCliOptions(args: ZentaoCommandArgs): ZentaoCliOptions { return { url: getString(args, "url"), account: getString(args, "account"), password: getString(args, "password"), - zentaoVersion: getString(args, "zentaoVersion"), + version: getString(args, "version"), skipSSL: getBoolean(args, "skipSSL"), }; } /** 命令参数优先,未传入时回退到环境变量。 */ export function resolveZentaoCliOptions(options: ZentaoCliOptions): ZentaoCliOptions { - return { - url: options.url ?? process.env.ZENTAO_URL, - account: options.account ?? process.env.ZENTAO_ACCOUNT, - password: options.password ?? process.env.ZENTAO_PASSWORD, - zentaoVersion: options.zentaoVersion ?? process.env.ZENTAO_VERSION, - skipSSL: options.skipSSL ?? process.env.ZENTAO_SKIP_SSL === "true", - }; + return resolveZentaoConfig(options, cliConfigSpecs); } const commonListOptions = (parser: Argv): Argv => @@ -62,6 +79,171 @@ const commonListOptions = (parser: Argv): Argv => describe: "返回数量限制", }); +function getRequiredString(args: ZentaoCommandArgs, key: string): string { + const value = getString(args, key); + if (!value) { + throw new Error(`缺少必要参数: ${key}`); + } + return value; +} + +function printConfigValue(key: ZentaoCliConfigKey, value: unknown): void { + console.log(`${key}: ${value ?? "null"}`); +} + +function printConfigValues(config: ZentaoCliOptions): void { + for (const key in cliConfigKeys) { + printConfigValue(key as ZentaoCliConfigKey, config[key as ZentaoCliConfigKey]); + } +} + +function normalizeConfigKey(key: string): ZentaoCliConfigKey { + if (key === "url") return "url"; + if (key === "account") return "account"; + if (key === "password") return "password"; + if (key === "version") return "version"; + if (key === "skipSSL" || key === "skip-ssl") return "skipSSL"; + throw new Error(`不支持的配置项: ${key},可用配置项: ${cliConfigKeys.join(", ")}`); +} + +function parseConfigSetValue(key: ZentaoCliConfigKey, value: string): string | boolean { + if (key !== "skipSSL") return value; + + const parsed = toConfigBoolean(value); + if (parsed === undefined) { + throw new Error(`配置项 ${key} 的值无效`); + } + return parsed; +} + +function getConfigValue(config: ZentaoCliOptions, key: ZentaoCliConfigKey): unknown { + return config[key]; +} + +function formatInteractiveCurrentValue(key: ZentaoCliConfigKey, value: unknown): string { + if (value === undefined) return "未设置"; + if (key === "password") return "已设置"; + return String(value); +} + +function formatInteractiveSavedValue(key: ZentaoCliConfigKey, value: unknown): string { + if (key === "password" && value !== undefined) return "******"; + return String(value); +} + +async function promptConfigSet(): Promise { + const savedConfig = readSavedZentaoConfig(); + const reader = createInterface({ input, output }); + const pendingEntries: Array<[ZentaoCliConfigKey, string | boolean]> = []; + + try { + for (const key of cliConfigKeys) { + const currentValue = savedConfig[key]; + const answer = await reader.question( + `${key}(当前: ${formatInteractiveCurrentValue(key, currentValue)},留空跳过): `, + ); + const trimmedAnswer = answer.trim(); + if (!trimmedAnswer) continue; + + pendingEntries.push([key, parseConfigSetValue(key, trimmedAnswer)]); + } + + if (pendingEntries.length === 0) { + console.log("未修改任何配置"); + return; + } + + console.log("将写入以下配置:"); + for (const [key, value] of pendingEntries) { + printConfigValue(key, formatInteractiveSavedValue(key, value)); + } + + const confirmed = await reader.question("确认保存? (y/N): "); + if (!["y", "yes"].includes(confirmed.trim().toLowerCase())) { + console.log("已取消"); + return; + } + + for (const [key, value] of pendingEntries) { + const nextConfig = updateSavedZentaoConfig(key, value); + printConfigValue(key, formatInteractiveSavedValue(key, nextConfig[key])); + } + } finally { + reader.close(); + } +} + +function registerConfigCommands(parser: Argv): Argv { + return parser.command( + "config [key] [value]", + "连接配置操作:get / set / remove", + (command) => + command + .positional("action", { + choices: ["get", "set", "remove"] as const, + describe: "操作类型", + }) + .positional("key", { + type: "string", + choices: cliConfigKeys, + describe: `配置项:${cliConfigKeys.join(" / ")}`, + }) + .positional("value", { + type: "string", + describe: "配置值", + }), + async (args: ZentaoCommandArgs) => { + const action = getRequiredString(args, "action"); + const rawKey = getString(args, "key"); + const rawValue = getString(args, "value"); + + switch (action) { + case "get": { + const inspection = inspectZentaoConfig(getZentaoCliOptions(args), cliConfigSpecs); + if (rawValue !== undefined) { + throw new Error("config get 不支持 value 参数"); + } + + if (rawKey) { + const key = normalizeConfigKey(rawKey); + printConfigValue(key, getConfigValue(inspection.values, key)); + return; + } + + printConfigValues(inspection.values); + return; + } + case "set": { + if (!rawKey) { + await promptConfigSet(); + return; + } + if (rawValue === undefined) { + throw new Error("缺少必要参数: value"); + } + + const key = normalizeConfigKey(rawKey); + const nextConfig = updateSavedZentaoConfig(key, parseConfigSetValue(key, rawValue)); + printConfigValue(key, nextConfig[key]); + return; + } + case "remove": { + if (!rawKey) throw new Error("缺少必要参数: key"); + if (rawValue !== undefined) { + throw new Error("config remove 不支持 value 参数"); + } + const key = normalizeConfigKey(rawKey); + removeSavedZentaoConfig(key); + console.log(`${key} removed`); + return; + } + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + function registerClientCommands(parser: Argv, getClient: ClientFactory): Argv { return parser.command( "client ", @@ -591,7 +773,7 @@ async function main(): Promise { password: options.password, rejectUnauthorized: options.skipSSL ? false : undefined, }, - normalizeZentaoVersion(options.zentaoVersion), + normalizeZentaoVersion(options.version), ); return client; }; @@ -609,6 +791,7 @@ async function main(): Promise { .wrap(Math.min(100, yargs().terminalWidth())); registerClientCommands(parser, getClient); + registerConfigCommands(parser); registerBugCommands(parser, getClient); registerStoryCommands(parser, getClient); registerTestCaseCommands(parser, getClient); diff --git a/packages/zentao-mcp/src/config.ts b/packages/zentao-mcp/src/config.ts new file mode 100644 index 0000000..411f5e6 --- /dev/null +++ b/packages/zentao-mcp/src/config.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ZentaoClientBaseOptions } from "./types"; + +export type ZentaoSavedConfig = Record; +export type ZentaoConfigKey = Extract; +export type ZentaoConfigSource = "argument" | "config" | "env" | "unset"; + +export interface ZentaoConfigSpec { + key: ZentaoConfigKey; + env?: string; + parse?: (value: unknown) => T[ZentaoConfigKey] | undefined; +} + +export interface ZentaoConfigInspection { + configFile: string; + values: T; + sources: Partial, ZentaoConfigSource>>; +} + +export function toConfigString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +} + +export function toConfigBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + + const normalizedValue = value.trim().toLowerCase(); + if (["true", "1", "yes", "y", "on"].includes(normalizedValue)) return true; + if (["false", "0", "no", "n", "off"].includes(normalizedValue)) return false; + return undefined; +} + +export function getZentaoConfigFilePath(): string { + if (process.env.ZENTAO_CONFIG_FILE) { + return process.env.ZENTAO_CONFIG_FILE; + } + + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configHome, "zentao", "config.json"); +} + +export function readSavedZentaoConfig(): ZentaoSavedConfig { + const configFile = getZentaoConfigFilePath(); + if (!fs.existsSync(configFile)) { + return {}; + } + + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`配置文件格式错误: ${configFile}`); + } + + const rawConfig = parsed as Record; + return Object.fromEntries( + Object.entries(rawConfig).filter(([, value]) => { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); + }), + ) as ZentaoSavedConfig; +} + +export function writeSavedZentaoConfig(config: ZentaoSavedConfig): void { + const configFile = getZentaoConfigFilePath(); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync(`${configFile}.tmp`, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(`${configFile}.tmp`, configFile); + fs.chmodSync(configFile, 0o600); +} + +function resolveConfigValue( + argumentValue: TValue | undefined, + savedValue: TValue | undefined, + envValue: TValue | undefined, +): { value?: TValue; source: ZentaoConfigSource } { + if (argumentValue !== undefined) return { value: argumentValue, source: "argument" }; + if (savedValue !== undefined) return { value: savedValue, source: "config" }; + if (envValue !== undefined) return { value: envValue, source: "env" }; + return { source: "unset" }; +} + +function parseConfigValue( + spec: ZentaoConfigSpec, + value: unknown, +): T[ZentaoConfigKey] | undefined { + if (value === undefined || value === null) return undefined; + return spec.parse ? spec.parse(value) : (value as T[ZentaoConfigKey]); +} + +export function inspectZentaoConfig( + options: T, + specs: readonly ZentaoConfigSpec[], +): ZentaoConfigInspection { + const savedConfig = readSavedZentaoConfig(); + const values = { ...options }; + const sources: Partial, ZentaoConfigSource>> = {}; + + for (const spec of specs) { + const key = spec.key; + const resolved = resolveConfigValue( + parseConfigValue(spec, options[key]), + parseConfigValue(spec, savedConfig[key]), + parseConfigValue(spec, spec.env ? process.env[spec.env] : undefined), + ); + + if (resolved.value !== undefined) { + values[key] = resolved.value; + } + sources[key] = resolved.source; + } + + return { + configFile: getZentaoConfigFilePath(), + values, + sources, + }; +} + +export function resolveZentaoConfig( + options: T, + specs: readonly ZentaoConfigSpec[], +): T { + return inspectZentaoConfig(options, specs).values; +} + +export function updateSavedZentaoConfig( + key: string, + value: string | number | boolean | null, +): ZentaoSavedConfig { + const nextConfig = { + ...readSavedZentaoConfig(), + [key]: value, + }; + writeSavedZentaoConfig(nextConfig); + return nextConfig; +} + +export function removeSavedZentaoConfig(key: string): ZentaoSavedConfig { + const nextConfig = { + ...readSavedZentaoConfig(), + [key]: undefined, + }; + writeSavedZentaoConfig(nextConfig); + return nextConfig; +} diff --git a/packages/zentao-mcp/src/index.ts b/packages/zentao-mcp/src/index.ts index a41e8ba..fbf950f 100644 --- a/packages/zentao-mcp/src/index.ts +++ b/packages/zentao-mcp/src/index.ts @@ -53,6 +53,12 @@ import dotenv from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { createZentaoClient } from "./zentao-clients"; +import { + resolveZentaoConfig, + toConfigBoolean, + toConfigString, + type ZentaoConfigSpec, +} from "./config"; import { normalizeZentaoVersion, addZentaoConnectionOptions, @@ -78,12 +84,20 @@ import type { // 加载环境变量 dotenv.config({ quiet: true }); +const mcpConfigSpecs: readonly ZentaoConfigSpec[] = [ + { key: "url", env: "ZENTAO_URL", parse: toConfigString }, + { key: "account", env: "ZENTAO_ACCOUNT", parse: toConfigString }, + { key: "password", env: "ZENTAO_PASSWORD", parse: toConfigString }, + { key: "version", env: "ZENTAO_VERSION", parse: toConfigString }, + { key: "skipSSL", env: "ZENTAO_SKIP_SSL", parse: toConfigBoolean }, +]; + export function getZentaoMcpOptions(args: ZentaoCommandArgs): ZentaoMcpOptions { return { url: getString(args, "url"), account: getString(args, "account"), password: getString(args, "password"), - zentaoVersion: getString(args, "zentaoVersion"), + version: getString(args, "version"), skipSSL: getBoolean(args, "skipSSL"), }; } @@ -117,13 +131,7 @@ async function saveFileToTempDirectory(file: ZentaoFileReadResult): Promise<{ /** 命令参数优先,未传入时回退到环境变量。 */ function resolveZentaoMcpOptions(options: ZentaoMcpOptions): ZentaoMcpOptions { - return { - url: options.url ?? process.env.ZENTAO_URL, - account: options.account ?? process.env.ZENTAO_ACCOUNT, - password: options.password ?? process.env.ZENTAO_PASSWORD, - zentaoVersion: options.zentaoVersion ?? process.env.ZENTAO_VERSION, - skipSSL: options.skipSSL ?? process.env.ZENTAO_SKIP_SSL === "true", - }; + return resolveZentaoConfig(options, mcpConfigSpecs); } /** 解析 MCP Server 启动参数。 */ @@ -154,7 +162,7 @@ try { password: options.password, rejectUnauthorized: options.skipSSL ? false : undefined, }, - normalizeZentaoVersion(options.zentaoVersion), + normalizeZentaoVersion(options.version), ); } catch (error) { console.error(error instanceof Error ? error.message : "MCP Server 配置错误"); diff --git a/packages/zentao-mcp/src/types.ts b/packages/zentao-mcp/src/types.ts index c475c54..c5bc184 100644 --- a/packages/zentao-mcp/src/types.ts +++ b/packages/zentao-mcp/src/types.ts @@ -7,7 +7,7 @@ export interface ZentaoClientBaseOptions { } export interface ZentaoMcpOptions extends ZentaoClientBaseOptions { - zentaoVersion?: string; + version?: string; skipSSL?: boolean; } diff --git a/packages/zentao-mcp/src/utils.ts b/packages/zentao-mcp/src/utils.ts index 7b0352b..072aba7 100644 --- a/packages/zentao-mcp/src/utils.ts +++ b/packages/zentao-mcp/src/utils.ts @@ -77,9 +77,9 @@ export function addZentaoConnectionOptions(parser: Argv): Argv { type: "string", describe: "禅道密码;未传入时读取 ZENTAO_PASSWORD", }) - .option("zentaoVersion", { + .option("version", { type: "string", - choices: ["legacy", "v1", "v2", "0", "1", "2"] as const, + choices: ["legacy", "v1", "v2"] as const, describe: "禅道客户端版本;未传入时读取 ZENTAO_VERSION", }) .option("skipSSL", { @@ -98,12 +98,12 @@ export function verifyZentaoClientOptions( if (!url || !account || !password) { throw new Error( [ - `请通过${sourceName}或环境变量提供禅道连接配置:`, - "--url / ZENTAO_URL - 禅道服务器地址", - "--account / ZENTAO_ACCOUNT - 禅道用户名", - "--password / ZENTAO_PASSWORD - 禅道密码", - "--zentaoVersion / ZENTAO_VERSION - 客户端版本(可选,支持 legacy / v1 / v2)", - "--skipSSL / ZENTAO_SKIP_SSL - 是否跳过 SSL 验证(可选,自签名证书时设为 true)", + `请通过${sourceName}、本地配置或环境变量提供禅道连接配置:`, + "--url / config url / ZENTAO_URL - 禅道服务器地址", + "--account / config account / ZENTAO_ACCOUNT - 禅道用户名", + "--password / config password / ZENTAO_PASSWORD - 禅道密码", + "--version / config version / ZENTAO_VERSION - 客户端版本(可选,支持 legacy / v1 / v2)", + "--skipSSL / config skipSSL / ZENTAO_SKIP_SSL - 是否跳过 SSL 验证(可选,自签名证书时设为 true)", ].join("\n"), ); } diff --git a/skills/zentao-cli/SKILL.md b/skills/zentao-cli/SKILL.md index 54553a2..7a78c8f 100644 --- a/skills/zentao-cli/SKILL.md +++ b/skills/zentao-cli/SKILL.md @@ -44,7 +44,7 @@ zentao users me \ --url "https://zentao.example.com" \ --account "user" \ --password "password" \ - --zentaoVersion "v2" \ + --version "v2" \ --skipSSL ```