Files
OmniRoute/bin/cli/commands/connect.mjs
Xiangzhe 0a74bfbdea feat(cli): relay-like CLI closure — target manifest, Codex TOML, Gemini launcher, guards
- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion
  derive targets, aliases and --model wiring from one table; drift test cross-checks
  manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts)
- dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+,
  verified against codex-cli 0.147.0): conservative merge, env_key auth (key never
  written), refuses invalid TOML, reports legacy config.yaml as migration note
- omniroute run gemini: launcher over OmniRoute's /v1beta surface via
  GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth
  (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct
- opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env
  NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts
- container-guard homologation for POST /api/cli-tools/apply (422 in container,
  dry-run preview allowed, host write passes) + docs; guard untouched
- typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures
  (UsageLike, no any), models.ts isValidModel params — typecheck:core and
  typecheck:noimplicit:core now clean
- relay core (prior session of this effort): omniroute run for 6 CLIs, configure
  picker with per-context favorites/recents, contexts with optional keychain +
  0600 fallback, provider CRUD with recursive redaction, completion updates, docs
2026-08-18 08:25:16 -03:00

135 lines
4.2 KiB
JavaScript

import { apiFetch } from "../api.mjs";
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
import { t } from "../i18n.mjs";
/**
* `omniroute connect <host>` — remote mode.
*
* Logs into a remote OmniRoute server and saves the result as the active context
* so every subsequent command targets that server. Two flows:
* - password: prompts for the management password → POST /api/cli/connect →
* server mints a scoped access token (default scope: admin).
* - token: `--key <oma_...>` validates via GET /api/cli/whoami and saves it.
*/
/** Normalize a host/URL into a server root baseUrl (no trailing path). */
export function normalizeBaseUrl(host, port) {
let value = String(host || "").trim();
if (!value) return "";
const hadScheme = /^https?:\/\//i.test(value);
if (!hadScheme) value = `http://${value}`;
try {
const u = new URL(value);
// Only apply the default port to a bare host; a full URL is taken as-is.
if (!hadScheme && !u.port && port) u.port = String(port);
return u.origin;
} catch {
return value;
}
}
/** Derive a clean context name from a host (strip scheme/port). */
export function hostLabel(host) {
let value = String(host || "")
.trim()
.replace(/^https?:\/\//i, "");
value = value.split("/")[0].split(":")[0];
return value || "remote";
}
async function readErrorMessage(res) {
try {
const body = await res.json();
return body?.error?.message || body?.error || `HTTP ${res.status}`;
} catch {
return `HTTP ${res.status}`;
}
}
export async function runConnectCommand(host, opts = {}) {
const baseUrl = normalizeBaseUrl(host, opts.port || "20128");
if (!baseUrl) {
printError("A host is required, e.g. omniroute connect 192.168.0.15");
return 2;
}
const name = opts.name || hostLabel(host);
let accessToken;
let scope;
if (opts.key) {
// Validate the pasted token against the remote.
const res = await apiFetch("/api/cli/whoami", {
baseUrl,
apiKey: opts.key,
acceptNotOk: true,
});
if (!res.ok) {
printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`);
return res.exitCode || 1;
}
const body = await res.json();
accessToken = opts.key;
scope = body.scope || "unknown";
} else {
const prompt = createPrompt();
let password;
try {
password = await prompt.askSecret(`Management password for ${baseUrl}`);
} finally {
prompt.close();
}
if (!password) {
printError("Password is required (or use --key <token>).");
return 2;
}
const res = await apiFetch("/api/cli/connect", {
baseUrl,
method: "POST",
body: { password, name, scope: opts.scope },
acceptNotOk: true,
retry: false,
});
if (!res.ok) {
printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`);
return res.exitCode || 1;
}
const body = await res.json();
accessToken = body.token;
scope = body.scope;
}
const cfg = loadContexts();
cfg.contexts = cfg.contexts || {};
cfg.contexts[name] = {
baseUrl,
accessToken,
scope,
description: `Remote OmniRoute (${host})`,
};
cfg.currentContext = name;
await saveContextsSecure(cfg);
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
printInfo("All commands now target this server.");
printInfo("Switch back to local with: omniroute contexts use default");
return 0;
}
export function registerConnect(program) {
program
.command("connect <host>")
.description(
t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode"
)
.option("--port <port>", "Server port when the host has none", "20128")
.option("--key <token>", "Use a pre-generated scoped access token (skips the password prompt)")
.option("--name <name>", "Context name to save (default: derived from host)")
.option("--scope <scope>", "Requested scope for the password flow (read|write|admin)")
.action(async (host, opts) => {
const code = await runConnectCommand(host, opts);
if (code !== 0) process.exit(code);
});
}