@massapi/svn-mcp 1.0.0
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/.cnb.yml +43 -0
- package/README.md +94 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +39 -0
- package/dist/svn-protocol.d.ts +61 -0
- package/dist/svn-protocol.js +366 -0
- package/package.json +24 -0
- package/src/index.ts +50 -0
- package/src/svn-protocol.ts +412 -0
- package/tsconfig.json +15 -0
package/.cnb.yml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# .cnb.yml
|
|
2
|
+
$:
|
|
3
|
+
tag_push:
|
|
4
|
+
- docker:
|
|
5
|
+
image: node:22-alpine
|
|
6
|
+
imports:
|
|
7
|
+
- https://cnb.cool/massapi/keys/-/blob/main/github.yml
|
|
8
|
+
stages:
|
|
9
|
+
- name: update version
|
|
10
|
+
# 修改 package.json 中的version为当前tag,但不会提交代码
|
|
11
|
+
script: npm version $CNB_BRANCH --no-git-tag-version
|
|
12
|
+
- name: build
|
|
13
|
+
image: node:20
|
|
14
|
+
script:
|
|
15
|
+
- npm ci
|
|
16
|
+
- npm run build
|
|
17
|
+
- name: npm publish
|
|
18
|
+
image: tencentcom/npm
|
|
19
|
+
settings:
|
|
20
|
+
# CNB_TOKEN_USER_NAME, CNB_TOKEN,CNB_COMMITTER_EMAIL 为流水线自带环境变量
|
|
21
|
+
username: $CNB_TOKEN_USER_NAME
|
|
22
|
+
token: $CNB_TOKEN
|
|
23
|
+
email: $CNB_COMMITTER_EMAIL
|
|
24
|
+
registry: https://npm.cnb.cool/massapi/npm-repo/-/packages/
|
|
25
|
+
folder: ./
|
|
26
|
+
fail_on_version_conflict: true
|
|
27
|
+
|
|
28
|
+
vscode:
|
|
29
|
+
- runner:
|
|
30
|
+
cpus: 4
|
|
31
|
+
docker:
|
|
32
|
+
image: docker.cnb.cool/massapi/default-dev-env
|
|
33
|
+
imports: https://cnb.cool/massapi/keys/-/blob/main/agent.yml
|
|
34
|
+
env:
|
|
35
|
+
CNB_WELCOME_CMD: echo "Welcome to CNB"
|
|
36
|
+
services:
|
|
37
|
+
- vscode
|
|
38
|
+
- docker
|
|
39
|
+
stages:
|
|
40
|
+
- name: set-agent-env
|
|
41
|
+
script: chmod +x /scripts/set-agent-env.sh; /scripts/set-agent-env.sh
|
|
42
|
+
- name: ls
|
|
43
|
+
script: ls -la
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# svn-mcp
|
|
2
|
+
|
|
3
|
+
Node.js-based SVN MCP server using stdio transport. Connects to svnserve via the native svn:// protocol — no SVN CLI installation required. Exposes 1 MCP tool:
|
|
4
|
+
* list_logs — Retrieves recent commit logs. Accepts 1 parameter `limit` (default: 10), representing the number of recent entries. Returns an array of objects, each containing author, date, message, revision, action, and file list.
|
|
5
|
+
|
|
6
|
+
All code comments and messages are in English.
|
|
7
|
+
Package name: @massapi/svn-mcp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Environment Variables
|
|
11
|
+
|
|
12
|
+
| Variable | Description |
|
|
13
|
+
|----------|-------------|
|
|
14
|
+
| SVN_URL | SVN repository URL, e.g. `svn://svn.example.com/repo/project/trunk` |
|
|
15
|
+
| SVN_USER | SVN username (optional for anonymous access) |
|
|
16
|
+
| SVN_PASSWORD | SVN password (optional for anonymous access) |
|
|
17
|
+
|
|
18
|
+
## Run
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Install dependencies
|
|
22
|
+
npm install
|
|
23
|
+
|
|
24
|
+
# Build
|
|
25
|
+
npm run build
|
|
26
|
+
|
|
27
|
+
# Start service
|
|
28
|
+
SVN_URL=svn://your-svn-host/repo/path SVN_USER=your-user SVN_PASSWORD=your-password npm start
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Publish to npm
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm config set //registry.npmjs.org/:_authToken=npm_token
|
|
42
|
+
npm publish --access public --loglevel verbose
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Inspector Test
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
SVN_URL=svn://your-svn-host/repo/path SVN_USER=your-user SVN_PASSWORD=your-password npm run inspect
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This opens the Inspector UI in your browser for interactive MCP tool testing.
|
|
52
|
+
|
|
53
|
+
### Inspector CLI Test
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm config set registry https://registry.npmjs.org/
|
|
57
|
+
|
|
58
|
+
npx -y -p @massapi/svn-mcp -p @modelcontextprotocol/inspector mcp-inspector --cli \
|
|
59
|
+
-e SVN_URL=svn://svn.example.com/repo/project/trunk \
|
|
60
|
+
-e SVN_USER=admin \
|
|
61
|
+
-e SVN_PASSWORD=your-password \
|
|
62
|
+
svn-mcp \
|
|
63
|
+
--method tools/call \
|
|
64
|
+
--tool-name list_logs
|
|
65
|
+
|
|
66
|
+
npx -y -p @massapi/svn-mcp -p @modelcontextprotocol/inspector mcp-inspector --cli \
|
|
67
|
+
-e SVN_URL=svn://svn.example.com/repo/project/trunk \
|
|
68
|
+
-e SVN_USER=admin \
|
|
69
|
+
-e SVN_PASSWORD=your-password \
|
|
70
|
+
svn-mcp \
|
|
71
|
+
--method tools/call \
|
|
72
|
+
--tool-name list_logs \
|
|
73
|
+
--tool-arg limit=5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Configure in Claude Desktop
|
|
77
|
+
|
|
78
|
+
Add to your Claude Desktop config file:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"mcpServers": {
|
|
83
|
+
"svn": {
|
|
84
|
+
"command": "node",
|
|
85
|
+
"args": ["/path/to/svn-mcp/dist/index.js"],
|
|
86
|
+
"env": {
|
|
87
|
+
"SVN_URL": "svn://svn.example.com/repo/project/trunk",
|
|
88
|
+
"SVN_USER": "your-username",
|
|
89
|
+
"SVN_PASSWORD": "your-password"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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 { z } from "zod";
|
|
5
|
+
import { fetchSvnLogs } from "./svn-protocol.js";
|
|
6
|
+
// SVN server configuration via environment variables
|
|
7
|
+
// SVN_URL format: svn://host:port/repo/path
|
|
8
|
+
const SVN_URL = process.env.SVN_URL || "";
|
|
9
|
+
const SVN_USER = process.env.SVN_USER || "";
|
|
10
|
+
const SVN_PASSWORD = process.env.SVN_PASSWORD || "";
|
|
11
|
+
/** Parse svn:// URL into host, port, path components. */
|
|
12
|
+
function parseSvnUrl(url) {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
return {
|
|
15
|
+
host: parsed.hostname,
|
|
16
|
+
port: parsed.port ? Number(parsed.port) : 3690,
|
|
17
|
+
path: parsed.pathname,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async function main() {
|
|
21
|
+
const server = new McpServer({
|
|
22
|
+
name: "svn-mcp",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
});
|
|
25
|
+
server.tool("list_logs", "Get recent SVN commit logs via native svn:// protocol", { limit: z.number().default(10).describe("Number of recent commits to retrieve") }, async ({ limit }) => {
|
|
26
|
+
const { host, port, path } = parseSvnUrl(SVN_URL);
|
|
27
|
+
const logs = await fetchSvnLogs(host, port, SVN_USER, SVN_PASSWORD, path, limit);
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: JSON.stringify(logs, null, 2) }],
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
const transport = new StdioServerTransport();
|
|
33
|
+
await server.connect(transport);
|
|
34
|
+
}
|
|
35
|
+
main().catch((err) => {
|
|
36
|
+
console.error("Failed to start svn-mcp:", err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
export type SvnItem = string | number | SvnItem[];
|
|
3
|
+
/**
|
|
4
|
+
* Streaming reader for the SVN ra_svn wire protocol.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SvnRaReader {
|
|
7
|
+
private buf;
|
|
8
|
+
private resolver;
|
|
9
|
+
constructor(socket: net.Socket);
|
|
10
|
+
private recv;
|
|
11
|
+
/** Ensure at least n chars in buffer. */
|
|
12
|
+
private ensure;
|
|
13
|
+
/** Skip whitespace. */
|
|
14
|
+
private skipWs;
|
|
15
|
+
/** Peek at next non-whitespace char. */
|
|
16
|
+
private peek;
|
|
17
|
+
/** Read one ra_svn item. */
|
|
18
|
+
readItem(): Promise<SvnItem>;
|
|
19
|
+
readWord(): Promise<string>;
|
|
20
|
+
readNumber(): Promise<number>;
|
|
21
|
+
readString(): Promise<string>;
|
|
22
|
+
readList(): Promise<SvnItem[]>;
|
|
23
|
+
/** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
|
|
24
|
+
readResponse(): Promise<{
|
|
25
|
+
cmd: string;
|
|
26
|
+
params: SvnItem[];
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export interface LogEntry {
|
|
30
|
+
revision: number;
|
|
31
|
+
author: string;
|
|
32
|
+
date: string;
|
|
33
|
+
message: string;
|
|
34
|
+
files: {
|
|
35
|
+
path: string;
|
|
36
|
+
action: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
export declare class SvnRaClient {
|
|
41
|
+
private host;
|
|
42
|
+
private port;
|
|
43
|
+
private user;
|
|
44
|
+
private password;
|
|
45
|
+
private repoPath;
|
|
46
|
+
private socket;
|
|
47
|
+
private reader;
|
|
48
|
+
private connected;
|
|
49
|
+
constructor(host: string, port: number, user: string, password: string, repoPath: string);
|
|
50
|
+
connect(): Promise<void>;
|
|
51
|
+
private doCramMd5;
|
|
52
|
+
getLogs(limit: number): Promise<LogEntry[]>;
|
|
53
|
+
disconnect(): void;
|
|
54
|
+
private send;
|
|
55
|
+
private findMechs;
|
|
56
|
+
private findChallenge;
|
|
57
|
+
/** Extract revision number from ( success ( N ) ) response. */
|
|
58
|
+
private extractRev;
|
|
59
|
+
private parseLogEntry;
|
|
60
|
+
}
|
|
61
|
+
export declare function fetchSvnLogs(host: string, port: number, user: string, password: string, repoPath: string, limit: number): Promise<LogEntry[]>;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Streaming reader for the SVN ra_svn wire protocol.
|
|
5
|
+
*/
|
|
6
|
+
export class SvnRaReader {
|
|
7
|
+
buf = "";
|
|
8
|
+
resolver = null;
|
|
9
|
+
constructor(socket) {
|
|
10
|
+
socket.on("data", (data) => {
|
|
11
|
+
const chunk = data.toString("utf-8");
|
|
12
|
+
if (this.resolver) {
|
|
13
|
+
const r = this.resolver;
|
|
14
|
+
this.resolver = null;
|
|
15
|
+
r(chunk);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
this.buf += chunk;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async recv() {
|
|
23
|
+
return new Promise((resolve) => { this.resolver = resolve; });
|
|
24
|
+
}
|
|
25
|
+
/** Ensure at least n chars in buffer. */
|
|
26
|
+
async ensure(n) {
|
|
27
|
+
while (this.buf.length < n) {
|
|
28
|
+
this.buf += await this.recv();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Skip whitespace. */
|
|
32
|
+
async skipWs() {
|
|
33
|
+
for (;;) {
|
|
34
|
+
await this.ensure(1);
|
|
35
|
+
if (this.buf[0] === " " || this.buf[0] === "\n" || this.buf[0] === "\r") {
|
|
36
|
+
this.buf = this.buf.slice(1);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Peek at next non-whitespace char. */
|
|
44
|
+
async peek() {
|
|
45
|
+
await this.skipWs();
|
|
46
|
+
await this.ensure(1);
|
|
47
|
+
return this.buf[0];
|
|
48
|
+
}
|
|
49
|
+
/** Read one ra_svn item. */
|
|
50
|
+
async readItem() {
|
|
51
|
+
const ch = await this.peek();
|
|
52
|
+
if (ch === "(")
|
|
53
|
+
return this.readList();
|
|
54
|
+
if (ch >= "0" && ch <= "9") {
|
|
55
|
+
// Look ahead to distinguish string (N:data) from number
|
|
56
|
+
await this.ensure(2);
|
|
57
|
+
for (let i = 0; i < this.buf.length; i++) {
|
|
58
|
+
const c = this.buf[i];
|
|
59
|
+
if (c === ":")
|
|
60
|
+
return this.readString();
|
|
61
|
+
if (c === " " || c === "\n" || c === "\r" || c === ")")
|
|
62
|
+
return this.readNumber();
|
|
63
|
+
if (c < "0" || c > "9")
|
|
64
|
+
return this.readNumber();
|
|
65
|
+
}
|
|
66
|
+
return this.readNumber();
|
|
67
|
+
}
|
|
68
|
+
return this.readWord();
|
|
69
|
+
}
|
|
70
|
+
async readWord() {
|
|
71
|
+
await this.skipWs();
|
|
72
|
+
let word = "";
|
|
73
|
+
for (;;) {
|
|
74
|
+
await this.ensure(1);
|
|
75
|
+
const c = this.buf[0];
|
|
76
|
+
if (/[A-Za-z0-9_-]/.test(c)) {
|
|
77
|
+
word += c;
|
|
78
|
+
this.buf = this.buf.slice(1);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
return word;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async readNumber() {
|
|
86
|
+
await this.skipWs();
|
|
87
|
+
let digits = "";
|
|
88
|
+
for (;;) {
|
|
89
|
+
await this.ensure(1);
|
|
90
|
+
const c = this.buf[0];
|
|
91
|
+
if (c >= "0" && c <= "9") {
|
|
92
|
+
digits += c;
|
|
93
|
+
this.buf = this.buf.slice(1);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return Number(digits);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async readString() {
|
|
101
|
+
await this.skipWs();
|
|
102
|
+
let lenStr = "";
|
|
103
|
+
for (;;) {
|
|
104
|
+
await this.ensure(1);
|
|
105
|
+
if (this.buf[0] === ":") {
|
|
106
|
+
this.buf = this.buf.slice(1);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
lenStr += this.buf[0];
|
|
110
|
+
this.buf = this.buf.slice(1);
|
|
111
|
+
}
|
|
112
|
+
const len = Number(lenStr);
|
|
113
|
+
while (Buffer.byteLength(this.buf, "utf-8") < len) {
|
|
114
|
+
this.buf += await this.recv();
|
|
115
|
+
}
|
|
116
|
+
const raw = Buffer.from(this.buf, "utf-8");
|
|
117
|
+
const str = raw.toString("utf-8", 0, len);
|
|
118
|
+
this.buf = raw.toString("utf-8", len);
|
|
119
|
+
return str;
|
|
120
|
+
}
|
|
121
|
+
async readList() {
|
|
122
|
+
await this.skipWs();
|
|
123
|
+
await this.ensure(1);
|
|
124
|
+
if (this.buf[0] !== "(")
|
|
125
|
+
throw new Error(`Expected '(' got '${this.buf[0]}'`);
|
|
126
|
+
this.buf = this.buf.slice(1);
|
|
127
|
+
const items = [];
|
|
128
|
+
for (;;) {
|
|
129
|
+
const ch = await this.peek();
|
|
130
|
+
if (ch === ")") {
|
|
131
|
+
this.buf = this.buf.slice(1);
|
|
132
|
+
return items;
|
|
133
|
+
}
|
|
134
|
+
items.push(await this.readItem());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
|
|
138
|
+
async readResponse() {
|
|
139
|
+
const ch = await this.peek();
|
|
140
|
+
if (ch === "d") {
|
|
141
|
+
const word = await this.readWord();
|
|
142
|
+
return { cmd: word, params: [] };
|
|
143
|
+
}
|
|
144
|
+
const list = await this.readList();
|
|
145
|
+
if (list.length === 0)
|
|
146
|
+
return { cmd: "", params: [] };
|
|
147
|
+
if (typeof list[0] === "string") {
|
|
148
|
+
return { cmd: list[0], params: list.slice(1) };
|
|
149
|
+
}
|
|
150
|
+
return { cmd: "", params: list };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Encoder helpers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
function w(word) { return word + " "; }
|
|
157
|
+
function n(num) { return num + " "; }
|
|
158
|
+
function s(str) { const b = Buffer.from(str, "utf-8"); return b.length + ":" + str + " "; }
|
|
159
|
+
function b(val) { return val ? "true " : "false "; }
|
|
160
|
+
export class SvnRaClient {
|
|
161
|
+
host;
|
|
162
|
+
port;
|
|
163
|
+
user;
|
|
164
|
+
password;
|
|
165
|
+
repoPath;
|
|
166
|
+
socket;
|
|
167
|
+
reader;
|
|
168
|
+
connected = false;
|
|
169
|
+
constructor(host, port, user, password, repoPath) {
|
|
170
|
+
this.host = host;
|
|
171
|
+
this.port = port;
|
|
172
|
+
this.user = user;
|
|
173
|
+
this.password = password;
|
|
174
|
+
this.repoPath = repoPath;
|
|
175
|
+
}
|
|
176
|
+
async connect() {
|
|
177
|
+
this.socket = await new Promise((resolve, reject) => {
|
|
178
|
+
const sock = net.createConnection({ host: this.host, port: this.port }, () => resolve(sock));
|
|
179
|
+
sock.on("error", reject);
|
|
180
|
+
});
|
|
181
|
+
this.reader = new SvnRaReader(this.socket);
|
|
182
|
+
this.connected = true;
|
|
183
|
+
// 1. Read server greeting
|
|
184
|
+
// ( success ( min max ( mechs ) ( cap1 cap2 ... ) ) )
|
|
185
|
+
await this.reader.readResponse();
|
|
186
|
+
// 2. Send client response: ( version ( caps ) url client-str ( ) )
|
|
187
|
+
const url = `svn://${this.host}${this.repoPath}`;
|
|
188
|
+
this.send("( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo log-revprops ) " +
|
|
189
|
+
s(url) +
|
|
190
|
+
s("SVN/1.14.0") +
|
|
191
|
+
"( ) ) ");
|
|
192
|
+
// 3. Read auth request: ( success ( ( CRAM-MD5 ) realm ) )
|
|
193
|
+
const authResp = await this.reader.readResponse();
|
|
194
|
+
const mechs = this.findMechs(authResp.params);
|
|
195
|
+
if (mechs.length === 0) {
|
|
196
|
+
// No auth – server sends ( success ( ( ) 0: ) )
|
|
197
|
+
}
|
|
198
|
+
else if (mechs.includes("CRAM-MD5")) {
|
|
199
|
+
await this.doCramMd5();
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
throw new Error("Unsupported auth: " + mechs.join(", "));
|
|
203
|
+
}
|
|
204
|
+
// 4. Read repos-info: ( success ( uuid url ( caps ) ) )
|
|
205
|
+
await this.reader.readResponse();
|
|
206
|
+
}
|
|
207
|
+
async doCramMd5() {
|
|
208
|
+
// Send: ( CRAM-MD5 ( ) )
|
|
209
|
+
this.send("( CRAM-MD5 ( ) ) ");
|
|
210
|
+
// Read challenge: ( step ( challenge-string ) )
|
|
211
|
+
const step = await this.reader.readResponse();
|
|
212
|
+
const challenge = this.findChallenge(step.params);
|
|
213
|
+
// Compute HMAC-MD5
|
|
214
|
+
const hmac = crypto.createHmac("md5", this.password);
|
|
215
|
+
hmac.update(challenge);
|
|
216
|
+
const hex = hmac.digest("hex");
|
|
217
|
+
const reply = `${this.user} ${hex}`;
|
|
218
|
+
// Send response as a BARE string (not wrapped in a tuple!)
|
|
219
|
+
this.send(s(reply));
|
|
220
|
+
// Read auth result: ( success ( ) )
|
|
221
|
+
const authResult = await this.reader.readResponse();
|
|
222
|
+
if (authResult.cmd !== "success") {
|
|
223
|
+
throw new Error("Auth failed: " + JSON.stringify(authResult));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async getLogs(limit) {
|
|
227
|
+
// First get the latest revision
|
|
228
|
+
this.send("( get-latest-rev ( ) ) ");
|
|
229
|
+
// trivial auth + response
|
|
230
|
+
await this.reader.readResponse();
|
|
231
|
+
const revResp = await this.reader.readResponse();
|
|
232
|
+
// Response is ( success ( N ) ), so revResp.params[0] is [N]
|
|
233
|
+
const latestRev = this.extractRev(revResp.params);
|
|
234
|
+
// ( log ( ( path ) ( start-rev ) ( end-rev ) changed-paths strict-node limit include-merged revprops ( revprop-names ) ) )
|
|
235
|
+
this.send("( log ( ( 0: ) ( " + n(latestRev) + ") ( 0 ) " +
|
|
236
|
+
b(true) + b(false) +
|
|
237
|
+
n(limit) + b(false) +
|
|
238
|
+
w("revprops") + " ( " +
|
|
239
|
+
s("svn:author") + s("svn:date") + s("svn:log") +
|
|
240
|
+
") ) ) ");
|
|
241
|
+
// trivial auth
|
|
242
|
+
await this.reader.readResponse();
|
|
243
|
+
// Read log entries until "done"
|
|
244
|
+
const entries = [];
|
|
245
|
+
for (;;) {
|
|
246
|
+
const resp = await this.reader.readResponse();
|
|
247
|
+
if (resp.cmd === "done")
|
|
248
|
+
break;
|
|
249
|
+
entries.push(this.parseLogEntry(resp.params));
|
|
250
|
+
}
|
|
251
|
+
// Final: ( success ( ) )
|
|
252
|
+
await this.reader.readResponse();
|
|
253
|
+
return entries;
|
|
254
|
+
}
|
|
255
|
+
disconnect() {
|
|
256
|
+
if (this.connected) {
|
|
257
|
+
this.socket.destroy();
|
|
258
|
+
this.connected = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
send(data) {
|
|
262
|
+
this.socket.write(data, "utf-8");
|
|
263
|
+
}
|
|
264
|
+
findMechs(params) {
|
|
265
|
+
const result = [];
|
|
266
|
+
const walk = (items) => {
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
if (typeof item === "string" && /^[A-Z][A-Z0-9-]/.test(item)) {
|
|
269
|
+
result.push(item);
|
|
270
|
+
}
|
|
271
|
+
else if (Array.isArray(item)) {
|
|
272
|
+
walk(item);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
walk(params);
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
findChallenge(params) {
|
|
280
|
+
const walk = (items) => {
|
|
281
|
+
for (const item of items) {
|
|
282
|
+
if (typeof item === "string" && item.includes("@"))
|
|
283
|
+
return item;
|
|
284
|
+
if (Array.isArray(item)) {
|
|
285
|
+
const r = walk(item);
|
|
286
|
+
if (r)
|
|
287
|
+
return r;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return "";
|
|
291
|
+
};
|
|
292
|
+
return walk(params);
|
|
293
|
+
}
|
|
294
|
+
/** Extract revision number from ( success ( N ) ) response. */
|
|
295
|
+
extractRev(params) {
|
|
296
|
+
for (const p of params) {
|
|
297
|
+
if (typeof p === "number")
|
|
298
|
+
return p;
|
|
299
|
+
if (Array.isArray(p)) {
|
|
300
|
+
const r = this.extractRev(p);
|
|
301
|
+
if (r > 0)
|
|
302
|
+
return r;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
parseLogEntry(items) {
|
|
308
|
+
// Wire: ( ( changed-paths ) rev ( author ) ( date ) ( message ) has-children invalid-revnum revprop-count ( ) subtractive-merge )
|
|
309
|
+
let idx = 0;
|
|
310
|
+
// Changed paths
|
|
311
|
+
const files = [];
|
|
312
|
+
if (Array.isArray(items[idx])) {
|
|
313
|
+
const pathsList = items[idx];
|
|
314
|
+
for (const p of pathsList) {
|
|
315
|
+
if (Array.isArray(p)) {
|
|
316
|
+
const e = p;
|
|
317
|
+
// ( path action ( ) ( kind text-mods prop-mods ) )
|
|
318
|
+
let kind = "";
|
|
319
|
+
if (Array.isArray(e[3]) && typeof e[3][0] === "string") {
|
|
320
|
+
kind = e[3][0];
|
|
321
|
+
}
|
|
322
|
+
files.push({
|
|
323
|
+
path: typeof e[0] === "string" ? e[0] : "",
|
|
324
|
+
action: typeof e[1] === "string" ? e[1] : "",
|
|
325
|
+
kind,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
idx++;
|
|
330
|
+
}
|
|
331
|
+
// Revision
|
|
332
|
+
const revision = typeof items[idx] === "number" ? items[idx] : 0;
|
|
333
|
+
idx++;
|
|
334
|
+
// ( author ) ( date ) ( message ) – each is a list with one string
|
|
335
|
+
const readOptStr = () => {
|
|
336
|
+
if (idx >= items.length)
|
|
337
|
+
return "";
|
|
338
|
+
const item = items[idx++];
|
|
339
|
+
if (typeof item === "string")
|
|
340
|
+
return item;
|
|
341
|
+
if (Array.isArray(item) && item.length === 1 && typeof item[0] === "string")
|
|
342
|
+
return item[0];
|
|
343
|
+
if (Array.isArray(item) && item.length === 0)
|
|
344
|
+
return "";
|
|
345
|
+
return "";
|
|
346
|
+
};
|
|
347
|
+
const author = readOptStr();
|
|
348
|
+
const date = readOptStr();
|
|
349
|
+
const message = readOptStr();
|
|
350
|
+
return { revision, author, date, message, files };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Convenience
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
export async function fetchSvnLogs(host, port, user, password, repoPath, limit) {
|
|
357
|
+
const client = new SvnRaClient(host, port, user, password, repoPath);
|
|
358
|
+
try {
|
|
359
|
+
await client.connect();
|
|
360
|
+
return await client.getLogs(limit);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
client.disconnect();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=svn-protocol.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@massapi/svn-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SVN MCP server based on Node.js, using native svn:// protocol via stdio transport",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"svn-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"keywords": ["svn", "mcp"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.12.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.15.0",
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { fetchSvnLogs } from "./svn-protocol.js";
|
|
7
|
+
|
|
8
|
+
// SVN server configuration via environment variables
|
|
9
|
+
// SVN_URL format: svn://host:port/repo/path
|
|
10
|
+
const SVN_URL = process.env.SVN_URL || "";
|
|
11
|
+
const SVN_USER = process.env.SVN_USER || "";
|
|
12
|
+
const SVN_PASSWORD = process.env.SVN_PASSWORD || "";
|
|
13
|
+
|
|
14
|
+
/** Parse svn:// URL into host, port, path components. */
|
|
15
|
+
function parseSvnUrl(url: string): { host: string; port: number; path: string } {
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
return {
|
|
18
|
+
host: parsed.hostname,
|
|
19
|
+
port: parsed.port ? Number(parsed.port) : 3690,
|
|
20
|
+
path: parsed.pathname,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: "svn-mcp",
|
|
27
|
+
version: "1.0.0",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
server.tool(
|
|
31
|
+
"list_logs",
|
|
32
|
+
"Get recent SVN commit logs via native svn:// protocol",
|
|
33
|
+
{ limit: z.number().default(10).describe("Number of recent commits to retrieve") },
|
|
34
|
+
async ({ limit }) => {
|
|
35
|
+
const { host, port, path } = parseSvnUrl(SVN_URL);
|
|
36
|
+
const logs = await fetchSvnLogs(host, port, SVN_USER, SVN_PASSWORD, path, limit);
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: JSON.stringify(logs, null, 2) }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const transport = new StdioServerTransport();
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
main().catch((err) => {
|
|
48
|
+
console.error("Failed to start svn-mcp:", err);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// SVN RA (ra_svn) wire protocol – framing, auth, and log retrieval
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type SvnItem = string | number | SvnItem[];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Streaming reader for the SVN ra_svn wire protocol.
|
|
12
|
+
*/
|
|
13
|
+
export class SvnRaReader {
|
|
14
|
+
private buf = "";
|
|
15
|
+
private resolver: ((chunk: string) => void) | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(socket: net.Socket) {
|
|
18
|
+
socket.on("data", (data: Buffer) => {
|
|
19
|
+
const chunk = data.toString("utf-8");
|
|
20
|
+
if (this.resolver) {
|
|
21
|
+
const r = this.resolver;
|
|
22
|
+
this.resolver = null;
|
|
23
|
+
r(chunk);
|
|
24
|
+
} else {
|
|
25
|
+
this.buf += chunk;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async recv(): Promise<string> {
|
|
31
|
+
return new Promise<string>((resolve) => { this.resolver = resolve; });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ensure at least n chars in buffer. */
|
|
35
|
+
private async ensure(n: number): Promise<void> {
|
|
36
|
+
while (this.buf.length < n) {
|
|
37
|
+
this.buf += await this.recv();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Skip whitespace. */
|
|
42
|
+
private async skipWs(): Promise<void> {
|
|
43
|
+
for (;;) {
|
|
44
|
+
await this.ensure(1);
|
|
45
|
+
if (this.buf[0] === " " || this.buf[0] === "\n" || this.buf[0] === "\r") {
|
|
46
|
+
this.buf = this.buf.slice(1);
|
|
47
|
+
} else {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Peek at next non-whitespace char. */
|
|
54
|
+
private async peek(): Promise<string> {
|
|
55
|
+
await this.skipWs();
|
|
56
|
+
await this.ensure(1);
|
|
57
|
+
return this.buf[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read one ra_svn item. */
|
|
61
|
+
async readItem(): Promise<SvnItem> {
|
|
62
|
+
const ch = await this.peek();
|
|
63
|
+
if (ch === "(") return this.readList();
|
|
64
|
+
if (ch >= "0" && ch <= "9") {
|
|
65
|
+
// Look ahead to distinguish string (N:data) from number
|
|
66
|
+
await this.ensure(2);
|
|
67
|
+
for (let i = 0; i < this.buf.length; i++) {
|
|
68
|
+
const c = this.buf[i];
|
|
69
|
+
if (c === ":") return this.readString();
|
|
70
|
+
if (c === " " || c === "\n" || c === "\r" || c === ")") return this.readNumber();
|
|
71
|
+
if (c < "0" || c > "9") return this.readNumber();
|
|
72
|
+
}
|
|
73
|
+
return this.readNumber();
|
|
74
|
+
}
|
|
75
|
+
return this.readWord();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async readWord(): Promise<string> {
|
|
79
|
+
await this.skipWs();
|
|
80
|
+
let word = "";
|
|
81
|
+
for (;;) {
|
|
82
|
+
await this.ensure(1);
|
|
83
|
+
const c = this.buf[0];
|
|
84
|
+
if (/[A-Za-z0-9_-]/.test(c)) {
|
|
85
|
+
word += c;
|
|
86
|
+
this.buf = this.buf.slice(1);
|
|
87
|
+
} else {
|
|
88
|
+
return word;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async readNumber(): Promise<number> {
|
|
94
|
+
await this.skipWs();
|
|
95
|
+
let digits = "";
|
|
96
|
+
for (;;) {
|
|
97
|
+
await this.ensure(1);
|
|
98
|
+
const c = this.buf[0];
|
|
99
|
+
if (c >= "0" && c <= "9") {
|
|
100
|
+
digits += c;
|
|
101
|
+
this.buf = this.buf.slice(1);
|
|
102
|
+
} else {
|
|
103
|
+
return Number(digits);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async readString(): Promise<string> {
|
|
109
|
+
await this.skipWs();
|
|
110
|
+
let lenStr = "";
|
|
111
|
+
for (;;) {
|
|
112
|
+
await this.ensure(1);
|
|
113
|
+
if (this.buf[0] === ":") {
|
|
114
|
+
this.buf = this.buf.slice(1);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
lenStr += this.buf[0];
|
|
118
|
+
this.buf = this.buf.slice(1);
|
|
119
|
+
}
|
|
120
|
+
const len = Number(lenStr);
|
|
121
|
+
while (Buffer.byteLength(this.buf, "utf-8") < len) {
|
|
122
|
+
this.buf += await this.recv();
|
|
123
|
+
}
|
|
124
|
+
const raw = Buffer.from(this.buf, "utf-8");
|
|
125
|
+
const str = raw.toString("utf-8", 0, len);
|
|
126
|
+
this.buf = raw.toString("utf-8", len);
|
|
127
|
+
return str;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async readList(): Promise<SvnItem[]> {
|
|
131
|
+
await this.skipWs();
|
|
132
|
+
await this.ensure(1);
|
|
133
|
+
if (this.buf[0] !== "(") throw new Error(`Expected '(' got '${this.buf[0]}'`);
|
|
134
|
+
this.buf = this.buf.slice(1);
|
|
135
|
+
const items: SvnItem[] = [];
|
|
136
|
+
for (;;) {
|
|
137
|
+
const ch = await this.peek();
|
|
138
|
+
if (ch === ")") {
|
|
139
|
+
this.buf = this.buf.slice(1);
|
|
140
|
+
return items;
|
|
141
|
+
}
|
|
142
|
+
items.push(await this.readItem());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read a top-level response: either a list `( cmd ... )` or the word `done`. */
|
|
147
|
+
async readResponse(): Promise<{ cmd: string; params: SvnItem[] }> {
|
|
148
|
+
const ch = await this.peek();
|
|
149
|
+
if (ch === "d") {
|
|
150
|
+
const word = await this.readWord();
|
|
151
|
+
return { cmd: word, params: [] };
|
|
152
|
+
}
|
|
153
|
+
const list = await this.readList();
|
|
154
|
+
if (list.length === 0) return { cmd: "", params: [] };
|
|
155
|
+
if (typeof list[0] === "string") {
|
|
156
|
+
return { cmd: list[0] as string, params: list.slice(1) };
|
|
157
|
+
}
|
|
158
|
+
return { cmd: "", params: list };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Encoder helpers
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function w(word: string): string { return word + " "; }
|
|
167
|
+
function n(num: number): string { return num + " "; }
|
|
168
|
+
function s(str: string): string { const b = Buffer.from(str, "utf-8"); return b.length + ":" + str + " "; }
|
|
169
|
+
function b(val: boolean): string { return val ? "true " : "false "; }
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Client
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export interface LogEntry {
|
|
176
|
+
revision: number;
|
|
177
|
+
author: string;
|
|
178
|
+
date: string;
|
|
179
|
+
message: string;
|
|
180
|
+
files: { path: string; action: string; kind: string }[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class SvnRaClient {
|
|
184
|
+
private socket!: net.Socket;
|
|
185
|
+
private reader!: SvnRaReader;
|
|
186
|
+
private connected = false;
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
private host: string,
|
|
190
|
+
private port: number,
|
|
191
|
+
private user: string,
|
|
192
|
+
private password: string,
|
|
193
|
+
private repoPath: string
|
|
194
|
+
) {}
|
|
195
|
+
|
|
196
|
+
async connect(): Promise<void> {
|
|
197
|
+
this.socket = await new Promise<net.Socket>((resolve, reject) => {
|
|
198
|
+
const sock = net.createConnection({ host: this.host, port: this.port }, () => resolve(sock));
|
|
199
|
+
sock.on("error", reject);
|
|
200
|
+
});
|
|
201
|
+
this.reader = new SvnRaReader(this.socket);
|
|
202
|
+
this.connected = true;
|
|
203
|
+
|
|
204
|
+
// 1. Read server greeting
|
|
205
|
+
// ( success ( min max ( mechs ) ( cap1 cap2 ... ) ) )
|
|
206
|
+
await this.reader.readResponse();
|
|
207
|
+
|
|
208
|
+
// 2. Send client response: ( version ( caps ) url client-str ( ) )
|
|
209
|
+
const url = `svn://${this.host}${this.repoPath}`;
|
|
210
|
+
this.send(
|
|
211
|
+
"( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo log-revprops ) " +
|
|
212
|
+
s(url) +
|
|
213
|
+
s("SVN/1.14.0") +
|
|
214
|
+
"( ) ) "
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// 3. Read auth request: ( success ( ( CRAM-MD5 ) realm ) )
|
|
218
|
+
const authResp = await this.reader.readResponse();
|
|
219
|
+
const mechs = this.findMechs(authResp.params);
|
|
220
|
+
|
|
221
|
+
if (mechs.length === 0) {
|
|
222
|
+
// No auth – server sends ( success ( ( ) 0: ) )
|
|
223
|
+
} else if (mechs.includes("CRAM-MD5")) {
|
|
224
|
+
await this.doCramMd5();
|
|
225
|
+
} else {
|
|
226
|
+
throw new Error("Unsupported auth: " + mechs.join(", "));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 4. Read repos-info: ( success ( uuid url ( caps ) ) )
|
|
230
|
+
await this.reader.readResponse();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async doCramMd5(): Promise<void> {
|
|
234
|
+
// Send: ( CRAM-MD5 ( ) )
|
|
235
|
+
this.send("( CRAM-MD5 ( ) ) ");
|
|
236
|
+
|
|
237
|
+
// Read challenge: ( step ( challenge-string ) )
|
|
238
|
+
const step = await this.reader.readResponse();
|
|
239
|
+
const challenge = this.findChallenge(step.params);
|
|
240
|
+
|
|
241
|
+
// Compute HMAC-MD5
|
|
242
|
+
const hmac = crypto.createHmac("md5", this.password);
|
|
243
|
+
hmac.update(challenge);
|
|
244
|
+
const hex = hmac.digest("hex");
|
|
245
|
+
const reply = `${this.user} ${hex}`;
|
|
246
|
+
|
|
247
|
+
// Send response as a BARE string (not wrapped in a tuple!)
|
|
248
|
+
this.send(s(reply));
|
|
249
|
+
|
|
250
|
+
// Read auth result: ( success ( ) )
|
|
251
|
+
const authResult = await this.reader.readResponse();
|
|
252
|
+
if (authResult.cmd !== "success") {
|
|
253
|
+
throw new Error("Auth failed: " + JSON.stringify(authResult));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getLogs(limit: number): Promise<LogEntry[]> {
|
|
258
|
+
// First get the latest revision
|
|
259
|
+
this.send("( get-latest-rev ( ) ) ");
|
|
260
|
+
// trivial auth + response
|
|
261
|
+
await this.reader.readResponse();
|
|
262
|
+
const revResp = await this.reader.readResponse();
|
|
263
|
+
// Response is ( success ( N ) ), so revResp.params[0] is [N]
|
|
264
|
+
const latestRev = this.extractRev(revResp.params);
|
|
265
|
+
|
|
266
|
+
// ( log ( ( path ) ( start-rev ) ( end-rev ) changed-paths strict-node limit include-merged revprops ( revprop-names ) ) )
|
|
267
|
+
this.send(
|
|
268
|
+
"( log ( ( 0: ) ( " + n(latestRev) + ") ( 0 ) " +
|
|
269
|
+
b(true) + b(false) +
|
|
270
|
+
n(limit) + b(false) +
|
|
271
|
+
w("revprops") + " ( " +
|
|
272
|
+
s("svn:author") + s("svn:date") + s("svn:log") +
|
|
273
|
+
") ) ) "
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// trivial auth
|
|
277
|
+
await this.reader.readResponse();
|
|
278
|
+
|
|
279
|
+
// Read log entries until "done"
|
|
280
|
+
const entries: LogEntry[] = [];
|
|
281
|
+
for (;;) {
|
|
282
|
+
const resp = await this.reader.readResponse();
|
|
283
|
+
if (resp.cmd === "done") break;
|
|
284
|
+
entries.push(this.parseLogEntry(resp.params));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Final: ( success ( ) )
|
|
288
|
+
await this.reader.readResponse();
|
|
289
|
+
|
|
290
|
+
return entries;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
disconnect(): void {
|
|
294
|
+
if (this.connected) {
|
|
295
|
+
this.socket.destroy();
|
|
296
|
+
this.connected = false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private send(data: string): void {
|
|
301
|
+
this.socket.write(data, "utf-8");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private findMechs(params: SvnItem[]): string[] {
|
|
305
|
+
const result: string[] = [];
|
|
306
|
+
const walk = (items: SvnItem[]): void => {
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
if (typeof item === "string" && /^[A-Z][A-Z0-9-]/.test(item)) {
|
|
309
|
+
result.push(item);
|
|
310
|
+
} else if (Array.isArray(item)) {
|
|
311
|
+
walk(item);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
walk(params);
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private findChallenge(params: SvnItem[]): string {
|
|
320
|
+
const walk = (items: SvnItem[]): string => {
|
|
321
|
+
for (const item of items) {
|
|
322
|
+
if (typeof item === "string" && item.includes("@")) return item;
|
|
323
|
+
if (Array.isArray(item)) {
|
|
324
|
+
const r = walk(item);
|
|
325
|
+
if (r) return r;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return "";
|
|
329
|
+
};
|
|
330
|
+
return walk(params);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Extract revision number from ( success ( N ) ) response. */
|
|
334
|
+
private extractRev(params: SvnItem[]): number {
|
|
335
|
+
for (const p of params) {
|
|
336
|
+
if (typeof p === "number") return p;
|
|
337
|
+
if (Array.isArray(p)) {
|
|
338
|
+
const r = this.extractRev(p);
|
|
339
|
+
if (r > 0) return r;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private parseLogEntry(items: SvnItem[]): LogEntry {
|
|
346
|
+
// Wire: ( ( changed-paths ) rev ( author ) ( date ) ( message ) has-children invalid-revnum revprop-count ( ) subtractive-merge )
|
|
347
|
+
let idx = 0;
|
|
348
|
+
|
|
349
|
+
// Changed paths
|
|
350
|
+
const files: { path: string; action: string; kind: string }[] = [];
|
|
351
|
+
if (Array.isArray(items[idx])) {
|
|
352
|
+
const pathsList = items[idx] as SvnItem[];
|
|
353
|
+
for (const p of pathsList) {
|
|
354
|
+
if (Array.isArray(p)) {
|
|
355
|
+
const e = p as SvnItem[];
|
|
356
|
+
// ( path action ( ) ( kind text-mods prop-mods ) )
|
|
357
|
+
let kind = "";
|
|
358
|
+
if (Array.isArray(e[3]) && typeof (e[3] as SvnItem[])[0] === "string") {
|
|
359
|
+
kind = (e[3] as SvnItem[])[0] as string;
|
|
360
|
+
}
|
|
361
|
+
files.push({
|
|
362
|
+
path: typeof e[0] === "string" ? e[0] : "",
|
|
363
|
+
action: typeof e[1] === "string" ? e[1] : "",
|
|
364
|
+
kind,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
idx++;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Revision
|
|
372
|
+
const revision = typeof items[idx] === "number" ? (items[idx] as number) : 0;
|
|
373
|
+
idx++;
|
|
374
|
+
|
|
375
|
+
// ( author ) ( date ) ( message ) – each is a list with one string
|
|
376
|
+
const readOptStr = (): string => {
|
|
377
|
+
if (idx >= items.length) return "";
|
|
378
|
+
const item = items[idx++];
|
|
379
|
+
if (typeof item === "string") return item;
|
|
380
|
+
if (Array.isArray(item) && item.length === 1 && typeof item[0] === "string") return item[0];
|
|
381
|
+
if (Array.isArray(item) && item.length === 0) return "";
|
|
382
|
+
return "";
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const author = readOptStr();
|
|
386
|
+
const date = readOptStr();
|
|
387
|
+
const message = readOptStr();
|
|
388
|
+
|
|
389
|
+
return { revision, author, date, message, files };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Convenience
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
export async function fetchSvnLogs(
|
|
398
|
+
host: string,
|
|
399
|
+
port: number,
|
|
400
|
+
user: string,
|
|
401
|
+
password: string,
|
|
402
|
+
repoPath: string,
|
|
403
|
+
limit: number
|
|
404
|
+
): Promise<LogEntry[]> {
|
|
405
|
+
const client = new SvnRaClient(host, port, user, password, repoPath);
|
|
406
|
+
try {
|
|
407
|
+
await client.connect();
|
|
408
|
+
return await client.getLogs(limit);
|
|
409
|
+
} finally {
|
|
410
|
+
client.disconnect();
|
|
411
|
+
}
|
|
412
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|