safe-push 0.4.0 → 0.5.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/.claude/settings.local.json +7 -3
- package/.mcp.json +8 -0
- package/bun.lock +179 -0
- package/dist/index.js +21772 -4869
- package/dist/mcp.js +29184 -0
- package/package.json +2 -1
- package/src/commands/mcp.ts +383 -0
- package/src/index.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safe-push",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Git push safety checker - blocks pushes to forbidden areas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"generate-schema": "bun run ./scripts/generate-schema.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
16
17
|
"@opentelemetry/api": "^1.9.0",
|
|
17
18
|
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
|
18
19
|
"@opentelemetry/resources": "^2.5.1",
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import {
|
|
7
|
+
createServer,
|
|
8
|
+
type IncomingMessage,
|
|
9
|
+
type ServerResponse,
|
|
10
|
+
} from "node:http";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { loadConfig } from "../config";
|
|
15
|
+
import { checkPush, checkVisibility } from "../checker";
|
|
16
|
+
import { isGitRepository, hasCommits, execPush } from "../git";
|
|
17
|
+
|
|
18
|
+
function createMcpServer(): McpServer {
|
|
19
|
+
const server = new McpServer({
|
|
20
|
+
name: "safe-push",
|
|
21
|
+
version: "0.3.0",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
server.tool(
|
|
25
|
+
"push",
|
|
26
|
+
"Run safety checks and execute git push. Checks forbidden paths, branch ownership, and repository visibility before pushing.",
|
|
27
|
+
{
|
|
28
|
+
force: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"Bypass safety checks (except visibility). Use when a previous push was blocked and you want to override."
|
|
33
|
+
),
|
|
34
|
+
dryRun: z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Show what would be pushed without actually pushing."),
|
|
38
|
+
args: z
|
|
39
|
+
.array(z.string())
|
|
40
|
+
.optional()
|
|
41
|
+
.describe(
|
|
42
|
+
"Additional git push arguments (e.g. remote name, refspec, flags like --no-verify)."
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
async ({ force, dryRun, args }) => {
|
|
46
|
+
try {
|
|
47
|
+
// SAFE_PUSH_GIT_ROOT が設定されている場合は chdir
|
|
48
|
+
const gitRoot = process.env.SAFE_PUSH_GIT_ROOT;
|
|
49
|
+
if (gitRoot) {
|
|
50
|
+
process.chdir(path.resolve(gitRoot));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Git リポジトリ内か確認
|
|
54
|
+
if (!(await isGitRepository())) {
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text" as const,
|
|
59
|
+
text: "Error: Not a git repository",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// コミットが存在するか確認
|
|
67
|
+
if (!(await hasCommits())) {
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text" as const,
|
|
72
|
+
text: "Error: No commits found",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = loadConfig();
|
|
80
|
+
const gitArgs = args ?? [];
|
|
81
|
+
|
|
82
|
+
// visibility チェック(force でもバイパス不可)
|
|
83
|
+
if (config.allowedVisibility && config.allowedVisibility.length > 0) {
|
|
84
|
+
try {
|
|
85
|
+
const visibilityResult = await checkVisibility(
|
|
86
|
+
config.allowedVisibility
|
|
87
|
+
);
|
|
88
|
+
if (visibilityResult && !visibilityResult.allowed) {
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: "text" as const,
|
|
93
|
+
text: `Blocked: ${visibilityResult.reason}`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text" as const,
|
|
104
|
+
text: `Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n${error instanceof Error ? error.message : String(error)}`,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// force の場合はチェックをスキップ
|
|
113
|
+
if (force) {
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text" as const,
|
|
119
|
+
text: "Dry run: would push (checks bypassed with force)",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await execPush(gitArgs);
|
|
126
|
+
if (result.success) {
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "text" as const,
|
|
131
|
+
text: result.output
|
|
132
|
+
? `Push successful (force)\n${result.output}`
|
|
133
|
+
: "Push successful (force)",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text" as const,
|
|
142
|
+
text: `Push failed: ${result.output}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
isError: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 通常のチェック
|
|
150
|
+
const checkResult = await checkPush(config);
|
|
151
|
+
|
|
152
|
+
if (!checkResult.allowed) {
|
|
153
|
+
const details = checkResult.details;
|
|
154
|
+
let message = `Push blocked: ${checkResult.reason}\n\nDetails:\n- Branch: ${details.currentBranch}\n- New branch: ${details.isNewBranch}\n- Own last commit: ${details.isOwnLastCommit}\n- Author: ${details.authorEmail}\n- Local: ${details.localEmail}`;
|
|
155
|
+
|
|
156
|
+
if (details.forbiddenFiles.length > 0) {
|
|
157
|
+
message += `\n- Forbidden files: ${details.forbiddenFiles.join(", ")}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// onForbidden: "prompt" の場合、MCP ではインタラクティブ確認不可なので force を案内
|
|
161
|
+
if (
|
|
162
|
+
config.onForbidden === "prompt" &&
|
|
163
|
+
details.hasForbiddenChanges
|
|
164
|
+
) {
|
|
165
|
+
message +=
|
|
166
|
+
'\n\nThis repository is configured with onForbidden: "prompt". Since interactive confirmation is not available via MCP, you can re-run with force: true to bypass this check.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
content: [
|
|
171
|
+
{
|
|
172
|
+
type: "text" as const,
|
|
173
|
+
text: message,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// チェック通過
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
const details = checkResult.details;
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: "text" as const,
|
|
187
|
+
text: `Dry run: would push\nReason: ${checkResult.reason}\nBranch: ${details.currentBranch}`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// push 実行
|
|
194
|
+
const result = await execPush(gitArgs);
|
|
195
|
+
if (result.success) {
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "text" as const,
|
|
200
|
+
text: result.output
|
|
201
|
+
? `Push successful\n${result.output}`
|
|
202
|
+
: "Push successful",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text" as const,
|
|
211
|
+
text: `Push failed: ${result.output}`,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "text" as const,
|
|
221
|
+
text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return server;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function startStdioServer(): Promise<void> {
|
|
234
|
+
const server = createMcpServer();
|
|
235
|
+
const transport = new StdioServerTransport();
|
|
236
|
+
await server.connect(transport);
|
|
237
|
+
console.error("safe-push MCP server started (stdio)");
|
|
238
|
+
await new Promise<void>((resolve) => {
|
|
239
|
+
transport.onclose = () => resolve();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function startHttpServer(port: number): Promise<void> {
|
|
244
|
+
const transports = new Map<string, StreamableHTTPServerTransport>();
|
|
245
|
+
|
|
246
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
let data = "";
|
|
249
|
+
req.on("data", (chunk: Buffer) => {
|
|
250
|
+
data += chunk.toString();
|
|
251
|
+
});
|
|
252
|
+
req.on("end", () => resolve(data));
|
|
253
|
+
req.on("error", reject);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const httpServer = createServer(
|
|
258
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
259
|
+
const url = new URL(
|
|
260
|
+
req.url ?? "/",
|
|
261
|
+
`http://${req.headers.host ?? "localhost"}`
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (url.pathname !== "/mcp") {
|
|
265
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
266
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const method = req.method?.toUpperCase();
|
|
271
|
+
|
|
272
|
+
if (method === "POST") {
|
|
273
|
+
try {
|
|
274
|
+
const body = await readBody(req);
|
|
275
|
+
const parsedBody = JSON.parse(body);
|
|
276
|
+
const sessionId = req.headers["mcp-session-id"] as
|
|
277
|
+
| string
|
|
278
|
+
| undefined;
|
|
279
|
+
|
|
280
|
+
let transport: StreamableHTTPServerTransport;
|
|
281
|
+
|
|
282
|
+
if (sessionId && transports.has(sessionId)) {
|
|
283
|
+
transport = transports.get(sessionId)!;
|
|
284
|
+
} else if (!sessionId && isInitializeRequest(parsedBody)) {
|
|
285
|
+
transport = new StreamableHTTPServerTransport({
|
|
286
|
+
sessionIdGenerator: () => randomUUID(),
|
|
287
|
+
onsessioninitialized: (sid: string) => {
|
|
288
|
+
transports.set(sid, transport);
|
|
289
|
+
console.error(`Session initialized: ${sid}`);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
transport.onclose = () => {
|
|
294
|
+
const sid = transport.sessionId;
|
|
295
|
+
if (sid) {
|
|
296
|
+
transports.delete(sid);
|
|
297
|
+
console.error(`Session closed: ${sid}`);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const server = createMcpServer();
|
|
302
|
+
await server.connect(transport);
|
|
303
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
304
|
+
return;
|
|
305
|
+
} else {
|
|
306
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
307
|
+
res.end(
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
jsonrpc: "2.0",
|
|
310
|
+
error: {
|
|
311
|
+
code: -32000,
|
|
312
|
+
message: "Bad Request: No valid session ID provided",
|
|
313
|
+
},
|
|
314
|
+
id: null,
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
321
|
+
} catch {
|
|
322
|
+
if (!res.headersSent) {
|
|
323
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
324
|
+
res.end(
|
|
325
|
+
JSON.stringify({
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
error: { code: -32603, message: "Internal server error" },
|
|
328
|
+
id: null,
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else if (method === "GET") {
|
|
334
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
335
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
336
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
337
|
+
res.end("Invalid or missing session ID");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const transport = transports.get(sessionId)!;
|
|
341
|
+
await transport.handleRequest(req, res);
|
|
342
|
+
} else if (method === "DELETE") {
|
|
343
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
344
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
345
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
346
|
+
res.end("Invalid or missing session ID");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const transport = transports.get(sessionId)!;
|
|
350
|
+
await transport.handleRequest(req, res);
|
|
351
|
+
} else {
|
|
352
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
353
|
+
res.end("Method Not Allowed");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await new Promise<void>((resolve) => {
|
|
359
|
+
httpServer.listen(port, () => {
|
|
360
|
+
console.error(`safe-push MCP server started (HTTP) on port ${port}`);
|
|
361
|
+
});
|
|
362
|
+
httpServer.on("close", resolve);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function createMcpCommand(): Command {
|
|
367
|
+
const cmd = new Command("mcp")
|
|
368
|
+
.description("Start MCP server for Claude Code integration")
|
|
369
|
+
.option("--http", "Start as HTTP server instead of stdio")
|
|
370
|
+
.option("--port <number>", "HTTP server port (default: PORT env or 3000)")
|
|
371
|
+
.action(async (opts: { http?: boolean; port?: string }) => {
|
|
372
|
+
if (opts.http) {
|
|
373
|
+
const port = opts.port
|
|
374
|
+
? Number(opts.port)
|
|
375
|
+
: Number(process.env.PORT) || 3000;
|
|
376
|
+
await startHttpServer(port);
|
|
377
|
+
} else {
|
|
378
|
+
await startStdioServer();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return cmd;
|
|
383
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,16 +3,18 @@ import { Command } from "commander";
|
|
|
3
3
|
import { createCheckCommand } from "./commands/check";
|
|
4
4
|
import { createPushCommand } from "./commands/push";
|
|
5
5
|
import { createConfigCommand } from "./commands/config";
|
|
6
|
+
import { createMcpCommand } from "./commands/mcp";
|
|
6
7
|
import { initTelemetry, shutdownTelemetry } from "./telemetry";
|
|
7
8
|
import { loadConfig } from "./config";
|
|
8
9
|
import { ExitError } from "./types";
|
|
10
|
+
import packageJson from "../package.json";
|
|
9
11
|
|
|
10
12
|
const program = new Command();
|
|
11
13
|
|
|
12
14
|
program
|
|
13
15
|
.name("safe-push")
|
|
14
16
|
.description("Git push safety checker - blocks pushes to forbidden areas")
|
|
15
|
-
.version(
|
|
17
|
+
.version(packageJson.version)
|
|
16
18
|
.option("--trace [exporter]", "Enable OpenTelemetry tracing (otlp|console)");
|
|
17
19
|
|
|
18
20
|
program.hook("preAction", async () => {
|
|
@@ -37,6 +39,7 @@ program.hook("preAction", async () => {
|
|
|
37
39
|
program.addCommand(createCheckCommand());
|
|
38
40
|
program.addCommand(createPushCommand());
|
|
39
41
|
program.addCommand(createConfigCommand());
|
|
42
|
+
program.addCommand(createMcpCommand());
|
|
40
43
|
|
|
41
44
|
let exitCode = 0;
|
|
42
45
|
try {
|