feat(cli): fase 8.1/8.6/8.14 — spinner, open, clipboard e environment helpers

- spinner.mjs: withSpinner/shouldUseSpinner com suporte a quiet/output/CI/NO_COLOR
- open.mjs: comando `open` com 16 recursos, respeita ambientes restritos
- environment.mjs: detectRestrictedEnvironment/getEnvBanner (codespaces/wsl/gitpod/replit/ci)
- clipboard.mjs: copyToClipboard/isClipboardSupported (pbcopy/clip/xclip/xsel/wl-copy)
- check-env-doc-sync: vars de plataforma/OS adicionadas ao IGNORE_FROM_CODE
This commit is contained in:
diegosouzapw
2026-05-15 03:00:52 -03:00
parent 2f2583a02f
commit 7a2682efb5
12 changed files with 500 additions and 0 deletions

75
bin/cli/commands/open.mjs Normal file
View File

@@ -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");
}
});
}

View File

@@ -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);

View File

@@ -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": {

View File

@@ -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": {

30
bin/cli/spinner.mjs Normal file
View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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"}`;
}

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
function withEnv(vars: Record<string, string | undefined>, fn: () => void) {
const saved: Record<string, string | undefined> = {};
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");
});

View File

@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
async function captureStdout(fn: () => Promise<void>): Promise<string> {
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/<name>", () => {
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/<section>", () => {
const base = "http://localhost:20128";
const section = "memory";
const url = `${base}/dashboard/settings/${encodeURIComponent(section)}`;
assert.ok(url.includes("/dashboard/settings/memory"));
});

View File

@@ -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");
});