@andrebuzeli/git-mcp 15.8.4 → 15.8.6

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.
@@ -1,472 +1,472 @@
1
- import Ajv from "ajv";
2
- import axios from "axios";
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import archiver from "archiver";
6
- import { asToolError, asToolResult, errorToResponse, mapExternalError } from "../utils/errors.js";
7
- import { getRepoNameFromPath, validateProjectPath } from "../utils/repoHelpers.js";
8
- import { runBoth } from "../utils/providerExec.js";
9
-
10
- /**
11
- * Compacta uma pasta em ZIP e retorna o caminho do arquivo temporário
12
- * @param {string} folderPath - Caminho da pasta a compactar
13
- * @param {string} zipName - Nome do arquivo ZIP (sem extensão)
14
- * @returns {Promise<string>} Caminho do arquivo ZIP criado
15
- */
16
- async function compactFolder(folderPath, zipName) {
17
- return new Promise((resolve, reject) => {
18
- const outputPath = path.join(process.cwd(), 'temp_scripts', `${zipName}.zip`);
19
- const output = fs.createWriteStream(outputPath);
20
- const archive = archiver('zip', { zlib: { level: 9 } });
21
-
22
- output.on('close', () => resolve(outputPath));
23
- output.on('error', reject);
24
- archive.on('error', reject);
25
-
26
- archive.pipe(output);
27
- archive.directory(folderPath, false);
28
- archive.finalize();
29
- });
30
- }
31
-
32
- /**
33
- * Faz upload de um asset para um release
34
- * @param {Object} pm - Provider Manager
35
- * @param {string} owner - Dono do repo
36
- * @param {string} repo - Nome do repo
37
- * @param {number} releaseId - ID do release
38
- * @param {string} assetPath - Caminho do arquivo a fazer upload
39
- * @param {string} assetName - Nome do asset (com extensão)
40
- * @returns {Promise<Object>} Resultado do upload
41
- */
42
- async function uploadAsset(pm, owner, repo, releaseId, assetPath, assetName) {
43
- const fileBuffer = fs.readFileSync(assetPath);
44
- const fileSize = fs.statSync(assetPath).size;
45
-
46
- const out = await runBoth(pm, {
47
- github: async () => {
48
- const response = await pm.github.request("POST /repos/{owner}/{repo}/releases/{release_id}/assets", {
49
- owner,
50
- repo,
51
- release_id: releaseId,
52
- name: assetName,
53
- data: fileBuffer,
54
- headers: {
55
- 'content-type': 'application/octet-stream',
56
- 'content-length': fileSize
57
- }
58
- });
59
- return { ok: true, id: response.data.id, url: response.data.browser_download_url };
60
- },
61
- gitea: async () => {
62
- const base = pm.giteaUrl.replace(/\/$/, "");
63
-
64
- const response = await axios.post(
65
- `${base}/api/v1/repos/${owner}/${repo}/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
66
- fileBuffer,
67
- {
68
- headers: {
69
- 'Authorization': `token ${pm.giteaToken}`,
70
- 'Content-Type': 'application/octet-stream',
71
- 'Content-Length': fileSize
72
- }
73
- }
74
- );
75
- return { ok: true, id: response.data.id, url: response.data.browser_download_url };
76
- }
77
- });
78
-
79
- return out;
80
- }
81
-
82
- const ajv = new Ajv({ allErrors: true });
83
-
84
- export function createGitRemoteTool(pm, git) {
85
- const inputSchema = {
86
- type: "object",
87
- properties: {
88
- projectPath: {
89
- type: "string",
90
- description: "Caminho absoluto do diretório do projeto"
91
- },
92
- action: {
93
- type: "string",
94
- enum: [
95
- "list", "list-all", "ensure", "repo-delete",
96
- "release-create", "topics-set", "milestone-create", "label-create",
97
- "fork-create", "fork-list", "star-set", "star-unset",
98
- "subscription-set", "subscription-unset", "contents-create"
99
- ],
100
- description: `Ação a executar:
101
-
102
- CONFIGURAÇÃO:
103
- CONFIGURAÇÃO:
104
- - list: Lista remotes configurados (github, gitea, origin)
105
- - list-all: Lista TODOS os repositórios da conta (GitHub + Gitea)
106
- - ensure: Cria repos no GitHub/Gitea e configura remotes (USE ESTE se push falhar)
107
-
108
- REPOSITÓRIO:
109
- - repo-delete: Deleta repositório no GitHub E Gitea (⚠️ PERIGOSO)
110
- - release-create: Cria release/versão no GitHub e Gitea
111
- - topics-set: Define tópicos/tags do repositório
112
- - milestone-create: Cria milestone para organização
113
- - label-create: Cria label para issues
114
-
115
- SOCIAL:
116
- - star-set: Adiciona estrela ao repo
117
- - star-unset: Remove estrela
118
- - fork-create: Cria fork do repositório
119
- - fork-list: Lista forks existentes
120
-
121
- NOTIFICAÇÕES:
122
- - subscription-set: Ativa notificações do repo
123
- - subscription-unset: Desativa notificações
124
-
125
- ARQUIVOS VIA API:
126
- - contents-create: Cria arquivo diretamente via API (sem git local)`
127
- },
128
- tag: {
129
- type: "string",
130
- description: "Tag para release-create. Ex: 'v1.0.0'"
131
- },
132
- name: {
133
- type: "string",
134
- description: "Nome da release, label, etc."
135
- },
136
- body: {
137
- type: "string",
138
- description: "Descrição/corpo da release"
139
- },
140
- assets: {
141
- type: "array",
142
- items: {
143
- oneOf: [
144
- { type: "string" }, // path simples: "dist/app.exe"
145
- {
146
- type: "object",
147
- properties: {
148
- path: { type: "string" }, // "dist/build"
149
- name: { type: "string" } // "MeuApp-Windows" (sem extensao)
150
- },
151
- required: ["path"]
152
- }
153
- ]
154
- },
155
- description: "Assets para upload. String = nome automatico, Objeto = nome custom. Pastas são compactadas automaticamente"
156
- },
157
- topics: {
158
- type: "array",
159
- items: { type: "string" },
160
- description: "Lista de tópicos para topics-set. Ex: ['javascript', 'nodejs', 'mcp']"
161
- },
162
- path: {
163
- type: "string",
164
- description: "Caminho do arquivo para contents-create"
165
- },
166
- content: {
167
- type: "string",
168
- description: "Conteúdo do arquivo para contents-create"
169
- },
170
- branch: {
171
- type: "string",
172
- description: "Branch alvo para contents-create. Default: main"
173
- },
174
- color: {
175
- type: "string",
176
- description: "Cor do label em hex (sem #). Ex: 'ff0000' para vermelho"
177
- },
178
- title: {
179
- type: "string",
180
- description: "Título do milestone"
181
- },
182
- isPublic: {
183
- type: "boolean",
184
- description: "Se true, repositório será PÚBLICO. Default: false (privado). Aplica-se a action='ensure'"
185
- }
186
- },
187
- required: ["projectPath", "action"],
188
- additionalProperties: false
189
- };
190
-
191
- const description = `Operações em repositórios remotos GitHub e Gitea.
192
-
193
- AÇÕES MAIS USADAS:
194
- - ensure: Configurar remotes e criar repos (ESSENCIAL antes de push)
195
- - list: Ver remotes configurados
196
- - release-create: Publicar nova versão
197
-
198
- EXECUÇÃO PARALELA:
199
- Todas as operações são executadas em AMBOS os providers (GitHub + Gitea) simultaneamente.
200
-
201
- QUANDO USAR:
202
- - Se git-workflow push falhar: use action='ensure'
203
- - Para publicar release: use action='release-create'
204
- - Para configurar tópicos: use action='topics-set'`;
205
-
206
- async function handle(args) {
207
- const validate = ajv.compile(inputSchema);
208
- if (!validate(args || {})) return asToolError("VALIDATION_ERROR", "Parâmetros inválidos", validate.errors);
209
- const { projectPath, action } = args;
210
- try {
211
- validateProjectPath(projectPath);
212
- if (action === "list") {
213
- const remotes = await git.listRemotes(projectPath);
214
-
215
- // Debug info: calculate what URLs should be
216
- let repoName = getRepoNameFromPath(projectPath);
217
- if (repoName === "GIT_MCP") repoName = "git-mcp";
218
- const calculated = await pm.getRemoteUrls(repoName);
219
-
220
- return asToolResult({
221
- remotes,
222
- configured: remotes.length > 0,
223
- hasGithub: remotes.some(r => r.remote === "github"),
224
- hasGitea: remotes.some(r => r.remote === "gitea"),
225
- debug: {
226
- repoName,
227
- calculatedUrls: calculated,
228
- env: {
229
- hasGithubToken: !!pm.githubToken,
230
- hasGiteaToken: !!pm.giteaToken,
231
- giteaUrl: pm.giteaUrl
232
- }
233
- },
234
- message: remotes.length === 0 ? "Nenhum remote configurado. Use action='ensure' para configurar." : undefined
235
- });
236
- }
237
-
238
- if (action === "list-all") {
239
- const repos = await pm.listAllRepos();
240
- const summary = [];
241
- if (repos.github?.length) summary.push(`${repos.github.length} GitHub repos`);
242
- if (repos.gitea?.length) summary.push(`${repos.gitea.length} Gitea repos`);
243
-
244
- return asToolResult({
245
- success: true,
246
- summary: summary.join(", "),
247
- github: repos.github,
248
- gitea: repos.gitea
249
- });
250
- }
251
- if (action === "ensure") {
252
- const repo = getRepoNameFromPath(projectPath);
253
- const isPublic = args.isPublic === true; // Default: privado
254
- const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
255
- const ghOwner = await pm.getGitHubOwner();
256
- const geOwner = await pm.getGiteaOwner();
257
- const githubUrl = ghOwner ? `https://github.com/${ghOwner}/${repo}.git` : "";
258
- const base = pm.giteaUrl?.replace(/\/$/, "") || "";
259
- const giteaUrl = geOwner && base ? `${base}/${geOwner}/${repo}.git` : "";
260
- await git.ensureRemotes(projectPath, { githubUrl, giteaUrl });
261
- const remotes = await git.listRemotes(projectPath);
262
- return asToolResult({
263
- success: true,
264
- isPrivate: !isPublic,
265
- ensured,
266
- remotes,
267
- urls: { github: githubUrl, gitea: giteaUrl },
268
- message: "Remotes configurados. Agora pode usar git-workflow push."
269
- });
270
- }
271
- if (action === "repo-delete") {
272
- const repo = getRepoNameFromPath(projectPath);
273
- const out = await runBoth(pm, {
274
- github: async (owner) => { await pm.github.rest.repos.delete({ owner, repo }); return { ok: true }; },
275
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.delete(`${base}/api/v1/repos/${owner}/${repo}`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
276
- });
277
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), deleted: repo, providers: out, warning: "Repositório deletado permanentemente!" });
278
- }
279
- if (action === "release-create") {
280
- const repo = getRepoNameFromPath(projectPath);
281
- if (!args.tag) return asToolError("MISSING_PARAMETER", "tag é obrigatório para criar release", { parameter: "tag", example: "v1.0.0" });
282
- const tag = args.tag;
283
- const name = args.name || tag;
284
- const body = args.body || "";
285
-
286
- // Criar release primeiro
287
- const out = await runBoth(pm, {
288
- github: async (owner) => { const r = await pm.github.rest.repos.createRelease({ owner, repo, tag_name: tag, name, body, draft: false, prerelease: false }); return { ok: true, id: r.data.id, url: r.data.html_url }; },
289
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/releases`, { tag_name: tag, name, body }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, id: r.data?.id }; }
290
- });
291
-
292
- const releaseCreated = !!(out.github?.ok || out.gitea?.ok);
293
- if (!releaseCreated) {
294
- return asToolResult({ success: false, tag, name, providers: out, error: "Falha ao criar release" });
295
- }
296
-
297
- // Processar assets se fornecidos
298
- let assetsUploaded = [];
299
- if (args.assets && args.assets.length > 0) {
300
- const tempFiles = []; // Arquivos temporários a serem deletados
301
- const [ghOwner, geOwner] = await Promise.all([
302
- pm.getGitHubOwner().catch(() => ""),
303
- pm.getGiteaOwner().catch(() => "")
304
- ]);
305
-
306
- for (const asset of args.assets) {
307
- try {
308
- const assetPath = typeof asset === 'string' ? asset : asset.path;
309
- const customName = typeof asset === 'object' ? asset.name : null;
310
-
311
- // Resolver caminho absoluto
312
- const absolutePath = path.resolve(projectPath, assetPath);
313
-
314
- // Verificar se existe
315
- if (!fs.existsSync(absolutePath)) {
316
- assetsUploaded.push({ path: assetPath, error: "Arquivo/pasta não encontrado" });
317
- continue;
318
- }
319
-
320
- const stats = fs.statSync(absolutePath);
321
- let uploadPath = absolutePath;
322
- let uploadName = customName;
323
- let isTempFile = false;
324
-
325
- if (stats.isDirectory()) {
326
- // Compactar pasta
327
- const zipName = customName || path.basename(absolutePath);
328
- const zipPath = await compactFolder(absolutePath, zipName);
329
- uploadPath = zipPath;
330
- uploadName = `${zipName}.zip`;
331
- tempFiles.push(zipPath);
332
- isTempFile = true;
333
- } else {
334
- // Arquivo direto
335
- if (!uploadName) {
336
- uploadName = path.basename(absolutePath);
337
- } else {
338
- // Adicionar extensão se não tiver
339
- const ext = path.extname(absolutePath);
340
- if (!uploadName.includes('.')) {
341
- uploadName += ext;
342
- }
343
- }
344
- }
345
-
346
- // Upload para GitHub e Gitea
347
- const uploadResult = await uploadAsset(pm, ghOwner || geOwner, repo, out.github?.id || out.gitea?.id, uploadPath, uploadName);
348
-
349
- assetsUploaded.push({
350
- path: assetPath,
351
- name: uploadName,
352
- uploaded: !!(uploadResult.github?.ok || uploadResult.gitea?.ok),
353
- providers: uploadResult
354
- });
355
-
356
- // Deletar arquivo temporário se foi criado
357
- if (isTempFile && tempFiles.includes(uploadPath)) {
358
- try {
359
- fs.unlinkSync(uploadPath);
360
- tempFiles.splice(tempFiles.indexOf(uploadPath), 1);
361
- } catch (e) {
362
- console.warn(`Falha ao deletar arquivo temporário: ${uploadPath}`);
363
- }
364
- }
365
-
366
- } catch (error) {
367
- assetsUploaded.push({ path: typeof asset === 'string' ? asset : asset.path, error: String(error.message || error) });
368
- }
369
- }
370
- }
371
-
372
- return asToolResult({
373
- success: releaseCreated,
374
- tag,
375
- name,
376
- providers: out,
377
- assets: assetsUploaded.length > 0 ? assetsUploaded : undefined
378
- });
379
- }
380
- if (action === "topics-set") {
381
- const repo = getRepoNameFromPath(projectPath);
382
- const topics = Array.isArray(args.topics) ? args.topics : [];
383
- if (topics.length === 0) return asToolError("MISSING_PARAMETER", "topics é obrigatório", { parameter: "topics", example: ["javascript", "nodejs"] });
384
- const out = await runBoth(pm, {
385
- github: async (owner) => { await pm.github.request("PUT /repos/{owner}/{repo}/topics", { owner, repo, names: topics, headers: { accept: "application/vnd.github.mercy-preview+json" } }); return { ok: true }; },
386
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.put(`${base}/api/v1/repos/${owner}/${repo}/topics`, { topics }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
387
- });
388
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), topics, providers: out });
389
- }
390
- if (action === "milestone-create") {
391
- const repo = getRepoNameFromPath(projectPath);
392
- const title = args.title || args.name || "v1.0";
393
- const out = await runBoth(pm, {
394
- github: async (owner) => { const r = await pm.github.request("POST /repos/{owner}/{repo}/milestones", { owner, repo, title }); return { ok: true, id: r.data?.id }; },
395
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/milestones`, { title }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, id: r.data?.id }; }
396
- });
397
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), title, providers: out });
398
- }
399
- if (action === "label-create") {
400
- const repo = getRepoNameFromPath(projectPath);
401
- const name = args.name || "bug";
402
- const color = String(args.color || "ff0000").replace(/^#/, "");
403
- const out = await runBoth(pm, {
404
- github: async (owner) => { await pm.github.request("POST /repos/{owner}/{repo}/labels", { owner, repo, name, color }); return { ok: true }; },
405
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.post(`${base}/api/v1/repos/${owner}/${repo}/labels`, { name, color }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
406
- });
407
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), label: { name, color }, providers: out });
408
- }
409
- if (action === "fork-create") {
410
- const repo = getRepoNameFromPath(projectPath);
411
- const out = await runBoth(pm, {
412
- github: async (owner) => { const r = await pm.github.rest.repos.createFork({ owner, repo }); return { ok: true, full_name: r.data?.full_name }; },
413
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/forks`, {}, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, full_name: r.data?.full_name }; }
414
- });
415
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), providers: out });
416
- }
417
- if (action === "fork-list") {
418
- const repo = getRepoNameFromPath(projectPath);
419
- const out = await runBoth(pm, {
420
- github: async (owner) => { const r = await pm.github.rest.repos.listForks({ owner, repo }); return { ok: true, count: r.data.length }; },
421
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.get(`${base}/api/v1/repos/${owner}/${repo}/forks`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, count: (r.data || []).length }; }
422
- });
423
- return asToolResult({ providers: out });
424
- }
425
- if (action === "star-set" || action === "star-unset") {
426
- const repo = getRepoNameFromPath(projectPath);
427
- const out = await runBoth(pm, {
428
- github: async (owner) => {
429
- if (action === "star-set") { await pm.github.request("PUT /user/starred/{owner}/{repo}", { owner, repo }); return { ok: true }; }
430
- await pm.github.request("DELETE /user/starred/{owner}/{repo}", { owner, repo }); return { ok: true };
431
- },
432
- gitea: async (owner) => {
433
- const base = pm.giteaUrl.replace(/\/$/, "");
434
- if (action === "star-set") { await axios.put(`${base}/api/v1/user/starred/${owner}/${repo}`, {}, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
435
- await axios.delete(`${base}/api/v1/user/starred/${owner}/${repo}`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true };
436
- }
437
- });
438
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), action, providers: out });
439
- }
440
- if (action === "subscription-set" || action === "subscription-unset") {
441
- const repo = getRepoNameFromPath(projectPath);
442
- const out = await runBoth(pm, {
443
- github: async (owner) => { if (action === "subscription-set") { await pm.github.request("PUT /repos/{owner}/{repo}/subscription", { owner, repo, subscribed: true }); } else { await pm.github.request("DELETE /repos/{owner}/{repo}/subscription", { owner, repo }); } return { ok: true }; },
444
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); if (action === "subscription-set") { await axios.put(`${base}/api/v1/repos/${owner}/${repo}/subscription`, { subscribed: true }, { headers: { Authorization: `token ${pm.giteaToken}` } }); } else { await axios.delete(`${base}/api/v1/repos/${owner}/${repo}/subscription`, { headers: { Authorization: `token ${pm.giteaToken}` } }); } return { ok: true }; }
445
- });
446
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), action, providers: out });
447
- }
448
- if (action === "contents-create") {
449
- const repo = getRepoNameFromPath(projectPath);
450
- if (!args.path) return asToolError("MISSING_PARAMETER", "path é obrigatório", { parameter: "path" });
451
- if (!args.content) return asToolError("MISSING_PARAMETER", "content é obrigatório", { parameter: "content" });
452
- const filePath = args.path;
453
- const message = args.message || `Add ${filePath}`;
454
- const data = args.content;
455
- const branch = args.branch || "main";
456
- const b64 = Buffer.from(data, "utf8").toString("base64");
457
- const out = await runBoth(pm, {
458
- github: async (owner) => { await pm.github.request("PUT /repos/{owner}/{repo}/contents/{path}", { owner, repo, path: filePath, message, content: b64, branch }); return { ok: true }; },
459
- gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.post(`${base}/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`, { content: b64, message, branch }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
460
- });
461
- return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), path: filePath, branch, providers: out });
462
- }
463
- return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
464
- availableActions: ["list", "ensure", "repo-delete", "release-create", "topics-set", "milestone-create", "label-create", "fork-create", "fork-list", "star-set", "star-unset", "subscription-set", "subscription-unset", "contents-create"]
465
- });
466
- } catch (e) {
467
- return errorToResponse(e);
468
- }
469
- }
470
-
471
- return { name: "git-remote", description, inputSchema, handle };
472
- }
1
+ import Ajv from "ajv";
2
+ import axios from "axios";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import archiver from "archiver";
6
+ import { asToolError, asToolResult, errorToResponse, mapExternalError } from "../utils/errors.js";
7
+ import { getRepoNameFromPath, validateProjectPath } from "../utils/repoHelpers.js";
8
+ import { runBoth } from "../utils/providerExec.js";
9
+
10
+ /**
11
+ * Compacta uma pasta em ZIP e retorna o caminho do arquivo temporário
12
+ * @param {string} folderPath - Caminho da pasta a compactar
13
+ * @param {string} zipName - Nome do arquivo ZIP (sem extensão)
14
+ * @returns {Promise<string>} Caminho do arquivo ZIP criado
15
+ */
16
+ async function compactFolder(folderPath, zipName) {
17
+ return new Promise((resolve, reject) => {
18
+ const outputPath = path.join(process.cwd(), 'temp_scripts', `${zipName}.zip`);
19
+ const output = fs.createWriteStream(outputPath);
20
+ const archive = archiver('zip', { zlib: { level: 9 } });
21
+
22
+ output.on('close', () => resolve(outputPath));
23
+ output.on('error', reject);
24
+ archive.on('error', reject);
25
+
26
+ archive.pipe(output);
27
+ archive.directory(folderPath, false);
28
+ archive.finalize();
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Faz upload de um asset para um release
34
+ * @param {Object} pm - Provider Manager
35
+ * @param {string} owner - Dono do repo
36
+ * @param {string} repo - Nome do repo
37
+ * @param {number} releaseId - ID do release
38
+ * @param {string} assetPath - Caminho do arquivo a fazer upload
39
+ * @param {string} assetName - Nome do asset (com extensão)
40
+ * @returns {Promise<Object>} Resultado do upload
41
+ */
42
+ async function uploadAsset(pm, owner, repo, releaseId, assetPath, assetName) {
43
+ const fileBuffer = fs.readFileSync(assetPath);
44
+ const fileSize = fs.statSync(assetPath).size;
45
+
46
+ const out = await runBoth(pm, {
47
+ github: async () => {
48
+ const response = await pm.github.request("POST /repos/{owner}/{repo}/releases/{release_id}/assets", {
49
+ owner,
50
+ repo,
51
+ release_id: releaseId,
52
+ name: assetName,
53
+ data: fileBuffer,
54
+ headers: {
55
+ 'content-type': 'application/octet-stream',
56
+ 'content-length': fileSize
57
+ }
58
+ });
59
+ return { ok: true, id: response.data.id, url: response.data.browser_download_url };
60
+ },
61
+ gitea: async () => {
62
+ const base = pm.giteaUrl.replace(/\/$/, "");
63
+
64
+ const response = await axios.post(
65
+ `${base}/api/v1/repos/${owner}/${repo}/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
66
+ fileBuffer,
67
+ {
68
+ headers: {
69
+ 'Authorization': `token ${pm.giteaToken}`,
70
+ 'Content-Type': 'application/octet-stream',
71
+ 'Content-Length': fileSize
72
+ }
73
+ }
74
+ );
75
+ return { ok: true, id: response.data.id, url: response.data.browser_download_url };
76
+ }
77
+ });
78
+
79
+ return out;
80
+ }
81
+
82
+ const ajv = new Ajv({ allErrors: true });
83
+
84
+ export function createGitRemoteTool(pm, git) {
85
+ const inputSchema = {
86
+ type: "object",
87
+ properties: {
88
+ projectPath: {
89
+ type: "string",
90
+ description: "Caminho absoluto do diretório do projeto"
91
+ },
92
+ action: {
93
+ type: "string",
94
+ enum: [
95
+ "list", "list-all", "ensure", "repo-delete",
96
+ "release-create", "topics-set", "milestone-create", "label-create",
97
+ "fork-create", "fork-list", "star-set", "star-unset",
98
+ "subscription-set", "subscription-unset", "contents-create"
99
+ ],
100
+ description: `Ação a executar:
101
+
102
+ CONFIGURAÇÃO:
103
+ CONFIGURAÇÃO:
104
+ - list: Lista remotes configurados (github, gitea, origin)
105
+ - list-all: Lista TODOS os repositórios da conta (GitHub + Gitea)
106
+ - ensure: Cria repos no GitHub/Gitea e configura remotes (USE ESTE se push falhar)
107
+
108
+ REPOSITÓRIO:
109
+ - repo-delete: Deleta repositório no GitHub E Gitea (⚠️ PERIGOSO)
110
+ - release-create: Cria release/versão no GitHub e Gitea
111
+ - topics-set: Define tópicos/tags do repositório
112
+ - milestone-create: Cria milestone para organização
113
+ - label-create: Cria label para issues
114
+
115
+ SOCIAL:
116
+ - star-set: Adiciona estrela ao repo
117
+ - star-unset: Remove estrela
118
+ - fork-create: Cria fork do repositório
119
+ - fork-list: Lista forks existentes
120
+
121
+ NOTIFICAÇÕES:
122
+ - subscription-set: Ativa notificações do repo
123
+ - subscription-unset: Desativa notificações
124
+
125
+ ARQUIVOS VIA API:
126
+ - contents-create: Cria arquivo diretamente via API (sem git local)`
127
+ },
128
+ tag: {
129
+ type: "string",
130
+ description: "Tag para release-create. Ex: 'v1.0.0'"
131
+ },
132
+ name: {
133
+ type: "string",
134
+ description: "Nome da release, label, etc."
135
+ },
136
+ body: {
137
+ type: "string",
138
+ description: "Descrição/corpo da release"
139
+ },
140
+ assets: {
141
+ type: "array",
142
+ items: {
143
+ oneOf: [
144
+ { type: "string" }, // path simples: "dist/app.exe"
145
+ {
146
+ type: "object",
147
+ properties: {
148
+ path: { type: "string" }, // "dist/build"
149
+ name: { type: "string" } // "MeuApp-Windows" (sem extensao)
150
+ },
151
+ required: ["path"]
152
+ }
153
+ ]
154
+ },
155
+ description: "Assets para upload. String = nome automatico, Objeto = nome custom. Pastas são compactadas automaticamente"
156
+ },
157
+ topics: {
158
+ type: "array",
159
+ items: { type: "string" },
160
+ description: "Lista de tópicos para topics-set. Ex: ['javascript', 'nodejs', 'mcp']"
161
+ },
162
+ path: {
163
+ type: "string",
164
+ description: "Caminho do arquivo para contents-create"
165
+ },
166
+ content: {
167
+ type: "string",
168
+ description: "Conteúdo do arquivo para contents-create"
169
+ },
170
+ branch: {
171
+ type: "string",
172
+ description: "Branch alvo para contents-create. Default: main"
173
+ },
174
+ color: {
175
+ type: "string",
176
+ description: "Cor do label em hex (sem #). Ex: 'ff0000' para vermelho"
177
+ },
178
+ title: {
179
+ type: "string",
180
+ description: "Título do milestone"
181
+ },
182
+ isPublic: {
183
+ type: "boolean",
184
+ description: "Se true, repositório será PÚBLICO. Default: false (privado). Aplica-se a action='ensure'"
185
+ }
186
+ },
187
+ required: ["projectPath", "action"],
188
+ additionalProperties: false
189
+ };
190
+
191
+ const description = `Operações em repositórios remotos GitHub e Gitea.
192
+
193
+ AÇÕES MAIS USADAS:
194
+ - ensure: Configurar remotes e criar repos (ESSENCIAL antes de push)
195
+ - list: Ver remotes configurados
196
+ - release-create: Publicar nova versão
197
+
198
+ EXECUÇÃO PARALELA:
199
+ Todas as operações são executadas em AMBOS os providers (GitHub + Gitea) simultaneamente.
200
+
201
+ QUANDO USAR:
202
+ - Se git-workflow push falhar: use action='ensure'
203
+ - Para publicar release: use action='release-create'
204
+ - Para configurar tópicos: use action='topics-set'`;
205
+
206
+ async function handle(args) {
207
+ const validate = ajv.compile(inputSchema);
208
+ if (!validate(args || {})) return asToolError("VALIDATION_ERROR", "Parâmetros inválidos", validate.errors);
209
+ const { projectPath, action } = args;
210
+ try {
211
+ validateProjectPath(projectPath);
212
+ if (action === "list") {
213
+ const remotes = await git.listRemotes(projectPath);
214
+
215
+ // Debug info: calculate what URLs should be
216
+ let repoName = getRepoNameFromPath(projectPath);
217
+ if (repoName === "GIT_MCP") repoName = "git-mcp";
218
+ const calculated = await pm.getRemoteUrls(repoName);
219
+
220
+ return asToolResult({
221
+ remotes,
222
+ configured: remotes.length > 0,
223
+ hasGithub: remotes.some(r => r.remote === "github"),
224
+ hasGitea: remotes.some(r => r.remote === "gitea"),
225
+ debug: {
226
+ repoName,
227
+ calculatedUrls: calculated,
228
+ env: {
229
+ hasGithubToken: !!pm.githubToken,
230
+ hasGiteaToken: !!pm.giteaToken,
231
+ giteaUrl: pm.giteaUrl
232
+ }
233
+ },
234
+ message: remotes.length === 0 ? "Nenhum remote configurado. Use action='ensure' para configurar." : undefined
235
+ });
236
+ }
237
+
238
+ if (action === "list-all") {
239
+ const repos = await pm.listAllRepos();
240
+ const summary = [];
241
+ if (repos.github?.length) summary.push(`${repos.github.length} GitHub repos`);
242
+ if (repos.gitea?.length) summary.push(`${repos.gitea.length} Gitea repos`);
243
+
244
+ return asToolResult({
245
+ success: true,
246
+ summary: summary.join(", "),
247
+ github: repos.github,
248
+ gitea: repos.gitea
249
+ });
250
+ }
251
+ if (action === "ensure") {
252
+ const repo = getRepoNameFromPath(projectPath);
253
+ const isPublic = args.isPublic === true; // Default: privado
254
+ const ensured = await pm.ensureRepos({ repoName: repo, createIfMissing: true, isPublic });
255
+ const ghOwner = await pm.getGitHubOwner();
256
+ const geOwner = await pm.getGiteaOwner();
257
+ const githubUrl = ghOwner ? `https://github.com/${ghOwner}/${repo}.git` : "";
258
+ const base = pm.giteaUrl?.replace(/\/$/, "") || "";
259
+ const giteaUrl = geOwner && base ? `${base}/${geOwner}/${repo}.git` : "";
260
+ await git.ensureRemotes(projectPath, { githubUrl, giteaUrl });
261
+ const remotes = await git.listRemotes(projectPath);
262
+ return asToolResult({
263
+ success: true,
264
+ isPrivate: !isPublic,
265
+ ensured,
266
+ remotes,
267
+ urls: { github: githubUrl, gitea: giteaUrl },
268
+ message: "Remotes configurados. Agora pode usar git-workflow push."
269
+ });
270
+ }
271
+ if (action === "repo-delete") {
272
+ const repo = getRepoNameFromPath(projectPath);
273
+ const out = await runBoth(pm, {
274
+ github: async (owner) => { await pm.github.rest.repos.delete({ owner, repo }); return { ok: true }; },
275
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.delete(`${base}/api/v1/repos/${owner}/${repo}`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
276
+ });
277
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), deleted: repo, providers: out, warning: "Repositório deletado permanentemente!" });
278
+ }
279
+ if (action === "release-create") {
280
+ const repo = getRepoNameFromPath(projectPath);
281
+ if (!args.tag) return asToolError("MISSING_PARAMETER", "tag é obrigatório para criar release", { parameter: "tag", example: "v1.0.0" });
282
+ const tag = args.tag;
283
+ const name = args.name || tag;
284
+ const body = args.body || "";
285
+
286
+ // Criar release primeiro
287
+ const out = await runBoth(pm, {
288
+ github: async (owner) => { const r = await pm.github.rest.repos.createRelease({ owner, repo, tag_name: tag, name, body, draft: false, prerelease: false }); return { ok: true, id: r.data.id, url: r.data.html_url }; },
289
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/releases`, { tag_name: tag, name, body }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, id: r.data?.id }; }
290
+ });
291
+
292
+ const releaseCreated = !!(out.github?.ok || out.gitea?.ok);
293
+ if (!releaseCreated) {
294
+ return asToolResult({ success: false, tag, name, providers: out, error: "Falha ao criar release" });
295
+ }
296
+
297
+ // Processar assets se fornecidos
298
+ let assetsUploaded = [];
299
+ if (args.assets && args.assets.length > 0) {
300
+ const tempFiles = []; // Arquivos temporários a serem deletados
301
+ const [ghOwner, geOwner] = await Promise.all([
302
+ pm.getGitHubOwner().catch(() => ""),
303
+ pm.getGiteaOwner().catch(() => "")
304
+ ]);
305
+
306
+ for (const asset of args.assets) {
307
+ try {
308
+ const assetPath = typeof asset === 'string' ? asset : asset.path;
309
+ const customName = typeof asset === 'object' ? asset.name : null;
310
+
311
+ // Resolver caminho absoluto
312
+ const absolutePath = path.resolve(projectPath, assetPath);
313
+
314
+ // Verificar se existe
315
+ if (!fs.existsSync(absolutePath)) {
316
+ assetsUploaded.push({ path: assetPath, error: "Arquivo/pasta não encontrado" });
317
+ continue;
318
+ }
319
+
320
+ const stats = fs.statSync(absolutePath);
321
+ let uploadPath = absolutePath;
322
+ let uploadName = customName;
323
+ let isTempFile = false;
324
+
325
+ if (stats.isDirectory()) {
326
+ // Compactar pasta
327
+ const zipName = customName || path.basename(absolutePath);
328
+ const zipPath = await compactFolder(absolutePath, zipName);
329
+ uploadPath = zipPath;
330
+ uploadName = `${zipName}.zip`;
331
+ tempFiles.push(zipPath);
332
+ isTempFile = true;
333
+ } else {
334
+ // Arquivo direto
335
+ if (!uploadName) {
336
+ uploadName = path.basename(absolutePath);
337
+ } else {
338
+ // Adicionar extensão se não tiver
339
+ const ext = path.extname(absolutePath);
340
+ if (!uploadName.includes('.')) {
341
+ uploadName += ext;
342
+ }
343
+ }
344
+ }
345
+
346
+ // Upload para GitHub e Gitea
347
+ const uploadResult = await uploadAsset(pm, ghOwner || geOwner, repo, out.github?.id || out.gitea?.id, uploadPath, uploadName);
348
+
349
+ assetsUploaded.push({
350
+ path: assetPath,
351
+ name: uploadName,
352
+ uploaded: !!(uploadResult.github?.ok || uploadResult.gitea?.ok),
353
+ providers: uploadResult
354
+ });
355
+
356
+ // Deletar arquivo temporário se foi criado
357
+ if (isTempFile && tempFiles.includes(uploadPath)) {
358
+ try {
359
+ fs.unlinkSync(uploadPath);
360
+ tempFiles.splice(tempFiles.indexOf(uploadPath), 1);
361
+ } catch (e) {
362
+ console.warn(`Falha ao deletar arquivo temporário: ${uploadPath}`);
363
+ }
364
+ }
365
+
366
+ } catch (error) {
367
+ assetsUploaded.push({ path: typeof asset === 'string' ? asset : asset.path, error: String(error.message || error) });
368
+ }
369
+ }
370
+ }
371
+
372
+ return asToolResult({
373
+ success: releaseCreated,
374
+ tag,
375
+ name,
376
+ providers: out,
377
+ assets: assetsUploaded.length > 0 ? assetsUploaded : undefined
378
+ });
379
+ }
380
+ if (action === "topics-set") {
381
+ const repo = getRepoNameFromPath(projectPath);
382
+ const topics = Array.isArray(args.topics) ? args.topics : [];
383
+ if (topics.length === 0) return asToolError("MISSING_PARAMETER", "topics é obrigatório", { parameter: "topics", example: ["javascript", "nodejs"] });
384
+ const out = await runBoth(pm, {
385
+ github: async (owner) => { await pm.github.request("PUT /repos/{owner}/{repo}/topics", { owner, repo, names: topics, headers: { accept: "application/vnd.github.mercy-preview+json" } }); return { ok: true }; },
386
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.put(`${base}/api/v1/repos/${owner}/${repo}/topics`, { topics }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
387
+ });
388
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), topics, providers: out });
389
+ }
390
+ if (action === "milestone-create") {
391
+ const repo = getRepoNameFromPath(projectPath);
392
+ const title = args.title || args.name || "v1.0";
393
+ const out = await runBoth(pm, {
394
+ github: async (owner) => { const r = await pm.github.request("POST /repos/{owner}/{repo}/milestones", { owner, repo, title }); return { ok: true, id: r.data?.id }; },
395
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/milestones`, { title }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, id: r.data?.id }; }
396
+ });
397
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), title, providers: out });
398
+ }
399
+ if (action === "label-create") {
400
+ const repo = getRepoNameFromPath(projectPath);
401
+ const name = args.name || "bug";
402
+ const color = String(args.color || "ff0000").replace(/^#/, "");
403
+ const out = await runBoth(pm, {
404
+ github: async (owner) => { await pm.github.request("POST /repos/{owner}/{repo}/labels", { owner, repo, name, color }); return { ok: true }; },
405
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.post(`${base}/api/v1/repos/${owner}/${repo}/labels`, { name, color }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
406
+ });
407
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), label: { name, color }, providers: out });
408
+ }
409
+ if (action === "fork-create") {
410
+ const repo = getRepoNameFromPath(projectPath);
411
+ const out = await runBoth(pm, {
412
+ github: async (owner) => { const r = await pm.github.rest.repos.createFork({ owner, repo }); return { ok: true, full_name: r.data?.full_name }; },
413
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/forks`, {}, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, full_name: r.data?.full_name }; }
414
+ });
415
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), providers: out });
416
+ }
417
+ if (action === "fork-list") {
418
+ const repo = getRepoNameFromPath(projectPath);
419
+ const out = await runBoth(pm, {
420
+ github: async (owner) => { const r = await pm.github.rest.repos.listForks({ owner, repo }); return { ok: true, count: r.data.length }; },
421
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); const r = await axios.get(`${base}/api/v1/repos/${owner}/${repo}/forks`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true, count: (r.data || []).length }; }
422
+ });
423
+ return asToolResult({ providers: out });
424
+ }
425
+ if (action === "star-set" || action === "star-unset") {
426
+ const repo = getRepoNameFromPath(projectPath);
427
+ const out = await runBoth(pm, {
428
+ github: async (owner) => {
429
+ if (action === "star-set") { await pm.github.request("PUT /user/starred/{owner}/{repo}", { owner, repo }); return { ok: true }; }
430
+ await pm.github.request("DELETE /user/starred/{owner}/{repo}", { owner, repo }); return { ok: true };
431
+ },
432
+ gitea: async (owner) => {
433
+ const base = pm.giteaUrl.replace(/\/$/, "");
434
+ if (action === "star-set") { await axios.put(`${base}/api/v1/user/starred/${owner}/${repo}`, {}, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
435
+ await axios.delete(`${base}/api/v1/user/starred/${owner}/${repo}`, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true };
436
+ }
437
+ });
438
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), action, providers: out });
439
+ }
440
+ if (action === "subscription-set" || action === "subscription-unset") {
441
+ const repo = getRepoNameFromPath(projectPath);
442
+ const out = await runBoth(pm, {
443
+ github: async (owner) => { if (action === "subscription-set") { await pm.github.request("PUT /repos/{owner}/{repo}/subscription", { owner, repo, subscribed: true }); } else { await pm.github.request("DELETE /repos/{owner}/{repo}/subscription", { owner, repo }); } return { ok: true }; },
444
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); if (action === "subscription-set") { await axios.put(`${base}/api/v1/repos/${owner}/${repo}/subscription`, { subscribed: true }, { headers: { Authorization: `token ${pm.giteaToken}` } }); } else { await axios.delete(`${base}/api/v1/repos/${owner}/${repo}/subscription`, { headers: { Authorization: `token ${pm.giteaToken}` } }); } return { ok: true }; }
445
+ });
446
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), action, providers: out });
447
+ }
448
+ if (action === "contents-create") {
449
+ const repo = getRepoNameFromPath(projectPath);
450
+ if (!args.path) return asToolError("MISSING_PARAMETER", "path é obrigatório", { parameter: "path" });
451
+ if (!args.content) return asToolError("MISSING_PARAMETER", "content é obrigatório", { parameter: "content" });
452
+ const filePath = args.path;
453
+ const message = args.message || `Add ${filePath}`;
454
+ const data = args.content;
455
+ const branch = args.branch || "main";
456
+ const b64 = Buffer.from(data, "utf8").toString("base64");
457
+ const out = await runBoth(pm, {
458
+ github: async (owner) => { await pm.github.request("PUT /repos/{owner}/{repo}/contents/{path}", { owner, repo, path: filePath, message, content: b64, branch }); return { ok: true }; },
459
+ gitea: async (owner) => { const base = pm.giteaUrl.replace(/\/$/, ""); await axios.post(`${base}/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`, { content: b64, message, branch }, { headers: { Authorization: `token ${pm.giteaToken}` } }); return { ok: true }; }
460
+ });
461
+ return asToolResult({ success: !!(out.github?.ok || out.gitea?.ok), path: filePath, branch, providers: out });
462
+ }
463
+ return asToolError("VALIDATION_ERROR", `Ação '${action}' não suportada`, {
464
+ availableActions: ["list", "ensure", "repo-delete", "release-create", "topics-set", "milestone-create", "label-create", "fork-create", "fork-list", "star-set", "star-unset", "subscription-set", "subscription-unset", "contents-create"]
465
+ });
466
+ } catch (e) {
467
+ return errorToResponse(e);
468
+ }
469
+ }
470
+
471
+ return { name: "git-remote", description, inputSchema, handle };
472
+ }