diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index b3f11b7895..1f6195cc9f 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -58,7 +58,7 @@ const SCRATCH_NAME_CONFLICT = // Config file paths // --------------------------------------------------------------------------- -function globalConfigPath(): string { +export function globalConfigPath(): string { // altimate_change start — the same home Global.Path uses, so the test preload's OPENCODE_TEST_HOME keeps the // suite out of the developer's real connections (Bun caches os.homedir(), so changing HOME does not). return path.join(Global.Path.home, ".altimate-code", "connections.json") @@ -93,7 +93,7 @@ function projectRoot(): string { } // altimate_change end -function localConfigPath(): string { +export function localConfigPath(): string { // altimate_change start — resolve against the project, not the launch cwd return path.join(projectRoot(), ".altimate-code", "connections.json") // altimate_change end @@ -172,9 +172,13 @@ function loadFromFile(filePath: string): Record { } } +// altimate_change start — exported so user-facing hints name the variable the loader reads +export const CONN_ENV_PREFIX = "ALTIMATE_CODE_CONN_" +// altimate_change end + function loadFromEnv(): Record { const result: Record = {} - const prefix = "ALTIMATE_CODE_CONN_" + const prefix = CONN_ENV_PREFIX for (const [key, value] of Object.entries(process.env)) { if (!key.startsWith(prefix) || !value) continue @@ -193,14 +197,17 @@ function loadFromEnv(): Record { } /** Load all connection configs. Local overrides global; env overrides both. */ -export function load(): void { +export function load(includeProjectConfig = true): void { configs.clear() // altimate_change start — absolutize store paths against the directory that // declared them, so a later process.chdir() (--dir) cannot move the store. const base = projectRoot() const global = resolveStorePaths(loadFromFile(globalConfigPath()), path.dirname(globalConfigPath())) - const local = resolveStorePaths(loadFromFile(localConfigPath()), base) + // altimate_change start — see ensureLoaded(): a later reload must not pull in another project's file + const local = includeProjectConfig ? resolveStorePaths(loadFromFile(localConfigPath()), base) : {} + if (includeProjectConfig) loadedProjectDir = base + // altimate_change end const env = resolveStorePaths(loadFromEnv(), base) // altimate_change end @@ -217,13 +224,38 @@ export function load(): void { if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) loaded = true + // altimate_change start — real sources were read, so the registry is no longer test-injected + injected = false + // altimate_change end } /** Ensure configs are loaded. */ function ensureLoaded(): void { if (!loaded) load() + // altimate_change start — the registry latches on first load, so a connections file created after an empty + // answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to + // replace or close, and a populated registry never changes under a running session. + else if (configs.size === 0 && !injected) load(projectConfigApplies()) + // altimate_change end } +// altimate_change start — the registry is process-global; this is the project whose local file it was loaded with +let loadedProjectDir: string | undefined + +/** + * Whether the current project's local connections file is the one this registry reads. A server serving several + * directories shares one registry (pre-existing: the first load's project-local file applies process-wide), so a + * later empty-registry reload must not load another project's file into it. + */ +export function projectConfigApplies(): boolean { + return loadedProjectDir === undefined || loadedProjectDir === projectRoot() +} +// altimate_change end + +// altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one +let injected = false +// altimate_change end + // --------------------------------------------------------------------------- // Driver factory // --------------------------------------------------------------------------- @@ -861,6 +893,10 @@ export function reset(): void { pending.clear() loaded = false censusSent = false + // altimate_change start + injected = false + loadedProjectDir = undefined + // altimate_change end } /** @@ -872,4 +908,7 @@ export function setConfigs(newConfigs: Record): void { configs.set(name, config) } loaded = true + // altimate_change start + injected = true + // altimate_change end } diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 21dcdfab46..eb6c2ba66a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -5,6 +5,32 @@ import { Dispatcher } from "../native" import * as Precedence from "../workspace/precedence" // altimate_change end +// altimate_change start — point at the locations the registry actually reads +import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath, projectConfigApplies } from "../native/connections/registry" + +/** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */ +export function noConnectionsMessage(): string { + return [ + "No warehouse connections configured.", + "", + `Preferred: use the warehouse_add tool. It stores passwords and keys in the OS credential store when one is available; otherwise it keeps them out of the file for this session only, so use ${CONN_ENV_PREFIX} to make them persistent.`, + "", + "Alternatively, connections are read from these sources (later ones override earlier ones):", + ` 1. ${globalConfigPath()} (global)`, + ...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []), + ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (set them in the shell before starting altimate-code; a running process cannot see variables exported later)`, + "", + "After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", + "{", + ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', + "}", + `To keep the password out of the file, omit it and set ${CONN_ENV_PREFIX} to the full config as JSON instead.`, + "", + "Do not put connections.json in .opencode/; it is not read from there.", + ].join("\n") +} +// altimate_change end + export const WarehouseListTool = Tool.define("warehouse_list", { description: "List all configured warehouse connections. Shows connection name, type, and database.", parameters: z.object({}), @@ -17,7 +43,7 @@ export const WarehouseListTool = Tool.define("warehouse_list", { return { title: "Warehouses: none configured", metadata: { count: 0 }, - output: "No warehouse connections configured.\n\nTo add a connection, create a connections.json file in .opencode/ with:\n{\n \"my-db\": { \"type\": \"postgres\", \"host\": \"localhost\", \"port\": 5432, \"database\": \"mydb\", \"user\": \"user\", \"password\": \"pass\" }\n}", + output: noConnectionsMessage(), } } diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts new file mode 100644 index 0000000000..2128e69a24 --- /dev/null +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import * as Registry from "../../src/altimate/native/connections/registry" +import { WarehouseListTool, noConnectionsMessage } from "../../src/altimate/tools/warehouse-list" +import { registerAll } from "../../src/altimate/native/connections/register" +import { SessionID, MessageID } from "../../src/session/schema" +import { initTool } from "./tool-fixture" + +function toolCtx() { + return { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } as any +} + +let project: string +let prevCwd: string +let ambientConnEnv: Record + +beforeEach(() => { + // Ambient connection env vars would make the empty-registry assertions depend on the caller's shell. + ambientConnEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith(Registry.CONN_ENV_PREFIX) || value === undefined) continue + ambientConnEnv[key] = value + delete process.env[key] + } + prevCwd = process.cwd() + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-"))) + process.chdir(project) + Registry.reset() + registerAll() +}) + +afterEach(() => { + process.chdir(prevCwd) + Registry.reset() + fs.rmSync(project, { recursive: true, force: true }) + Object.assign(process.env, ambientConnEnv) +}) + +describe("warehouse_list no-connections hint", () => { + test("names the paths and env prefix the registry reads, not .opencode", () => { + const msg = noConnectionsMessage() + expect(msg).toContain(Registry.globalConfigPath()) + expect(msg).toContain(Registry.localConfigPath()) + expect(msg).toContain(Registry.CONN_ENV_PREFIX) + expect(msg).toContain("warehouse_add") + expect(msg).toContain('"type": "postgres"') + expect(msg).not.toContain(".opencode/connections.json") + expect(Registry.localConfigPath()).toBe(path.join(project, ".altimate-code", "connections.json")) + }) + + test("a connections.json written to the project path named by the hint is loaded", () => { + const named = Registry.localConfigPath() + expect(noConnectionsMessage()).toContain(named) + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "hint-db": { type: "postgres", host: "localhost", database: "d" } })) + Registry.load() + expect(Registry.names()).toContain("hint-db") + }) + + test("a connections.json at the legacy .opencode location is not loaded", () => { + fs.mkdirSync(path.join(project, ".opencode")) + fs.writeFileSync( + path.join(project, ".opencode", "connections.json"), + JSON.stringify({ "wrong-db": { type: "postgres", host: "localhost" } }), + ) + Registry.load() + expect(Registry.names()).not.toContain("wrong-db") + }) + + test("the tool lists a connection created after an empty answer, in the same process", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + const first = await tool.execute({}, ctx) + expect(first.output).toContain(Registry.localConfigPath()) + + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "late-db": { type: "postgres", host: "localhost", database: "d" } })) + + const second = await tool.execute({}, ctx) + expect(second.output).toContain("late-db") + // the lookup sql tools use resolves it too + expect(Registry.getConfig("late-db")?.type).toBe("postgres") + }) + + test("an env var set after an empty answer is picked up", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + await tool.execute({}, ctx) + const previous = process.env.ALTIMATE_CODE_CONN_ENVLATE + process.env.ALTIMATE_CODE_CONN_ENVLATE = JSON.stringify({ type: "postgres", host: "h" }) + try { + expect((await tool.execute({}, ctx)).output).toContain("envlate") + } finally { + if (previous === undefined) delete process.env.ALTIMATE_CODE_CONN_ENVLATE + else process.env.ALTIMATE_CODE_CONN_ENVLATE = previous + } + }) + + test("with a connection loaded, editing the file does not replace the configs", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "old" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "new" }, "db-b": { type: "postgres" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + expect(Registry.names()).toEqual(["db-a"]) + }) + + test("a populated registry is not clobbered when the working project changes", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "proj-a": { type: "postgres" } })) + expect(Registry.names()).toEqual(["proj-a"]) + + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) + try { + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual(["proj-a"]) + } finally { + process.chdir(project) + fs.rmSync(other, { recursive: true, force: true }) + } + }) + + test("an empty registry first loaded for one project does not pick up another project's file", () => { + expect(Registry.names()).toEqual([]) + const dirA = project + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) + try { + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual([]) + expect(Registry.projectConfigApplies()).toBe(false) + expect(noConnectionsMessage()).not.toContain(Registry.localConfigPath()) + expect(noConnectionsMessage()).toContain(Registry.globalConfigPath()) + process.chdir(dirA) + expect(Registry.projectConfigApplies()).toBe(true) + // the same project still picks up its own file after an empty answer + fs.mkdirSync(path.join(dirA, ".altimate-code")) + fs.writeFileSync( + path.join(dirA, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-a": { type: "postgres" } }), + ) + expect(Registry.names()).toEqual(["proj-a"]) + } finally { + process.chdir(dirA) + fs.rmSync(other, { recursive: true, force: true }) + } + }) + + test("setConfigs-injected connections are kept", () => { + Registry.setConfigs({ injected: { type: "postgres" } }) + expect(Registry.names()).toEqual(["injected"]) + }) +})