From 4c197eef91e56cac5ea9ced3c42ac0bf6ef0ff1d Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sat, 28 Feb 2026 03:17:56 -0300 Subject: [PATCH] =?UTF-8?q?Adicionar=20publica=C3=A7=C3=A3o=20autom=C3=A1t?= =?UTF-8?q?ica=20de=20projetos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Botão publicar (rocket) nas pastas raiz do explorador - Cria repositório no Gitea, faz git init + push - Atualiza Caddyfile com subdomínio e file_server - Adiciona volume ao docker-compose e reinicia Caddy - Botões lado a lado (download, publicar, excluir) no file explorer - Dockerfile: adiciona git e docker-cli --- Dockerfile | 1 + public/css/styles.css | 18 ++++- public/js/api.js | 1 + public/js/app.js | 1 + public/js/components/files.js | 40 +++++++++- src/routes/api.js | 143 +++++++++++++++++++++++++++++++++- 6 files changed, 200 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 96eaeba..88471db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM node:22-alpine +RUN apk add --no-cache git docker-cli WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev diff --git a/public/css/styles.css b/public/css/styles.css index 0811b8a..ba3e701 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -5292,10 +5292,24 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr .files-th-actions, .files-td-actions { - width: 50px; - text-align: center; + width: 120px; + text-align: right; } +.files-td-actions { + display: flex; + gap: 4px; + justify-content: flex-end; + align-items: center; +} + +.btn-publish { color: var(--success); } +.btn-publish:hover { background: rgba(16, 185, 129, 0.1); } + +.publish-result { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; } +.publish-result-item { font-size: 14px; } +.publish-result-item a { color: var(--accent); text-decoration: underline; } + .files-entry-link { display: inline-flex; align-items: center; diff --git a/public/js/api.js b/public/js/api.js index 32cabec..183fd13 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -145,6 +145,7 @@ const API = { files: { list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); }, delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); }, + publish(path) { return API.request('POST', '/files/publish', { path }); }, }, reports: { diff --git a/public/js/app.js b/public/js/app.js index 153e444..e15cc61 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -774,6 +774,7 @@ const App = { case 'navigate-files': FilesUI.navigate(path || ''); break; case 'download-file': FilesUI.downloadFile(path); break; case 'download-folder': FilesUI.downloadFolder(path); break; + case 'publish-project': FilesUI.publishProject(path); break; case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break; } }); diff --git a/public/js/components/files.js b/public/js/components/files.js index 79183d4..ab866f0 100644 --- a/public/js/components/files.js +++ b/public/js/components/files.js @@ -90,8 +90,12 @@ const FilesUI = { const downloadBtn = entry.type === 'directory' ? `` : ``; + const isRootDir = entry.type === 'directory' && !currentPath; + const publishBtn = isRootDir + ? `` + : ''; const deleteBtn = ``; - const actions = `${downloadBtn}${deleteBtn}`; + const actions = `${downloadBtn}${publishBtn}${deleteBtn}`; return ` @@ -150,6 +154,40 @@ const FilesUI = { a.click(); }, + async publishProject(path) { + const name = path.split('/').pop(); + const confirmed = await Modal.confirm( + 'Publicar projeto', + `Isso irá criar o repositório "${name}" no Gitea, fazer push dos arquivos e publicar em ${name}.nitro-cloud.duckdns.org. Continuar?` + ); + if (!confirmed) return; + + try { + Toast.info('Publicando projeto... isso pode levar alguns segundos'); + const result = await API.files.publish(path); + Toast.success(`Projeto publicado com sucesso!`); + + const modal = document.getElementById('execution-detail-modal-overlay'); + const title = document.getElementById('execution-detail-title'); + const content = document.getElementById('execution-detail-content'); + if (modal && title && content) { + title.textContent = 'Projeto Publicado'; + content.innerHTML = ` +
+ + +
Status: ${Utils.escapeHtml(result.status)}
+ ${result.message ? `
${Utils.escapeHtml(result.message)}
` : ''} +
`; + Modal.open('execution-detail-modal-overlay'); + } + + await FilesUI.navigate(FilesUI.currentPath); + } catch (err) { + Toast.error(`Erro ao publicar: ${err.message}`); + } + }, + async deleteEntry(path, entryType) { const label = entryType === 'directory' ? 'pasta' : 'arquivo'; const name = path.split('/').pop(); diff --git a/src/routes/api.js b/src/routes/api.js index ba4c4e1..d1c78d8 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -11,7 +11,7 @@ import * as pipeline from '../agents/pipeline.js'; import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { cached } from '../cache/index.js'; -import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs'; +import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs'; import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path'; import { createGzip } from 'zlib'; import { Readable } from 'stream'; @@ -1160,4 +1160,145 @@ router.delete('/files', (req, res) => { } }); +router.post('/files/publish', async (req, res) => { + const { path: projectPath } = req.body; + if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' }); + + const targetPath = resolveProjectPath(projectPath); + if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' }); + if (!existsSync(targetPath)) return res.status(404).json({ error: 'Projeto não encontrado' }); + if (!statSync(targetPath).isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' }); + + const projectName = basename(targetPath).toLowerCase().replace(/[^a-z0-9-]/g, '-'); + const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000'; + const GITEA_USER = process.env.GITEA_USER || 'fred'; + const GITEA_PASS = process.env.GITEA_PASS || ''; + const DOMAIN = process.env.DOMAIN || 'nitro-cloud.duckdns.org'; + const VPS_COMPOSE_DIR = process.env.VPS_COMPOSE_DIR || '/vps'; + + if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado no servidor' }); + + const exec = (cmd, opts = {}) => new Promise((resolve, reject) => { + const proc = spawnProcess('sh', ['-c', cmd], { cwd: opts.cwd || targetPath, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } }); + let stdout = '', stderr = ''; + proc.stdout.on('data', d => stdout += d); + proc.stderr.on('data', d => stderr += d); + proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`))); + }); + + const steps = []; + + try { + const authUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}`; + const repoApiUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USER}/${projectName}`; + const createUrl = `${GITEA_URL}/api/v1/user/repos`; + const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64'); + + let repoExists = false; + try { + const check = await fetch(repoApiUrl, { headers: { Authorization: authHeader } }); + repoExists = check.ok; + } catch {} + + if (!repoExists) { + const createRes = await fetch(createUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + body: JSON.stringify({ name: projectName, auto_init: false, private: false }), + }); + if (!createRes.ok) { + const err = await createRes.json().catch(() => ({})); + throw new Error(`Erro ao criar repositório: ${err.message || createRes.statusText}`); + } + steps.push('Repositório criado no Gitea'); + } else { + steps.push('Repositório já existe no Gitea'); + } + + const repoUrl = `${authUrl}/${GITEA_USER}/${projectName}.git`; + const gitDir = `${targetPath}/.git`; + + if (!existsSync(gitDir)) { + await exec('git init'); + await exec(`git remote add origin "${repoUrl}"`); + steps.push('Git inicializado'); + } else { + try { + await exec('git remote get-url origin'); + await exec(`git remote set-url origin "${repoUrl}"`); + } catch { + await exec(`git remote add origin "${repoUrl}"`); + } + steps.push('Remote atualizado'); + } + + await exec('git add -A'); + try { + await exec('git -c user.name="Agents Orchestrator" -c user.email="agents@nitro-cloud" commit -m "Publicação automática"'); + steps.push('Commit criado'); + } catch { + steps.push('Sem alterações para commit'); + } + + await exec('git push -u origin HEAD:main --force'); + steps.push('Push realizado'); + + const caddyFile = `${VPS_COMPOSE_DIR}/caddy/Caddyfile`; + if (existsSync(caddyFile)) { + const caddyContent = readFileSync(caddyFile, 'utf-8'); + const marker = `@${projectName} host ${projectName}.${DOMAIN}`; + + if (!caddyContent.includes(marker)) { + const block = `\n @${projectName} host ${projectName}.${DOMAIN}\n handle @${projectName} {\n root * /srv/${projectName}\n file_server\n try_files {path} /index.html\n }\n`; + const updated = caddyContent.replace( + /(\n? {4}handle \{[\s\S]*?respond.*?200[\s\S]*?\})/, + block + '$1' + ); + writeFileSync(caddyFile, updated); + steps.push('Caddyfile atualizado'); + } else { + steps.push('Caddyfile já configurado'); + } + } + + const composePath = `${VPS_COMPOSE_DIR}/docker-compose.yml`; + if (existsSync(composePath)) { + const composeContent = readFileSync(composePath, 'utf-8'); + const volumeLine = `/home/projetos/${basename(targetPath)}:/srv/${projectName}:ro`; + if (!composeContent.includes(volumeLine)) { + const updated = composeContent.replace( + /(- .\/caddy\/config:\/config)/, + `$1\n - ${volumeLine}` + ); + writeFileSync(composePath, updated); + steps.push('Volume adicionado ao docker-compose'); + } else { + steps.push('Volume já configurado'); + } + } + + try { + await exec(`docker compose -f ${VPS_COMPOSE_DIR}/docker-compose.yml up -d --no-deps caddy`, { cwd: VPS_COMPOSE_DIR }); + steps.push('Caddy reiniciado'); + } catch (e) { + steps.push(`Caddy: reinício manual necessário (${e.message})`); + } + + const siteUrl = `https://${projectName}.${DOMAIN}`; + const repoWebUrl = `https://git.${DOMAIN}/${GITEA_USER}/${projectName}`; + + res.json({ + status: 'Publicado', + siteUrl, + repoUrl: repoWebUrl, + projectName, + steps, + message: `Acesse ${siteUrl} em alguns segundos`, + }); + + } catch (err) { + res.status(500).json({ error: err.message, steps }); + } +}); + export default router;