From c30ccd8c1809ea1fa25c75260080df5167fa8154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 03:29:48 +0100 Subject: [PATCH 1/3] feat(cli): flatten lobu context config --- packages/cli/src/commands/context.ts | 20 +- packages/cli/src/commands/dev.ts | 12 +- packages/cli/src/index.ts | 37 +--- .../src/internal/__tests__/context.test.ts | 132 ++++++++----- packages/cli/src/internal/context.ts | 185 +++++++++++------- .../src/content/docs/guides/testing.md | 2 +- .../landing/src/content/docs/reference/cli.md | 2 +- packages/server/src/start-local.ts | 7 +- .../src/utils/__tests__/user-config.test.ts | 88 +++++---- packages/server/src/utils/user-config.ts | 82 ++++---- scripts/e2e-lobu-apply.sh | 2 +- scripts/task-setup.sh | 3 +- 12 files changed, 313 insertions(+), 259 deletions(-) diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index a650cebe5..6e3c5c78d 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -16,7 +16,7 @@ export async function contextListCommand(): Promise { console.log(chalk.bold("\n Lobu contexts")); for (const [name, context] of Object.entries(config.contexts)) { const marker = name === currentContext ? chalk.green(" *") : " "; - console.log(`${marker} ${name} ${chalk.dim(context.apiUrl)}`); + console.log(`${marker} ${name} ${chalk.dim(context.url)}`); } if (process.env.LOBU_CONTEXT || process.env.LOBU_API_URL) { @@ -37,7 +37,7 @@ export async function contextCurrentCommand(): Promise { console.log(chalk.bold("\n Current context")); console.log(chalk.dim(` Name: ${context.name}`)); - console.log(chalk.dim(` API URL: ${context.apiUrl}`)); + console.log(chalk.dim(` URL: ${context.apiUrl}`)); if (context.source === "env") { console.log(chalk.dim(" Source: environment override")); } @@ -46,29 +46,21 @@ export async function contextCurrentCommand(): Promise { export async function contextAddCommand(options: { name: string; - apiUrl: string; - port?: number; - host?: string; - databaseUrl?: string; - dataDir?: string; + url: string; cwd?: string; lifecycle?: "managed" | "external"; }): Promise { const server: LobuServerConfig = {}; - if (options.port !== undefined) server.port = options.port; - if (options.host) server.host = options.host; - if (options.databaseUrl) server.databaseUrl = options.databaseUrl; - if (options.dataDir) server.dataDir = options.dataDir; if (options.cwd) server.cwd = options.cwd; if (options.lifecycle) server.lifecycle = options.lifecycle; await addContext( options.name, - options.apiUrl, + options.url, Object.keys(server).length === 0 ? undefined : server ); console.log( - chalk.green(`\n Saved context ${options.name} -> ${options.apiUrl}\n`) + chalk.green(`\n Saved context ${options.name} -> ${options.url}\n`) ); } @@ -87,5 +79,5 @@ export async function contextUseCommand(name: string): Promise { } console.log(chalk.green(`\n Switched to context ${trimmedName}`)); - console.log(chalk.dim(` API URL: ${context.apiUrl}\n`)); + console.log(chalk.dim(` URL: ${context.url}\n`)); } diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 445feea6e..0ba131a25 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -82,13 +82,9 @@ export async function devCommand( // Precedence: shell > project .env > user config > defaults. const userServerConfig = await getServerConfig().catch(() => undefined); const userServerEnv: Record = {}; - if (userServerConfig?.databaseUrl) - userServerEnv.DATABASE_URL = userServerConfig.databaseUrl; if (userServerConfig?.port) userServerEnv.PORT = String(userServerConfig.port); if (userServerConfig?.host) userServerEnv.HOST = userServerConfig.host; - if (userServerConfig?.dataDir) - userServerEnv.LOBU_DATA_DIR = userServerConfig.dataDir; const mergedEnv = { ...userServerEnv, @@ -98,14 +94,12 @@ export async function devCommand( const hasDatabaseUrl = Boolean(mergedEnv.DATABASE_URL?.trim()); // Refuse to boot against a shared/non-local DATABASE_URL that came from the - // parent shell rather than the project's own .env or the user's config. - // A common footgun: "local lobu run" silently writes into prod / a - // teammate's tailnet DB. The project pinning its own DATABASE_URL, or the - // user persisting one in ~/.config/lobu/config.json, is explicit consent. + // parent shell rather than the project's own .env. A common footgun: + // "local lobu run" silently writes into prod / a teammate's tailnet DB. + // Project pinning in .env is explicit consent. if ( hasDatabaseUrl && !envVars.DATABASE_URL?.trim() && - !userServerEnv.DATABASE_URL?.trim() && isSharedDatabaseUrl(mergedEnv.DATABASE_URL!) && !options.unsafeSharedDb ) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c1e6ab097..bb2391b39 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -532,30 +532,7 @@ Memory: context .command("add ") .description("Add a named context") - .requiredOption("--api-url ", "API base URL for this context") - .option( - "--port ", - "Server port (when this context owns a managed lobu server)", - (value: string) => { - if (!/^\d+$/.test(value)) { - throw new Error(`--port must be an integer, got "${value}"`); - } - const n = Number.parseInt(value, 10); - if (n < 1 || n > 65535) { - throw new Error(`--port must be in 1-65535, got ${n}`); - } - return n; - } - ) - .option("--host ", "Server host (default: 127.0.0.1)") - .option( - "--database-url ", - "Postgres DATABASE_URL for the managed server" - ) - .option( - "--data-dir ", - "LOBU_DATA_DIR for the managed server (state, PGlite)" - ) + .requiredOption("--url ", "Base URL for this context") .option( "--cwd ", "Working directory the lifecycle owner cd's into before spawning `lobu run` (used by per-worktree contexts)" @@ -574,11 +551,7 @@ Memory: async ( name: string, options: { - apiUrl: string; - port?: number; - host?: string; - databaseUrl?: string; - dataDir?: string; + url: string; cwd?: string; lifecycle?: "managed" | "external"; } @@ -586,11 +559,7 @@ Memory: const { contextAddCommand } = await import("./commands/context.js"); await contextAddCommand({ name, - apiUrl: options.apiUrl, - port: options.port, - host: options.host, - databaseUrl: options.databaseUrl, - dataDir: options.dataDir, + url: options.url, cwd: options.cwd, lifecycle: options.lifecycle, }); diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index ca9385012..257e1d36b 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -54,10 +54,10 @@ describe("context management", () => { currentContext: "prod", contexts: { lobu: { - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", activeOrg: "default-org", }, - prod: { apiUrl: "https://prod.lobu.ai/api/v1", activeOrg: "prod-org" }, + prod: { url: "https://prod.lobu.ai/api/v1", activeOrg: "prod-org" }, }, }; readFileSpy.mockResolvedValue(JSON.stringify(configData)); @@ -73,12 +73,12 @@ describe("context management", () => { expect(saved.contexts.prod.activeOrg).toBe("prod-org"); }); - test("finds contexts by normalized API URL", async () => { + test("finds contexts by normalized URL", async () => { const configData = { currentContext: "lobu", contexts: { - lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, - custom: { apiUrl: "https://custom.lobu.ai/api/v1" }, + lobu: { url: "https://app.lobu.ai/api/v1" }, + custom: { url: "https://custom.lobu.ai/api/v1" }, }, }; readFileSpy.mockResolvedValue(JSON.stringify(configData)); @@ -91,13 +91,46 @@ describe("context management", () => { expect(none).toBeUndefined(); }); + test("reads legacy apiUrl contexts and saves the new url shape", async () => { + const configData = { + currentContext: "legacy", + contexts: { + legacy: { + apiUrl: "http://localhost:8788/api/v1", + server: { + cwd: "/Users/me/Code/lobu/.claude/worktrees/legacy", + lifecycle: "managed", + }, + }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getServerConfig("legacy")).toEqual({ + lifecycle: "managed", + cwd: "/Users/me/Code/lobu/.claude/worktrees/legacy", + host: "localhost", + port: 8788, + }); + + await setActiveOrg("new-org", "legacy"); + const [, written] = writeFileSpy.mock.calls.at(-1)!; + const saved = JSON.parse(written as string); + expect(saved.contexts.legacy).toEqual({ + url: "http://localhost:8788/api/v1", + lifecycle: "managed", + cwd: "/Users/me/Code/lobu/.claude/worktrees/legacy", + activeOrg: "new-org", + }); + }); + test("finds contexts by normalized memory URL", async () => { const configData = { currentContext: "lobu", contexts: { - lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, + lobu: { url: "https://app.lobu.ai/api/v1" }, local: { - apiUrl: "http://localhost:8787/api/v1", + url: "http://localhost:8787/api/v1", memoryUrl: "http://localhost:8787/mcp/acme", }, }, @@ -109,47 +142,52 @@ describe("context management", () => { expect(matched?.name).toBe("local"); }); - test("reads and persists the server block per context", async () => { + test("derives managed server settings from flat context fields", async () => { const configData = { currentContext: "local", contexts: { local: { - apiUrl: "http://localhost:8787/api/v1", - server: { - databaseUrl: "postgres://burakemre@localhost:5432/lobu", - port: 9000, - host: "0.0.0.0", - dataDir: "/tmp/lobu-data", - }, + url: "http://localhost:9000/api/v1", + lifecycle: "managed", + cwd: "/tmp/lobu-worktree", }, }, }; readFileSpy.mockResolvedValue(JSON.stringify(configData)); expect(await getServerConfig("local")).toEqual({ - databaseUrl: "postgres://burakemre@localhost:5432/lobu", + lifecycle: "managed", + cwd: "/tmp/lobu-worktree", port: 9000, - host: "0.0.0.0", - dataDir: "/tmp/lobu-data", + host: "localhost", }); - await setServerConfig( - { databaseUrl: "postgres://new/db", port: 8788 }, - "local" - ); + await setServerConfig({ lifecycle: "managed", cwd: "/tmp/new" }, "local"); const [, written] = writeFileSpy.mock.calls.at(-1)!; const saved = JSON.parse(written as string) as typeof configData; - expect(saved.contexts.local.server).toEqual({ - databaseUrl: "postgres://new/db", - port: 8788, + expect(saved.contexts.local).toEqual({ + url: "http://localhost:9000/api/v1", + lifecycle: "managed", + cwd: "/tmp/new", }); }); - test("addContext stores optional server config (port + cwd + lifecycle)", async () => { + test("external contexts do not produce server settings", async () => { + const configData = { + currentContext: "prod", + contexts: { + prod: { url: "https://app.lobu.ai/api/v1", lifecycle: "external" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getServerConfig("prod")).toBeUndefined(); + }); + + test("addContext stores flat lifecycle config", async () => { readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); await addContext("verify-flow", "http://localhost:8788", { - port: 8788, cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", lifecycle: "managed", }); @@ -157,12 +195,9 @@ describe("context management", () => { const [, written] = writeFileSpy.mock.calls.at(-1)!; const saved = JSON.parse(written as string); expect(saved.contexts["verify-flow"]).toEqual({ - apiUrl: "http://localhost:8788", - server: { - port: 8788, - cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", - lifecycle: "managed", - }, + url: "http://localhost:8788", + cwd: "/Users/me/Code/lobu/.claude/worktrees/verify-flow", + lifecycle: "managed", }); }); @@ -170,7 +205,7 @@ describe("context management", () => { readFileSpy.mockResolvedValue( JSON.stringify({ contexts: { - [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + [DEFAULT_CONTEXT_NAME]: { url: "https://app.lobu.ai/api/v1" }, }, }) ); @@ -181,7 +216,7 @@ describe("context management", () => { expect(writeFileSpy.mock.calls.length).toBe(0); }); - test("addContext without server keeps shape backwards-compatible", async () => { + test("addContext without lifecycle keeps a minimal shape", async () => { readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); await addContext("plain", "https://example.com/api/v1"); @@ -189,7 +224,7 @@ describe("context management", () => { const [, written] = writeFileSpy.mock.calls.at(-1)!; const saved = JSON.parse(written as string); expect(saved.contexts.plain).toEqual({ - apiUrl: "https://example.com/api/v1", + url: "https://example.com/api/v1", }); }); @@ -198,8 +233,8 @@ describe("context management", () => { JSON.stringify({ currentContext: "verify-flow", contexts: { - lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, - "verify-flow": { apiUrl: "http://localhost:8788" }, + lobu: { url: "https://app.lobu.ai/api/v1" }, + "verify-flow": { url: "http://localhost:8788" }, }, }) ); @@ -222,7 +257,7 @@ describe("context management", () => { readFileSpy.mockResolvedValue( JSON.stringify({ contexts: { - [DEFAULT_CONTEXT_NAME]: { apiUrl: "https://app.lobu.ai/api/v1" }, + [DEFAULT_CONTEXT_NAME]: { url: "https://app.lobu.ai/api/v1" }, }, }) ); @@ -232,25 +267,22 @@ describe("context management", () => { ); }); - test("drops invalid server fields during normalization", async () => { + test("drops invalid lifecycle fields during normalization", async () => { const configData = { currentContext: "local", contexts: { local: { - apiUrl: "http://localhost:8787/api/v1", - server: { - databaseUrl: " ", - port: -1, - host: " ", - dataDir: "/cfg/data", - // unknown field — should be ignored - phaserBank: 5, - }, + url: "http://localhost:8787/api/v1", + lifecycle: "maybe", + cwd: " ", }, }, }; readFileSpy.mockResolvedValue(JSON.stringify(configData)); - expect(await getServerConfig("local")).toEqual({ dataDir: "/cfg/data" }); + const config = await loadContextConfig(); + expect(config.contexts.local).toEqual({ + url: "http://localhost:8787/api/v1", + }); }); }); diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index b41459051..37bad7401 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -4,17 +4,15 @@ import { join } from "node:path"; export const LOBU_CONFIG_DIR = join(homedir(), ".config", "lobu"); export const DEFAULT_CONTEXT_NAME = "lobu"; -const DEFAULT_API_URL = "https://app.lobu.ai/api/v1"; +const DEFAULT_CONTEXT_URL = "https://app.lobu.ai/api/v1"; const CONTEXTS_FILE = join(LOBU_CONFIG_DIR, "config.json"); export const DEFAULT_MEMORY_URL = "https://lobu.ai/mcp"; export interface LobuServerConfig { - databaseUrl?: string; port?: number; host?: string; - dataDir?: string; // Directory the lifecycle owner should `cd` into before spawning // `lobu run`. Used by per-worktree contexts so the menubar launches // the server against the worktree's source (hot-reload on the right @@ -22,16 +20,16 @@ export interface LobuServerConfig { cwd?: string; // "managed" → the Mac menubar (or another lifecycle owner) spawns // `lobu run` for this context. "external" → just connect; never - // spawn or kill. Absent → infer from apiUrl: loopback ⇒ managed, - // remote ⇒ external. Today only the Mac menubar reads this. + // spawn or kill. Absent → infer at the lifecycle owner only. lifecycle?: "managed" | "external"; } interface LobuContextEntry { - apiUrl: string; + url: string; activeOrg?: string; memoryUrl?: string; - server?: LobuServerConfig; + lifecycle?: "managed" | "external"; + cwd?: string; } interface LobuContextConfig { @@ -45,9 +43,19 @@ export interface ResolvedContext { source: "default" | "config" | "env"; } +interface StoredContextEntry { + url?: unknown; + apiUrl?: unknown; + activeOrg?: unknown; + memoryUrl?: unknown; + lifecycle?: unknown; + cwd?: unknown; + server?: unknown; +} + interface StoredContextConfig { currentContext?: string; - contexts?: Record; + contexts?: Record; } export async function loadContextConfig(): Promise { @@ -165,11 +173,7 @@ export async function resolveContext( const contextName = requestedContext || config.currentContext; const context = config.contexts[contextName]; if (context) { - return { - name: contextName, - apiUrl: normalizeApiUrl(context.apiUrl), - source: contextName === DEFAULT_CONTEXT_NAME ? "default" : "config", - }; + return contextToResolvedContext(contextName, context); } throw new Error( @@ -179,7 +183,7 @@ export async function resolveContext( export async function addContext( name: string, - apiUrl: string, + url: string, server?: LobuServerConfig ): Promise { const trimmedName = name.trim(); @@ -194,12 +198,12 @@ export async function addContext( const config = await loadContextConfig(); const entry: LobuContextEntry = { - apiUrl: normalizeAndValidateApiUrl(apiUrl), + url: normalizeAndValidateApiUrl(url), }; - const normalizedServer = server ? normalizeServerConfig(server) : undefined; - if (normalizedServer) { - entry.server = normalizedServer; - } + const lifecycle = normalizeLifecycle(server?.lifecycle); + if (lifecycle) entry.lifecycle = lifecycle; + if (server?.cwd?.trim()) entry.cwd = server.cwd.trim(); + config.contexts[trimmedName] = entry; await saveContextConfig(config); return config; @@ -239,7 +243,7 @@ export async function setCurrentContext( const config = await loadContextConfig(); if (!config.contexts[trimmedName]) { throw new Error( - `Unknown context "${trimmedName}". Run \`lobu context add ${trimmedName} --api-url \` first.` + `Unknown context "${trimmedName}". Run \`lobu context add ${trimmedName} --url \` first.` ); } @@ -250,25 +254,12 @@ export async function setCurrentContext( function normalizeContextConfig(raw: StoredContextConfig): LobuContextConfig { const contexts: Record = { - [DEFAULT_CONTEXT_NAME]: { apiUrl: DEFAULT_API_URL }, + [DEFAULT_CONTEXT_NAME]: { url: DEFAULT_CONTEXT_URL }, }; for (const [name, value] of Object.entries(raw.contexts ?? {})) { - if (!value || typeof value.apiUrl !== "string") { - continue; - } - contexts[name] = { - apiUrl: normalizeApiUrl(value.apiUrl), - activeOrg: - typeof value.activeOrg === "string" - ? value.activeOrg.trim() - : undefined, - memoryUrl: - typeof value.memoryUrl === "string" - ? value.memoryUrl.trim() - : undefined, - server: normalizeServerConfig(value.server), - }; + const normalized = normalizeContextEntry(value); + if (normalized) contexts[name] = normalized; } const currentContext = @@ -282,35 +273,56 @@ function normalizeContextConfig(raw: StoredContextConfig): LobuContextConfig { }; } -function normalizeServerConfig(raw: unknown): LobuServerConfig | undefined { +function normalizeContextEntry( + raw: StoredContextEntry +): LobuContextEntry | undefined { if (!raw || typeof raw !== "object") return undefined; + const legacyServer = normalizeLegacyServerConfig(raw.server); + const rawUrl = firstString(raw.url, raw.apiUrl); + if (!rawUrl) return undefined; + + const entry: LobuContextEntry = { url: normalizeApiUrl(rawUrl) }; + const activeOrg = normalizeString(raw.activeOrg); + if (activeOrg) entry.activeOrg = activeOrg; + const memoryUrl = normalizeString(raw.memoryUrl); + if (memoryUrl) entry.memoryUrl = memoryUrl; + const lifecycle = normalizeLifecycle(raw.lifecycle) ?? legacyServer.lifecycle; + if (lifecycle) entry.lifecycle = lifecycle; + const cwd = normalizeString(raw.cwd) ?? legacyServer.cwd; + if (cwd) entry.cwd = cwd; + + return entry; +} + +function normalizeLegacyServerConfig( + raw: unknown +): Pick { + if (!raw || typeof raw !== "object") return {}; const src = raw as Record; - const out: LobuServerConfig = {}; + const out: Pick = {}; + const cwd = normalizeString(src.cwd); + if (cwd) out.cwd = cwd; + const lifecycle = normalizeLifecycle(src.lifecycle); + if (lifecycle) out.lifecycle = lifecycle; + return out; +} - if (typeof src.databaseUrl === "string" && src.databaseUrl.trim()) { - out.databaseUrl = src.databaseUrl.trim(); - } - if ( - typeof src.port === "number" && - Number.isInteger(src.port) && - src.port > 0 - ) { - out.port = src.port; - } - if (typeof src.host === "string" && src.host.trim()) { - out.host = src.host.trim(); - } - if (typeof src.dataDir === "string" && src.dataDir.trim()) { - out.dataDir = src.dataDir.trim(); - } - if (typeof src.cwd === "string" && src.cwd.trim()) { - out.cwd = src.cwd.trim(); - } - if (src.lifecycle === "managed" || src.lifecycle === "external") { - out.lifecycle = src.lifecycle; - } +function normalizeLifecycle( + value: unknown +): "managed" | "external" | undefined { + return value === "managed" || value === "external" ? value : undefined; +} - return Object.keys(out).length === 0 ? undefined : out; +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + const normalized = normalizeString(value); + if (normalized) return normalized; + } + return undefined; } export async function getServerConfig( @@ -319,13 +331,16 @@ export async function getServerConfig( const config = await loadContextConfig(); // Honor LOBU_CONTEXT the same way resolveContext() does — without this, // a caller that sets the env var to pin a context (e.g. the Mac menubar - // spawning `lobu run` with LOBU_CONTEXT=local) gets the server block - // from `currentContext` instead, because no contextName was passed. + // spawning `lobu run` with LOBU_CONTEXT=local) gets the server settings + // from `currentContext` instead. const name = contextName?.trim() || process.env.LOBU_CONTEXT?.trim() || config.currentContext; - return config.contexts[name]?.server; + const context = config.contexts[name]; + if (!context || context.lifecycle !== "managed") return undefined; + + return deriveManagedServerConfig(context); } export async function setServerConfig( @@ -339,15 +354,47 @@ export async function setServerConfig( throw new Error(`Unknown context "${name}".`); } - context.server = server ? normalizeServerConfig(server) : undefined; + if (!server) { + delete context.lifecycle; + delete context.cwd; + } else { + const lifecycle = normalizeLifecycle(server.lifecycle); + if (lifecycle) context.lifecycle = lifecycle; + else delete context.lifecycle; + if (server.cwd?.trim()) context.cwd = server.cwd.trim(); + else delete context.cwd; + } await saveContextConfig(config); return config; } +function deriveManagedServerConfig( + context: LobuContextEntry +): LobuServerConfig | undefined { + const out: LobuServerConfig = { lifecycle: context.lifecycle }; + if (context.cwd) out.cwd = context.cwd; + + try { + const parsed = new URL(context.url); + const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined; + if (port && Number.isInteger(port) && port > 0 && port <= 65535) { + out.port = port; + } + if (parsed.hostname) { + out.host = parsed.hostname; + } + } catch { + // URL validation happens when contexts are saved/loaded; ignore here so a + // hand-edited malformed config does not make the caller crash. + } + + return Object.keys(out).length === 0 ? undefined : out; +} + function normalizeAndValidateApiUrl(apiUrl: string): string { const normalized = normalizeApiUrl(apiUrl.trim()); if (!normalized) { - throw new Error("API URL cannot be empty."); + throw new Error("URL cannot be empty."); } try { @@ -356,7 +403,7 @@ function normalizeAndValidateApiUrl(apiUrl: string): string { throw new Error("Missing protocol or host"); } } catch { - throw new Error(`Invalid API URL: ${apiUrl}`); + throw new Error(`Invalid URL: ${apiUrl}`); } return normalized; @@ -377,7 +424,7 @@ export async function findContextByUrl( const normalizedSearch = normalizeApiUrl(apiUrl); for (const [name, context] of Object.entries(config.contexts)) { - if (normalizeApiUrl(context.apiUrl) === normalizedSearch) { + if (normalizeApiUrl(context.url) === normalizedSearch) { return contextToResolvedContext(name, context); } } @@ -409,7 +456,7 @@ function contextToResolvedContext( ): ResolvedContext { return { name, - apiUrl: normalizeApiUrl(context.apiUrl), + apiUrl: normalizeApiUrl(context.url), source: name === DEFAULT_CONTEXT_NAME ? "default" : "config", }; } diff --git a/packages/landing/src/content/docs/guides/testing.md b/packages/landing/src/content/docs/guides/testing.md index 3421640d4..b4ce96a56 100644 --- a/packages/landing/src/content/docs/guides/testing.md +++ b/packages/landing/src/content/docs/guides/testing.md @@ -151,7 +151,7 @@ Use named contexts to test against staging or production: ```bash # Add a staging context -npx @lobu/cli@latest context add staging --api-url https://staging.example.com/api/v1 +npx @lobu/cli@latest context add staging --url https://staging.example.com/api/v1 npx @lobu/cli@latest login -c staging # Chat with the staging agent diff --git a/packages/landing/src/content/docs/reference/cli.md b/packages/landing/src/content/docs/reference/cli.md index 90ff43e09..92a62066e 100644 --- a/packages/landing/src/content/docs/reference/cli.md +++ b/packages/landing/src/content/docs/reference/cli.md @@ -87,7 +87,7 @@ Manage named API contexts. ```bash npx @lobu/cli@latest context list npx @lobu/cli@latest context current -npx @lobu/cli@latest context add staging --api-url https://staging.example.com/api/v1 +npx @lobu/cli@latest context add staging --url https://staging.example.com/api/v1 npx @lobu/cli@latest context use staging ``` diff --git a/packages/server/src/start-local.ts b/packages/server/src/start-local.ts index acd8f7297..0e446fb35 100644 --- a/packages/server/src/start-local.ts +++ b/packages/server/src/start-local.ts @@ -41,11 +41,8 @@ import { applyUserServerConfigToEnv } from "./utils/user-config"; // / PORT / HOST reads below so user-config overrides from // ~/.config/lobu/config.json land in time. // -// DATABASE_URL is also filled in, but this bundle always boots PGlite and -// overwrites it below. External-Postgres routing happens upstream in -// `lobu run` (packages/cli/src/commands/dev.ts), which switches bundles when -// the user config or env pins DATABASE_URL. So in practice only LOBU_DATA_DIR -// / PORT / HOST flow through this call. +// Managed context config only contributes PORT / HOST. External-Postgres +// routing happens upstream in `lobu run` via project `.env` or shell env. applyUserServerConfigToEnv(); import { PGlite } from "@electric-sql/pglite"; diff --git a/packages/server/src/utils/__tests__/user-config.test.ts b/packages/server/src/utils/__tests__/user-config.test.ts index 93f475487..e3cf50163 100644 --- a/packages/server/src/utils/__tests__/user-config.test.ts +++ b/packages/server/src/utils/__tests__/user-config.test.ts @@ -15,7 +15,7 @@ function writeConfig(payload: unknown): string { return path; } -const ENV_KEYS = ['DATABASE_URL', 'PORT', 'HOST', 'LOBU_DATA_DIR', 'LOBU_CONTEXT']; +const ENV_KEYS = ['PORT', 'HOST', 'LOBU_CONTEXT']; const saved: Record = {}; beforeEach(() => { @@ -47,26 +47,22 @@ describe('loadUserServerConfig', () => { expect(loadUserServerConfig(path)).toBeUndefined(); }); - it('returns the current context server block', () => { + it('returns the current managed context settings', () => { const path = writeConfig({ currentContext: 'local', contexts: { local: { - apiUrl: 'http://localhost:8787/api/v1', - server: { - databaseUrl: 'postgres://burakemre@localhost:5432/lobu', - port: 9000, - host: '0.0.0.0', - dataDir: '/tmp/lobu-data', - }, + url: 'http://localhost:8787/api/v1', + lifecycle: 'managed', + cwd: '/tmp/lobu-worktree', }, }, }); expect(loadUserServerConfig(path)).toEqual({ - databaseUrl: 'postgres://burakemre@localhost:5432/lobu', - port: 9000, - host: '0.0.0.0', - dataDir: '/tmp/lobu-data', + lifecycle: 'managed', + cwd: '/tmp/lobu-worktree', + port: 8787, + host: 'localhost', }); }); @@ -74,47 +70,69 @@ describe('loadUserServerConfig', () => { const path = writeConfig({ contexts: { lobu: { - apiUrl: 'https://app.lobu.ai/api/v1', - server: { databaseUrl: 'postgres://x/y' }, + url: 'http://localhost:8788/api/v1', + lifecycle: 'managed', }, }, }); - expect(loadUserServerConfig(path)).toEqual({ databaseUrl: 'postgres://x/y' }); + expect(loadUserServerConfig(path)).toEqual({ + lifecycle: 'managed', + port: 8788, + host: 'localhost', + }); }); it('honors the context override', () => { const path = writeConfig({ currentContext: 'prod', contexts: { - prod: { apiUrl: 'https://app.lobu.ai/api/v1', server: { port: 8080 } }, + prod: { url: 'https://app.lobu.ai/api/v1', lifecycle: 'external' }, local: { - apiUrl: 'http://localhost:8787/api/v1', - server: { databaseUrl: 'postgres://local/db' }, + url: 'http://localhost:8789/api/v1', + lifecycle: 'managed', }, }, }); expect(loadUserServerConfig(path, 'local')).toEqual({ - databaseUrl: 'postgres://local/db', + lifecycle: 'managed', + port: 8789, + host: 'localhost', }); }); - it('returns undefined when the server block is empty / invalid', () => { + it('returns undefined when the context is external', () => { const path = writeConfig({ - currentContext: 'local', - contexts: { - local: { apiUrl: 'http://localhost:8787/api/v1', server: { port: 'nope' } }, - }, + currentContext: 'prod', + contexts: { prod: { url: 'https://app.lobu.ai/api/v1', lifecycle: 'external' } }, }); expect(loadUserServerConfig(path)).toBeUndefined(); }); - it('returns undefined when the context has no server block', () => { + it('returns undefined when the context has no lifecycle marker', () => { const path = writeConfig({ currentContext: 'local', - contexts: { local: { apiUrl: 'http://localhost:8787/api/v1' } }, + contexts: { local: { url: 'http://localhost:8787/api/v1' } }, }); expect(loadUserServerConfig(path)).toBeUndefined(); }); + + it('reads legacy apiUrl + server lifecycle/cwd', () => { + const path = writeConfig({ + currentContext: 'local', + contexts: { + local: { + apiUrl: 'http://localhost:8790/api/v1', + server: { lifecycle: 'managed', cwd: '/tmp/legacy' }, + }, + }, + }); + expect(loadUserServerConfig(path)).toEqual({ + lifecycle: 'managed', + cwd: '/tmp/legacy', + port: 8790, + host: 'localhost', + }); + }); }); describe('applyUserServerConfigToEnv', () => { @@ -123,30 +141,22 @@ describe('applyUserServerConfigToEnv', () => { currentContext: 'local', contexts: { local: { - apiUrl: 'http://localhost:8787/api/v1', - server: { - databaseUrl: 'postgres://from-config/db', - port: 9000, - host: 'cfg-host', - dataDir: '/cfg/data', - }, + url: 'http://cfg-host:9000/api/v1', + lifecycle: 'managed', }, }, }); - process.env.DATABASE_URL = 'postgres://from-env/db'; + process.env.HOST = 'env-host'; applyUserServerConfigToEnv(path); - expect(process.env.DATABASE_URL).toBe('postgres://from-env/db'); expect(process.env.PORT).toBe('9000'); - expect(process.env.HOST).toBe('cfg-host'); - expect(process.env.LOBU_DATA_DIR).toBe('/cfg/data'); + expect(process.env.HOST).toBe('env-host'); }); it('no-ops when no config file is present', () => { applyUserServerConfigToEnv(join(tmpdir(), 'missing.json')); - expect(process.env.DATABASE_URL).toBeUndefined(); expect(process.env.PORT).toBeUndefined(); }); }); diff --git a/packages/server/src/utils/user-config.ts b/packages/server/src/utils/user-config.ts index fa6b7bc2d..67ed99e66 100644 --- a/packages/server/src/utils/user-config.ts +++ b/packages/server/src/utils/user-config.ts @@ -3,14 +3,15 @@ * * Reads `~/.config/lobu/config.json` (owned by the CLI's * `packages/cli/src/internal/context.ts`) and returns the current context's - * `server` block. The Mac app writes this file to point a stable local server - * at a brew-managed Postgres without per-project `.env` plumbing. + * managed-server launch settings. The Mac app writes this file so a stable + * local server can be started for a selected context without per-project `.env` + * plumbing. * * Sync on purpose — start-local.ts reads env at module-load time, so this has * to resolve before the first env read. Cost is one ~1KB JSON file per boot. * - * Schema is duplicated from the CLI's `LobuServerConfig` rather than imported - * to keep `@lobu/server` free of a `@lobu/cli` dependency. + * Schema is duplicated from the CLI's context loader rather than imported to + * keep `@lobu/server` free of a `@lobu/cli` dependency. */ import { readFileSync } from 'node:fs'; @@ -18,20 +19,20 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; export interface UserServerConfig { - databaseUrl?: string; port?: number; host?: string; - dataDir?: string; + cwd?: string; /// "managed" → the Mac menubar (or another lifecycle owner) spawns /// `lobu run` for this context. "external" → just connect; never - /// spawn or kill. Absent → infer from apiUrl: loopback ⇒ managed, - /// remote ⇒ external. Today only the Mac menubar reads this; the - /// CLI's `lobu run` ignores it. + /// spawn or kill. Absent → infer only at the lifecycle owner. lifecycle?: "managed" | "external"; } interface StoredEntry { + url?: unknown; apiUrl?: unknown; + lifecycle?: unknown; + cwd?: unknown; server?: unknown; } @@ -67,33 +68,52 @@ export function loadUserServerConfig( const entry = parsed.contexts?.[contextName]; if (!entry) return undefined; - return normalizeServerConfig(entry.server); + return deriveManagedServerConfig(entry); } -function normalizeServerConfig(raw: unknown): UserServerConfig | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const src = raw as Record; - const out: UserServerConfig = {}; +function deriveManagedServerConfig(entry: StoredEntry): UserServerConfig | undefined { + const lifecycle = normalizeLifecycle(entry.lifecycle) ?? normalizeLegacyLifecycle(entry.server); + if (lifecycle !== 'managed') return undefined; - if (typeof src.databaseUrl === 'string' && src.databaseUrl.trim()) { - out.databaseUrl = src.databaseUrl.trim(); - } - if (typeof src.port === 'number' && Number.isInteger(src.port) && src.port > 0) { - out.port = src.port; - } - if (typeof src.host === 'string' && src.host.trim()) { - out.host = src.host.trim(); - } - if (typeof src.dataDir === 'string' && src.dataDir.trim()) { - out.dataDir = src.dataDir.trim(); - } - if (src.lifecycle === 'managed' || src.lifecycle === 'external') { - out.lifecycle = src.lifecycle; + const rawUrl = normalizeString(entry.url) ?? normalizeString(entry.apiUrl); + const out: UserServerConfig = { lifecycle }; + const cwd = normalizeString(entry.cwd) ?? normalizeLegacyCwd(entry.server); + if (cwd) out.cwd = cwd; + + if (rawUrl) { + try { + const parsed = new URL(rawUrl); + const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined; + if (port && Number.isInteger(port) && port > 0 && port <= 65535) { + out.port = port; + } + if (parsed.hostname) out.host = parsed.hostname; + } catch { + // Ignore malformed hand-edited URLs; context validation lives in the CLI. + } } return Object.keys(out).length === 0 ? undefined : out; } +function normalizeLegacyLifecycle(raw: unknown): "managed" | "external" | undefined { + if (!raw || typeof raw !== 'object') return undefined; + return normalizeLifecycle((raw as Record).lifecycle); +} + +function normalizeLegacyCwd(raw: unknown): string | undefined { + if (!raw || typeof raw !== 'object') return undefined; + return normalizeString((raw as Record).cwd); +} + +function normalizeLifecycle(value: unknown): "managed" | "external" | undefined { + return value === 'managed' || value === 'external' ? value : undefined; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + /** * Fill missing env vars from the user config. Env wins; we never overwrite a * value the operator already set. @@ -105,18 +125,12 @@ export function applyUserServerConfigToEnv( const cfg = loadUserServerConfig(configPath, contextOverride ?? process.env.LOBU_CONTEXT); if (!cfg) return undefined; - if (cfg.databaseUrl && !process.env.DATABASE_URL?.trim()) { - process.env.DATABASE_URL = cfg.databaseUrl; - } if (cfg.port && !process.env.PORT?.trim()) { process.env.PORT = String(cfg.port); } if (cfg.host && !process.env.HOST?.trim()) { process.env.HOST = cfg.host; } - if (cfg.dataDir && !process.env.LOBU_DATA_DIR?.trim()) { - process.env.LOBU_DATA_DIR = cfg.dataDir; - } return cfg; } diff --git a/scripts/e2e-lobu-apply.sh b/scripts/e2e-lobu-apply.sh index 2799136a6..27422739d 100755 --- a/scripts/e2e-lobu-apply.sh +++ b/scripts/e2e-lobu-apply.sh @@ -188,7 +188,7 @@ export HOME="${CLI_HOME}" mkdir -p "${HOME}/.config/lobu" # Add a context pointing at our local server, then mark it current. -${LOBU} context add e2e --api-url "${API_URL}" >/dev/null +${LOBU} context add e2e --url "${API_URL}" >/dev/null ${LOBU} context use e2e >/dev/null # `lobu login --token ` saves the PAT verbatim (no OAuth round-trip). diff --git a/scripts/task-setup.sh b/scripts/task-setup.sh index 5901369a0..7c25c2b82 100755 --- a/scripts/task-setup.sh +++ b/scripts/task-setup.sh @@ -184,8 +184,7 @@ echo "$name" > "$worktree_dir/.task" # worktree is still useful even if the lobu CLI isn't on PATH yet. if command -v lobu >/dev/null 2>&1; then if lobu context add "$name" \ - --api-url "http://localhost:$port" \ - --port "$port" \ + --url "http://localhost:$port" \ --cwd "$worktree_dir" \ --lifecycle managed >/dev/null; then echo "→ registered Lobu context '$name' (menubar can spawn its server)" From 290c465142df7b5d886b4b6ae5477695ceb9d53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 14:06:15 +0100 Subject: [PATCH 2/3] fix(cli): update context-config test fixtures to flat url shape; pin owletto to reachable main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context-config flatten changed loadContextConfig()'s normalized return shape from { apiUrl } to flat { url }. Two test fixtures still mocked loadContextConfig with the old { apiUrl } shape, so context.url came back undefined and normalizeApiUrl(undefined) threw inside findContextByUrl — failing 6 applyCommand dry-run / org-resolution tests. Update the mocks to the normalized flat { url } contract that loadContextConfig actually returns. Also reset the owletto submodule pointer from the unreachable SHA 2c9c4c9 to owletto/main HEAD 2a2cc35, which is reachable and safe for production cloning. --- .../commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts | 6 +++--- packages/cli/src/internal/__tests__/org-resolution.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts index a90b05536..8f82aa635 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts @@ -133,7 +133,7 @@ describe("applyCommand --dry-run", () => { spyOn(context, "getActiveOrg").mockResolvedValue("acme"); spyOn(context, "loadContextConfig").mockResolvedValue({ currentContext: "prod", - contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } }, + contexts: { prod: { url: "https://app.lobu.ai/api/v1" } }, }); }); @@ -226,7 +226,7 @@ describe("applyCommand org resolution", () => { spyOn(context, "getActiveOrg").mockResolvedValue(undefined); spyOn(context, "loadContextConfig").mockResolvedValue({ currentContext: "prod", - contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } }, + contexts: { prod: { url: "https://app.lobu.ai/api/v1" } }, }); }); @@ -335,7 +335,7 @@ describe("applyCommand — missing lobu.toml", () => { spyOn(context, "getActiveOrg").mockResolvedValue("acme"); spyOn(context, "loadContextConfig").mockResolvedValue({ currentContext: "prod", - contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } }, + contexts: { prod: { url: "https://app.lobu.ai/api/v1" } }, }); }); diff --git a/packages/cli/src/internal/__tests__/org-resolution.test.ts b/packages/cli/src/internal/__tests__/org-resolution.test.ts index 11341bb4e..f95f27991 100644 --- a/packages/cli/src/internal/__tests__/org-resolution.test.ts +++ b/packages/cli/src/internal/__tests__/org-resolution.test.ts @@ -45,7 +45,7 @@ describe("resolveApiClient — org resolution", () => { spyOn(context, "findContextByUrl").mockResolvedValue(undefined); spyOn(context, "loadContextConfig").mockResolvedValue({ currentContext: "prod", - contexts: { prod: { apiUrl: "https://app.lobu.ai/api/v1" } }, + contexts: { prod: { url: "https://app.lobu.ai/api/v1" } }, }); }); From 90af9295c85c221d93cb037305e49f78c4c40bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 14:34:18 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(cli):=20harden=20context=20flattening?= =?UTF-8?q?=20=E2=80=94=20shared-DB=20merge=20guard,=20url=20normalization?= =?UTF-8?q?,=20managed-cwd=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev: evaluate the EFFECTIVE merged DATABASE_URL in the shared-DB refusal (shell precedence over .env), not just project-.env presence; extract shouldRefuseSharedDatabaseUrl() and unit-test the shell-override footgun - context: rename ResolvedContext.apiUrl -> url for flat-schema consistency; fix `lobu context current` to print context.url - context/user-config: derive default port 80/443 for scheme-only URLs and strip IPv6 brackets from the derived host in both managed-config paths - context: reject `cwd` on non-managed contexts (was a silent no-op) in addContext + setServerConfig - context: drop malformed stored URLs during normalization so resolveContext always returns a usable endpoint - tests: regression coverage for every behavior above --- packages/cli/src/__tests__/dev.test.ts | 70 +++++++++++ packages/cli/src/__tests__/token.test.ts | 2 +- .../apply/__tests__/apply-cmd-dryrun.test.ts | 6 +- .../src/commands/_lib/connector-run-cmd.ts | 2 +- packages/cli/src/commands/chat.ts | 2 +- packages/cli/src/commands/context.ts | 2 +- packages/cli/src/commands/dev.ts | 40 +++++- packages/cli/src/commands/login.ts | 4 +- .../memory/_lib/openclaw-auth.test.ts | 4 +- packages/cli/src/commands/org.ts | 2 +- packages/cli/src/commands/token.ts | 2 +- packages/cli/src/commands/whoami.ts | 6 +- .../src/internal/__tests__/api-client.test.ts | 8 +- .../src/internal/__tests__/context.test.ts | 117 +++++++++++++++++- .../internal/__tests__/credentials.test.ts | 6 +- .../internal/__tests__/org-resolution.test.ts | 2 +- packages/cli/src/internal/api-client.ts | 8 +- packages/cli/src/internal/context.ts | 65 ++++++++-- packages/cli/src/internal/credentials.ts | 4 +- .../src/utils/__tests__/user-config.test.ts | 51 ++++++++ packages/server/src/utils/user-config.ts | 17 ++- 21 files changed, 373 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/__tests__/dev.test.ts b/packages/cli/src/__tests__/dev.test.ts index c430c88ef..736cd25e0 100644 --- a/packages/cli/src/__tests__/dev.test.ts +++ b/packages/cli/src/__tests__/dev.test.ts @@ -14,6 +14,7 @@ import { findEnclosingMonorepoRoot, isSharedDatabaseUrl, resolveBackendBundle, + shouldRefuseSharedDatabaseUrl, } from "../commands/dev"; const here = dirname(fileURLToPath(import.meta.url)); @@ -172,6 +173,75 @@ describe("lobu run backend bundle resolution", () => { expect(isSharedDatabaseUrl("not-a-url")).toBe(false); }); + describe("shouldRefuseSharedDatabaseUrl", () => { + const SHARED = "postgres://u:p@db.example.com:5432/prod"; + const LOCAL = "postgres://localhost:5432/proj_dev"; + + test("refuses when a shared shell URL overrides a loopback .env URL", () => { + // The footgun: .env pins a local DB, but the shell exports a prod URL + // that wins the merge. Gating on .env presence alone used to pass here. + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: SHARED, + projectEnvDatabaseUrl: LOCAL, + unsafeSharedDb: false, + }) + ).toBe(true); + }); + + test("allows when the project's own .env shared URL survives the merge", () => { + // Pinning the shared URL in .env is explicit consent — the effective + // value equals the project .env value, so the project owns it. + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: SHARED, + projectEnvDatabaseUrl: SHARED, + unsafeSharedDb: false, + }) + ).toBe(false); + }); + + test("refuses a shared shell URL when .env pins nothing", () => { + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: SHARED, + projectEnvDatabaseUrl: undefined, + unsafeSharedDb: false, + }) + ).toBe(true); + }); + + test("allows a loopback effective URL regardless of source", () => { + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: LOCAL, + projectEnvDatabaseUrl: undefined, + unsafeSharedDb: false, + }) + ).toBe(false); + }); + + test("--unsafe-shared-db bypasses the refusal", () => { + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: SHARED, + projectEnvDatabaseUrl: LOCAL, + unsafeSharedDb: true, + }) + ).toBe(false); + }); + + test("no effective URL means no refusal (PGlite path)", () => { + expect( + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl: undefined, + projectEnvDatabaseUrl: undefined, + unsafeSharedDb: false, + }) + ).toBe(false); + }); + }); + test("CLI build copies local runtime assets for installed lobu run", () => { expect(existsSync(join(repoRoot, "db", "migrations"))).toBe(true); expect( diff --git a/packages/cli/src/__tests__/token.test.ts b/packages/cli/src/__tests__/token.test.ts index ed71fb4cb..209854690 100644 --- a/packages/cli/src/__tests__/token.test.ts +++ b/packages/cli/src/__tests__/token.test.ts @@ -12,7 +12,7 @@ describe("token create", () => { test("creates an org-scoped PAT using the current OAuth login", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(credentials, "getToken").mockResolvedValue("oauth-access-token"); diff --git a/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts index 8f82aa635..f450ce415 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/apply-cmd-dryrun.test.ts @@ -126,7 +126,7 @@ describe("applyCommand --dry-run", () => { silenceOutput(); spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(credentials, "getToken").mockResolvedValue("tok"); @@ -219,7 +219,7 @@ describe("applyCommand org resolution", () => { silenceOutput(); spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(credentials, "getToken").mockResolvedValue("tok"); @@ -328,7 +328,7 @@ describe("applyCommand — missing lobu.toml", () => { silenceOutput(); spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(credentials, "getToken").mockResolvedValue("tok"); diff --git a/packages/cli/src/commands/_lib/connector-run-cmd.ts b/packages/cli/src/commands/_lib/connector-run-cmd.ts index 75957cc95..a5cf64903 100644 --- a/packages/cli/src/commands/_lib/connector-run-cmd.ts +++ b/packages/cli/src/commands/_lib/connector-run-cmd.ts @@ -171,7 +171,7 @@ export async function connectorRun( const explicitUrl = args.url?.trim(); const apiBase = explicitUrl ? new URL(explicitUrl).origin - : apiBaseFrom(ctx.apiUrl); + : apiBaseFrom(ctx.url); const envToken = process.env.LOBU_API_TOKEN?.trim(); let token: string; diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 9a6ce57a1..c83272338 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -83,7 +83,7 @@ export async function chatCommand( // gateway was running, or worse, hitting the wrong instance. const ctx = await resolveContext(options.context).catch(() => null); gatewayUrl = ctx - ? apiBaseFromContextUrl(ctx.apiUrl) + ? apiBaseFromContextUrl(ctx.url) : await resolveGatewayUrl({ cwd }); } // The Agent API lives under `/lobu` on every Lobu deployment; the diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 6e3c5c78d..956aff832 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -37,7 +37,7 @@ export async function contextCurrentCommand(): Promise { console.log(chalk.bold("\n Current context")); console.log(chalk.dim(` Name: ${context.name}`)); - console.log(chalk.dim(` URL: ${context.apiUrl}`)); + console.log(chalk.dim(` URL: ${context.url}`)); if (context.source === "env") { console.log(chalk.dim(" Source: environment override")); } diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 0ba131a25..8c3248d5a 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -53,6 +53,34 @@ export function isSharedDatabaseUrl(databaseUrl: string): boolean { } } +/** + * Decide whether `lobu run` must refuse to boot because the EFFECTIVE + * DATABASE_URL points at a shared/non-local DB the project never opted into. + * + * `mergedEnv` gives the shell higher precedence than the project's `.env`, so + * the project only "owns" the URL when its `.env` value is the exact one that + * survived the merge. Gating on project-`.env` *presence* alone (the old bug) + * let a shared/prod shell URL win silently whenever `.env` also happened to + * define its own DATABASE_URL — re-pointing "local dev" at shared/prod data. + * + * Exported for unit tests; the safety gate in `devCommand` is the consumer. + */ +export function shouldRefuseSharedDatabaseUrl(input: { + effectiveDatabaseUrl: string | undefined; + projectEnvDatabaseUrl: string | undefined; + unsafeSharedDb: boolean | undefined; +}): boolean { + const effective = input.effectiveDatabaseUrl?.trim(); + if (!effective) return false; + if (input.unsafeSharedDb) return false; + + const projectEnv = input.projectEnvDatabaseUrl?.trim(); + const projectEnvOwnsIt = !!projectEnv && projectEnv === effective; + if (projectEnvOwnsIt) return false; + + return isSharedDatabaseUrl(effective); +} + type BackendBundleKind = "postgres" | "pglite"; /** @@ -91,17 +119,19 @@ export async function devCommand( ...envVars, ...(process.env as Record), }; - const hasDatabaseUrl = Boolean(mergedEnv.DATABASE_URL?.trim()); + const effectiveDatabaseUrl = mergedEnv.DATABASE_URL?.trim(); + const hasDatabaseUrl = Boolean(effectiveDatabaseUrl); // Refuse to boot against a shared/non-local DATABASE_URL that came from the // parent shell rather than the project's own .env. A common footgun: // "local lobu run" silently writes into prod / a teammate's tailnet DB. // Project pinning in .env is explicit consent. if ( - hasDatabaseUrl && - !envVars.DATABASE_URL?.trim() && - isSharedDatabaseUrl(mergedEnv.DATABASE_URL!) && - !options.unsafeSharedDb + shouldRefuseSharedDatabaseUrl({ + effectiveDatabaseUrl, + projectEnvDatabaseUrl: envVars.DATABASE_URL, + unsafeSharedDb: options.unsafeSharedDb, + }) ) { spinner.fail("DATABASE_URL inherited from shell points at a shared DB"); console.error( diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index bb3f1f24f..8a59278e8 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -77,7 +77,7 @@ export async function loginCommand(options: LoginOptions): Promise { let discovery: OAuthDiscovery; try { - discovery = await discoverOAuth(target.apiUrl); + discovery = await discoverOAuth(target.url); } catch (err) { const message = err instanceof OAuthError ? err.message : String((err as Error).message); @@ -300,7 +300,7 @@ export async function loginCommand(options: LoginOptions): Promise { } async function loginWithToken( - target: { apiUrl: string; name: string }, + target: { url: string; name: string }, rawToken: string ): Promise { const token = rawToken.trim(); diff --git a/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts b/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts index ee770d1c8..b33f55b0e 100644 --- a/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts +++ b/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts @@ -9,14 +9,14 @@ const CLOUD_MCP_URL = "https://lobu.ai/mcp"; function mockProdMemoryContext() { spyOn(internal, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://community.lobu.ai/api/v1", + url: "https://community.lobu.ai/api/v1", source: "config", }); spyOn(internal, "getMemoryUrl").mockImplementation(async () => CLOUD_MCP_URL); spyOn(internal, "getActiveOrg").mockImplementation(async () => "buremba"); spyOn(internal, "findContextByMemoryUrl").mockResolvedValue({ name: "lobu", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "default", }); spyOn(internal, "getToken").mockImplementation(async (contextName) => diff --git a/packages/cli/src/commands/org.ts b/packages/cli/src/commands/org.ts index 2214662c6..2efa4d5ae 100644 --- a/packages/cli/src/commands/org.ts +++ b/packages/cli/src/commands/org.ts @@ -61,7 +61,7 @@ export async function orgCreateCommand( options?: { name?: string; context?: string } ): Promise { const target = await resolveContext(options?.context); - const origin = new URL(target.apiUrl).origin; + const origin = new URL(target.url).origin; const name = options?.name?.trim() || slug; const url = `${origin}/orgs/new?slug=${encodeURIComponent(slug)}&name=${encodeURIComponent(name)}`; console.log( diff --git a/packages/cli/src/commands/token.ts b/packages/cli/src/commands/token.ts index 4c5c80224..611442587 100644 --- a/packages/cli/src/commands/token.ts +++ b/packages/cli/src/commands/token.ts @@ -43,7 +43,7 @@ export async function tokenCommand(options: { } console.log(chalk.cyan(`\n Context: ${target.name}`)); - console.log(chalk.dim(` API URL: ${target.apiUrl}`)); + console.log(chalk.dim(` API URL: ${target.url}`)); console.log(" Token: available (use `lobu token --raw` to print it)\n"); } diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index cade8e76e..12b20264c 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -21,20 +21,20 @@ export async function whoamiCommand(options?: { chalk.dim("\n Authenticated via LOBU_API_TOKEN environment variable.") ); console.log(chalk.dim(` Context: ${target.name}`)); - console.log(chalk.dim(` API URL: ${target.apiUrl}`)); + console.log(chalk.dim(` API URL: ${target.url}`)); console.log(chalk.dim(" Lobu Cloud is in early access.\n")); return; } console.log(chalk.dim("\n Not logged in.")); console.log(chalk.dim(` Context: ${target.name}`)); - console.log(chalk.dim(` API URL: ${target.apiUrl}`)); + console.log(chalk.dim(` API URL: ${target.url}`)); console.log(chalk.dim(" Run `lobu login` to authenticate.\n")); return; } console.log(chalk.bold("\n Lobu CLI")); console.log(chalk.dim(` Context: ${target.name}`)); - console.log(chalk.dim(` API URL: ${target.apiUrl}`)); + console.log(chalk.dim(` API URL: ${target.url}`)); if (creds.name) { console.log(chalk.dim(` Name: ${creds.name}`)); } diff --git a/packages/cli/src/internal/__tests__/api-client.test.ts b/packages/cli/src/internal/__tests__/api-client.test.ts index ab01d6784..8eb5998a4 100644 --- a/packages/cli/src/internal/__tests__/api-client.test.ts +++ b/packages/cli/src/internal/__tests__/api-client.test.ts @@ -69,14 +69,14 @@ describe("resolveApiClient", () => { test("resolves the token from the context that owns an overridden API URL", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "default", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "default", }); spyOn(context, "findContextByUrl").mockImplementation(async (url) => { if (url === "https://custom.lobu.ai/api/v1") { return { name: "custom", - apiUrl: "https://custom.lobu.ai/api/v1", + url: "https://custom.lobu.ai/api/v1", source: "config", }; } @@ -104,7 +104,7 @@ describe("resolveApiClient", () => { test("reads the active org from the resolved context", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(credentials, "getToken").mockResolvedValue("prod-token"); @@ -124,7 +124,7 @@ describe("resolveApiClient", () => { test("listOrganizations refuses unmatched URL overrides with stored credentials", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "default", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "default", }); spyOn(context, "findContextByUrl").mockResolvedValue(undefined); diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts index 257e1d36b..174be8c04 100644 --- a/packages/cli/src/internal/__tests__/context.test.ts +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -85,7 +85,7 @@ describe("context management", () => { const matched = await findContextByUrl("https://custom.lobu.ai/api/v1/"); expect(matched?.name).toBe("custom"); - expect(matched?.apiUrl).toBe("https://custom.lobu.ai/api/v1"); + expect(matched?.url).toBe("https://custom.lobu.ai/api/v1"); const none = await findContextByUrl("https://unknown.ai"); expect(none).toBeUndefined(); @@ -172,6 +172,54 @@ describe("context management", () => { }); }); + test("derives default ports for scheme-only managed URLs", async () => { + const configData = { + currentContext: "secure", + contexts: { + secure: { url: "https://example.com/api/v1", lifecycle: "managed" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getServerConfig("secure")).toEqual({ + lifecycle: "managed", + port: 443, + host: "example.com", + }); + }); + + test("derives port 80 for a scheme-only http managed URL", async () => { + const configData = { + currentContext: "plain", + contexts: { + plain: { url: "http://localhost/api/v1", lifecycle: "managed" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getServerConfig("plain")).toEqual({ + lifecycle: "managed", + port: 80, + host: "localhost", + }); + }); + + test("strips IPv6 brackets from the derived managed host", async () => { + const configData = { + currentContext: "v6", + contexts: { + v6: { url: "http://[::1]:8787/api/v1", lifecycle: "managed" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getServerConfig("v6")).toEqual({ + lifecycle: "managed", + port: 8787, + host: "::1", + }); + }); + test("external contexts do not produce server settings", async () => { const configData = { currentContext: "prod", @@ -201,6 +249,43 @@ describe("context management", () => { }); }); + test("addContext rejects cwd on a non-managed context", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await expect( + addContext("ext", "http://localhost:8788", { + cwd: "/tmp/lobu-worktree", + lifecycle: "external", + }) + ).rejects.toThrow(/`cwd` can only be set on managed contexts/); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + + test("addContext rejects cwd when lifecycle is absent", async () => { + readFileSpy.mockResolvedValue(JSON.stringify({ contexts: {} })); + + await expect( + addContext("plain", "http://localhost:8788", { + cwd: "/tmp/lobu-worktree", + }) + ).rejects.toThrow(/`cwd` can only be set on managed contexts/); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + + test("setServerConfig rejects cwd on a non-managed context", async () => { + readFileSpy.mockResolvedValue( + JSON.stringify({ + currentContext: "local", + contexts: { local: { url: "http://localhost:8788/api/v1" } }, + }) + ); + + await expect( + setServerConfig({ cwd: "/tmp/lobu-worktree" }, "local") + ).rejects.toThrow(/`cwd` can only be set on managed contexts/); + expect(writeFileSpy.mock.calls.length).toBe(0); + }); + test("addContext refuses to overwrite the default context", async () => { readFileSpy.mockResolvedValue( JSON.stringify({ @@ -267,6 +352,36 @@ describe("context management", () => { ); }); + test("drops malformed stored URLs during normalization", async () => { + const configData = { + currentContext: "lobu", + contexts: { + lobu: { url: "https://app.lobu.ai/api/v1" }, + broken: { url: "localhost:4111" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + const config = await loadContextConfig(); + expect(config.contexts.broken).toBeUndefined(); + expect(config.contexts.lobu).toBeDefined(); + }); + + test("a malformed currentContext URL falls back to the default", async () => { + const configData = { + currentContext: "broken", + contexts: { + broken: { url: "localhost:4111" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + const config = await loadContextConfig(); + // The malformed entry is dropped, so currentContext can't point at it. + expect(config.contexts.broken).toBeUndefined(); + expect(config.currentContext).toBe(DEFAULT_CONTEXT_NAME); + }); + test("drops invalid lifecycle fields during normalization", async () => { const configData = { currentContext: "local", diff --git a/packages/cli/src/internal/__tests__/credentials.test.ts b/packages/cli/src/internal/__tests__/credentials.test.ts index 608e4cc21..740adc2e2 100644 --- a/packages/cli/src/internal/__tests__/credentials.test.ts +++ b/packages/cli/src/internal/__tests__/credentials.test.ts @@ -55,7 +55,7 @@ describe("credentials", () => { spyOn(fs, "mkdir").mockResolvedValue(undefined); spyOn(context, "resolveContext").mockImplementation(async () => ({ name: currentContextName, - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "default", })); }); @@ -100,7 +100,7 @@ describe("credentials", () => { test("loadCredentials accepts legacy flat shape only for default context", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "lobu", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "default", }); const legacy = { @@ -117,7 +117,7 @@ describe("credentials", () => { test("loadCredentials ignores legacy flat shape for non-default contexts", async () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "prod-non-default", - apiUrl: "https://prod.lobu.ai/api/v1", + url: "https://prod.lobu.ai/api/v1", source: "config", }); const legacy = { accessToken: "legacy-token" }; diff --git a/packages/cli/src/internal/__tests__/org-resolution.test.ts b/packages/cli/src/internal/__tests__/org-resolution.test.ts index f95f27991..425af243d 100644 --- a/packages/cli/src/internal/__tests__/org-resolution.test.ts +++ b/packages/cli/src/internal/__tests__/org-resolution.test.ts @@ -39,7 +39,7 @@ describe("resolveApiClient — org resolution", () => { spyOn(context, "resolveContext").mockResolvedValue({ name: "prod", - apiUrl: "https://app.lobu.ai/api/v1", + url: "https://app.lobu.ai/api/v1", source: "config", }); spyOn(context, "findContextByUrl").mockResolvedValue(undefined); diff --git a/packages/cli/src/internal/api-client.ts b/packages/cli/src/internal/api-client.ts index 34b744a1a..5a2e0f105 100644 --- a/packages/cli/src/internal/api-client.ts +++ b/packages/cli/src/internal/api-client.ts @@ -98,7 +98,7 @@ export async function resolveApiClient( options: ApiClientOptions = {} ): Promise { const target = await resolveApiTarget(options); - const apiBaseUrl = apiBaseFromContextUrl(target.apiUrl); + const apiBaseUrl = apiBaseFromContextUrl(target.url); const token = process.env.LOBU_API_TOKEN || (await getToken(target.name)); if (!token) { @@ -139,7 +139,7 @@ export async function listOrganizations( return getOrganizationsFromUserInfo( target.name, token, - apiBaseFromContextUrl(target.apiUrl), + apiBaseFromContextUrl(target.url), options.fetchImpl, { useStoredUserInfoEndpoint: target.useStoredUserInfoEndpoint } ); @@ -163,7 +163,7 @@ async function resolveApiTarget( } const apiBaseUrl = apiBaseFromContextUrl(options.apiUrl); - const contextApiBaseUrl = apiBaseFromContextUrl(requested.apiUrl); + const contextApiBaseUrl = apiBaseFromContextUrl(requested.url); if (!process.env.LOBU_API_TOKEN && apiBaseUrl !== contextApiBaseUrl) { throw new ApiClientError( `Refusing to send stored context credentials for "${requested.name}" to ${apiBaseUrl}. Add a context for that URL or set LOBU_API_TOKEN explicitly.` @@ -172,7 +172,7 @@ async function resolveApiTarget( return { ...requested, - apiUrl: options.apiUrl, + url: options.apiUrl, useStoredUserInfoEndpoint: apiBaseUrl === contextApiBaseUrl, }; } diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index 37bad7401..cbfbe4a73 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -39,7 +39,7 @@ interface LobuContextConfig { export interface ResolvedContext { name: string; - apiUrl: string; + url: string; source: "default" | "config" | "env"; } @@ -164,7 +164,7 @@ export async function resolveContext( if (envApiUrl) { return { name: requestedContext || (await getCurrentContextName()), - apiUrl: normalizeApiUrl(envApiUrl), + url: normalizeApiUrl(envApiUrl), source: "env", }; } @@ -201,8 +201,15 @@ export async function addContext( url: normalizeAndValidateApiUrl(url), }; const lifecycle = normalizeLifecycle(server?.lifecycle); + const cwd = server?.cwd?.trim(); + // `cwd` only takes effect for managed contexts — getServerConfig() drops + // everything else. Persisting it on a non-managed context would be a silent + // no-op, so reject the combination instead of saving dead config. + if (cwd && lifecycle !== "managed") { + throw new Error("`cwd` can only be set on managed contexts."); + } if (lifecycle) entry.lifecycle = lifecycle; - if (server?.cwd?.trim()) entry.cwd = server.cwd.trim(); + if (cwd) entry.cwd = cwd; config.contexts[trimmedName] = entry; await saveContextConfig(config); @@ -281,7 +288,19 @@ function normalizeContextEntry( const rawUrl = firstString(raw.url, raw.apiUrl); if (!rawUrl) return undefined; - const entry: LobuContextEntry = { url: normalizeApiUrl(rawUrl) }; + // Reject hand-edited / malformed stored URLs (e.g. "localhost:4111" with no + // scheme) instead of letting them survive normalization. Every write path + // already validates, so a stored entry that fails validation is corrupt — + // dropping it keeps the invariant that resolveContext() returns a usable + // endpoint. + let normalizedUrl: string; + try { + normalizedUrl = normalizeAndValidateApiUrl(rawUrl); + } catch { + return undefined; + } + + const entry: LobuContextEntry = { url: normalizedUrl }; const activeOrg = normalizeString(raw.activeOrg); if (activeOrg) entry.activeOrg = activeOrg; const memoryUrl = normalizeString(raw.memoryUrl); @@ -359,9 +378,15 @@ export async function setServerConfig( delete context.cwd; } else { const lifecycle = normalizeLifecycle(server.lifecycle); + const cwd = server.cwd?.trim(); + // Same invariant as addContext: a non-managed context with a `cwd` is dead + // config — getServerConfig() never returns it. + if (cwd && lifecycle !== "managed") { + throw new Error("`cwd` can only be set on managed contexts."); + } if (lifecycle) context.lifecycle = lifecycle; else delete context.lifecycle; - if (server.cwd?.trim()) context.cwd = server.cwd.trim(); + if (cwd) context.cwd = cwd; else delete context.cwd; } await saveContextConfig(config); @@ -376,12 +401,13 @@ function deriveManagedServerConfig( try { const parsed = new URL(context.url); - const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined; + const port = derivePort(parsed); if (port && Number.isInteger(port) && port > 0 && port <= 65535) { out.port = port; } - if (parsed.hostname) { - out.host = parsed.hostname; + const host = stripIpv6Brackets(parsed.hostname); + if (host) { + out.host = host; } } catch { // URL validation happens when contexts are saved/loaded; ignore here so a @@ -391,6 +417,27 @@ function deriveManagedServerConfig( return Object.keys(out).length === 0 ? undefined : out; } +/** + * Resolve the effective port for a parsed context URL. An explicit `:port` + * wins; otherwise fall back to the protocol default (80 for http, 443 for + * https) so callers that bind from this struct don't drift from a context URL + * like `http://localhost/api/v1` that already implies a port. + */ +function derivePort(parsed: URL): number | undefined { + if (parsed.port) return Number.parseInt(parsed.port, 10); + if (parsed.protocol === "http:") return 80; + if (parsed.protocol === "https:") return 443; + return undefined; +} + +/** + * `new URL("http://[::1]:8787").hostname` returns `[::1]` with brackets, which + * Node's `server.listen({ host })` rejects. Strip them before exporting HOST. + */ +function stripIpv6Brackets(hostname: string): string { + return hostname.replace(/^\[|\]$/g, ""); +} + function normalizeAndValidateApiUrl(apiUrl: string): string { const normalized = normalizeApiUrl(apiUrl.trim()); if (!normalized) { @@ -456,7 +503,7 @@ function contextToResolvedContext( ): ResolvedContext { return { name, - apiUrl: normalizeApiUrl(context.url), + url: normalizeApiUrl(context.url), source: name === DEFAULT_CONTEXT_NAME ? "default" : "config", }; } diff --git a/packages/cli/src/internal/credentials.ts b/packages/cli/src/internal/credentials.ts index 1589513af..7768a0d8e 100644 --- a/packages/cli/src/internal/credentials.ts +++ b/packages/cli/src/internal/credentials.ts @@ -163,9 +163,9 @@ export async function getToken(contextName?: string): Promise { */ async function tryLocalInit(contextName?: string): Promise { const target = await resolveContext(contextName); - if (!isLoopbackUrl(target.apiUrl)) return null; + if (!isLoopbackUrl(target.url)) return null; try { - const res = await fetch(`${target.apiUrl}/api/local-init`, { + const res = await fetch(`${target.url}/api/local-init`, { method: "POST", headers: { "X-Lobu-Client": "cli" }, }); diff --git a/packages/server/src/utils/__tests__/user-config.test.ts b/packages/server/src/utils/__tests__/user-config.test.ts index e3cf50163..aaf28c1f4 100644 --- a/packages/server/src/utils/__tests__/user-config.test.ts +++ b/packages/server/src/utils/__tests__/user-config.test.ts @@ -116,6 +116,57 @@ describe('loadUserServerConfig', () => { expect(loadUserServerConfig(path)).toBeUndefined(); }); + it('derives the default port for a scheme-only https URL', () => { + const path = writeConfig({ + currentContext: 'prod', + contexts: { + prod: { + url: 'https://example.com/api/v1', + lifecycle: 'managed', + }, + }, + }); + expect(loadUserServerConfig(path)).toEqual({ + lifecycle: 'managed', + port: 443, + host: 'example.com', + }); + }); + + it('derives the default port for a scheme-only http URL', () => { + const path = writeConfig({ + currentContext: 'local', + contexts: { + local: { + url: 'http://localhost/api/v1', + lifecycle: 'managed', + }, + }, + }); + expect(loadUserServerConfig(path)).toEqual({ + lifecycle: 'managed', + port: 80, + host: 'localhost', + }); + }); + + it('strips IPv6 brackets from the derived host', () => { + const path = writeConfig({ + currentContext: 'local', + contexts: { + local: { + url: 'http://[::1]:8787/api/v1', + lifecycle: 'managed', + }, + }, + }); + expect(loadUserServerConfig(path)).toEqual({ + lifecycle: 'managed', + port: 8787, + host: '::1', + }); + }); + it('reads legacy apiUrl + server lifecycle/cwd', () => { const path = writeConfig({ currentContext: 'local', diff --git a/packages/server/src/utils/user-config.ts b/packages/server/src/utils/user-config.ts index 67ed99e66..a4379ef36 100644 --- a/packages/server/src/utils/user-config.ts +++ b/packages/server/src/utils/user-config.ts @@ -83,11 +83,24 @@ function deriveManagedServerConfig(entry: StoredEntry): UserServerConfig | undef if (rawUrl) { try { const parsed = new URL(rawUrl); - const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined; + // An explicit `:port` wins; otherwise fall back to the protocol default + // (80 for http, 443 for https) so a scheme-only context URL like + // `https://example.com/api/v1` doesn't drop the implied port. + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === 'http:' + ? 80 + : parsed.protocol === 'https:' + ? 443 + : undefined; if (port && Number.isInteger(port) && port > 0 && port <= 65535) { out.port = port; } - if (parsed.hostname) out.host = parsed.hostname; + // `new URL("http://[::1]:8787").hostname` keeps the brackets, which + // Node's `httpServer.listen({ host })` rejects with ENOTFOUND — strip + // them before exporting HOST. + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + if (host) out.host = host; } catch { // Ignore malformed hand-edited URLs; context validation lives in the CLI. }