Skip to content

Commit af57566

Browse files
committed
release: merge develop into main for v0.18.3
2 parents 3207599 + b666924 commit af57566

7 files changed

Lines changed: 186 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.18.3] - 2026-04-12
9+
10+
### Added
11+
12+
- **CLI update mode**`npx @evoapi/evo-nexus@latest .` now detects existing installations and runs pull + rebuild + restart instead of failing with "directory already exists". Stops services before pull, rebuilds frontend, restarts via `start-services.sh`
13+
- **Backup import** — new "Importar" button in Backups page to upload external `.zip` backup files into the local backups list. Validates ZIP integrity before accepting
14+
- **S3-compatible storage support** — added `AWS_ENDPOINT_URL` and `BACKUP_S3_PREFIX` fields to the backup Storage Provider config panel for Cloudflare R2, Backblaze B2, MinIO, and any S3-compatible provider
15+
16+
### Fixed
17+
18+
- **`npx @evoapi/evo-nexus .` on existing repo** — no longer crashes with "fatal: destination path '.' already exists". Auto-detects `.git` + `pyproject.toml` and switches to update flow
19+
- **S3 client for non-AWS providers** — boto3 client now uses `AWS_ENDPOINT_URL` when set, enabling R2/Backblaze/MinIO connectivity
20+
821
## [0.18.2] - 2026-04-12
922

1023
### Added

cli/bin/cli.mjs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,43 @@ async function main() {
133133
const targetDir = filteredArgs[0] || ".";
134134
const targetPath = resolve(process.cwd(), targetDir);
135135

136-
if (targetDir === ".") {
136+
// Detect if target is an existing EvoNexus installation (git repo with pyproject.toml)
137+
const isExistingInstall = existsSync(resolve(targetPath, ".git")) && existsSync(resolve(targetPath, "pyproject.toml"));
138+
139+
if (isExistingInstall) {
140+
// ── Update mode ─────────────────────────────
141+
console.log(` ${GREEN}Existing EvoNexus installation detected.${RESET}\n`);
142+
143+
// Show current version
144+
try {
145+
const { readFileSync } = await import("fs");
146+
const pyproject = readFileSync(resolve(targetPath, "pyproject.toml"), "utf-8");
147+
const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
148+
if (match) console.log(` Current version: ${DIM}${match[1]}${RESET}`);
149+
} catch {}
150+
151+
// Stop running services before updating
152+
console.log(` ${DIM}Stopping services...${RESET}`);
153+
try { run("pkill -f 'terminal-server/bin/server.js' 2>/dev/null || true", { cwd: targetPath }); } catch {}
154+
try { run("pkill -f 'app.py' 2>/dev/null || true", { cwd: targetPath }); } catch {}
155+
156+
// Pull latest
157+
console.log(`\n ${BOLD}Pulling latest changes...${RESET}\n`);
158+
run("git fetch origin", { cwd: targetPath });
159+
// Detect current branch
160+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: targetPath, encoding: "utf-8" }).trim();
161+
run(`git pull origin ${branch}`, { cwd: targetPath });
162+
163+
// Show new version
164+
try {
165+
const { readFileSync } = await import("fs");
166+
const pyproject = readFileSync(resolve(targetPath, "pyproject.toml"), "utf-8");
167+
const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
168+
if (match) console.log(`\n Updated to: ${GREEN}${BOLD}${match[1]}${RESET}`);
169+
} catch {}
170+
171+
console.log();
172+
} else if (targetDir === ".") {
137173
// Clone into current directory
138174
const { readdirSync } = await import("fs");
139175
const files = readdirSync(targetPath).filter(f => !f.startsWith("."));
@@ -176,7 +212,36 @@ async function main() {
176212
console.log(`\n ${GREEN}${RESET} Frontend dependencies installed`);
177213
}
178214

179-
// ── Run setup wizard ───────────────────────
215+
// ── Update mode: rebuild + restart, skip setup wizard ─────
216+
if (isExistingInstall) {
217+
console.log(`\n ${DIM}Building dashboard frontend...${RESET}`);
218+
try {
219+
run("npm run build --silent", { cwd: frontendDir });
220+
console.log(` ${GREEN}${RESET} Dashboard rebuilt`);
221+
} catch {
222+
console.log(` ${YELLOW}!${RESET} Frontend build failed — run: cd dashboard/frontend && npm run build`);
223+
}
224+
225+
// Restart services if start-services.sh exists
226+
const startScript = resolve(targetPath, "start-services.sh");
227+
if (existsSync(startScript)) {
228+
console.log(`\n ${DIM}Restarting services...${RESET}`);
229+
run(`bash ${startScript}`, { cwd: targetPath });
230+
// Wait and verify
231+
await new Promise(r => setTimeout(r, 3000));
232+
try {
233+
execSync("curl -sf http://localhost:8080/api/version", { timeout: 5000 });
234+
console.log(` ${GREEN}${RESET} Dashboard restarted`);
235+
} catch {
236+
console.log(` ${YELLOW}!${RESET} Dashboard may not have started — check logs/dashboard.log`);
237+
}
238+
}
239+
240+
console.log(`\n ${GREEN}${BOLD}EvoNexus updated successfully!${RESET}\n`);
241+
process.exit(0);
242+
}
243+
244+
// ── Run setup wizard (fresh install only) ─────
180245
console.log(`\n ${BOLD}Starting setup wizard...${RESET}\n`);
181246

182247
const pythonCmd = check("uv --version") ? "uv run python" : "python3";

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.18.2",
3+
"version": "0.18.3",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/routes/backups.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,49 @@ def delete_backup(filename):
158158
return jsonify({"status": "deleted"})
159159

160160

161+
@bp.route("/api/backups/upload", methods=["POST"])
162+
def upload_backup():
163+
"""Import an external backup ZIP file into the local backups directory."""
164+
denied = _require("config", "manage")
165+
if denied:
166+
return denied
167+
168+
if "file" not in request.files:
169+
return jsonify({"error": "No file uploaded"}), 400
170+
171+
f = request.files["file"]
172+
if not f.filename or not f.filename.endswith(".zip"):
173+
return jsonify({"error": "Only .zip files are accepted"}), 400
174+
175+
# Sanitize filename
176+
import re
177+
safe_name = re.sub(r"[^\w\-.]", "_", f.filename)
178+
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
179+
dest = BACKUPS_DIR / safe_name
180+
181+
# Prevent overwrite
182+
if dest.exists():
183+
return jsonify({"error": f"File {safe_name} already exists"}), 409
184+
185+
f.save(str(dest))
186+
187+
# Validate it's a real ZIP
188+
import zipfile
189+
try:
190+
with zipfile.ZipFile(dest, "r") as zf:
191+
zf.testzip()
192+
except (zipfile.BadZipFile, Exception):
193+
dest.unlink()
194+
return jsonify({"error": "Invalid ZIP file"}), 400
195+
196+
audit(current_user, "upload", "backups", f"Imported backup {safe_name} ({dest.stat().st_size} bytes)")
197+
return jsonify({
198+
"status": "uploaded",
199+
"filename": safe_name,
200+
"size": dest.stat().st_size,
201+
}), 201
202+
203+
161204
@bp.route("/api/backups/s3")
162205
def list_s3_backups():
163206
"""List backup files stored in S3."""
@@ -175,7 +218,8 @@ def list_s3_backups():
175218
return jsonify({"backups": [], "error": "boto3 not installed"})
176219

177220
try:
178-
s3 = boto3.client("s3")
221+
endpoint_url = os.environ.get("AWS_ENDPOINT_URL")
222+
s3 = boto3.client("s3", endpoint_url=endpoint_url) if endpoint_url else boto3.client("s3")
179223
prefix = os.environ.get("BACKUP_S3_PREFIX", "")
180224
response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
181225
backups = []
@@ -208,7 +252,8 @@ def download_s3_backup(key):
208252

209253
try:
210254
import boto3
211-
s3 = boto3.client("s3")
255+
endpoint_url = os.environ.get("AWS_ENDPOINT_URL")
256+
s3 = boto3.client("s3", endpoint_url=endpoint_url) if endpoint_url else boto3.client("s3")
212257
filename = key.rsplit("/", 1)[-1]
213258
local_path = BACKUPS_DIR / filename
214259
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)

dashboard/frontend/src/pages/Backups.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
22
import {
33
HardDriveDownload, Plus, Download, RotateCcw, Trash2, RefreshCw,
44
Cloud, HardDrive, AlertCircle, CheckCircle, Loader2, FileArchive,
5-
ChevronDown, Eye, EyeOff, Save,
5+
ChevronDown, Eye, EyeOff, Save, Upload,
66
} from 'lucide-react'
77
import { api } from '../lib/api'
88

@@ -48,7 +48,9 @@ const S3_FIELDS = [
4848
{ envKey: 'BACKUP_S3_BUCKET', label: 'S3 Bucket', hint: 'Nome do bucket (ex: my-backups)', required: true, sensitive: false },
4949
{ envKey: 'AWS_ACCESS_KEY_ID', label: 'Access Key ID', hint: 'IAM access key', required: true, sensitive: true },
5050
{ envKey: 'AWS_SECRET_ACCESS_KEY', label: 'Secret Access Key', hint: 'IAM secret key', required: true, sensitive: true },
51-
{ envKey: 'AWS_DEFAULT_REGION', label: 'Region', hint: 'ex: us-east-1, sa-east-1', required: false, sensitive: false },
51+
{ envKey: 'AWS_DEFAULT_REGION', label: 'Region', hint: 'ex: us-east-1, sa-east-1, auto', required: false, sensitive: false },
52+
{ envKey: 'AWS_ENDPOINT_URL', label: 'Endpoint URL', hint: 'Para R2, Backblaze, MinIO (ex: https://xxx.r2.cloudflarestorage.com)', required: false, sensitive: false },
53+
{ envKey: 'BACKUP_S3_PREFIX', label: 'Prefix', hint: 'Prefixo das chaves no bucket (ex: backups/evonexus/)', required: false, sensitive: false },
5254
]
5355

5456
function S3ConfigPanel({ config, onSaved }: { config: BackupConfig; onSaved: () => void }) {
@@ -142,7 +144,8 @@ function S3ConfigPanel({ config, onSaved }: { config: BackupConfig; onSaved: ()
142144
{expanded && (
143145
<div className="px-4 pb-4 border-t border-[#21262d]">
144146
<p className="text-xs text-[#667085] mt-3 mb-4">
145-
Configure S3 para backup remoto. Deixe vazio para usar apenas backup local.
147+
Configure S3 para backup remoto. Compatível com AWS S3, Cloudflare R2, Backblaze B2, MinIO e qualquer storage S3-compatível.
148+
Para providers não-AWS, preencha o <strong>Endpoint URL</strong>. Deixe tudo vazio para usar apenas backup local.
146149
</p>
147150
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
148151
{S3_FIELDS.map(field => {
@@ -216,6 +219,8 @@ export default function Backups() {
216219
const [jobStatus, setJobStatus] = useState<string>('idle')
217220
const [showRestoreModal, setShowRestoreModal] = useState<string | null>(null)
218221
const [restoreMode, setRestoreMode] = useState<'merge' | 'replace'>('merge')
222+
const [uploading, setUploading] = useState(false)
223+
const uploadRef = { current: null as HTMLInputElement | null }
219224

220225
const fetchS3 = useCallback(async () => {
221226
setS3Loading(true)
@@ -303,8 +308,48 @@ export default function Backups() {
303308
}
304309
}
305310

311+
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
312+
const file = e.target.files?.[0]
313+
if (!file) return
314+
e.target.value = '' // Reset input
315+
if (!file.name.endsWith('.zip')) {
316+
alert('Apenas arquivos .zip são aceitos')
317+
return
318+
}
319+
setUploading(true)
320+
try {
321+
const formData = new FormData()
322+
formData.append('file', file)
323+
const base = import.meta.env.DEV ? 'http://localhost:8080' : ''
324+
const res = await fetch(`${base}/api/backups/upload`, {
325+
method: 'POST',
326+
credentials: 'include',
327+
body: formData,
328+
})
329+
if (!res.ok) {
330+
const data = await res.json().catch(() => ({}))
331+
alert(data.error || 'Erro ao importar backup')
332+
return
333+
}
334+
fetchData()
335+
} catch {
336+
alert('Erro ao importar backup')
337+
} finally {
338+
setUploading(false)
339+
}
340+
}
341+
306342
return (
307343
<div>
344+
{/* Hidden file input for import */}
345+
<input
346+
ref={el => { uploadRef.current = el }}
347+
type="file"
348+
accept=".zip"
349+
onChange={handleUpload}
350+
className="hidden"
351+
/>
352+
308353
{/* Header */}
309354
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
310355
<div className="flex items-center gap-3">
@@ -323,6 +368,14 @@ export default function Backups() {
323368
>
324369
<RefreshCw size={16} />
325370
</button>
371+
<button
372+
onClick={() => uploadRef.current?.click()}
373+
disabled={uploading}
374+
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-[#21262d] text-[#D0D5DD] hover:bg-[#161b22] transition-colors text-sm disabled:opacity-50"
375+
>
376+
{uploading ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
377+
{uploading ? 'Importando...' : 'Importar'}
378+
</button>
326379
{config?.s3_configured && config?.boto3_available && (
327380
<button
328381
onClick={() => handleBackup('s3')}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "evo-nexus"
3-
version = "0.18.2"
3+
version = "0.18.3"
44
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
55
requires-python = ">=3.10"
66
dependencies = [

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)