Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions packages/opencode/src/altimate/native/connections/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -172,9 +172,13 @@ function loadFromFile(filePath: string): Record<string, ConnectionConfig> {
}
}

// 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<string, ConnectionConfig> {
const result: Record<string, ConnectionConfig> = {}
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
Expand All @@ -193,14 +197,17 @@ function loadFromEnv(): Record<string, ConnectionConfig> {
}

/** 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

Expand All @@ -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())
Comment thread
anandgupta42 marked this conversation as resolved.
// 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -861,6 +893,10 @@ export function reset(): void {
pending.clear()
loaded = false
censusSent = false
// altimate_change start
injected = false
loadedProjectDir = undefined
// altimate_change end
}

/**
Expand All @@ -872,4 +908,7 @@ export function setConfigs(newConfigs: Record<string, ConnectionConfig>): void {
configs.set(name, config)
}
loaded = true
// altimate_change start
injected = true
Comment thread
anandgupta42 marked this conversation as resolved.
// altimate_change end
}
28 changes: 27 additions & 1 deletion packages/opencode/src/altimate/tools/warehouse-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}<NAME> 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}<NAME> 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" }',
"}",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`To keep the password out of the file, omit it and set ${CONN_ENV_PREFIX}<NAME> 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({}),
Expand All @@ -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(),
}
}

Expand Down
176 changes: 176 additions & 0 deletions packages/opencode/test/altimate/warehouse-list-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>

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)
Comment thread
anandgupta42 marked this conversation as resolved.
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" })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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"])
})
})
Loading