feat: add the feature for user save combo

This commit is contained in:
2026-07-16 16:52:37 +07:00
parent e3cf2e7708
commit 448a8aedc7
20 changed files with 1215 additions and 114 deletions
@@ -268,6 +268,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.30",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.30",
"X-CORE-VERSION": "0.5.30",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.30",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.30",
"X-CORE-VERSION": "0.5.30",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.30",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.30",
"X-CORE-VERSION": "0.5.30",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -381,6 +427,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -482,6 +547,40 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -539,6 +638,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -828,6 +949,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -866,6 +1006,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -1012,6 +1171,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
"stream": "https://api.cline.bot/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
@@ -1047,6 +1213,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.featherless.ai/v1/chat/completions",
"stream": "https://api.featherless.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
@@ -1082,6 +1255,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
{
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
@@ -1103,6 +1283,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
@@ -1194,6 +1381,13 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.perplexity.ai/v1/responses",
"stream": "https://api.perplexity.ai/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
@@ -1208,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
"stream": "https://api.venice.ai/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
CliToolConfigValidationError,
isPersistableCliTool,
normalizeCliToolConfig,
} from "@/shared/constants/cliToolConfig.js";
describe("CLI tool configuration contract", () => {
it("allows every non-MITM CLI tool and rejects MITM tool IDs", () => {
for (const toolId of ["claude", "codex", "opencode", "cowork", "cursor", "copilot"]) {
expect(isPersistableCliTool(toolId)).toBe(true);
}
for (const toolId of ["antigravity", "kiro", "unknown", ""]) {
expect(isPersistableCliTool(toolId)).toBe(false);
}
});
it("normalizes Claude config and strips unknown fields", () => {
expect(normalizeCliToolConfig("claude", {
baseUrl: "https://router.example/v1/",
apiKeyMode: "managed",
apiKeyId: "key-1",
claudeModels: { sonnet: " cc/sonnet ", opus: "cc/opus", haiku: "" },
claudeThinking: { sonnet: "high", opus: "", ignored: "max" },
transientModalOpen: true,
})).toEqual({
baseUrl: "https://router.example/v1",
apiKeyMode: "managed",
apiKeyId: "key-1",
claudeModels: { sonnet: "cc/sonnet", opus: "cc/opus", haiku: "" },
claudeThinking: { sonnet: "high" },
});
});
it("normalizes tool-specific model selections", () => {
expect(normalizeCliToolConfig("codex", {
baseUrl: "http://localhost:20128/v1",
apiKeyMode: "custom",
apiKeyId: "must-be-removed",
codexModel: "cx/gpt",
codexThinking: "xhigh",
})).toMatchObject({ apiKeyMode: "custom", apiKeyId: null, codexModel: "cx/gpt", codexThinking: "xhigh" });
expect(normalizeCliToolConfig("opencode", {
baseUrl: "https://router.example",
apiKeyMode: "managed",
apiKeyId: null,
opencodeModels: ["cc/a", "cc/a", "cx/b"],
opencodeDefaultModel: "missing/model",
})).toMatchObject({ opencodeModels: ["cc/a", "cx/b"], opencodeDefaultModel: "cc/a" });
expect(normalizeCliToolConfig("cowork", {
baseUrl: "https://router.example",
apiKeyMode: "managed",
selectedModels: ["cc/a"],
coworkThinking: { "cc/a": "high", "stale/model": "low" },
})).toMatchObject({ selectedModels: ["cc/a"], coworkThinking: { "cc/a": "high" } });
expect(normalizeCliToolConfig("cursor", {
apiKeyMode: "managed",
apiKeyId: "key-1",
selectedModels: ["cc/a", "cx/b"],
})).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cx/b"] });
expect(normalizeCliToolConfig("copilot", {
baseUrl: "https://router.example/v1",
selectedModels: ["cc/a"],
copilotThinking: { "cc/a": "high", "stale/model": "low" },
copilotTokens: {
"cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 },
"stale/model": { maxInputTokens: 1 },
},
})).toEqual({
baseUrl: "https://router.example/v1",
selectedModels: ["cc/a"],
copilotThinking: { "cc/a": "high" },
copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } },
});
});
it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => {
expect(() => normalizeCliToolConfig("claude", {
baseUrl: "https://router.example",
apiKey: "secret",
})).toThrowError(CliToolConfigValidationError);
expect(() => normalizeCliToolConfig("codex", {
baseUrl: "file:///tmp/socket",
apiKeyMode: "managed",
})).toThrow("Endpoint must use HTTP or HTTPS");
expect(() => normalizeCliToolConfig("copilot", {
baseUrl: "https://router.example",
selectedModels: ["cc/a"],
copilotTokens: { "cc/a": { maxInputTokens: -1 } },
})).toThrow("maxInputTokens must be a positive integer");
});
});
+104
View File
@@ -0,0 +1,104 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const originalDataDir = process.env.DATA_DIR;
let tempDir;
let db;
let ownerOne;
let ownerTwo;
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-tool-config-"));
process.env.DATA_DIR = tempDir;
try { global._dbAdapter?.instance?.close?.(); } catch {}
delete global._dbAdapter;
vi.resetModules();
db = await import("@/lib/db/index.js");
await db.initDb();
ownerOne = await db.createUser({ username: "cli-owner-one", password: "password", role: "admin" });
ownerTwo = await db.createUser({ username: "cli-owner-two", password: "password", role: "user" });
});
afterAll(() => {
try { global._dbAdapter?.instance?.close?.(); } catch {}
delete global._dbAdapter;
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
const claudeConfig = (model) => ({
baseUrl: "https://router.example/v1",
apiKeyMode: "managed",
apiKeyId: null,
claudeModels: { sonnet: model, opus: "", haiku: "" },
claudeThinking: { sonnet: "high" },
});
describe("CLI tool configuration persistence", () => {
it("round-trips and atomically overwrites one owner/tool row", async () => {
const created = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-a"));
expect(created).toMatchObject({ ownerId: ownerOne.id, toolId: "claude", config: claudeConfig("cc/sonnet-a") });
const updated = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-b"));
expect(updated.config.claudeModels.sonnet).toBe("cc/sonnet-b");
expect((await db.getCliToolConfigsByOwnerId(ownerOne.id)).filter((row) => row.toolId === "claude")).toHaveLength(1);
});
it("isolates users and permits concurrent saves to different tools", async () => {
await Promise.all([
db.upsertCliToolConfig(ownerOne.id, "cursor", {
apiKeyMode: "custom",
selectedModels: ["cc/a", "cx/b"],
}),
db.upsertCliToolConfig(ownerTwo.id, "cursor", {
apiKeyMode: "managed",
apiKeyId: null,
selectedModels: ["gg/c"],
}),
db.upsertCliToolConfig(ownerOne.id, "codex", {
baseUrl: "https://router.example/v1",
apiKeyMode: "managed",
apiKeyId: null,
codexModel: "cx/gpt",
codexThinking: "high",
}),
]);
expect((await db.getCliToolConfig(ownerOne.id, "cursor")).config.selectedModels).toEqual(["cc/a", "cx/b"]);
expect((await db.getCliToolConfig(ownerTwo.id, "cursor")).config.selectedModels).toEqual(["gg/c"]);
expect((await db.getCliToolConfig(ownerOne.id, "codex")).config.codexModel).toBe("cx/gpt");
});
it("includes valid configurations in export/import and skips malformed rows", async () => {
const exported = await db.exportDb();
expect(exported.cliToolConfigs).toEqual(expect.arrayContaining([
expect.objectContaining({ ownerId: ownerOne.id, toolId: "claude" }),
expect.objectContaining({ ownerId: ownerTwo.id, toolId: "cursor" }),
]));
exported.cliToolConfigs.push({
ownerId: ownerOne.id,
toolId: "kiro",
config: { apiKey: "must-not-import" },
});
await db.importDb(exported);
expect(await db.getCliToolConfig(ownerOne.id, "claude")).not.toBeNull();
expect(await db.getCliToolConfig(ownerOne.id, "kiro")).toBeNull();
});
it("removes configurations when their owner is deleted", async () => {
const disposable = await db.createUser({ username: "cli-disposable", password: "password", role: "user" });
await db.upsertCliToolConfig(disposable.id, "cursor", {
apiKeyMode: "managed",
apiKeyId: null,
selectedModels: ["cc/a"],
});
expect(await db.deleteUser(disposable.id)).toBe(true);
expect(await db.getCliToolConfig(disposable.id, "cursor")).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const requireCurrentDashboardUser = vi.fn();
const getApiKeyByIdAndOwnerId = vi.fn();
const getCliToolConfig = vi.fn();
const upsertCliToolConfig = vi.fn();
vi.mock("next/server", () => ({
NextResponse: {
json(body, init = {}) {
return new Response(JSON.stringify(body), {
status: init.status || 200,
headers: { "Content-Type": "application/json" },
});
},
},
}));
vi.mock("@/lib/auth/currentUser", () => ({ requireCurrentDashboardUser }));
vi.mock("@/lib/db/index.js", () => ({
getApiKeyByIdAndOwnerId,
getCliToolConfig,
upsertCliToolConfig,
}));
const { GET, PUT } = await import("@/app/api/cli-tools/config/[toolId]/route.js");
const context = (toolId) => ({ params: Promise.resolve({ toolId }) });
const putRequest = (body) => new Request("https://9router.local/api/cli-tools/config/claude", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
describe("/api/cli-tools/config/[toolId]", () => {
beforeEach(() => {
requireCurrentDashboardUser.mockReset();
getApiKeyByIdAndOwnerId.mockReset();
getCliToolConfig.mockReset();
upsertCliToolConfig.mockReset();
requireCurrentDashboardUser.mockResolvedValue({ id: "user-1", role: "user" });
});
it("returns only the authenticated user's saved config without caching", async () => {
getCliToolConfig.mockResolvedValue({
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
updatedAt: "2026-07-16T00:00:00.000Z",
});
const response = await GET(new Request("https://9router.local"), context("cursor"));
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(getCliToolConfig).toHaveBeenCalledWith("user-1", "cursor");
await expect(response.json()).resolves.toEqual({
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
updatedAt: "2026-07-16T00:00:00.000Z",
});
});
it("rejects unauthenticated and MITM tool requests", async () => {
requireCurrentDashboardUser.mockRejectedValueOnce(new Error("Unauthorized"));
expect((await GET(new Request("https://9router.local"), context("cursor"))).status).toBe(401);
expect((await GET(new Request("https://9router.local"), context("kiro"))).status).toBe(404);
expect(getCliToolConfig).not.toHaveBeenCalled();
});
it("validates managed key ownership before saving", async () => {
getApiKeyByIdAndOwnerId.mockResolvedValue(null);
const response = await PUT(putRequest({
baseUrl: "https://router.example/v1",
apiKeyMode: "managed",
apiKeyId: "foreign-key",
claudeModels: {},
claudeThinking: {},
}), context("claude"));
expect(response.status).toBe(404);
expect(getApiKeyByIdAndOwnerId).toHaveBeenCalledWith("foreign-key", "user-1");
expect(upsertCliToolConfig).not.toHaveBeenCalled();
});
it("normalizes a custom-key config without persisting plaintext", async () => {
upsertCliToolConfig.mockImplementation(async (ownerId, toolId, config) => ({
ownerId,
toolId,
config,
updatedAt: "2026-07-16T00:00:00.000Z",
}));
const response = await PUT(putRequest({
baseUrl: "https://router.example/v1/",
apiKeyMode: "custom",
apiKeyId: "ignored-key-id",
claudeModels: { sonnet: "cc/sonnet" },
claudeThinking: { sonnet: "high" },
}), context("claude"));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.config).toMatchObject({ apiKeyMode: "custom", apiKeyId: null });
expect(JSON.stringify(body)).not.toContain("ignored-key-id");
expect(getApiKeyByIdAndOwnerId).not.toHaveBeenCalled();
});
it("rejects plaintext API keys and malformed JSON", async () => {
const secretResponse = await PUT(putRequest({
baseUrl: "https://router.example/v1",
apiKeyMode: "custom",
apiKey: "secret-value",
claudeModels: {},
}), context("claude"));
expect(secretResponse.status).toBe(400);
expect(JSON.stringify(await secretResponse.json())).not.toContain("secret-value");
const malformedResponse = await PUT(new Request("https://9router.local", {
method: "PUT",
body: "{",
}), context("claude"));
expect(malformedResponse.status).toBe(400);
expect(upsertCliToolConfig).not.toHaveBeenCalled();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
ensureCliToolV1Endpoint,
isLocalCliToolUrl,
resolveCliToolBaseUrl,
resolveInitialCliToolBaseUrl,
} from "@/shared/utils/cliToolEndpoint.js";
describe("CLI tool endpoint resolution", () => {
it("keeps local dashboard origins local, including custom ports", () => {
expect(resolveCliToolBaseUrl({ appUrl: "http://localhost:30100" })).toBe("http://localhost:30100");
expect(resolveCliToolBaseUrl({ appUrl: "http://127.0.0.1:20128/" })).toBe("http://127.0.0.1:20128");
expect(isLocalCliToolUrl("http://app.localhost:20128")).toBe(true);
});
it("uses the browser deployment origin instead of localhost or a generic cloud URL", () => {
expect(resolveCliToolBaseUrl({
appUrl: "https://router.customer.example",
cloudEnabled: true,
cloudUrl: "https://9router.com",
configuredBaseUrl: "http://localhost:20128",
})).toBe("https://router.customer.example");
});
it("uses a public endpoint for externally hosted tools when the dashboard is local", () => {
expect(resolveCliToolBaseUrl({
appUrl: "http://localhost:20128",
requiresExternalUrl: true,
tunnelEnabled: true,
tunnelPublicUrl: "https://tunnel.example/",
cloudEnabled: true,
cloudUrl: "https://cloud.example",
})).toBe("https://tunnel.example");
expect(resolveCliToolBaseUrl({
appUrl: "http://localhost:20128",
requiresExternalUrl: true,
cloudEnabled: true,
cloudUrl: "https://cloud.example/",
})).toBe("https://cloud.example");
});
it("adds /v1 exactly once to generated endpoints", () => {
expect(ensureCliToolV1Endpoint("https://router.example/")).toBe("https://router.example/v1");
expect(ensureCliToolV1Endpoint("https://router.example/v1/")).toBe("https://router.example/v1");
});
it("migrates the old hardcoded localhost default when opened on a deployment", () => {
expect(resolveInitialCliToolBaseUrl(
"http://127.0.0.1:20128/v1",
"https://router.customer.example",
)).toBe("https://router.customer.example/v1");
});
it("preserves an explicit remote saved endpoint and local saved endpoint on local runtime", () => {
expect(resolveInitialCliToolBaseUrl(
"https://custom-gateway.example/v1",
"https://router.customer.example",
)).toBe("https://custom-gateway.example/v1");
expect(resolveInitialCliToolBaseUrl(
"http://localhost:30100/v1",
"http://localhost:20128",
)).toBe("http://localhost:30100/v1");
});
});