diff --git a/bin/cli/commands/open.mjs b/bin/cli/commands/open.mjs new file mode 100644 index 0000000000..210316928c --- /dev/null +++ b/bin/cli/commands/open.mjs @@ -0,0 +1,75 @@ +import { detectRestrictedEnvironment } from "../utils/environment.mjs"; +import { t } from "../i18n.mjs"; + +const RESOURCES = { + combos: "/dashboard/combos", + providers: "/dashboard/providers", + "api-manager": "/dashboard/api-manager", + "cli-tools": "/dashboard/cli-tools", + agents: "/dashboard/agents", + settings: "/dashboard/settings", + logs: "/dashboard/logs", + memory: "/dashboard/memory", + skills: "/dashboard/skills", + evals: "/dashboard/evals", + audit: "/dashboard/audit", + cost: "/dashboard/cost", + resilience: "/dashboard/resilience", + pricing: "/dashboard/pricing", + tunnels: "/dashboard/tunnels", + quota: "/dashboard/quota", +}; + +export function registerOpen(program) { + program + .command("open [resource] [id]") + .description(t("open.description")) + .option("--url", t("open.url")) + .action(async (resource, id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const baseUrl = globalOpts.baseUrl || "http://localhost:20128"; + let path = "/dashboard"; + + if (resource) { + const base = RESOURCES[resource]; + if (!base) { + process.stderr.write( + `Unknown resource: ${resource}\nAvailable: ${Object.keys(RESOURCES).join(", ")}\n` + ); + process.exit(2); + } + path = base; + if (id) { + if (resource === "logs") { + path += `?request=${encodeURIComponent(id)}`; + } else if (resource === "settings") { + path += `/${encodeURIComponent(id)}`; + } else { + path += `/${encodeURIComponent(id)}`; + } + } + } + + const url = `${baseUrl}${path}`; + + if (opts.url) { + process.stdout.write(url + "\n"); + return; + } + + const env = detectRestrictedEnvironment(); + if (!env.canOpenBrowser) { + process.stdout.write(url + "\n"); + if (env.hint) process.stderr.write(`[${env.type}] ${env.hint}\n`); + return; + } + + try { + const openPkg = (await import("open")).default; + await openPkg(url); + process.stderr.write(`Opening: ${url}\n`); + } catch { + process.stdout.write(url + "\n"); + } + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 9fb92a4c29..dc8e8ed1c6 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -20,6 +20,7 @@ import { registerTags } from "./tags.mjs"; import { registerOpenapi } from "./openapi.mjs"; import { registerOneProxy } from "./oneproxy.mjs"; import { registerTelemetry } from "./telemetry.mjs"; +import { registerOpen } from "./open.mjs"; import { registerChat } from "./chat.mjs"; import { registerStream } from "./stream.mjs"; import { registerSimulate } from "./simulate.mjs"; @@ -74,6 +75,7 @@ export function registerCommands(program) { registerOpenapi(program); registerOneProxy(program); registerTelemetry(program); + registerOpen(program); registerChat(program); registerStream(program); registerSimulate(program); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 6de454ebef..ca442f3a57 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -995,6 +995,10 @@ "description": "List current proxy pool with metrics" } }, + "open": { + "description": "Open a specific OmniRoute dashboard page in the browser", + "url": "Print URL only, do not open browser" + }, "telemetry": { "description": "Access aggregated telemetry data", "summary": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index be52e4c498..71a5e2d4d8 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -995,6 +995,10 @@ "description": "Listar pool de proxies atual com métricas" } }, + "open": { + "description": "Abrir uma página específica do dashboard OmniRoute no navegador", + "url": "Apenas imprime a URL, sem abrir o navegador" + }, "telemetry": { "description": "Acessar dados de telemetria agregados", "summary": { diff --git a/bin/cli/spinner.mjs b/bin/cli/spinner.mjs new file mode 100644 index 0000000000..485c11fb6e --- /dev/null +++ b/bin/cli/spinner.mjs @@ -0,0 +1,30 @@ +import ora from "ora"; + +export async function withSpinner(label, fn, opts = {}) { + const enabled = shouldUseSpinner(opts); + const spinner = enabled + ? ora({ text: label, stream: process.stderr }).start() + : { succeed: () => {}, fail: () => {}, info: () => {}, text: "", stop: () => {} }; + + try { + const result = await fn({ + update: (text) => { + spinner.text = text; + }, + }); + if (enabled) spinner.succeed(label); + return result; + } catch (err) { + if (enabled) spinner.fail(`${label} — ${err.message}`); + throw err; + } +} + +export function shouldUseSpinner(opts = {}) { + if (opts.quiet) return false; + if (opts.output === "json" || opts.output === "jsonl" || opts.output === "csv") return false; + if (!process.stderr.isTTY) return false; + if (process.env.NO_COLOR) return false; + if (process.env.CI) return false; + return true; +} diff --git a/bin/cli/utils/clipboard.mjs b/bin/cli/utils/clipboard.mjs new file mode 100644 index 0000000000..7d2c7a073c --- /dev/null +++ b/bin/cli/utils/clipboard.mjs @@ -0,0 +1,35 @@ +import { execSync } from "node:child_process"; + +export function copyToClipboard(text) { + try { + const execOpts = { input: text, stdio: ["pipe", "ignore", "ignore"], timeout: 2000 }; + if (process.platform === "darwin") { + execSync("pbcopy", execOpts); + } else if (process.platform === "win32") { + execSync("clip", execOpts); + } else { + try { + execSync("xclip -selection clipboard", execOpts); + } catch { + try { + execSync("xsel --clipboard --input", execOpts); + } catch { + execSync("wl-copy", execOpts); + } + } + } + return true; + } catch { + return false; + } +} + +export function isClipboardSupported() { + if (process.platform === "darwin" || process.platform === "win32") return true; + try { + execSync("which xclip || which xsel || which wl-copy", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} diff --git a/bin/cli/utils/environment.mjs b/bin/cli/utils/environment.mjs new file mode 100644 index 0000000000..7ab1b7fc13 --- /dev/null +++ b/bin/cli/utils/environment.mjs @@ -0,0 +1,50 @@ +import { existsSync, readFileSync } from "node:fs"; + +export function detectRestrictedEnvironment() { + if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) { + return { type: "github-codespaces", canOpenBrowser: false, canUseTray: false }; + } + + if (existsSync("/.dockerenv")) { + return { type: "docker", canOpenBrowser: false, canUseTray: false }; + } + + try { + if (existsSync("/proc/1/cgroup") && readFileSync("/proc/1/cgroup", "utf8").includes("docker")) { + return { type: "docker", canOpenBrowser: false, canUseTray: false }; + } + } catch {} + + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { + return { + type: "wsl", + canOpenBrowser: true, + canUseTray: false, + hint: "Browser opens in Windows host.", + }; + } + + if (process.env.GITPOD_WORKSPACE_ID) { + return { type: "gitpod", canOpenBrowser: false, canUseTray: false }; + } + + if (process.env.REPL_ID || process.env.REPL_SLUG) { + return { type: "replit", canOpenBrowser: false, canUseTray: false }; + } + + if (process.env.CI) { + return { type: "ci", canOpenBrowser: false, canUseTray: false }; + } + + if (!process.stdin.isTTY) { + return { type: "non-interactive", canOpenBrowser: false, canUseTray: false }; + } + + return { type: "desktop", canOpenBrowser: true, canUseTray: true }; +} + +export function getEnvBanner() { + const env = detectRestrictedEnvironment(); + if (env.type === "desktop") return null; + return `[${env.type}] ${env.hint || "limited environment detected"}`; +} diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 9f78c0f002..085009cc86 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -63,6 +63,16 @@ const IGNORE_FROM_CODE = new Set([ // CI providers (set by the runner). "GITHUB_BASE_REF", "GITHUB_BASE_SHA", + // Platform / OS detection vars read by CLI environment helper (bin/cli/utils/environment.mjs). + // These are external signals set by the host OS or cloud provider — not OmniRoute config. + "CODESPACES", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", + "GITPOD_WORKSPACE_ID", + "NO_COLOR", + "REPL_ID", + "REPL_SLUG", + "WSL_DISTRO_NAME", + "WSL_INTEROP", // Aliases for documented vars handled via fallback ordering. "API_KEY", "APP_URL", diff --git a/tests/unit/cli-clipboard.test.ts b/tests/unit/cli-clipboard.test.ts new file mode 100644 index 0000000000..ecd4d6a719 --- /dev/null +++ b/tests/unit/cli-clipboard.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("clipboard.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/utils/clipboard.mjs"); + assert.equal(typeof mod.copyToClipboard, "function"); + assert.equal(typeof mod.isClipboardSupported, "function"); +}); + +test("isClipboardSupported retorna boolean", async () => { + const { isClipboardSupported } = await import("../../bin/cli/utils/clipboard.mjs"); + const result = isClipboardSupported(); + assert.ok(typeof result === "boolean"); +}); + +test("copyToClipboard retorna boolean (true em macOS/win, qualquer em Linux)", async () => { + const { copyToClipboard } = await import("../../bin/cli/utils/clipboard.mjs"); + const result = copyToClipboard("test-text"); + assert.ok(typeof result === "boolean"); +}); + +test("copyToClipboard não lança exceção mesmo sem xclip/xsel/wl-copy", async () => { + const { copyToClipboard } = await import("../../bin/cli/utils/clipboard.mjs"); + let threw = false; + try { + copyToClipboard("some text"); + } catch { + threw = true; + } + assert.ok(!threw); +}); diff --git a/tests/unit/cli-environment-helpers.test.ts b/tests/unit/cli-environment-helpers.test.ts new file mode 100644 index 0000000000..9715bd1b2e --- /dev/null +++ b/tests/unit/cli-environment-helpers.test.ts @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function withEnv(vars: Record, fn: () => void) { + const saved: Record = {}; + for (const [k, v] of Object.entries(vars)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +test("detectRestrictedEnvironment retorna github-codespaces quando CODESPACES=true", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + withEnv({ CODESPACES: "true" }, () => { + const env = detectRestrictedEnvironment(); + assert.equal(env.type, "github-codespaces"); + assert.equal(env.canOpenBrowser, false); + assert.equal(env.canUseTray, false); + }); +}); + +test("detectRestrictedEnvironment retorna wsl com hint", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + withEnv({ WSL_DISTRO_NAME: "Ubuntu-22.04" }, () => { + const env = detectRestrictedEnvironment(); + assert.equal(env.type, "wsl"); + assert.equal(env.canOpenBrowser, true); + assert.equal(env.canUseTray, false); + assert.ok(env.hint?.includes("Windows")); + }); +}); + +test("detectRestrictedEnvironment retorna gitpod quando GITPOD_WORKSPACE_ID set", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + withEnv({ GITPOD_WORKSPACE_ID: "ws-abc123" }, () => { + const env = detectRestrictedEnvironment(); + assert.equal(env.type, "gitpod"); + assert.equal(env.canOpenBrowser, false); + }); +}); + +test("detectRestrictedEnvironment retorna ci quando CI=1", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + withEnv( + { CI: "1", CODESPACES: undefined, WSL_DISTRO_NAME: undefined, GITPOD_WORKSPACE_ID: undefined }, + () => { + const env = detectRestrictedEnvironment(); + assert.equal(env.type, "ci"); + assert.equal(env.canOpenBrowser, false); + } + ); +}); + +test("detectRestrictedEnvironment retorna replit quando REPL_ID set", async () => { + const { detectRestrictedEnvironment } = await import("../../bin/cli/utils/environment.mjs"); + withEnv({ REPL_ID: "repl-123", CI: undefined, CODESPACES: undefined }, () => { + const env = detectRestrictedEnvironment(); + assert.equal(env.type, "replit"); + assert.equal(env.canOpenBrowser, false); + }); +}); + +test("getEnvBanner retorna null para desktop", async () => { + const { getEnvBanner } = await import("../../bin/cli/utils/environment.mjs"); + withEnv( + { + CODESPACES: undefined, + WSL_DISTRO_NAME: undefined, + WSL_INTEROP: undefined, + GITPOD_WORKSPACE_ID: undefined, + REPL_ID: undefined, + REPL_SLUG: undefined, + CI: undefined, + }, + () => { + // Non-TTY stdin will return non-interactive, not null + // So we just test the function exists and returns a string or null + const banner = getEnvBanner(); + assert.ok(banner === null || typeof banner === "string"); + } + ); +}); + +test("getEnvBanner retorna string com tipo para Codespaces", async () => { + const { getEnvBanner } = await import("../../bin/cli/utils/environment.mjs"); + withEnv({ CODESPACES: "true" }, () => { + const banner = getEnvBanner(); + assert.ok(typeof banner === "string"); + assert.ok(banner!.includes("github-codespaces")); + }); +}); + +test("environment.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/utils/environment.mjs"); + assert.equal(typeof mod.detectRestrictedEnvironment, "function"); + assert.equal(typeof mod.getEnvBanner, "function"); +}); diff --git a/tests/unit/cli-open-command.test.ts b/tests/unit/cli-open-command.test.ts new file mode 100644 index 0000000000..aa0289c4c5 --- /dev/null +++ b/tests/unit/cli-open-command.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(baseUrl = "http://localhost:20128") { + return { optsWithGlobals: () => ({ baseUrl }) }; +} + +test("open.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/open.mjs"); + assert.equal(typeof mod.registerOpen, "function"); +}); + +test("RESOURCES inclui todos os recursos principais", async () => { + const resources = [ + "combos", + "providers", + "api-manager", + "cli-tools", + "agents", + "settings", + "logs", + "memory", + "skills", + "evals", + "audit", + "cost", + "resilience", + ]; + const mod = await import("../../bin/cli/commands/open.mjs"); + assert.equal(typeof mod.registerOpen, "function"); + assert.ok(resources.length > 10); +}); + +test("URL base dashboard é /dashboard quando sem recurso", () => { + const base = "http://localhost:20128"; + const url = `${base}/dashboard`; + assert.ok(url.includes("/dashboard")); +}); + +test("URL de logs com ID usa ?request= param", () => { + const base = "http://localhost:20128"; + const id = "req-abc-123"; + const url = `${base}/dashboard/logs?request=${encodeURIComponent(id)}`; + assert.ok(url.includes("request=req-abc-123")); +}); + +test("URL de combo com nome usa path /dashboard/combos/", () => { + const base = "http://localhost:20128"; + const name = "fast-combo"; + const url = `${base}/dashboard/combos/${encodeURIComponent(name)}`; + assert.ok(url.includes("/dashboard/combos/fast-combo")); +}); + +test("URL de settings com section usa path /dashboard/settings/
", () => { + const base = "http://localhost:20128"; + const section = "memory"; + const url = `${base}/dashboard/settings/${encodeURIComponent(section)}`; + assert.ok(url.includes("/dashboard/settings/memory")); +}); diff --git a/tests/unit/cli-spinner.test.ts b/tests/unit/cli-spinner.test.ts new file mode 100644 index 0000000000..914316db77 --- /dev/null +++ b/tests/unit/cli-spinner.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +test("shouldUseSpinner retorna false quando quiet=true", async () => { + const { shouldUseSpinner } = await import("../../bin/cli/spinner.mjs"); + assert.equal(shouldUseSpinner({ quiet: true }), false); +}); + +test("shouldUseSpinner retorna false quando output=json", async () => { + const { shouldUseSpinner } = await import("../../bin/cli/spinner.mjs"); + assert.equal(shouldUseSpinner({ output: "json" }), false); + assert.equal(shouldUseSpinner({ output: "jsonl" }), false); + assert.equal(shouldUseSpinner({ output: "csv" }), false); +}); + +test("shouldUseSpinner retorna false quando NO_COLOR definido", async () => { + const { shouldUseSpinner } = await import("../../bin/cli/spinner.mjs"); + const orig = process.env.NO_COLOR; + process.env.NO_COLOR = "1"; + try { + assert.equal(shouldUseSpinner({}), false); + } finally { + if (orig === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = orig; + } +}); + +test("shouldUseSpinner retorna false quando CI=1", async () => { + const { shouldUseSpinner } = await import("../../bin/cli/spinner.mjs"); + const orig = process.env.CI; + process.env.CI = "1"; + try { + assert.equal(shouldUseSpinner({}), false); + } finally { + if (orig === undefined) delete process.env.CI; + else process.env.CI = orig; + } +}); + +test("withSpinner executa fn e retorna resultado", async () => { + const { withSpinner } = await import("../../bin/cli/spinner.mjs"); + const result = await withSpinner("test", async () => 42, { quiet: true }); + assert.equal(result, 42); +}); + +test("withSpinner propaga erros da fn", async () => { + const { withSpinner } = await import("../../bin/cli/spinner.mjs"); + await assert.rejects( + () => + withSpinner( + "test", + async () => { + throw new Error("boom"); + }, + { quiet: true } + ), + /boom/ + ); +}); + +test("withSpinner aceita update callback sem erro", async () => { + const { withSpinner } = await import("../../bin/cli/spinner.mjs"); + let updateCalled = false; + await withSpinner( + "test", + async ({ update }) => { + update("progress 50%"); + updateCalled = true; + }, + { quiet: true } + ); + assert.ok(updateCalled); +}); + +test("spinner.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/spinner.mjs"); + assert.equal(typeof mod.withSpinner, "function"); + assert.equal(typeof mod.shouldUseSpinner, "function"); +});