test-execution-platform-mcp 0.6.0-rc2
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 +86 -0
- package/dist/config.js +11 -0
- package/dist/http/client.js +70 -0
- package/dist/index.js +59 -0
- package/dist/setup/cli.js +321 -0
- package/dist/setup/config.js +80 -0
- package/dist/setup/detect.js +46 -0
- package/dist/setup/index.js +8 -0
- package/dist/setup/toml-config.js +46 -0
- package/dist/setup/ui.js +54 -0
- package/dist/setup/uninstall.js +30 -0
- package/dist/setup/update.js +186 -0
- package/dist/setup/verify.js +137 -0
- package/dist/setup/version.js +32 -0
- package/dist/tools/core.js +82 -0
- package/dist/tools/dryRun.js +11 -0
- package/dist/tools/workflows.js +70 -0
- package/dist/types.js +6 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Test Execution Platform MCP Server
|
|
2
|
+
|
|
3
|
+
Local stdio MCP server for `https://qc.vivas.vn`.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## MCP config per agent
|
|
13
|
+
|
|
14
|
+
### Claude Desktop (`claude_desktop_config.json`)
|
|
15
|
+
|
|
16
|
+
Path:
|
|
17
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
18
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
19
|
+
- Linux: `$XDG_CONFIG_HOME/Claude/claude_desktop_config.json`
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"test-execution-platform": {
|
|
25
|
+
"command": "node",
|
|
26
|
+
"args": ["/path/to/test_execution_platform/mcp-server/dist/index.js"],
|
|
27
|
+
"env": {
|
|
28
|
+
"TEST_EXECUTION_API_BASE_URL": "https://qc.vivas.vn",
|
|
29
|
+
"TEST_EXECUTION_MCP_TOKEN": "tep_mcp_xxx"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Claude Code (`~/.claude.json`)
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"test-execution-platform": {
|
|
42
|
+
"command": "node",
|
|
43
|
+
"args": ["/path/to/test_execution_platform/mcp-server/dist/index.js"],
|
|
44
|
+
"env": {
|
|
45
|
+
"TEST_EXECUTION_API_BASE_URL": "https://qc.vivas.vn",
|
|
46
|
+
"TEST_EXECUTION_MCP_TOKEN": "tep_mcp_xxx"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Codex CLI (`$CODEX_HOME/config.toml`)
|
|
54
|
+
|
|
55
|
+
Path: `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`; override with the `CODEX_HOME` env var).
|
|
56
|
+
|
|
57
|
+
```toml
|
|
58
|
+
[mcp_servers.test-execution-platform]
|
|
59
|
+
command = "node"
|
|
60
|
+
args = ["/path/to/test_execution_platform/mcp-server/dist/index.js"]
|
|
61
|
+
env = { TEST_EXECUTION_API_BASE_URL = "https://qc.vivas.vn", TEST_EXECUTION_MCP_TOKEN = "tep_mcp_xxx" }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> Tip: chạy `dist/setup/cli.js` (hoặc `qc-mcp.exe setup` sau khi bundle) để wizard tự detect Claude Desktop, Claude Code, **và Codex CLI** rồi ghi config cho cả ba.
|
|
65
|
+
|
|
66
|
+
## Tool groups
|
|
67
|
+
|
|
68
|
+
### Core tools
|
|
69
|
+
|
|
70
|
+
- Projects/rounds: `list_projects`, `create_project`, `update_project`, `delete_project`, `list_rounds`, `create_round`, `update_round`, `delete_round`
|
|
71
|
+
- Sessions: `open_session`, `list_sessions`, `get_session`, `close_session`, `list_branches`
|
|
72
|
+
- Files/user stories: `get_file_tree`, `get_user_story_file`, `check_user_story_file_exists`, `delete_file`
|
|
73
|
+
- Testcases: `get_testcases_by_path`, `create_testcase`, `update_testcase`, `delete_testcase`, `update_testcase_dev_status`
|
|
74
|
+
- Notes: `get_notes`, `create_note`, `update_note`, `delete_note`
|
|
75
|
+
- Excel: `analyze_excel`, `import_excel`
|
|
76
|
+
|
|
77
|
+
### Workflow tools
|
|
78
|
+
|
|
79
|
+
- `prepare_review_context`
|
|
80
|
+
- `import_excel_with_preview`
|
|
81
|
+
- `bulk_update_testcases_with_preview`
|
|
82
|
+
- `prepare_ci_execution_context`
|
|
83
|
+
|
|
84
|
+
## Guardrails
|
|
85
|
+
|
|
86
|
+
Use `dry_run: true` before executing import, bulk update, and delete tools. Single testcase updates and note creation execute directly using the MCP token user's permissions.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function loadConfig(env = process.env) {
|
|
2
|
+
const token = env.TEST_EXECUTION_MCP_TOKEN?.trim();
|
|
3
|
+
if (!token) {
|
|
4
|
+
throw new Error('TEST_EXECUTION_MCP_TOKEN is required');
|
|
5
|
+
}
|
|
6
|
+
const rawBaseUrl = env.TEST_EXECUTION_API_BASE_URL?.trim() || 'https://qc.vivas.vn';
|
|
7
|
+
return {
|
|
8
|
+
apiBaseUrl: rawBaseUrl.replace(/\/+$/, ''),
|
|
9
|
+
mcpToken: token,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
backendError;
|
|
4
|
+
nextSteps;
|
|
5
|
+
constructor(status, message, backendError, nextSteps) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.backendError = backendError;
|
|
9
|
+
this.nextSteps = nextSteps;
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class ApiClient {
|
|
14
|
+
config;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
constructor(config, fetchImpl = fetch) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.fetchImpl = fetchImpl;
|
|
19
|
+
}
|
|
20
|
+
get(path) {
|
|
21
|
+
return this.request('GET', path);
|
|
22
|
+
}
|
|
23
|
+
post(path, body) {
|
|
24
|
+
return this.request('POST', path, body);
|
|
25
|
+
}
|
|
26
|
+
put(path, body) {
|
|
27
|
+
return this.request('PUT', path, body);
|
|
28
|
+
}
|
|
29
|
+
patch(path, body) {
|
|
30
|
+
return this.request('PATCH', path, body);
|
|
31
|
+
}
|
|
32
|
+
delete(path) {
|
|
33
|
+
return this.request('DELETE', path);
|
|
34
|
+
}
|
|
35
|
+
async request(method, path, body) {
|
|
36
|
+
const response = await this.fetchImpl(this.toUrl(path), {
|
|
37
|
+
method,
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${this.config.mcpToken}`,
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
const data = await response.json().catch(() => ({}));
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const message = typeof data?.error === 'string' ? data.error : `Backend returned HTTP ${response.status}`;
|
|
47
|
+
throw new ApiError(response.status, message, data, nextStepsForStatus(response.status));
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
toUrl(path) {
|
|
52
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
53
|
+
return `${this.config.apiBaseUrl}${normalizedPath}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function nextStepsForStatus(status) {
|
|
57
|
+
if (status === 401)
|
|
58
|
+
return ['Check TEST_EXECUTION_MCP_TOKEN or regenerate the token in account UI.'];
|
|
59
|
+
if (status === 403)
|
|
60
|
+
return ['Check the user role associated with the MCP token.'];
|
|
61
|
+
if (status === 404)
|
|
62
|
+
return ['Call list/get tools to verify the resource ID or path.'];
|
|
63
|
+
if (status === 409)
|
|
64
|
+
return ['Resolve duplicate/conflicting project, round, or testcase data.'];
|
|
65
|
+
if (status === 422)
|
|
66
|
+
return ['Validate markdown, Excel input, or testcase payload before retrying.'];
|
|
67
|
+
if (status >= 500)
|
|
68
|
+
return ['Check backend, Python service, or Git workspace logs before retrying.'];
|
|
69
|
+
return ['Check the tool input and retry.'];
|
|
70
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { ApiClient } from './http/client.js';
|
|
6
|
+
import { createCoreTools } from './tools/core.js';
|
|
7
|
+
import { createWorkflowTools } from './tools/workflows.js';
|
|
8
|
+
import { dispatch as setupDispatch } from './setup/cli.js';
|
|
9
|
+
// Single-binary dispatch: if argv contains a setup subcommand, forward to
|
|
10
|
+
// the wizard CLI instead of starting the stdio MCP server. This keeps the
|
|
11
|
+
// bundled binary to one file per platform.
|
|
12
|
+
const SETUP_SUBCOMMANDS = new Set([
|
|
13
|
+
'setup',
|
|
14
|
+
'verify',
|
|
15
|
+
'update',
|
|
16
|
+
'uninstall',
|
|
17
|
+
'version',
|
|
18
|
+
'help',
|
|
19
|
+
'--help',
|
|
20
|
+
'-h',
|
|
21
|
+
]);
|
|
22
|
+
const sub = process.argv[2];
|
|
23
|
+
if (sub && SETUP_SUBCOMMANDS.has(sub)) {
|
|
24
|
+
// Hand off to the setup CLI. The setup module guards its own argv entry
|
|
25
|
+
// via isDirectInvocation, so this import is safe.
|
|
26
|
+
setupDispatch(sub)
|
|
27
|
+
.then((code) => process.exit(code))
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
console.error('Unhandled error:', err);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// stdio MCP server path. Config is loaded lazily here so a missing token
|
|
35
|
+
// does not prevent `--help` / `--version` style commands from working.
|
|
36
|
+
const config = loadConfig();
|
|
37
|
+
const apiClient = new ApiClient(config);
|
|
38
|
+
const server = new McpServer({ name: 'test-execution-platform', version: '0.1.0' });
|
|
39
|
+
server.tool('health_check', 'Check backend connectivity and MCP token configuration.', {}, async () => {
|
|
40
|
+
const result = await apiClient.get('/health');
|
|
41
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
42
|
+
});
|
|
43
|
+
const coreTools = createCoreTools(apiClient);
|
|
44
|
+
const workflowTools = createWorkflowTools(apiClient);
|
|
45
|
+
for (const [name, definition] of Object.entries(coreTools)) {
|
|
46
|
+
server.tool(name, definition.description, definition.inputSchema.shape, async (input) => {
|
|
47
|
+
const result = await definition.handler(input);
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const [name, definition] of Object.entries(workflowTools)) {
|
|
52
|
+
server.tool(name, definition.description, definition.inputSchema.shape, async (input) => {
|
|
53
|
+
const result = await definition.handler(input);
|
|
54
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const transport = new StdioServerTransport();
|
|
58
|
+
await server.connect(transport);
|
|
59
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { exit } from 'node:process';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { getVersion } from './version.js';
|
|
7
|
+
import { formatBanner, formatStep, formatSuccess, formatWarn, formatError, prompt } from './ui.js';
|
|
8
|
+
import { detectClaudePaths } from './detect.js';
|
|
9
|
+
import { buildMcpEntry, mergeMcpServer, readJsonSafe, writeConfigWithBackup } from './config.js';
|
|
10
|
+
import { readTomlSafe, mergeCodexMcpServer, writeTomlWithBackup } from './toml-config.js';
|
|
11
|
+
import { verifyMcpHandshake } from './verify.js';
|
|
12
|
+
import { restoreOrRemoveMcpServer, restoreOrRemoveCodexMcpServer } from './uninstall.js';
|
|
13
|
+
import { fetchLatestRelease, fetchChecksums, parseChecksums, verifySha256, swapBinary, getInstalledVersion, compareVersions, selectAssetForPlatform, downloadToFile, confirmUpdate, } from './update.js';
|
|
14
|
+
// __filename / __dirname: when compiled to CommonJS, these are runtime
|
|
15
|
+
// globals; when compiled to ESM we derive them from import.meta.url.
|
|
16
|
+
const localFilename = typeof __filename !== 'undefined'
|
|
17
|
+
? __filename
|
|
18
|
+
: fileURLToPath(import.meta.url);
|
|
19
|
+
const localDirname = typeof __dirname !== 'undefined'
|
|
20
|
+
? __dirname
|
|
21
|
+
: dirname(localFilename);
|
|
22
|
+
const DEFAULT_API_BASE_URL = 'https://qc.vivas.vn';
|
|
23
|
+
const SERVER_NAME = 'test-execution-platform';
|
|
24
|
+
const TOKEN_PATTERN = /^tep_mcp_[a-f0-9]{64,}$/;
|
|
25
|
+
function printUsage() {
|
|
26
|
+
const lines = [
|
|
27
|
+
'QC MCP Setup Wizard',
|
|
28
|
+
'',
|
|
29
|
+
'Usage:',
|
|
30
|
+
' qc-mcp-setup.exe # run setup wizard (interactive)',
|
|
31
|
+
' qc-mcp.exe setup # same as above',
|
|
32
|
+
' qc-mcp.exe verify # run stdio handshake check',
|
|
33
|
+
' qc-mcp.exe update [flags] # download and replace binary',
|
|
34
|
+
' qc-mcp.exe uninstall # restore Claude/Codex config from backup',
|
|
35
|
+
' qc-mcp.exe version # print version',
|
|
36
|
+
' qc-mcp.exe help # this message',
|
|
37
|
+
'',
|
|
38
|
+
'Update flags:',
|
|
39
|
+
' --yes, -y skip interactive confirmation',
|
|
40
|
+
' --binary <path> override binary path (default: process.execPath)',
|
|
41
|
+
' --project <path> GitLab project path (default: dungnt/test_execution_platform)',
|
|
42
|
+
' --gitlab-url <url> GitLab base URL (default: https://gitlab.vivas.vn)',
|
|
43
|
+
'',
|
|
44
|
+
'Environment:',
|
|
45
|
+
' TEST_EXECUTION_GITLAB_TOKEN GitLab Project Access Token (scope: read_api)',
|
|
46
|
+
];
|
|
47
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
48
|
+
}
|
|
49
|
+
async function runSetup() {
|
|
50
|
+
const banner = formatBanner('QC MCP Setup Wizard', '(c) 2026 Test Execution Platform');
|
|
51
|
+
console.log(banner);
|
|
52
|
+
// Step 1: detect
|
|
53
|
+
const paths = detectClaudePaths();
|
|
54
|
+
console.log('\n' + formatStep(1, 4, 'Detecting Claude / Codex installations...'));
|
|
55
|
+
console.log(paths.claudeDesktopConfig ? ` ✓ Claude Desktop config: ${paths.claudeDesktopConfig}` : ' - Claude Desktop not found');
|
|
56
|
+
console.log(paths.claudeCodeConfig ? ` ✓ Claude Code config: ${paths.claudeCodeConfig}` : ' - Claude Code not found');
|
|
57
|
+
console.log(paths.codexConfig ? ` ✓ Codex CLI config: ${paths.codexConfig}` : ' - Codex CLI not found');
|
|
58
|
+
if (!paths.claudeDesktopConfig && !paths.claudeCodeConfig && !paths.codexConfig) {
|
|
59
|
+
console.log('\n' + formatError('No Claude/Codex installations detected. Install Claude Desktop, Claude Code, or Codex CLI first.'));
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
// Step 2: ask token
|
|
63
|
+
console.log('\n' + formatStep(2, 4, 'Configuration'));
|
|
64
|
+
const tokenRaw = await prompt('MCP Token (paste from account UI):');
|
|
65
|
+
const token = tokenRaw.trim();
|
|
66
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
67
|
+
console.log(formatError("Token must start with 'tep_mcp_' and contain only hex characters."));
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
console.log(' ' + formatSuccess('Token format looks good'));
|
|
71
|
+
// Step 3: write configs
|
|
72
|
+
console.log('\n' + formatStep(3, 4, 'Writing config files'));
|
|
73
|
+
const binaryPath = process.argv[0] === 'node' ? resolve(localDirname, '..', '..', '..', 'qc-mcp.exe') : process.execPath;
|
|
74
|
+
const entry = buildMcpEntry(binaryPath, DEFAULT_API_BASE_URL, token);
|
|
75
|
+
const targets = [];
|
|
76
|
+
if (paths.claudeDesktopConfig)
|
|
77
|
+
targets.push({ path: paths.claudeDesktopConfig, name: 'Claude Desktop', format: 'json' });
|
|
78
|
+
if (paths.claudeCodeConfig)
|
|
79
|
+
targets.push({ path: paths.claudeCodeConfig, name: 'Claude Code', format: 'json' });
|
|
80
|
+
if (paths.codexConfig)
|
|
81
|
+
targets.push({ path: paths.codexConfig, name: 'Codex CLI', format: 'toml' });
|
|
82
|
+
for (const target of targets) {
|
|
83
|
+
if (target.format === 'json') {
|
|
84
|
+
const current = readJsonSafe(target.path);
|
|
85
|
+
const merged = mergeMcpServer(current, SERVER_NAME, entry);
|
|
86
|
+
writeConfigWithBackup(target.path, merged);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const current = readTomlSafe(target.path);
|
|
90
|
+
const merged = mergeCodexMcpServer(current, SERVER_NAME, entry);
|
|
91
|
+
writeTomlWithBackup(target.path, merged);
|
|
92
|
+
}
|
|
93
|
+
console.log(' ' + formatSuccess(`${target.name}: ${target.path}`));
|
|
94
|
+
}
|
|
95
|
+
// Step 4: verify
|
|
96
|
+
console.log('\n' + formatStep(4, 4, 'Verification'));
|
|
97
|
+
const verifyResult = await verifyMcpHandshake({
|
|
98
|
+
binary: binaryPath,
|
|
99
|
+
apiBaseUrl: DEFAULT_API_BASE_URL,
|
|
100
|
+
token,
|
|
101
|
+
});
|
|
102
|
+
if (verifyResult.ok) {
|
|
103
|
+
console.log(' ' + formatSuccess(`Server: ${verifyResult.serverName ?? 'unknown'} v${verifyResult.serverVersion ?? '?'}`));
|
|
104
|
+
console.log(' ' + formatSuccess(`health_check: ${verifyResult.healthCheckText ?? 'ok'}`));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(' ' + formatWarn(`Verify failed: ${verifyResult.error ?? 'unknown'}`));
|
|
108
|
+
console.log(' ' + formatWarn('Config written. Please re-check token and restart Claude.'));
|
|
109
|
+
}
|
|
110
|
+
console.log('\n' + formatSuccess('All set! Restart Claude Desktop to load MCP.'));
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
async function runVerify() {
|
|
114
|
+
const binaryPath = process.argv[0] === 'node' ? resolve(localDirname, '..', '..', '..', 'qc-mcp.exe') : process.execPath;
|
|
115
|
+
if (!existsSync(binaryPath)) {
|
|
116
|
+
console.log(formatError(`Binary not found at ${binaryPath}`));
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const token = process.env.TEST_EXECUTION_MCP_TOKEN ?? '';
|
|
120
|
+
if (!token) {
|
|
121
|
+
console.log(formatError('TEST_EXECUTION_MCP_TOKEN env var required for verify.'));
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const result = await verifyMcpHandshake({
|
|
125
|
+
binary: binaryPath,
|
|
126
|
+
apiBaseUrl: process.env.TEST_EXECUTION_API_BASE_URL ?? DEFAULT_API_BASE_URL,
|
|
127
|
+
token,
|
|
128
|
+
});
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
console.log(formatSuccess(`Server: ${result.serverName} v${result.serverVersion}`));
|
|
131
|
+
console.log(formatSuccess(`health_check: ${result.healthCheckText}`));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
console.log(formatError(`Verify failed: ${result.error}`));
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
async function runUpdate() {
|
|
138
|
+
console.log(formatBanner('QC MCP Update', '(c) 2026 Test Execution Platform'));
|
|
139
|
+
const args = process.argv.slice(3);
|
|
140
|
+
const flags = {
|
|
141
|
+
yes: args.includes('--yes') || args.includes('-y'),
|
|
142
|
+
binary: getFlagValue(args, '--binary') ?? process.execPath,
|
|
143
|
+
project: getFlagValue(args, '--project') ?? 'dungnt/test_execution_platform',
|
|
144
|
+
baseUrl: getFlagValue(args, '--gitlab-url') ?? 'https://gitlab.vivas.vn',
|
|
145
|
+
};
|
|
146
|
+
const token = process.env.TEST_EXECUTION_GITLAB_TOKEN ?? '';
|
|
147
|
+
if (!token) {
|
|
148
|
+
console.log(formatError('TEST_EXECUTION_GITLAB_TOKEN env var required (Project Access Token with read_api scope).'));
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
if (!existsSync(flags.binary)) {
|
|
152
|
+
console.log(formatError(`Binary not found at ${flags.binary}`));
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
// Step 1: installed version
|
|
156
|
+
const installed = getInstalledVersion(flags.binary);
|
|
157
|
+
if (installed === '0.0.0') {
|
|
158
|
+
console.log(formatWarn('Cannot determine installed version. Skipping update.'));
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
console.log(formatStep(1, 5, `Installed: v${installed}`));
|
|
162
|
+
// Step 2: fetch latest release
|
|
163
|
+
const release = await fetchLatestRelease(flags.project, flags.baseUrl, token);
|
|
164
|
+
if (!release) {
|
|
165
|
+
console.log(formatError('No release found on GitLab.'));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const latest = release.tag.replace(/^v/, '');
|
|
169
|
+
console.log(formatStep(2, 5, `Latest: v${latest}`));
|
|
170
|
+
// Step 3: compare
|
|
171
|
+
const cmp = compareVersions(installed, latest);
|
|
172
|
+
if (cmp >= 0) {
|
|
173
|
+
console.log(formatSuccess(`Already up to date (v${installed}).`));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
console.log(formatStep(3, 5, `Update available: v${installed} → v${latest}`));
|
|
177
|
+
// Step 4: select platform asset
|
|
178
|
+
const assetName = selectAssetForPlatform(release.assets, process.platform, process.arch);
|
|
179
|
+
if (!assetName) {
|
|
180
|
+
console.log(formatError(`No binary available for ${process.platform}-${process.arch}.`));
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
const assetUrl = release.assets.get(assetName);
|
|
184
|
+
console.log(formatStep(4, 5, `Asset: ${assetName}`));
|
|
185
|
+
// Step 5: fetch SHA256SUMS.txt + verify expected hash exists
|
|
186
|
+
const checksumsUrl = release.assets.get('SHA256SUMS.txt');
|
|
187
|
+
if (!checksumsUrl) {
|
|
188
|
+
console.log(formatError('No SHA256SUMS.txt in release assets.'));
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
const checksumsText = await fetchChecksums(checksumsUrl, token);
|
|
192
|
+
if (!checksumsText) {
|
|
193
|
+
console.log(formatError('Cannot fetch SHA256SUMS.txt.'));
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
const checksums = parseChecksums(checksumsText);
|
|
197
|
+
const expectedSha = checksums.get(assetName);
|
|
198
|
+
if (!expectedSha) {
|
|
199
|
+
console.log(formatError(`No SHA256 in SHA256SUMS.txt for ${assetName}.`));
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
// Get asset size (HEAD request) — fall back to 0 if HEAD not supported
|
|
203
|
+
const size = await getContentLength(assetUrl, token);
|
|
204
|
+
// Confirm (skip if --yes)
|
|
205
|
+
if (!flags.yes) {
|
|
206
|
+
const ok = await confirmUpdate({ from: installed, to: latest, size, sha256: expectedSha });
|
|
207
|
+
if (!ok) {
|
|
208
|
+
console.log(formatWarn('Update cancelled.'));
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Download
|
|
213
|
+
const tmpPath = `${flags.binary}.new`;
|
|
214
|
+
console.log(formatStep(5, 5, 'Downloading...'));
|
|
215
|
+
try {
|
|
216
|
+
await downloadToFile(assetUrl, tmpPath, token, (bytes, total) => {
|
|
217
|
+
const pct = total > 0 ? Math.round((bytes / total) * 100) : 0;
|
|
218
|
+
process.stdout.write(`\r ${pct}% (${(bytes / 1024 / 1024).toFixed(1)}/${(total / 1024 / 1024).toFixed(1)} MB)`);
|
|
219
|
+
});
|
|
220
|
+
process.stdout.write('\n');
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (existsSync(tmpPath))
|
|
224
|
+
unlinkSync(tmpPath);
|
|
225
|
+
console.log(formatError(`Download failed: ${err.message}`));
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
// Verify SHA256
|
|
229
|
+
if (!verifySha256(tmpPath, expectedSha)) {
|
|
230
|
+
if (existsSync(tmpPath))
|
|
231
|
+
unlinkSync(tmpPath);
|
|
232
|
+
console.log(formatError(`SHA256 mismatch for ${assetName}.`));
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
// Swap
|
|
236
|
+
if (!swapBinary(flags.binary, tmpPath)) {
|
|
237
|
+
console.log(formatError('Swap failed. Current binary preserved.'));
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
// Update version file
|
|
241
|
+
writeFileSync(`${flags.binary}.version`, `${latest}\n`);
|
|
242
|
+
console.log(formatSuccess(`Updated to v${latest}. Restart to apply.`));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
async function getContentLength(url, token) {
|
|
246
|
+
const headers = {};
|
|
247
|
+
if (token)
|
|
248
|
+
headers['PRIVATE-TOKEN'] = token;
|
|
249
|
+
const res = await fetch(url, { method: 'HEAD', headers });
|
|
250
|
+
if (!res.ok)
|
|
251
|
+
return 0;
|
|
252
|
+
return Number(res.headers.get('content-length') ?? 0);
|
|
253
|
+
}
|
|
254
|
+
function getFlagValue(args, flag) {
|
|
255
|
+
const i = args.indexOf(flag);
|
|
256
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
257
|
+
}
|
|
258
|
+
async function runUninstall() {
|
|
259
|
+
const paths = detectClaudePaths();
|
|
260
|
+
let anyChange = false;
|
|
261
|
+
if (paths.claudeDesktopConfig) {
|
|
262
|
+
const result = restoreOrRemoveMcpServer(paths.claudeDesktopConfig, SERVER_NAME);
|
|
263
|
+
console.log(`Claude Desktop: ${result.restored ? 'restored from backup' : result.removed ? 'removed key' : 'no change'}`);
|
|
264
|
+
anyChange = true;
|
|
265
|
+
}
|
|
266
|
+
if (paths.claudeCodeConfig) {
|
|
267
|
+
const result = restoreOrRemoveMcpServer(paths.claudeCodeConfig, SERVER_NAME);
|
|
268
|
+
console.log(`Claude Code: ${result.restored ? 'restored from backup' : result.removed ? 'removed key' : 'no change'}`);
|
|
269
|
+
anyChange = true;
|
|
270
|
+
}
|
|
271
|
+
if (paths.codexConfig) {
|
|
272
|
+
const result = restoreOrRemoveCodexMcpServer(paths.codexConfig, SERVER_NAME);
|
|
273
|
+
console.log(`Codex CLI: ${result.restored ? 'restored from backup' : result.removed ? 'removed key' : 'no change'}`);
|
|
274
|
+
anyChange = true;
|
|
275
|
+
}
|
|
276
|
+
if (!anyChange) {
|
|
277
|
+
console.log('No Claude/Codex configs found.');
|
|
278
|
+
}
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
export async function dispatch(sub) {
|
|
282
|
+
switch (sub) {
|
|
283
|
+
case 'setup':
|
|
284
|
+
case undefined:
|
|
285
|
+
return runSetup();
|
|
286
|
+
case 'verify':
|
|
287
|
+
return runVerify();
|
|
288
|
+
case 'update':
|
|
289
|
+
return runUpdate();
|
|
290
|
+
case 'uninstall':
|
|
291
|
+
return runUninstall();
|
|
292
|
+
case 'version':
|
|
293
|
+
process.stdout.write(getVersion() + '\n');
|
|
294
|
+
return 0;
|
|
295
|
+
case 'help':
|
|
296
|
+
case '--help':
|
|
297
|
+
case '-h':
|
|
298
|
+
printUsage();
|
|
299
|
+
return 0;
|
|
300
|
+
default:
|
|
301
|
+
printUsage();
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// CLI entry — only run when invoked directly, not when imported by tests.
|
|
306
|
+
const isDirectInvocation = process.argv[1] && (() => {
|
|
307
|
+
try {
|
|
308
|
+
return fileURLToPath(import.meta.url) === process.argv[1];
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
})();
|
|
314
|
+
if (isDirectInvocation) {
|
|
315
|
+
dispatch(process.argv[2])
|
|
316
|
+
.then((code) => exit(code))
|
|
317
|
+
.catch((err) => {
|
|
318
|
+
console.error('Unhandled error:', err);
|
|
319
|
+
exit(1);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, basename, sep, win32 as win32Path } from 'node:path';
|
|
3
|
+
export function readJsonSafe(path) {
|
|
4
|
+
if (!existsSync(path))
|
|
5
|
+
return {};
|
|
6
|
+
try {
|
|
7
|
+
const raw = readFileSync(path, 'utf-8');
|
|
8
|
+
if (raw.trim() === '')
|
|
9
|
+
return {};
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function mergeMcpServer(current, serverName, entry) {
|
|
17
|
+
const mcpServers = { ...(current.mcpServers ?? {}) };
|
|
18
|
+
mcpServers[serverName] = { ...(mcpServers[serverName] ?? {}), ...entry };
|
|
19
|
+
return { ...current, mcpServers };
|
|
20
|
+
}
|
|
21
|
+
export function buildMcpEntry(command, baseUrl, token) {
|
|
22
|
+
return {
|
|
23
|
+
command,
|
|
24
|
+
env: {
|
|
25
|
+
TEST_EXECUTION_API_BASE_URL: baseUrl,
|
|
26
|
+
TEST_EXECUTION_MCP_TOKEN: token,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function backupPathFor(file) {
|
|
31
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
32
|
+
// Use the platform-appropriate path join so Windows gets backslashes.
|
|
33
|
+
const joiner = sep === win32Path.sep ? win32Path.join : join;
|
|
34
|
+
return joiner(dirname(file), `${basename(file)}.bak-${ts}`);
|
|
35
|
+
}
|
|
36
|
+
export { backupPathFor };
|
|
37
|
+
export function writeConfigWithBackup(file, content) {
|
|
38
|
+
const dir = dirname(file);
|
|
39
|
+
if (!existsSync(dir)) {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
if (existsSync(file)) {
|
|
43
|
+
copyFileSync(file, backupPathFor(file));
|
|
44
|
+
}
|
|
45
|
+
writeFileSync(file, JSON.stringify(content, null, 2), 'utf-8');
|
|
46
|
+
}
|
|
47
|
+
export function listBackups(file) {
|
|
48
|
+
const dir = dirname(file);
|
|
49
|
+
const base = basename(file);
|
|
50
|
+
if (!existsSync(dir))
|
|
51
|
+
return [];
|
|
52
|
+
return readdirSync(dir)
|
|
53
|
+
.filter((name) => name.startsWith(`${base}.bak-`))
|
|
54
|
+
.map((name) => join(dir, name))
|
|
55
|
+
.sort()
|
|
56
|
+
.reverse();
|
|
57
|
+
}
|
|
58
|
+
export function restoreLatestBackup(file) {
|
|
59
|
+
const backups = listBackups(file);
|
|
60
|
+
if (backups.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
const latest = backups[0];
|
|
63
|
+
const content = readFileSync(latest, 'utf-8');
|
|
64
|
+
writeFileSync(file, content, 'utf-8');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export function removeMcpServer(file, serverName) {
|
|
68
|
+
const data = readJsonSafe(file);
|
|
69
|
+
if (!data.mcpServers || !data.mcpServers[serverName])
|
|
70
|
+
return false;
|
|
71
|
+
delete data.mcpServers[serverName];
|
|
72
|
+
writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8');
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
export function cleanupOldBackups(file, keep) {
|
|
76
|
+
const backups = listBackups(file);
|
|
77
|
+
for (const old of backups.slice(keep)) {
|
|
78
|
+
unlinkSync(old);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { join, win32 } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
function envPath(...names) {
|
|
4
|
+
for (const name of names) {
|
|
5
|
+
const v = process.env[name];
|
|
6
|
+
if (v && v.length > 0)
|
|
7
|
+
return v;
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function codexHome(platform) {
|
|
12
|
+
const explicit = envPath('CODEX_HOME');
|
|
13
|
+
if (explicit)
|
|
14
|
+
return explicit;
|
|
15
|
+
const home = envPath('HOME', 'USERPROFILE') ?? homedir();
|
|
16
|
+
if (platform === 'win32')
|
|
17
|
+
return win32.join(home, '.codex');
|
|
18
|
+
return join(home, '.codex');
|
|
19
|
+
}
|
|
20
|
+
export function detectClaudePaths(platform = process.platform) {
|
|
21
|
+
if (platform === 'win32') {
|
|
22
|
+
const appdata = envPath('APPDATA') ?? win32.join(homedir(), 'AppData', 'Roaming');
|
|
23
|
+
const home = envPath('USERPROFILE') ?? homedir();
|
|
24
|
+
return {
|
|
25
|
+
claudeDesktopConfig: win32.join(appdata, 'Claude', 'claude_desktop_config.json'),
|
|
26
|
+
claudeCodeConfig: win32.join(home, '.claude.json'),
|
|
27
|
+
codexConfig: win32.join(codexHome(platform), 'config.toml'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'darwin') {
|
|
31
|
+
const home = envPath('HOME') ?? homedir();
|
|
32
|
+
return {
|
|
33
|
+
claudeDesktopConfig: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
34
|
+
claudeCodeConfig: join(home, '.claude.json'),
|
|
35
|
+
codexConfig: join(codexHome(platform), 'config.toml'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// linux and others
|
|
39
|
+
const home = envPath('HOME') ?? homedir();
|
|
40
|
+
const xdg = envPath('XDG_CONFIG_HOME') ?? join(home, '.config');
|
|
41
|
+
return {
|
|
42
|
+
claudeDesktopConfig: join(xdg, 'Claude', 'claude_desktop_config.json'),
|
|
43
|
+
claudeCodeConfig: join(home, '.claude.json'),
|
|
44
|
+
codexConfig: join(codexHome(platform), 'config.toml'),
|
|
45
|
+
};
|
|
46
|
+
}
|