diff --git a/README.md b/README.md index 956d3b3..3304569 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,20 @@ npm install -g @acehubert/testlink-mcp testlink --version ``` -推荐使用环境变量配置连接信息,避免 API Key 进入 shell 历史: +推荐使用本地配置或环境变量配置连接信息,避免 API Key 进入普通业务命令的 shell 历史: + +```bash +testlink config set url "https://your-testlink-server.com/testlink" +testlink config set apiKey "your_api_key" +testlink config get +testlink config get url +testlink config remove apiKey +``` + +解析优先级为:命令行参数 > 本地配置 > 环境变量。 +MCP Server 和 CLI 共用同一份本地配置,因此 `config set` 后无需再为 MCP 单独配置同样的值。 + +也可以使用环境变量配置连接信息: ```bash export TESTLINK_URL="https://your-testlink-server.com/testlink" diff --git a/src/cli.ts b/src/cli.ts index 23c92ed..595e5e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,15 +8,21 @@ import dotenv from "dotenv"; import yargs, { type Argv, type ArgumentsCamelCase } from "yargs"; import { hideBin } from "yargs/helpers"; import { TestLinkAPI, type TestLinkRecord } from "./api.js"; +import { + configKeys, + inspectTestLinkConfig, + removeSavedTestLinkConfig, + resolveTestLinkConfig, + updateSavedTestLinkConfig, + type TestLinkConfigKey, + type TestLinkConnectionOptions, +} from "./config.js"; dotenv.config({ quiet: true }); type CommandArgs = ArgumentsCamelCase>; -interface TestLinkCliOptions { - url?: string; - apiKey?: string; -} +type TestLinkCliOptions = TestLinkConnectionOptions; function getString(args: CommandArgs, key: string): string | undefined { const value = args[key]; @@ -27,12 +33,12 @@ function addConnectionOptions(parser: Argv): Argv { return parser .option("url", { type: "string", - describe: "TestLink 服务地址;未传入时读取 TESTLINK_URL", + describe: "服务地址;", }) .option("apiKey", { type: "string", alias: "api-key", - describe: "TestLink API Key;未传入时读取 TESTLINK_API_KEY", + describe: "API Key;", }); } @@ -46,22 +52,7 @@ export function getTestLinkCliOptions(args: CommandArgs): TestLinkCliOptions { export function resolveTestLinkCliOptions( options: TestLinkCliOptions, ): Required { - const resolvedOptions = { - url: options.url ?? process.env.TESTLINK_URL, - apiKey: options.apiKey ?? process.env.TESTLINK_API_KEY, - }; - - if (!resolvedOptions.url || !resolvedOptions.apiKey) { - throw new Error( - [ - "请通过命令行参数或环境变量提供 TestLink 连接配置:", - "--url / TESTLINK_URL - TestLink 服务地址", - "--apiKey / TESTLINK_API_KEY - TestLink API Key", - ].join("\n"), - ); - } - - return resolvedOptions as Required; + return resolveTestLinkConfig(options); } function getRequiredString(args: CommandArgs, key: string): string { @@ -92,11 +83,82 @@ function printJsonResult(result: unknown): void { console.log(JSON.stringify(result, null, 2)); } +function printConfigValue(key: TestLinkConfigKey, value: string | undefined): void { + console.log(`${key}: ${value ?? "null"}`); +} + +function printConfigValues(config: TestLinkCliOptions): void { + printConfigValue("url", config.url); + printConfigValue("apiKey", config.apiKey); +} + function getClient(args: CommandArgs): TestLinkAPI { const options = resolveTestLinkCliOptions(getTestLinkCliOptions(args)); return new TestLinkAPI(options.url, options.apiKey); } +function normalizeConfigKey(key: string): TestLinkConfigKey { + if (key === "url") return "url"; + if (key === "apiKey" || key === "api-key") return "apiKey"; + throw new Error(`不支持的配置项: ${key},可用配置项: url, apiKey`); +} + +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: configKeys, + describe: "配置项:url / apiKey", + }) + .positional("value", { + type: "string", + describe: "配置值", + }), + async (args: CommandArgs) => { + const action = getRequiredString(args, "action"); + + switch (action) { + case "get": { + const inspection = inspectTestLinkConfig(getTestLinkCliOptions(args)); + const rawKey = getString(args, "key"); + + if (rawKey) { + const key = normalizeConfigKey(rawKey); + printConfigValue(key, inspection.values[key]); + return; + } + + printConfigValues(inspection.values); + return; + } + case "set": { + const key = normalizeConfigKey(getRequiredString(args, "key")); + const value = getRequiredString(args, "value"); + updateSavedTestLinkConfig(key, value); + printConfigValue(key, value); + return; + } + case "remove": { + const key = normalizeConfigKey(getRequiredString(args, "key")); + removeSavedTestLinkConfig(key); + console.log(`${key} removed`); + return; + } + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + function registerProjectCommands(parser: Argv): Argv { return parser.command( "projects ", @@ -369,6 +431,7 @@ function registerRequirementCommands(parser: Argv): Argv { async function runCli(): Promise { let parser = addConnectionOptions(yargs(hideBin(process.argv)).scriptName("testlink")); + parser = registerConfigCommands(parser); parser = registerProjectCommands(parser); parser = registerCaseCommands(parser); parser = registerSuiteCommands(parser); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..94223de --- /dev/null +++ b/src/config.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface TestLinkConnectionOptions { + url?: string; + apiKey?: string; +} + +export type TestLinkConfigKey = keyof TestLinkConnectionOptions; +export type TestLinkConfigSource = "argument" | "config" | "env" | "unset"; + +export interface TestLinkConfigInspection { + configFile: string; + values: TestLinkConnectionOptions; + sources: Record; +} + +export const configKeys = ["url", "apiKey"] as const; + +export function getTestLinkConfigFilePath(): string { + if (process.env.TESTLINK_CONFIG_FILE) { + return process.env.TESTLINK_CONFIG_FILE; + } + + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configHome, "testlink", "config.json"); +} + +function toConfigValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +} + +export function readSavedTestLinkConfig(): TestLinkConnectionOptions { + const configFile = getTestLinkConfigFilePath(); + 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 { + url: toConfigValue(rawConfig.url), + apiKey: toConfigValue(rawConfig.apiKey), + }; +} + +export function writeSavedTestLinkConfig(config: TestLinkConnectionOptions): void { + const configFile = getTestLinkConfigFilePath(); + 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: string | undefined, + savedValue: string | undefined, + envValue: string | undefined, +): { value?: string; source: TestLinkConfigSource } { + if (argumentValue) return { value: argumentValue, source: "argument" }; + if (savedValue) return { value: savedValue, source: "config" }; + if (envValue) return { value: envValue, source: "env" }; + return { source: "unset" }; +} + +export function inspectTestLinkConfig( + options: TestLinkConnectionOptions, +): TestLinkConfigInspection { + const savedConfig = readSavedTestLinkConfig(); + const url = resolveConfigValue(options.url, savedConfig.url, process.env.TESTLINK_URL); + const apiKey = resolveConfigValue( + options.apiKey, + savedConfig.apiKey, + process.env.TESTLINK_API_KEY, + ); + + return { + configFile: getTestLinkConfigFilePath(), + values: { + url: url.value, + apiKey: apiKey.value, + }, + sources: { + url: url.source, + apiKey: apiKey.source, + }, + }; +} + +export function resolveTestLinkConfig( + options: TestLinkConnectionOptions, +): Required { + const inspection = inspectTestLinkConfig(options); + + if (!inspection.values.url || !inspection.values.apiKey) { + throw new Error( + [ + "请通过命令行参数、本地配置或环境变量提供 TestLink 连接配置:", + "--url / config url / TESTLINK_URL - TestLink 服务地址", + "--apiKey / config apiKey / TESTLINK_API_KEY - TestLink API Key", + `配置文件: ${inspection.configFile}`, + ].join("\n"), + ); + } + + return inspection.values as Required; +} + +export function updateSavedTestLinkConfig( + key: TestLinkConfigKey, + value: string, +): TestLinkConnectionOptions { + if (!configKeys.includes(key)) { + throw new Error(`不支持的配置项: ${key}`); + } + + const nextConfig = { + ...readSavedTestLinkConfig(), + [key]: value, + }; + writeSavedTestLinkConfig(nextConfig); + return nextConfig; +} + +export function removeSavedTestLinkConfig(key: TestLinkConfigKey): TestLinkConnectionOptions { + if (!configKeys.includes(key)) { + throw new Error(`不支持的配置项: ${key}`); + } + + const nextConfig = { + ...readSavedTestLinkConfig(), + [key]: undefined, + }; + writeSavedTestLinkConfig(nextConfig); + return nextConfig; +} diff --git a/src/index.ts b/src/index.ts index 7af76be..baa8505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import dotenv from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { TestLinkAPI } from "./api.js"; +import { resolveTestLinkConfig, type TestLinkConnectionOptions } from "./config.js"; export { TestLinkAPI } from "./api.js"; export type { TestLinkApiOptions } from "./api.js"; @@ -23,10 +24,7 @@ dotenv.config({ quiet: true }); type CommandArgs = Record; -interface TestLinkMcpOptions { - url?: string; - apiKey?: string; -} +type TestLinkMcpOptions = TestLinkConnectionOptions; function getString(args: CommandArgs, key: string): string | undefined { const value = args[key]; @@ -37,34 +35,19 @@ function addConnectionOptions(parser: ReturnType): ReturnType { - const resolvedOptions = { - url: options.url ?? process.env.TESTLINK_URL, - apiKey: options.apiKey ?? process.env.TESTLINK_API_KEY, - }; - - if (!resolvedOptions.url || !resolvedOptions.apiKey) { - throw new Error( - [ - "请通过启动参数或环境变量提供 TestLink 连接配置:", - "--url / TESTLINK_URL - TestLink 服务地址", - "--apiKey / TESTLINK_API_KEY - TestLink API Key", - ].join("\n"), - ); - } - - return resolvedOptions as Required; + return resolveTestLinkConfig(options); } function parseMcpOptions(): TestLinkMcpOptions {