Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
107 changes: 85 additions & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;

interface TestLinkCliOptions {
url?: string;
apiKey?: string;
}
type TestLinkCliOptions = TestLinkConnectionOptions;

function getString(args: CommandArgs, key: string): string | undefined {
const value = args[key];
Expand All @@ -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;",
});
}

Expand All @@ -46,22 +52,7 @@ export function getTestLinkCliOptions(args: CommandArgs): TestLinkCliOptions {
export function resolveTestLinkCliOptions(
options: TestLinkCliOptions,
): Required<TestLinkCliOptions> {
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<TestLinkCliOptions>;
return resolveTestLinkConfig(options);
}

function getRequiredString(args: CommandArgs, key: string): string {
Expand Down Expand Up @@ -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 <action> [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 <action>",
Expand Down Expand Up @@ -369,6 +431,7 @@ function registerRequirementCommands(parser: Argv): Argv {

async function runCli(): Promise<void> {
let parser = addConnectionOptions(yargs(hideBin(process.argv)).scriptName("testlink"));
parser = registerConfigCommands(parser);
parser = registerProjectCommands(parser);
parser = registerCaseCommands(parser);
parser = registerSuiteCommands(parser);
Expand Down
145 changes: 145 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<TestLinkConfigKey, TestLinkConfigSource>;
}

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<string, unknown>;
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<TestLinkConnectionOptions> {
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<TestLinkConnectionOptions>;
}

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;
}
27 changes: 5 additions & 22 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,10 +24,7 @@ dotenv.config({ quiet: true });

type CommandArgs = Record<string, unknown>;

interface TestLinkMcpOptions {
url?: string;
apiKey?: string;
}
type TestLinkMcpOptions = TestLinkConnectionOptions;

function getString(args: CommandArgs, key: string): string | undefined {
const value = args[key];
Expand All @@ -37,34 +35,19 @@ function addConnectionOptions(parser: ReturnType<typeof yargs>): ReturnType<type
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;",
});
}

export function resolveTestLinkMcpOptions(
options: TestLinkMcpOptions,
): Required<TestLinkMcpOptions> {
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<TestLinkMcpOptions>;
return resolveTestLinkConfig(options);
}

function parseMcpOptions(): TestLinkMcpOptions {
Expand Down