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;