From e3103d27e7b19849f681d1a4598166380ddb7e19 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sat, 28 Feb 2026 05:46:03 -0300 Subject: [PATCH] =?UTF-8?q?Importar=20projetos=20da=20m=C3=A1quina=20local?= =?UTF-8?q?=20via=20upload=20de=20pasta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitui o navegador de diretórios do servidor por upload de pasta local usando webkitdirectory. Filtra automaticamente .git, node_modules e padrões do .gitignore antes do envio. Cria o repo no Gitea, faz push e clona em /home/projetos/ para uso com agentes. --- public/css/styles.css | 85 ++++----- public/js/api.js | 16 +- public/js/components/import.js | 304 ++++++++++++++++++--------------- src/routes/api.js | 101 +++++++++++ 4 files changed, 312 insertions(+), 194 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index bd3feb7..f9742c7 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -5388,71 +5388,46 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr .import-path-row { display: flex; gap: 8px; -} - -.import-path-row .form-input { - flex: 1; -} - -.import-browser { - background: var(--bg-primary); - border: 1px solid var(--border-primary); - border-radius: 8px; - overflow: hidden; -} - -.import-browser-header { - padding: 8px 12px; - background: var(--bg-tertiary); - border-bottom: 1px solid var(--border-primary); -} - -.import-browser-list { - max-height: 300px; - overflow-y: auto; -} - -.import-browser-item { - display: flex; align-items: center; - justify-content: space-between; - padding: 6px 12px; - border-bottom: 1px solid var(--border-primary); - transition: background 0.15s; } -.import-browser-item:last-child { - border-bottom: none; -} - -.import-browser-item:hover { - background: var(--bg-card-hover); -} - -.import-browser-dir { +.import-folder-display { + flex: 1; display: flex; align-items: center; gap: 8px; + padding: 8px 12px; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: 6px; + font-size: 13px; + min-height: 40px; +} + +.import-preview { + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 12px 16px; +} + +.import-preview-stats { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.import-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; color: var(--text-primary); - text-decoration: none; - font-size: 13px; - flex: 1; - min-width: 0; } -.import-browser-dir:hover { - color: var(--accent); -} - -.import-browser-empty { - padding: 24px; - text-align: center; +.import-stat--muted { color: var(--text-muted); - font-size: 13px; -} - -.import-select-btn { - flex-shrink: 0; + font-size: 12px; } .import-repos-grid { diff --git a/public/js/api.js b/public/js/api.js index 211a36b..f0d2c61 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -151,7 +151,21 @@ const API = { projects: { browse(path) { return API.request('GET', `/browse?path=${encodeURIComponent(path || '/home')}`); }, - import(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); }, + importLocal(sourcePath, repoName) { return API.request('POST', '/projects/import', { sourcePath, repoName }); }, + async upload(files, paths, repoName) { + const form = new FormData(); + form.append('repoName', repoName); + form.append('paths', JSON.stringify(paths)); + for (const f of files) form.append('files', f); + const response = await fetch('/api/projects/upload', { + method: 'POST', + headers: { 'X-Client-Id': API.clientId }, + body: form, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Erro no upload'); + return data; + }, }, files: { diff --git a/public/js/components/import.js b/public/js/components/import.js index 78cc150..e08fd75 100644 --- a/public/js/components/import.js +++ b/public/js/components/import.js @@ -1,16 +1,18 @@ const ImportUI = { - _currentBrowsePath: '/home', - _selectedPath: '', + _selectedFiles: [], + _selectedPaths: [], + _folderName: '', _importing: false, + _excludedDirs: ['.git', 'node_modules', '__pycache__', '.next', '.nuxt', 'venv', '.venv', '.cache', '.parcel-cache', 'dist', 'build', '.output', '.svelte-kit', 'vendor', 'target', '.gradle', '.idea', '.vs', 'coverage', '.nyc_output'], + _excludedFiles: ['.DS_Store', 'Thumbs.db', 'desktop.ini', '*.pyc', '*.pyo', '*.class', '*.o', '*.so', '*.dll'], + async load() { const container = document.getElementById('import-container'); if (!container) return; let repos = []; - try { - repos = await API.repos.list(); - } catch {} + try { repos = await API.repos.list(); } catch {} container.innerHTML = `
@@ -19,26 +21,32 @@ const ImportUI = {

Importar Projeto

-

Selecione um diretório do servidor para importar ao Gitea. Os arquivos serão copiados respeitando o .gitignore, sem alterar o projeto original.

+

Selecione uma pasta do seu computador para enviar ao Gitea. Arquivos ignorados pelo .gitignore e pastas como node_modules serão filtrados automaticamente.

+ + +
- +
- - +
+ + Nenhuma pasta selecionada +
+
- + + +
- Letras minúsculas, números e hífens. Será criado no Gitea e clonado em /home/projetos/ + Letras minúsculas, números e hífens
-
@@ -67,7 +75,7 @@ const ImportUI = { const domain = 'nitro-cloud.duckdns.org'; const repoUrl = `https://git.${domain}/${repo.full_name || repo.name}`; const updated = repo.updated_at ? new Date(repo.updated_at).toLocaleDateString('pt-BR') : ''; - const size = repo.size ? ImportUI._formatSize(repo.size * 1024) : ''; + const size = repo.size ? ImportUI._fmtSize(repo.size * 1024) : ''; return `
@@ -85,7 +93,7 @@ const ImportUI = { `; }, - _formatSize(bytes) { + _fmtSize(bytes) { if (!bytes) return ''; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); @@ -93,152 +101,170 @@ const ImportUI = { }, _bindEvents() { - const browseBtn = document.getElementById('import-browse-btn'); + const selectBtn = document.getElementById('import-select-btn'); + const folderInput = document.getElementById('import-folder-input'); const submitBtn = document.getElementById('import-submit-btn'); - const pathInput = document.getElementById('import-path'); - if (browseBtn) { - browseBtn.addEventListener('click', () => { - const browser = document.getElementById('import-browser'); - if (!browser) return; - const isVisible = !browser.hidden; - browser.hidden = isVisible; - if (!isVisible) { - const currentVal = pathInput?.value.trim(); - ImportUI._browseTo(currentVal || '/home'); - } - }); - } - - if (pathInput) { - pathInput.addEventListener('change', () => { - const val = pathInput.value.trim(); - if (val) { - ImportUI._autoFillRepoName(val); - } - }); - - pathInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const val = pathInput.value.trim(); - if (val) { - ImportUI._autoFillRepoName(val); - const browser = document.getElementById('import-browser'); - if (browser && !browser.hidden) { - ImportUI._browseTo(val); - } - } - } - }); + if (selectBtn && folderInput) { + selectBtn.addEventListener('click', () => folderInput.click()); + folderInput.addEventListener('change', () => ImportUI._onFolderSelected(folderInput.files)); } if (submitBtn) { - submitBtn.addEventListener('click', () => ImportUI._doImport()); + submitBtn.addEventListener('click', () => ImportUI._doUpload()); } }, - _autoFillRepoName(path) { - const nameInput = document.getElementById('import-repo-name'); - if (!nameInput || nameInput.value.trim()) return; - const folderName = path.split('/').filter(Boolean).pop() || ''; - nameInput.value = folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + _shouldExclude(relativePath) { + const parts = relativePath.split('/'); + for (const part of parts.slice(0, -1)) { + if (ImportUI._excludedDirs.includes(part)) return true; + } + const fileName = parts[parts.length - 1]; + for (const pattern of ImportUI._excludedFiles) { + if (pattern.startsWith('*.')) { + if (fileName.endsWith(pattern.slice(1))) return true; + } else { + if (fileName === pattern) return true; + } + } + return false; }, - async _browseTo(path) { - try { - const data = await API.projects.browse(path); - ImportUI._currentBrowsePath = data.currentPath; - ImportUI._renderBrowser(data); - } catch (err) { - Toast.error(`Erro ao navegar: ${err.message}`); + _parseGitignore(content) { + const patterns = []; + for (const raw of content.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith('!')) continue; + patterns.push(line.replace(/\/$/, '')); } + return patterns; }, - _renderBrowser(data) { - const breadcrumbEl = document.getElementById('import-browser-breadcrumb'); - const listEl = document.getElementById('import-browser-list'); - if (!breadcrumbEl || !listEl) return; - - const parts = data.currentPath.split('/').filter(Boolean); - let breadcrumb = ` /`; - let accumulated = ''; - for (const part of parts) { - accumulated += '/' + part; - breadcrumb += ` / ${Utils.escapeHtml(part)}`; - } - breadcrumbEl.innerHTML = breadcrumb; - - const dirs = data.directories || []; - if (dirs.length === 0) { - listEl.innerHTML = '
Nenhum subdiretório encontrado
'; - } else { - listEl.innerHTML = dirs.map(d => ` - - `).join(''); - } - - Utils.refreshIcons(breadcrumbEl); - Utils.refreshIcons(listEl); - - breadcrumbEl.querySelectorAll('.import-browse-link').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - ImportUI._browseTo(link.dataset.browsePath); - }); - }); - - listEl.querySelectorAll('.import-browse-link').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - ImportUI._browseTo(link.dataset.browsePath); - }); - }); - - listEl.querySelectorAll('.import-select-btn').forEach(btn => { - btn.addEventListener('click', () => { - const selectedPath = btn.dataset.selectPath; - const selectedName = btn.dataset.selectName; - const pathInput = document.getElementById('import-path'); - const nameInput = document.getElementById('import-repo-name'); - if (pathInput) pathInput.value = selectedPath; - if (nameInput && !nameInput.value.trim()) { - nameInput.value = selectedName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + _matchesGitignore(relativePath, patterns) { + const parts = relativePath.split('/'); + for (const pattern of patterns) { + if (pattern.includes('/')) { + if (relativePath.startsWith(pattern + '/') || relativePath === pattern) return true; + } else if (pattern.startsWith('*.')) { + const ext = pattern.slice(1); + if (relativePath.endsWith(ext)) return true; + } else { + for (const part of parts) { + if (part === pattern) return true; } - document.getElementById('import-browser').hidden = true; - ImportUI._selectedPath = selectedPath; - }); - }); + } + } + return false; }, - async _doImport() { - if (ImportUI._importing) return; + _onFolderSelected(fileList) { + if (!fileList || fileList.length === 0) return; + + const allFiles = Array.from(fileList); + const firstPath = allFiles[0].webkitRelativePath || ''; + ImportUI._folderName = firstPath.split('/')[0] || 'projeto'; + + let gitignorePatterns = []; + const gitignoreFile = allFiles.find(f => { + const rel = f.webkitRelativePath || ''; + const parts = rel.split('/'); + return parts.length === 2 && parts[1] === '.gitignore'; + }); + + if (gitignoreFile) { + const reader = new FileReader(); + reader.onload = (e) => { + gitignorePatterns = ImportUI._parseGitignore(e.target.result); + ImportUI._applyFilter(allFiles, gitignorePatterns); + }; + reader.readAsText(gitignoreFile); + } else { + ImportUI._applyFilter(allFiles, []); + } + }, + + _applyFilter(allFiles, gitignorePatterns) { + const filtered = []; + const paths = []; + let totalSize = 0; + let excluded = 0; + + for (const file of allFiles) { + const fullRel = file.webkitRelativePath || file.name; + const relWithoutRoot = fullRel.split('/').slice(1).join('/'); + if (!relWithoutRoot) continue; + + if (ImportUI._shouldExclude(relWithoutRoot)) { excluded++; continue; } + if (gitignorePatterns.length > 0 && ImportUI._matchesGitignore(relWithoutRoot, gitignorePatterns)) { excluded++; continue; } + + filtered.push(file); + paths.push(fullRel); + totalSize += file.size; + } + + ImportUI._selectedFiles = filtered; + ImportUI._selectedPaths = paths; + + const display = document.getElementById('import-folder-display'); + if (display) { + display.innerHTML = ` + + ${Utils.escapeHtml(ImportUI._folderName)} + `; + Utils.refreshIcons(display); + } + + const preview = document.getElementById('import-preview'); + if (preview) { + preview.hidden = false; + preview.innerHTML = ` +
+
+ + ${filtered.length} arquivos selecionados +
+
+ + ${ImportUI._fmtSize(totalSize)} total +
+ ${excluded > 0 ? `
+ + ${excluded} arquivos ignorados (.gitignore / node_modules / etc.) +
` : ''} +
+ `; + Utils.refreshIcons(preview); + } + + const nameInput = document.getElementById('import-repo-name'); + if (nameInput && !nameInput.value.trim()) { + nameInput.value = ImportUI._folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + } + + const submitBtn = document.getElementById('import-submit-btn'); + if (submitBtn) submitBtn.disabled = filtered.length === 0; + }, + + async _doUpload() { + if (ImportUI._importing) return; + if (ImportUI._selectedFiles.length === 0) { Toast.warning('Selecione uma pasta primeiro'); return; } - const pathInput = document.getElementById('import-path'); const nameInput = document.getElementById('import-repo-name'); const submitBtn = document.getElementById('import-submit-btn'); - const sourcePath = pathInput?.value.trim(); - const repoName = nameInput?.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); - - if (!sourcePath) { Toast.warning('Informe o caminho do projeto'); return; } + const repoName = (nameInput?.value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); if (!repoName) { Toast.warning('Informe o nome do repositório'); return; } ImportUI._importing = true; if (submitBtn) { submitBtn.disabled = true; - submitBtn.innerHTML = ' Importando...'; + submitBtn.innerHTML = ' Enviando...'; Utils.refreshIcons(submitBtn); } try { - Toast.info('Importando projeto... isso pode levar alguns segundos'); - const result = await API.projects.import(sourcePath, repoName); + Toast.info(`Enviando ${ImportUI._selectedFiles.length} arquivos...`); + const result = await API.projects.upload(ImportUI._selectedFiles, ImportUI._selectedPaths, repoName); Toast.success('Projeto importado com sucesso!'); @@ -261,7 +287,9 @@ const ImportUI = { Modal.open('execution-detail-modal-overlay'); } - if (pathInput) pathInput.value = ''; + ImportUI._selectedFiles = []; + ImportUI._selectedPaths = []; + ImportUI._folderName = ''; if (nameInput) nameInput.value = ''; App._reposCache = null; await ImportUI.load(); diff --git a/src/routes/api.js b/src/routes/api.js index ebcfdd5..fadf94b 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -39,6 +39,11 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024, files: 20 }, }); +const importUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024, files: 10000 }, +}); + const router = Router(); export const hookRouter = Router(); @@ -1456,4 +1461,100 @@ router.post('/projects/import', async (req, res) => { } }); +router.post('/projects/upload', importUpload.array('files', 10000), async (req, res) => { + const repoName = (req.body.repoName || '').toLowerCase().replace(/[^a-z0-9-]/g, '-'); + if (!repoName) return res.status(400).json({ error: 'repoName é obrigatório' }); + + let paths; + try { paths = JSON.parse(req.body.paths || '[]'); } catch { return res.status(400).json({ error: 'paths inválido' }); } + + const files = req.files || []; + if (files.length === 0) return res.status(400).json({ error: 'Nenhum arquivo enviado' }); + if (files.length !== paths.length) return res.status(400).json({ error: 'Quantidade de files e paths diverge' }); + + 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'; + if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado' }); + + const steps = []; + const tmpDir = join(os.tmpdir(), `upload-${Date.now()}`); + + const exec = (cmd, cwd) => new Promise((resolve, reject) => { + const proc = spawnProcess('sh', ['-c', cmd], { cwd: cwd || tmpDir, 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}`))); + }); + + try { + mkdirSync(tmpDir, { recursive: true }); + + for (let i = 0; i < files.length; i++) { + const relativePath = paths[i].split('/').slice(1).join('/'); + if (!relativePath || relativePath.includes('..')) continue; + const dest = join(tmpDir, relativePath); + mkdirSync(dirname(dest), { recursive: true }); + const { writeFileSync: wfs } = await import('fs'); + wfs(dest, files[i].buffer); + } + steps.push(`${files.length} arquivos recebidos`); + + const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64'); + let repoExists = false; + try { + const check = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_USER}/${repoName}`, { headers: { Authorization: authHeader } }); + repoExists = check.ok; + } catch {} + + if (!repoExists) { + const createRes = await fetch(`${GITEA_URL}/api/v1/user/repos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + body: JSON.stringify({ name: repoName, auto_init: false, private: false }), + }); + if (!createRes.ok) throw new Error('Erro ao criar repositório no Gitea'); + steps.push('Repositório criado no Gitea'); + } else { + steps.push('Repositório já existe no Gitea'); + } + + const repoUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}/${GITEA_USER}/${repoName}.git`; + await exec('git init'); + await exec('git add -A'); + await exec(`git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN}" commit -m "Import do projeto ${repoName}"`); + await exec(`git remote add origin "${repoUrl}"`); + await exec('git push -u origin HEAD:main --force'); + steps.push('Push realizado para o Gitea'); + + const projectsDir = '/home/projetos'; + const targetDir = join(projectsDir, repoName); + if (existsSync(targetDir)) { + await exec(`git remote set-url origin "${repoUrl}"`, targetDir); + await exec('git fetch origin', targetDir); + await exec('git reset --hard origin/main', targetDir); + steps.push('Projeto atualizado em /home/projetos/'); + } else { + await exec(`git clone "${repoUrl}" "${targetDir}"`, projectsDir); + steps.push('Projeto clonado em /home/projetos/'); + } + + rmSync(tmpDir, { recursive: true, force: true }); + + res.json({ + status: 'Importado', + repoName, + repoUrl: `https://git.${DOMAIN}/${GITEA_USER}/${repoName}`, + projectDir: targetDir, + steps, + message: 'Projeto disponível no Gitea e pronto para uso com agentes', + }); + } catch (err) { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + res.status(500).json({ error: err.message, steps }); + } +}); + export default router;