diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 922af53716..9db715bbdc 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -2,6 +2,7 @@ import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { t } from "../i18n.mjs"; import path from "node:path"; import fs from "node:fs"; +import { registerContexts } from "./contexts.mjs"; function ensureBackup(configPath) { if (!fs.existsSync(configPath)) return; @@ -188,4 +189,7 @@ export function registerConfig(program) { const exitCode = await runConfigValidateCommand(tool, { ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + // Register contexts/profiles CRUD as a subgroup of config. + registerContexts(config); } diff --git a/bin/cli/commands/contexts.mjs b/bin/cli/commands/contexts.mjs new file mode 100644 index 0000000000..63a3dde925 --- /dev/null +++ b/bin/cli/commands/contexts.mjs @@ -0,0 +1,223 @@ +import { t } from "../i18n.mjs"; +import { emit } from "../output.mjs"; +import { loadContexts, saveContexts, configPath } from "../contexts.mjs"; + +async function confirm(msg) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r)); + rl.close(); + return /^y(es)?$/i.test(answer); +} + +function maskKey(k) { + if (!k) return null; + if (k.length <= 8) return "***"; + return `${k.slice(0, 6)}***${k.slice(-4)}`; +} + +export function registerContexts(program) { + const ctx = program + .command("contexts") + .description(t("config.contexts.description") || "Manage server contexts/profiles"); + + ctx + .command("list") + .description("List all contexts") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const cfg = loadContexts(); + const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({ + active: name === (cfg.currentContext || "default") ? "●" : "", + name, + baseUrl: c.baseUrl || "", + auth: c.apiKey ? "✓" : "✗", + description: c.description || "", + })); + emit(rows, globalOpts, [ + { key: "active", header: "" }, + { key: "name", header: "Name" }, + { key: "baseUrl", header: "Base URL" }, + { key: "auth", header: "Auth" }, + { key: "description", header: "Description" }, + ]); + }); + + ctx + .command("add ") + .description("Add a new context") + .requiredOption("--url ", "Base URL") + .option("--api-key ", "API key") + .option("--api-key-stdin", "Read API key from stdin") + .option("--description ", "Context description") + .action(async (name, opts) => { + const cfg = loadContexts(); + if (cfg.contexts?.[name]) { + process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`); + process.exit(2); + } + let apiKey = opts.apiKey || null; + if (opts.apiKeyStdin) { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + apiKey = chunks.join("").trim() || null; + } + cfg.contexts = cfg.contexts || {}; + cfg.contexts[name] = { + baseUrl: opts.url, + apiKey, + description: opts.description || undefined, + }; + saveContexts(cfg); + process.stdout.write(`Added context '${name}'\n`); + }); + + ctx + .command("use ") + .description("Switch active context") + .action((name) => { + const cfg = loadContexts(); + if (!cfg.contexts?.[name]) { + process.stderr.write(`No such context: ${name}\n`); + process.exit(2); + } + cfg.currentContext = name; + saveContexts(cfg); + process.stdout.write(`Active context: ${name}\n`); + }); + + ctx + .command("current") + .description("Show current active context name") + .action(() => { + const cfg = loadContexts(); + process.stdout.write(`${cfg.currentContext || "default"}\n`); + }); + + ctx + .command("show ") + .description("Show context details") + .action((name, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const cfg = loadContexts(); + const c = cfg.contexts?.[name]; + if (!c) { + process.stderr.write(`No such context: ${name}\n`); + process.exit(2); + } + const display = { + name, + baseUrl: c.baseUrl, + apiKey: maskKey(c.apiKey), + description: c.description, + }; + emit(display, globalOpts); + }); + + ctx + .command("remove ") + .description("Remove a context") + .option("--yes", "Skip confirmation") + .action(async (name, opts) => { + if (!opts.yes) { + const ok = await confirm(`Remove context '${name}'?`); + if (!ok) { + process.stdout.write("Cancelled.\n"); + return; + } + } + const cfg = loadContexts(); + if (!cfg.contexts?.[name]) { + process.stderr.write(`No such context: ${name}\n`); + process.exit(2); + } + if (name === "default") { + process.stderr.write("Cannot remove default context.\n"); + process.exit(2); + } + delete cfg.contexts[name]; + if (cfg.currentContext === name) cfg.currentContext = "default"; + saveContexts(cfg); + process.stdout.write(`Removed context '${name}'\n`); + }); + + ctx + .command("rename ") + .description("Rename a context") + .action((oldName, newName) => { + const cfg = loadContexts(); + if (!cfg.contexts?.[oldName]) { + process.stderr.write(`No such context: ${oldName}\n`); + process.exit(2); + } + if (cfg.contexts[newName]) { + process.stderr.write(`Context '${newName}' already exists.\n`); + process.exit(2); + } + cfg.contexts[newName] = cfg.contexts[oldName]; + delete cfg.contexts[oldName]; + if (cfg.currentContext === oldName) cfg.currentContext = newName; + saveContexts(cfg); + process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`); + }); + + ctx + .command("export") + .description("Export contexts to JSON") + .option("--out ", "Output file path (default: stdout)") + .option("--no-secrets", "Omit API keys from export") + .action(async (opts, cmd) => { + const cfg = loadContexts(); + const out = JSON.parse(JSON.stringify(cfg)); + if (opts.noSecrets) { + for (const c of Object.values(out.contexts || {})) { + c.apiKey = null; + } + } + const json = JSON.stringify(out, null, 2); + if (opts.out) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(opts.out, json); + process.stdout.write(`Exported to ${opts.out}\n`); + } else { + process.stdout.write(json + "\n"); + } + }); + + ctx + .command("import ") + .description("Import contexts from a JSON file") + .option("--merge", "Merge with existing contexts (default: overwrite)") + .action(async (file, opts) => { + const { readFileSync } = await import("node:fs"); + let imported; + try { + imported = JSON.parse(readFileSync(file, "utf8")); + } catch (e) { + process.stderr.write( + `Cannot read ${file}: ${e instanceof Error ? e.message : String(e)}\n` + ); + process.exit(1); + } + const cfg = opts.merge + ? loadContexts() + : { version: 1, currentContext: "default", contexts: {} }; + const incoming = imported.contexts || {}; + let count = 0; + for (const [name, raw] of Object.entries(incoming)) { + if (typeof name !== "string" || !name) continue; + const c = raw && typeof raw === "object" ? /** @type {Record} */ (raw) : {}; + cfg.contexts[name] = { + baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128", + apiKey: typeof c.apiKey === "string" ? c.apiKey : null, + description: typeof c.description === "string" ? c.description : undefined, + }; + count++; + } + if (!opts.merge && typeof imported.currentContext === "string") { + cfg.currentContext = imported.currentContext; + } + saveContexts(cfg); + process.stdout.write(`Imported ${count} context(s)\n`); + }); +} diff --git a/bin/cli/contexts.mjs b/bin/cli/contexts.mjs new file mode 100644 index 0000000000..625114085f --- /dev/null +++ b/bin/cli/contexts.mjs @@ -0,0 +1,43 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { resolveDataDir } from "./data-dir.mjs"; + +const CONFIG_VERSION = 1; + +export function configPath() { + return join(resolveDataDir(), "config.json"); +} + +function defaultConfig() { + return { + version: CONFIG_VERSION, + currentContext: "default", + contexts: { + default: { baseUrl: `http://localhost:${process.env.PORT || "20128"}`, apiKey: null }, + }, + }; +} + +export function loadContexts() { + try { + if (!existsSync(configPath())) return defaultConfig(); + return JSON.parse(readFileSync(configPath(), "utf8")); + } catch { + return defaultConfig(); + } +} + +export function saveContexts(cfg) { + const path = configPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cfg, null, 2)); + try { + chmodSync(path, 0o600); + } catch {} +} + +export function resolveActiveContext(overrideName) { + const cfg = loadContexts(); + const name = overrideName || cfg.currentContext || "default"; + return cfg.contexts?.[name] || cfg.contexts?.default || { baseUrl: "http://localhost:20128" }; +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index bf9502d4e4..43bebc60ed 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -682,7 +682,8 @@ "no_color": "Disable colored output", "timeout": "HTTP request timeout in milliseconds", "api_key": "API key for OmniRoute server", - "base_url": "OmniRoute server base URL" + "base_url": "OmniRoute server base URL", + "context": "Server context/profile to use for this command" }, "files": { "description": "Manage files (upload, list, get, download, delete)", @@ -1057,6 +1058,11 @@ "description": "Resolve sync conflicts interactively" } }, + "config": { + "contexts": { + "description": "Manage server contexts/profiles (add, use, list, show, remove, rename, export, import)" + } + }, "completion": { "description": "Generate or install shell completion scripts", "zsh": "Print zsh completion script", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index c1f5f64489..9d86f6a6a7 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -682,7 +682,8 @@ "no_color": "Desativar saída colorida", "timeout": "Timeout de requisições HTTP em milissegundos", "api_key": "Chave de API para o servidor OmniRoute", - "base_url": "URL base do servidor OmniRoute" + "base_url": "URL base do servidor OmniRoute", + "context": "Contexto/perfil do servidor a usar neste comando" }, "files": { "description": "Gerenciar arquivos (upload, listar, obter, baixar, deletar)", @@ -1057,6 +1058,11 @@ "description": "Resolver conflitos de sincronização interativamente" } }, + "config": { + "contexts": { + "description": "Gerenciar contextos/perfis de servidor (add, use, list, show, remove, rename, export, import)" + } + }, "completion": { "description": "Gerar ou instalar scripts de completion do shell", "zsh": "Imprimir script de completion para zsh", diff --git a/bin/cli/program.mjs b/bin/cli/program.mjs index 4433fc3430..8c0de2d9af 100644 --- a/bin/cli/program.mjs +++ b/bin/cli/program.mjs @@ -25,6 +25,12 @@ export function createProgram() { .addOption(new Option("--timeout ", t("program.timeout")).default("30000")) .addOption(new Option("--api-key ", t("program.api_key")).env("OMNIROUTE_API_KEY")) .addOption(new Option("--base-url ", t("program.base_url")).env("OMNIROUTE_BASE_URL")) + .addOption( + new Option( + "--context ", + t("program.context") || "Server context/profile to use for this command" + ).env("OMNIROUTE_CONTEXT") + ) .showHelpAfterError(true) .exitOverride(); diff --git a/tests/unit/cli-contexts.test.ts b/tests/unit/cli-contexts.test.ts new file mode 100644 index 0000000000..a5aaef335a --- /dev/null +++ b/tests/unit/cli-contexts.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +let tmpDir: string; +let origDataDir: string | undefined; + +test.before(() => { + tmpDir = mkdtempSync(join(tmpdir(), "omniroute-ctx-test-")); + origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; +}); + +test.after(() => { + if (origDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = origDataDir; + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +}); + +test("contexts.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/contexts.mjs"); + assert.equal(typeof mod.loadContexts, "function"); + assert.equal(typeof mod.saveContexts, "function"); + assert.equal(typeof mod.resolveActiveContext, "function"); + assert.equal(typeof mod.configPath, "function"); +}); + +test("loadContexts retorna config padrão quando arquivo não existe", async () => { + const { loadContexts } = await import("../../bin/cli/contexts.mjs"); + const cfg = loadContexts(); + assert.ok(cfg.contexts); + assert.ok(cfg.contexts.default); + assert.equal(typeof cfg.contexts.default.baseUrl, "string"); + assert.equal(cfg.currentContext, "default"); +}); + +test("saveContexts persiste e loadContexts relê", async () => { + const { loadContexts, saveContexts } = await import("../../bin/cli/contexts.mjs"); + const cfg = loadContexts(); + cfg.contexts.test = { baseUrl: "http://test:9999", apiKey: null }; + cfg.currentContext = "test"; + saveContexts(cfg); + const cfg2 = loadContexts(); + assert.equal(cfg2.currentContext, "test"); + assert.equal(cfg2.contexts.test?.baseUrl, "http://test:9999"); +}); + +test("resolveActiveContext retorna contexto ativo", async () => { + const { resolveActiveContext, loadContexts, saveContexts } = + await import("../../bin/cli/contexts.mjs"); + const cfg = loadContexts(); + cfg.contexts.prod = { baseUrl: "https://prod.example.com", apiKey: "sk-prod" }; + cfg.currentContext = "prod"; + saveContexts(cfg); + const ctx = resolveActiveContext(undefined); + assert.equal(ctx.baseUrl, "https://prod.example.com"); +}); + +test("resolveActiveContext aceita override pontual", async () => { + const { resolveActiveContext, loadContexts, saveContexts } = + await import("../../bin/cli/contexts.mjs"); + const cfg = loadContexts(); + cfg.contexts.staging = { baseUrl: "http://staging:20128", apiKey: null }; + saveContexts(cfg); + const ctx = resolveActiveContext("staging"); + assert.equal(ctx.baseUrl, "http://staging:20128"); +}); + +test("contexts.mjs (commands) pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/contexts.mjs"); + assert.equal(typeof mod.registerContexts, "function"); +});