mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(cli): fase 8.4 — profiles/contexts (omniroute config contexts)
- contexts.mjs: loadContexts/saveContexts/resolveActiveContext (~/.omniroute/config.json) - commands/contexts.mjs: CRUD completo (add/use/list/show/remove/rename/export/import) - config.mjs: subgroup contexts registrado sob config contexts - program.mjs: flag global --context <name> com env OMNIROUTE_CONTEXT - en.json/pt-BR.json: chaves program.context e config.contexts adicionadas - import usa validação explícita de campos (sem Object.assign cru)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
223
bin/cli/commands/contexts.mjs
Normal file
223
bin/cli/commands/contexts.mjs
Normal file
@@ -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 <name>")
|
||||
.description("Add a new context")
|
||||
.requiredOption("--url <u>", "Base URL")
|
||||
.option("--api-key <k>", "API key")
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--description <d>", "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 <name>")
|
||||
.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 <name>")
|
||||
.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 <name>")
|
||||
.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 <old> <new>")
|
||||
.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 <path>", "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 <file>")
|
||||
.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<string,unknown>} */ (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`);
|
||||
});
|
||||
}
|
||||
43
bin/cli/contexts.mjs
Normal file
43
bin/cli/contexts.mjs
Normal file
@@ -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" };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,12 @@ export function createProgram() {
|
||||
.addOption(new Option("--timeout <ms>", t("program.timeout")).default("30000"))
|
||||
.addOption(new Option("--api-key <key>", t("program.api_key")).env("OMNIROUTE_API_KEY"))
|
||||
.addOption(new Option("--base-url <url>", t("program.base_url")).env("OMNIROUTE_BASE_URL"))
|
||||
.addOption(
|
||||
new Option(
|
||||
"--context <name>",
|
||||
t("program.context") || "Server context/profile to use for this command"
|
||||
).env("OMNIROUTE_CONTEXT")
|
||||
)
|
||||
.showHelpAfterError(true)
|
||||
.exitOverride();
|
||||
|
||||
|
||||
76
tests/unit/cli-contexts.test.ts
Normal file
76
tests/unit/cli-contexts.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user