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;