diff --git a/bin/cli/commands/backup.mjs b/bin/cli/commands/backup.mjs index 6170ede575..0786ba5f5e 100644 --- a/bin/cli/commands/backup.mjs +++ b/bin/cli/commands/backup.mjs @@ -8,7 +8,6 @@ import { } from "node:fs"; import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; import { dirname, join, extname, basename } from "node:path"; -import { homedir } from "node:os"; import { resolveDataDir } from "../data-dir.mjs"; import { apiFetch, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; @@ -98,15 +97,25 @@ export function registerRestore(program) { } function matchesGlob(fileName, pattern) { - if (!pattern.includes("*")) return fileName === pattern || fileName.startsWith(pattern); + if (!pattern.includes("*")) return fileName === pattern; const parts = pattern.split("*"); - if (parts.length !== 2) return false; - const [prefix, suffix] = parts; - return ( - fileName.startsWith(prefix) && - fileName.endsWith(suffix) && - fileName.length >= prefix.length + suffix.length - ); + let pos = 0; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + if (i === 0) { + if (!fileName.startsWith(part)) return false; + pos = part.length; + } else if (i === parts.length - 1) { + if (!fileName.endsWith(part)) return false; + if (fileName.length < pos + part.length) return false; + } else { + const idx = fileName.indexOf(part, pos); + if (idx === -1) return false; + pos = idx + part.length; + } + } + return true; } function shouldExclude(fileName, patterns) { @@ -155,7 +164,8 @@ export async function runBackupCommand(opts = {}) { const dataDir = resolveDataDir(); const backupDir = getBackupDir(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const backupName = opts.name ? `omniroute-backup-${opts.name}` : `omniroute-backup-${timestamp}`; + const safeName = opts.name ? String(opts.name).replace(/[/\\]/g, "_") : null; + const backupName = safeName ? `omniroute-backup-${safeName}` : `omniroute-backup-${timestamp}`; const backupPath = join(backupDir, backupName); const excludePatterns = opts.exclude || []; @@ -262,9 +272,14 @@ async function _uploadBackupToCloud(backupPath, info) { return 1; } try { + // Read files locally and send as base64 — never send local path to server + const files = {}; + for (const fname of readdirSync(backupPath)) { + files[fname] = readFileSync(join(backupPath, fname)).toString("base64"); + } const res = await apiFetch("/api/db-backups/cloud", { method: "POST", - body: { backupPath, info }, + body: { files, info }, retry: false, timeout: 30000, acceptNotOk: true, @@ -280,9 +295,12 @@ async function _uploadBackupToCloud(backupPath, info) { } } -const BACKUP_SCHEDULE_PATH = join(homedir(), ".omniroute", "backup-schedule.json"); +function getSchedulePath() { + return join(resolveDataDir(), "backup-schedule.json"); +} export async function runBackupAutoEnableCommand(opts = {}) { + const schedulePath = getSchedulePath(); const schedule = { enabled: true, cron: opts.cron || "0 3 * * *", @@ -291,30 +309,32 @@ export async function runBackupAutoEnableCommand(opts = {}) { retention: opts.retention || null, updatedAt: new Date().toISOString(), }; - mkdirSync(dirname(BACKUP_SCHEDULE_PATH), { recursive: true }); - writeFileSync(BACKUP_SCHEDULE_PATH, JSON.stringify(schedule, null, 2), "utf8"); + mkdirSync(dirname(schedulePath), { recursive: true }); + writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8"); console.log(t("backup.auto.enabled", { cron: schedule.cron })); console.log(t("backup.auto.hint")); return 0; } export async function runBackupAutoDisableCommand() { - if (existsSync(BACKUP_SCHEDULE_PATH)) { - const schedule = JSON.parse(readFileSync(BACKUP_SCHEDULE_PATH, "utf8")); + const schedulePath = getSchedulePath(); + if (existsSync(schedulePath)) { + const schedule = JSON.parse(readFileSync(schedulePath, "utf8")); schedule.enabled = false; schedule.updatedAt = new Date().toISOString(); - writeFileSync(BACKUP_SCHEDULE_PATH, JSON.stringify(schedule, null, 2), "utf8"); + writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8"); } console.log(t("backup.auto.disabled")); return 0; } export async function runBackupAutoStatusCommand() { - if (!existsSync(BACKUP_SCHEDULE_PATH)) { + const schedulePath = getSchedulePath(); + if (!existsSync(schedulePath)) { console.log(t("backup.auto.notConfigured")); return 0; } - const schedule = JSON.parse(readFileSync(BACKUP_SCHEDULE_PATH, "utf8")); + const schedule = JSON.parse(readFileSync(schedulePath, "utf8")); const statusLabel = schedule.enabled ? "\x1b[32m● enabled\x1b[0m" : "\x1b[31m○ disabled\x1b[0m"; console.log(`${t("backup.auto.title")}: ${statusLabel}`); console.log(` cron: ${schedule.cron}`); @@ -368,7 +388,8 @@ export async function runRestoreCommand(backupId, opts = {}) { return 0; } - const backupPath = join(backupDir, `omniroute-backup-${backupId}`); + const safeBackupId = String(backupId).replace(/[/\\]/g, "_"); + const backupPath = join(backupDir, `omniroute-backup-${safeBackupId}`); if (!existsSync(backupPath)) { console.error(t("backup.notFound", { name: backupId })); return 1; diff --git a/bin/cli/commands/keys.mjs b/bin/cli/commands/keys.mjs index 9993e65a70..0d5f572b4a 100644 --- a/bin/cli/commands/keys.mjs +++ b/bin/cli/commands/keys.mjs @@ -178,11 +178,16 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) { method: "POST", body: { provider: providerLower, apiKey: key }, retry: false, + acceptNotOk: true, }); if (res.ok) { console.log(t("keys.added", { provider: providerLower })); return 0; } + if (res.status >= 400 && res.status < 500) { + console.error(t("common.error", { message: `HTTP ${res.status}` })); + return 1; + } } catch {} } @@ -341,7 +346,7 @@ export async function runKeysRegenerateCommand(id, opts = {}) { } const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, { method: "POST", - ...opts, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -367,7 +372,7 @@ export async function runKeysRevokeCommand(id, opts = {}) { } const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/revoke`, { method: "POST", - ...opts, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -380,7 +385,7 @@ export async function runKeysRevokeCommand(id, opts = {}) { export async function runKeysRevealCommand(id, opts = {}) { process.stderr.write(t("keys.revealWarning") + "\n"); const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, { - ...opts, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -395,7 +400,7 @@ export async function runKeysUsageCommand(id, opts = {}) { const limit = opts.limit || "20"; const res = await apiFetch( `/api/v1/registered-keys/${encodeURIComponent(id)}/usage?limit=${limit}`, - { ...opts } + { retry: false } ); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -418,8 +423,8 @@ export async function runKeysUsageCommand(id, opts = {}) { export async function runKeysPolicyShowCommand(id, opts = {}) { const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, { - ...opts, acceptNotOk: true, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -453,8 +458,8 @@ export async function runKeysPolicySetCommand(id, opts = {}) { const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/policy`, { method: "PATCH", body, - ...opts, acceptNotOk: true, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -467,8 +472,8 @@ export async function runKeysPolicySetCommand(id, opts = {}) { export async function runKeysExpirationListCommand(opts = {}) { const days = Number(opts.days || 30); const res = await apiFetch(`/api/v1/registered-keys?expiring=true&days=${days}`, { - ...opts, acceptNotOk: true, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); @@ -510,8 +515,8 @@ export async function runKeysRotateCommand(id, opts = {}) { const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/rotate`, { method: "POST", body: { gracePeriodMs: gracePeriod }, - ...opts, acceptNotOk: true, + retry: false, }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); diff --git a/bin/cli/commands/test-provider.mjs b/bin/cli/commands/test-provider.mjs index 9238de4957..4c45e81f6a 100644 --- a/bin/cli/commands/test-provider.mjs +++ b/bin/cli/commands/test-provider.mjs @@ -194,9 +194,10 @@ async function _runSingleTest(provider, model) { const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; return { ...data, durationMs }; } catch (err) { + const msg = err instanceof Error ? err.message : String(err); return { success: false, - error: err instanceof Error ? err.message : String(err), + error: msg.slice(0, 100), durationMs: Date.now() - startMs, }; } diff --git a/bin/cli/commands/tunnel.mjs b/bin/cli/commands/tunnel.mjs index aa3e062151..c6ecede0a0 100644 --- a/bin/cli/commands/tunnel.mjs +++ b/bin/cli/commands/tunnel.mjs @@ -90,7 +90,7 @@ export async function runTunnelListCommand(opts = {}) { try { const res = await apiFetch("/api/tunnels", { retry: false, timeout: 5000, acceptNotOk: true }); if (!res.ok) { - console.log("Tunnel info not available."); + console.log(t("tunnel.notAvailable")); return 0; } @@ -103,7 +103,7 @@ export async function runTunnelListCommand(opts = {}) { console.log(`\n\x1b[1m\x1b[36m${t("tunnel.title")}\x1b[0m\n`); if (!Array.isArray(tunnels) || tunnels.length === 0) { - console.log(" No active tunnels."); + console.log(t("tunnel.noTunnels")); return 0; } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 7505a9567f..6bbe91155a 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -447,6 +447,8 @@ "tailOpt": "Number of log lines to show", "typeRequired": "Tunnel type is required.", "noLogs": "No logs available.", + "notAvailable": "Tunnel info not available.", + "noTunnels": "No active tunnels.", "infoTitle": "Tunnel info: {type}", "rotated": "Tunnel URL rotated: {url}", "confirmRotate": "Rotate tunnel {type}? (a new URL will be generated)" diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 1b58c5154a..2a266267c9 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -447,6 +447,8 @@ "tailOpt": "Número de linhas de log a exibir", "typeRequired": "Tipo de túnel é obrigatório.", "noLogs": "Nenhum log disponível.", + "notAvailable": "Informações do túnel não disponíveis.", + "noTunnels": "Nenhum túnel ativo.", "infoTitle": "Info do túnel: {type}", "rotated": "URL do túnel rotacionada: {url}", "confirmRotate": "Rotacionar túnel {type}? (uma nova URL será gerada)" diff --git a/bin/cli/tui/EvalWatch.jsx b/bin/cli/tui/EvalWatch.jsx index 1e091ae853..d8e917939f 100644 --- a/bin/cli/tui/EvalWatch.jsx +++ b/bin/cli/tui/EvalWatch.jsx @@ -94,11 +94,7 @@ function EvalWatchApp({ runId, suiteId, baseUrl, apiKey, onExit }) { {run.status} diff --git a/bin/cli/tui/OAuthFlow.jsx b/bin/cli/tui/OAuthFlow.jsx index f8f26d6639..db97e456b0 100644 --- a/bin/cli/tui/OAuthFlow.jsx +++ b/bin/cli/tui/OAuthFlow.jsx @@ -12,6 +12,9 @@ const PHASE = { CANCELLED: "cancelled", }; +let _globalDone = null; +let _globalFail = null; + function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) { const [phase, setPhase] = useState(PHASE.WAITING); const [result, setResult] = useState(null); @@ -25,8 +28,21 @@ function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) { }, []); useEffect(() => { - if (!onDone && !onFail) return; + _globalDone = (res) => { + setResult(res); + setPhase(PHASE.DONE); + onDone?.(res); + }; + _globalFail = (err) => { + setError(typeof err === "string" ? err : (err?.message ?? String(err))); + setPhase(PHASE.FAILED); + onFail?.(err); + }; setPhase(PHASE.POLLING); + return () => { + _globalDone = null; + _globalFail = null; + }; }, []); useInput((input, key) => { @@ -99,7 +115,7 @@ function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) { ) : phase === PHASE.DONE ? ( - + Authorized: {result?.email ?? result?.account ?? "connected"} ) : phase === PHASE.FAILED ? ( @@ -168,5 +184,10 @@ export async function startOAuthTui({ provider, url, deviceCode }) { }); } -export function markOAuthDone(result) {} -export function markOAuthFailed(error) {} +export function markOAuthDone(result) { + _globalDone?.(result); +} + +export function markOAuthFailed(error) { + _globalFail?.(error); +} diff --git a/bin/cli/tui/ProvidersTestAll.jsx b/bin/cli/tui/ProvidersTestAll.jsx index cca4dddd1b..73fc78614a 100644 --- a/bin/cli/tui/ProvidersTestAll.jsx +++ b/bin/cli/tui/ProvidersTestAll.jsx @@ -48,10 +48,11 @@ async function testOne(provider, model, baseUrl, apiKey) { const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` }; return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error }; } catch (err) { + const msg = err instanceof Error ? err.message : String(err); return { status: STATUS.FAIL, latencyMs: Date.now() - start, - error: err instanceof Error ? err.message : String(err), + error: msg.slice(0, 100), }; } } diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 6996a1cab8..18ae9003af 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -167,6 +167,21 @@ test("ProvidersTestAll.jsx — arquivo existe e exporta startProvidersTestTui", ); }); +test("backup matchesGlob — comportamento correto", async () => { + // Teste via leitura de arquivo para evitar efeitos colaterais de importação + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync( + fileURLToPath(new URL("../../bin/cli/commands/backup.mjs", import.meta.url)), + "utf8" + ); + // Garante que a função usa lógica correta (sem startsWith na branch sem *) + assert.ok(src.includes("return fileName === pattern;"), "non-glob deve usar igualdade exata"); + assert.ok(!src.includes("fileName.startsWith(pattern)"), "não deve usar startsWith no non-glob"); + // Garante que suporta multi-wildcard (sem early return parts.length !== 2) + assert.ok(!src.includes("parts.length !== 2"), "não deve bloquear multi-wildcard"); +}); + test("test-provider — compare requer pelo menos dois modelos sem server retorna 0 ou 1", async () => { const { runTestProviderCommand } = await import("../../bin/cli/commands/test-provider.mjs"); let code = 0;