From 22c693b18afa263abc01de166971eaaa07809857 Mon Sep 17 00:00:00 2001 From: suryaiyer95 Date: Tue, 17 Mar 2026 11:21:34 -0700 Subject: [PATCH 1/3] feat: add altimate-backend TUI connect flow with credential validation - Parse url::instance::key from ApiMethod dialog and write ~/.altimate/altimate.json directly - Validate credentials against /dbt/v3/validate-credentials before saving (mirrors altimate-mcp-engine pattern) - Show inline error in dialog on invalid format, bad API key (401), or bad instance name (403) - Register AltimateAuthPlugin to surface "Connect to Altimate" method in /connect dialog - Add altimate-backend CUSTOM_LOADER: file-first, auth-store fallback - Add Filesystem.writeJson helper with mkdir-on-ENOENT and explicit chmod - 47 tests covering parseAltimateKey, saveCredentials, validateCredentials, and TUI round-trip --- docs/docs/getting-started.md | 16 + packages/opencode/src/altimate/api/client.ts | 83 +++- .../opencode/src/altimate/plugin/altimate.ts | 15 + .../cli/cmd/tui/component/dialog-provider.tsx | 43 ++ packages/opencode/src/plugin/index.ts | 7 +- packages/opencode/src/provider/provider.ts | 81 ++++ packages/opencode/src/util/filesystem.ts | 6 + .../opencode/test/altimate/datamate.test.ts | 383 ++++++++++++++++++ 8 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/altimate/plugin/altimate.ts diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 4a0851d447..84951a5979 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -81,6 +81,22 @@ Add a warehouse connection to `.altimate-code/connections.json`. Here's a quick For all warehouse types (Snowflake, BigQuery, Databricks, PostgreSQL, Redshift, DuckDB, MySQL, SQL Server) and advanced options (key-pair auth, ADC, SSH tunneling), see the [Warehouses reference](configure/warehouses.md). +### Connecting to Altimate + +If you have an Altimate platform account, run `/connect` in the TUI, select **Altimate**, and enter your credentials in this format: + +``` +instance-url::instance-name::api-key +``` + +For example: `https://api.getaltimate.com::acme::your-api-key` + +- **Instance URL** — `https://api.myaltimate.com` or `https://api.getaltimate.com` depending on your dashboard domain +- **Instance Name** — the subdomain from your Altimate dashboard URL (e.g. `acme` from `https://acme.app.myaltimate.com`) +- **API Key** — go to **Settings > API Keys** in your Altimate dashboard and click **Copy** + +Credentials are validated against the Altimate API before being saved. If you prefer to configure credentials directly (e.g. for CI or environment variable substitution), you can also create `~/.altimate/altimate.json` manually — if that file exists it takes priority over the TUI-entered credentials. + ## Step 4: Choose an Agent Mode altimate offers specialized agent modes for different workflows: diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 70346d2e39..ca8cde12f6 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -54,15 +54,96 @@ export namespace AltimateApi { return Filesystem.exists(credentialsPath()) } + function resolveEnvVars(obj: unknown): unknown { + if (typeof obj === "string") { + return obj.replace(/\$\{env:([^}]+)\}/g, (_, envVar) => { + const value = process.env[envVar] + if (!value) throw new Error(`Environment variable ${envVar} not found`) + return value + }) + } + if (Array.isArray(obj)) return obj.map(resolveEnvVars) + if (obj && typeof obj === "object") + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveEnvVars(v)])) + return obj + } + export async function getCredentials(): Promise { const p = credentialsPath() if (!(await Filesystem.exists(p))) { throw new Error(`Altimate credentials not found at ${p}`) } - const raw = JSON.parse(await Filesystem.readText(p)) + const raw = resolveEnvVars(JSON.parse(await Filesystem.readText(p))) return AltimateCredentials.parse(raw) } + export function parseAltimateKey(value: string): { + altimateUrl: string + altimateInstanceName: string + altimateApiKey: string + } | null { + const parts = value.trim().split("::") + if (parts.length < 3) return null + const url = parts[0].trim() + const instance = parts[1].trim() + const key = parts.slice(2).join("::").trim() + if (!url || !instance || !key) return null + if (!url.startsWith("http://") && !url.startsWith("https://")) return null + return { altimateUrl: url, altimateInstanceName: instance, altimateApiKey: key } + } + + export async function saveCredentials(creds: { + altimateUrl: string + altimateInstanceName: string + altimateApiKey: string + mcpServerUrl?: string + }): Promise { + await Filesystem.writeJson(credentialsPath(), creds, 0o600) + } + + const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/ + + /** Validates credentials against the Altimate API. + * Mirrors AltimateSettingsHelper.validateSettings from altimate-mcp-engine. */ + export async function validateCredentials(creds: { + altimateUrl: string + altimateInstanceName: string + altimateApiKey: string + }): Promise<{ ok: true } | { ok: false; error: string }> { + if (!VALID_TENANT_REGEX.test(creds.altimateInstanceName)) { + return { + ok: false, + error: + "Invalid instance name (must be lowercase letters, numbers, underscores, hyphens, starting with letter or underscore)", + } + } + try { + const url = `${creds.altimateUrl.replace(/\/+$/, "")}/dbt/v3/validate-credentials` + const res = await fetch(url, { + method: "GET", + headers: { + "x-tenant": creds.altimateInstanceName, + Authorization: `Bearer ${creds.altimateApiKey}`, + "Content-Type": "application/json", + }, + }) + if (res.status === 401) { + const body = await res.text() + return { ok: false, error: `Invalid API key - ${body}` } + } + if (res.status === 403) { + const body = await res.text() + return { ok: false, error: `Invalid instance name - ${body}` } + } + if (!res.ok) { + return { ok: false, error: `Connection failed (${res.status} ${res.statusText})` } + } + return { ok: true } + } catch { + return { ok: false, error: "Could not reach Altimate API" } + } + } + async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { const url = `${creds.altimateUrl}${endpoint}` const res = await fetch(url, { diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts new file mode 100644 index 0000000000..510f0e1de7 --- /dev/null +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -0,0 +1,15 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function AltimateAuthPlugin(_input: PluginInput): Promise { + return { + auth: { + provider: "altimate-backend", + methods: [ + { + type: "api", + label: "Connect to Altimate", + }, + ], + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 3425fba31d..a086bf430c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -13,6 +13,9 @@ import { DialogModel } from "./dialog-model" import { useKeyboard } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" +// altimate_change start — import AltimateApi for direct credential file write +import { AltimateApi } from "../../../../altimate/api/client" +// altimate_change end const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -210,6 +213,9 @@ function ApiMethod(props: ApiMethodProps) { const sdk = useSDK() const sync = useSync() const { theme } = useTheme() + // altimate_change start — altimate-backend: validation error signal + const [validationError, setValidationError] = createSignal(null) + // altimate_change end return ( ), + // altimate_change start — altimate-backend credential format description + "altimate-backend": ( + + + Enter your Altimate credentials in this format: + + + instance-url::instance-name::api-key + + + e.g. https://api.getaltimate.com::mycompany::abc123 + + + {validationError()!} + + + ), + // altimate_change end }[props.providerID] ?? undefined } onConfirm={async (value) => { if (!value) return + // altimate_change start — altimate-backend: validate then write credentials file directly + if (props.providerID === "altimate-backend") { + const parsed = AltimateApi.parseAltimateKey(value) + if (!parsed) { + setValidationError("Invalid format — use: instance-url::instance-name::api-key") + return + } + const validation = await AltimateApi.validateCredentials(parsed) + if (!validation.ok) { + setValidationError(validation.error) + return + } + await AltimateApi.saveCredentials(parsed) + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => ) + return + } + // altimate_change end await sdk.client.auth.set({ providerID: props.providerID, auth: { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 2b61f0eb29..7c8f0dbf18 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -15,6 +15,9 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au // altimate_change start — snowflake cortex plugin import import { SnowflakeCortexAuthPlugin } from "../altimate/plugin/snowflake" // altimate_change end +// altimate_change start — altimate backend auth plugin +import { AltimateAuthPlugin } from "../altimate/plugin/altimate" +// altimate_change end export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -25,8 +28,8 @@ export namespace Plugin { // GitlabAuthPlugin uses a different version of @opencode-ai/plugin (from npm) // vs the workspace version, causing a type mismatch on internal HeyApiClient. // The types are structurally compatible at runtime. - // altimate_change start — snowflake cortex internal plugin - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin] + // altimate_change start — snowflake cortex and altimate backend internal plugins + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin, AltimateAuthPlugin] // altimate_change end const state = Instance.state(async () => { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4650918eab..b63dc4e4c5 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -18,6 +18,7 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" +import { AltimateApi } from "../altimate/api/client" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -181,6 +182,47 @@ export namespace Provider { options: hasKey ? {} : { apiKey: "public" }, } }, + // altimate_change start — Altimate backend provider: ~/.altimate/altimate.json first, auth store (TUI-configured) as fallback + "altimate-backend": async () => { + // Path 1: ~/.altimate/altimate.json (primary — manual file or env-var substitution, never overwritten) + const isConfigured = await AltimateApi.isConfigured() + if (isConfigured) { + try { + const creds = await AltimateApi.getCredentials() + return { + autoload: true, + options: { + baseURL: `${creds.altimateUrl.replace(/\/+$/, "")}/agents/v1`, + apiKey: creds.altimateApiKey, + headers: { + "x-tenant": creds.altimateInstanceName, + }, + }, + } + } catch { + return { autoload: false } + } + } + // Path 2: auth store (populated by TUI entry, file not yet written) + const auth = await Auth.get("altimate-backend" as any) + if (auth?.type === "api") { + const parsed = AltimateApi.parseAltimateKey(auth.key) + if (parsed) { + return { + autoload: true, + options: { + baseURL: `${parsed.altimateUrl.replace(/\/+$/, "")}/agents/v1`, + apiKey: parsed.altimateApiKey, + headers: { + "x-tenant": parsed.altimateInstanceName, + }, + }, + } + } + } + return { autoload: false } + }, + // altimate_change end openai: async () => { return { autoload: false, @@ -973,6 +1015,45 @@ export namespace Provider { } // altimate_change end + // altimate_change start — register altimate-backend as an OpenAI-compatible provider + if (!database["altimate-backend"]) { + const backendModels: Record = { + "altimate-default": { + id: ModelID.make("altimate-default"), + providerID: ProviderID.make("altimate-backend"), + name: "Altimate AI", + family: "openai", + api: { id: "altimate-default", url: "", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 128000 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2025-01-01", + variants: {}, + }, + } + database["altimate-backend"] = { + id: ProviderID.make("altimate-backend"), + name: "Altimate", + source: "custom", + env: [], + options: {}, + models: backendModels, + } + } + // altimate_change end + + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 0f96003383..8cd25209d2 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -55,6 +55,9 @@ export namespace Filesystem { try { if (mode) { await writeFile(p, content, { mode }) + // altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied + await chmod(p, mode) + // altimate_change end } else { await writeFile(p, content) } @@ -63,6 +66,9 @@ export namespace Filesystem { await mkdir(dirname(p), { recursive: true }) if (mode) { await writeFile(p, content, { mode }) + // altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied + await chmod(p, mode) + // altimate_change end } else { await writeFile(p, content) } diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts index 74614efe43..bd05eab26d 100644 --- a/packages/opencode/test/altimate/datamate.test.ts +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -124,6 +124,389 @@ describe("getCredentials", () => { ) await expect(AltimateApi.getCredentials()).rejects.toThrow() }) + + test("resolves ${env:VAR} substitution in all fields", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "${env:TEST_ALT_URL}", + altimateInstanceName: "${env:TEST_ALT_INSTANCE}", + altimateApiKey: "${env:TEST_ALT_KEY}", + }), + ) + process.env.TEST_ALT_URL = "https://api.envtest.com" + process.env.TEST_ALT_INSTANCE = "envtenant" + process.env.TEST_ALT_KEY = "envkey456" + try { + const creds = await AltimateApi.getCredentials() + expect(creds.altimateUrl).toBe("https://api.envtest.com") + expect(creds.altimateInstanceName).toBe("envtenant") + expect(creds.altimateApiKey).toBe("envkey456") + } finally { + delete process.env.TEST_ALT_URL + delete process.env.TEST_ALT_INSTANCE + delete process.env.TEST_ALT_KEY + } + }) + + test("resolves ${env:VAR} mixed with literal text", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.myaltimate.com", + altimateInstanceName: "${env:TEST_ALT_INSTANCE_MIX}", + altimateApiKey: "prefix-${env:TEST_ALT_KEY_MIX}-suffix", + }), + ) + process.env.TEST_ALT_INSTANCE_MIX = "mixedtenant" + process.env.TEST_ALT_KEY_MIX = "secret" + try { + const creds = await AltimateApi.getCredentials() + expect(creds.altimateInstanceName).toBe("mixedtenant") + expect(creds.altimateApiKey).toBe("prefix-secret-suffix") + } finally { + delete process.env.TEST_ALT_INSTANCE_MIX + delete process.env.TEST_ALT_KEY_MIX + } + }) + + test("throws when referenced env var is not set", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.myaltimate.com", + altimateInstanceName: "tenant", + altimateApiKey: "${env:THIS_VAR_DOES_NOT_EXIST_12345}", + }), + ) + delete process.env.THIS_VAR_DOES_NOT_EXIST_12345 + await expect(AltimateApi.getCredentials()).rejects.toThrow( + "Environment variable THIS_VAR_DOES_NOT_EXIST_12345 not found", + ) + }) + + test("leaves literal values unchanged when no substitution syntax present", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.myaltimate.com", + altimateInstanceName: "plaintenant", + altimateApiKey: "plain-key-no-substitution", + }), + ) + const creds = await AltimateApi.getCredentials() + expect(creds.altimateApiKey).toBe("plain-key-no-substitution") + }) + + test("resolves optional mcpServerUrl field via env var", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.myaltimate.com", + altimateInstanceName: "tenant", + altimateApiKey: "key", + mcpServerUrl: "${env:TEST_MCP_URL}", + }), + ) + process.env.TEST_MCP_URL = "https://custom.mcp.example.com/sse" + try { + const creds = await AltimateApi.getCredentials() + expect(creds.mcpServerUrl).toBe("https://custom.mcp.example.com/sse") + } finally { + delete process.env.TEST_MCP_URL + } + }) +}) + +// --------------------------------------------------------------------------- +// parseAltimateKey +// --------------------------------------------------------------------------- + +describe("parseAltimateKey", () => { + test("parses valid 3-part input", () => { + const r = AltimateApi.parseAltimateKey("https://api.getaltimate.com::mycompany::abc123") + expect(r).toEqual({ altimateUrl: "https://api.getaltimate.com", altimateInstanceName: "mycompany", altimateApiKey: "abc123" }) + }) + + test("trims whitespace", () => { + const r = AltimateApi.parseAltimateKey(" https://api.getaltimate.com :: mycompany :: abc123 ") + expect(r?.altimateUrl).toBe("https://api.getaltimate.com") + expect(r?.altimateInstanceName).toBe("mycompany") + expect(r?.altimateApiKey).toBe("abc123") + }) + + test("allows :: in the api key (joins remaining parts)", () => { + const r = AltimateApi.parseAltimateKey("https://api.getaltimate.com::mycompany::key::extra") + expect(r?.altimateApiKey).toBe("key::extra") + }) + + test("returns null for too few parts", () => { + expect(AltimateApi.parseAltimateKey("https://api.getaltimate.com::mycompany")).toBeNull() + }) + + test("returns null for empty url", () => { + expect(AltimateApi.parseAltimateKey("::mycompany::key")).toBeNull() + }) + + test("returns null for empty instance", () => { + expect(AltimateApi.parseAltimateKey("https://api.getaltimate.com::::key")).toBeNull() + }) + + test("returns null for non-http url", () => { + expect(AltimateApi.parseAltimateKey("ftp://api.getaltimate.com::mycompany::key")).toBeNull() + }) + + test("returns null for empty string", () => { + expect(AltimateApi.parseAltimateKey("")).toBeNull() + }) + + test("supports http:// for local dev", () => { + const r = AltimateApi.parseAltimateKey("http://localhost:8000::dev::localkey") + expect(r?.altimateUrl).toBe("http://localhost:8000") + expect(r?.altimateInstanceName).toBe("dev") + expect(r?.altimateApiKey).toBe("localkey") + }) +}) + +// --------------------------------------------------------------------------- +// saveCredentials +// --------------------------------------------------------------------------- + +describe("saveCredentials", () => { + const testHome = path.join(tmpRoot, "save-test") + + beforeEach(async () => { + process.env.OPENCODE_TEST_HOME = testHome + await fsp.mkdir(testHome, { recursive: true }) + }) + + afterEach(async () => { + delete process.env.OPENCODE_TEST_HOME + await fsp.rm(testHome, { recursive: true, force: true }).catch(() => {}) + }) + + test("writes all fields to altimate.json", async () => { + await AltimateApi.saveCredentials({ + altimateUrl: "https://api.save-test.com", + altimateInstanceName: "savetenant", + altimateApiKey: "savekey", + }) + const written = JSON.parse(await fsp.readFile(AltimateApi.credentialsPath(), "utf-8")) + expect(written.altimateUrl).toBe("https://api.save-test.com") + expect(written.altimateInstanceName).toBe("savetenant") + expect(written.altimateApiKey).toBe("savekey") + }) + + test("creates parent directory if missing", async () => { + const dirPath = path.join(testHome, ".altimate") + await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => {}) + await AltimateApi.saveCredentials({ + altimateUrl: "https://api.save-test.com", + altimateInstanceName: "savetenant", + altimateApiKey: "savekey", + }) + expect(await fsp.access(AltimateApi.credentialsPath()).then(() => true).catch(() => false)).toBe(true) + }) + + test("sets file permissions to 0o600", async () => { + await AltimateApi.saveCredentials({ + altimateUrl: "https://api.save-test.com", + altimateInstanceName: "savetenant", + altimateApiKey: "savekey", + }) + const stat = await fsp.stat(AltimateApi.credentialsPath()) + expect(stat.mode & 0o777).toBe(0o600) + }) + + test("writes optional mcpServerUrl when provided", async () => { + await AltimateApi.saveCredentials({ + altimateUrl: "https://api.save-test.com", + altimateInstanceName: "savetenant", + altimateApiKey: "savekey", + mcpServerUrl: "https://custom.mcp.example.com/sse", + }) + const written = JSON.parse(await fsp.readFile(AltimateApi.credentialsPath(), "utf-8")) + expect(written.mcpServerUrl).toBe("https://custom.mcp.example.com/sse") + }) + + test("omits mcpServerUrl field when not provided", async () => { + await AltimateApi.saveCredentials({ + altimateUrl: "https://api.save-test.com", + altimateInstanceName: "savetenant", + altimateApiKey: "savekey", + }) + const written = JSON.parse(await fsp.readFile(AltimateApi.credentialsPath(), "utf-8")) + expect(written.mcpServerUrl).toBeUndefined() + }) +}) + + +// --------------------------------------------------------------------------- +// TUI credential round-trip: parseAltimateKey → saveCredentials → getCredentials +// --------------------------------------------------------------------------- + +describe("TUI credential round-trip", () => { + const testHome = path.join(tmpRoot, "roundtrip-test") + + beforeEach(async () => { + process.env.OPENCODE_TEST_HOME = testHome + await fsp.mkdir(testHome, { recursive: true }) + }) + + afterEach(async () => { + delete process.env.OPENCODE_TEST_HOME + await fsp.rm(testHome, { recursive: true, force: true }).catch(() => {}) + }) + + test("parse → save → getCredentials returns same values", async () => { + const parsed = AltimateApi.parseAltimateKey( + "https://api.getaltimate.com::megatenant::e7ad942d0e64c873074f762f409989a4", + ) + expect(parsed).not.toBeNull() + await AltimateApi.saveCredentials(parsed!) + const creds = await AltimateApi.getCredentials() + expect(creds.altimateUrl).toBe("https://api.getaltimate.com") + expect(creds.altimateInstanceName).toBe("megatenant") + expect(creds.altimateApiKey).toBe("e7ad942d0e64c873074f762f409989a4") + }) + + test("trailing slash in url is preserved through round-trip", async () => { + const parsed = AltimateApi.parseAltimateKey("https://api.getaltimate.com/::tenant::key") + expect(parsed).not.toBeNull() + await AltimateApi.saveCredentials(parsed!) + const creds = await AltimateApi.getCredentials() + expect(creds.altimateUrl).toBe("https://api.getaltimate.com/") + }) + + test("api key containing :: survives round-trip", async () => { + const parsed = AltimateApi.parseAltimateKey("https://api.getaltimate.com::tenant::part1::part2") + expect(parsed).not.toBeNull() + await AltimateApi.saveCredentials(parsed!) + const creds = await AltimateApi.getCredentials() + expect(creds.altimateApiKey).toBe("part1::part2") + }) +}) + +// --------------------------------------------------------------------------- +// validateCredentials — mirrors AltimateSettingsHelper.validateSettings +// --------------------------------------------------------------------------- + +describe("validateCredentials", () => { + const validCreds = { + altimateUrl: "https://api.getaltimate.com", + altimateInstanceName: "mycompany", + altimateApiKey: "abc123", + } + + const originalFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("returns ok:true on 200 response", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch + const result = await AltimateApi.validateCredentials(validCreds) + expect(result).toEqual({ ok: true }) + }) + + test("returns ok:false with 'Invalid API key' message on 401", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ detail: "Invalid API key" }), { + status: 401, + statusText: "Unauthorized", + })) as unknown as typeof fetch + const result = await AltimateApi.validateCredentials(validCreds) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("Invalid API key") + }) + + test("returns ok:false with 'Invalid instance name' message on 403", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ detail: "Invalid instance name" }), { + status: 403, + statusText: "Forbidden", + })) as unknown as typeof fetch + const result = await AltimateApi.validateCredentials(validCreds) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("Invalid instance name") + }) + + test("returns ok:false with status code on other HTTP errors", async () => { + globalThis.fetch = (async () => + new Response("{}", { status: 500, statusText: "Internal Server Error" })) as unknown as typeof fetch + const result = await AltimateApi.validateCredentials(validCreds) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("500") + }) + + test("returns ok:false when network fetch throws", async () => { + globalThis.fetch = (async () => { throw new Error("Network error") }) as unknown as typeof fetch + const result = await AltimateApi.validateCredentials(validCreds) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toBe("Could not reach Altimate API") + }) + + test("returns ok:false for instance name with uppercase letters", async () => { + const result = await AltimateApi.validateCredentials({ ...validCreds, altimateInstanceName: "MyCompany" }) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("Invalid instance name") + }) + + test("returns ok:false for instance name starting with a number", async () => { + const result = await AltimateApi.validateCredentials({ ...validCreds, altimateInstanceName: "1company" }) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("Invalid instance name") + }) + + test("returns ok:false for instance name with spaces", async () => { + const result = await AltimateApi.validateCredentials({ ...validCreds, altimateInstanceName: "my company" }) + expect(result.ok).toBe(false) + expect((result as { ok: false; error: string }).error).toContain("Invalid instance name") + }) + + test("accepts valid instance names with hyphens and underscores", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch + for (const name of ["test-instance", "test_instance", "_test", "a", "test123_name-here"]) { + const result = await AltimateApi.validateCredentials({ ...validCreds, altimateInstanceName: name }) + expect(result.ok).toBe(true) + } + }) + + test("calls the correct endpoint URL with correct headers", async () => { + let capturedUrl = "" + let capturedHeaders: Record = {} + globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(url) + capturedHeaders = Object.fromEntries(new Headers(init?.headers as HeadersInit).entries()) + return new Response(JSON.stringify({ ok: true }), { status: 200 }) + }) as unknown as typeof fetch + await AltimateApi.validateCredentials(validCreds) + expect(capturedUrl).toBe("https://api.getaltimate.com/dbt/v3/validate-credentials") + expect(capturedHeaders["x-tenant"]).toBe("mycompany") + expect(capturedHeaders["authorization"]).toBe("Bearer abc123") + }) + + test("strips trailing slash from url before calling endpoint", async () => { + let capturedUrl = "" + globalThis.fetch = (async (url: RequestInfo | URL) => { + capturedUrl = String(url) + return new Response(JSON.stringify({ ok: true }), { status: 200 }) + }) as unknown as typeof fetch + await AltimateApi.validateCredentials({ ...validCreds, altimateUrl: "https://api.getaltimate.com/" }) + expect(capturedUrl).toBe("https://api.getaltimate.com/dbt/v3/validate-credentials") + }) }) // --------------------------------------------------------------------------- From d7197a80db73b5d4621a79612e649b7102bd2be7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 2 Apr 2026 09:57:55 -0700 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback on Altimate provider PR - `resolveEnvVars`: check `=== undefined` instead of `!value` to allow empty-string env vars - Normalize `altimateUrl` (strip trailing slashes) in both `getCredentials()` and `saveCredentials()` to prevent malformed `//` URLs in `request()` - Include underlying error details in `validateCredentials` catch for easier debugging - Suppress stale `altimate-backend` auth via `Auth.remove()` when the loader cannot build valid options - Wrap `saveCredentials`/`dispose`/`bootstrap` in try-catch in `dialog-provider.tsx` - Document `altimate.json` schema and `${env:VAR_NAME}` substitution in `getting-started.md` - Add `text` language identifier to credential format code block - Update tests for URL normalization and error message changes Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/getting-started.md | 30 ++++++++++++++++++- packages/opencode/src/altimate/api/client.ts | 19 ++++++++---- .../cli/cmd/tui/component/dialog-provider.tsx | 12 +++++--- packages/opencode/src/provider/provider.ts | 4 +++ .../opencode/test/altimate/datamate.test.ts | 6 ++-- 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 84951a5979..a107293431 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -85,7 +85,7 @@ For all warehouse types (Snowflake, BigQuery, Databricks, PostgreSQL, Redshift, If you have an Altimate platform account, run `/connect` in the TUI, select **Altimate**, and enter your credentials in this format: -``` +```text instance-url::instance-name::api-key ``` @@ -97,6 +97,34 @@ For example: `https://api.getaltimate.com::acme::your-api-key` Credentials are validated against the Altimate API before being saved. If you prefer to configure credentials directly (e.g. for CI or environment variable substitution), you can also create `~/.altimate/altimate.json` manually — if that file exists it takes priority over the TUI-entered credentials. +**`altimate.json` schema:** + +```json +{ + "altimateUrl": "https://api.myaltimate.com", + "altimateInstanceName": "acme", + "altimateApiKey": "your-api-key", + "mcpServerUrl": "https://mcpserver.getaltimate.com/sse" +} +``` + +| Field | Required | Description | +|---|---|---| +| `altimateUrl` | Yes | Full base URL of the Altimate API | +| `altimateInstanceName` | Yes | Your tenant/instance identifier | +| `altimateApiKey` | Yes | API key from **Settings > API Keys** | +| `mcpServerUrl` | No | Custom MCP server URL (defaults to the hosted endpoint) | + +You can use `${env:VAR_NAME}` syntax to reference environment variables instead of hardcoding secrets: + +```json +{ + "altimateUrl": "https://api.myaltimate.com", + "altimateInstanceName": "acme", + "altimateApiKey": "${env:ALTIMATE_API_KEY}" +} +``` + ## Step 4: Choose an Agent Mode altimate offers specialized agent modes for different workflows: diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index ca8cde12f6..70c7ac0141 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -58,7 +58,7 @@ export namespace AltimateApi { if (typeof obj === "string") { return obj.replace(/\$\{env:([^}]+)\}/g, (_, envVar) => { const value = process.env[envVar] - if (!value) throw new Error(`Environment variable ${envVar} not found`) + if (value === undefined) throw new Error(`Environment variable ${envVar} not found`) return value }) } @@ -74,7 +74,11 @@ export namespace AltimateApi { throw new Error(`Altimate credentials not found at ${p}`) } const raw = resolveEnvVars(JSON.parse(await Filesystem.readText(p))) - return AltimateCredentials.parse(raw) + const creds = AltimateCredentials.parse(raw) + return { + ...creds, + altimateUrl: creds.altimateUrl.replace(/\/+$/, ""), + } } export function parseAltimateKey(value: string): { @@ -98,7 +102,11 @@ export namespace AltimateApi { altimateApiKey: string mcpServerUrl?: string }): Promise { - await Filesystem.writeJson(credentialsPath(), creds, 0o600) + await Filesystem.writeJson( + credentialsPath(), + { ...creds, altimateUrl: creds.altimateUrl.replace(/\/+$/, "") }, + 0o600, + ) } const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/ @@ -139,8 +147,9 @@ export namespace AltimateApi { return { ok: false, error: `Connection failed (${res.status} ${res.statusText})` } } return { ok: true } - } catch { - return { ok: false, error: "Could not reach Altimate API" } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + return { ok: false, error: `Could not reach Altimate API: ${detail}` } } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index a086bf430c..4653fbe9e7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -279,10 +279,14 @@ function ApiMethod(props: ApiMethodProps) { setValidationError(validation.error) return } - await AltimateApi.saveCredentials(parsed) - await sdk.client.instance.dispose() - await sync.bootstrap() - dialog.replace(() => ) + try { + await AltimateApi.saveCredentials(parsed) + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => ) + } catch (err) { + setValidationError(err instanceof Error ? err.message : "Failed to save credentials") + } return } // altimate_change end diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f78287d905..8d1533a6d1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -200,6 +200,8 @@ export namespace Provider { }, } } catch { + // Suppress stale auth so the generic Auth.all() merge doesn't leave an invalid provider + await Auth.remove(ProviderID.make("altimate-backend")) return { autoload: false } } } @@ -219,6 +221,8 @@ export namespace Provider { }, } } + // Invalid key format — remove stale auth entry + await Auth.remove(ProviderID.make("altimate-backend")) } return { autoload: false } }, diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts index bd05eab26d..ef3f30be1d 100644 --- a/packages/opencode/test/altimate/datamate.test.ts +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -380,12 +380,12 @@ describe("TUI credential round-trip", () => { expect(creds.altimateApiKey).toBe("e7ad942d0e64c873074f762f409989a4") }) - test("trailing slash in url is preserved through round-trip", async () => { + test("trailing slash in url is stripped through round-trip", async () => { const parsed = AltimateApi.parseAltimateKey("https://api.getaltimate.com/::tenant::key") expect(parsed).not.toBeNull() await AltimateApi.saveCredentials(parsed!) const creds = await AltimateApi.getCredentials() - expect(creds.altimateUrl).toBe("https://api.getaltimate.com/") + expect(creds.altimateUrl).toBe("https://api.getaltimate.com") }) test("api key containing :: survives round-trip", async () => { @@ -454,7 +454,7 @@ describe("validateCredentials", () => { globalThis.fetch = (async () => { throw new Error("Network error") }) as unknown as typeof fetch const result = await AltimateApi.validateCredentials(validCreds) expect(result.ok).toBe(false) - expect((result as { ok: false; error: string }).error).toBe("Could not reach Altimate API") + expect((result as { ok: false; error: string }).error).toContain("Could not reach Altimate API") }) test("returns ok:false for instance name with uppercase letters", async () => { From 3c5450b48a274bebd75f43ecf795854bf53261e2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 2 Apr 2026 11:22:52 -0700 Subject: [PATCH 3/3] fix: guard `Auth.remove()` calls and add empty-string env var test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `.catch(() => {})` to `Auth.remove()` in provider.ts to prevent a storage error from crashing all provider initialization - Add test verifying that `${env:VAR}` resolves to `""` when the env var is set to an empty string (covers the `!value` → `=== undefined` change) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/provider/provider.ts | 4 ++-- .../opencode/test/altimate/datamate.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8d1533a6d1..9e81a4ff38 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -201,7 +201,7 @@ export namespace Provider { } } catch { // Suppress stale auth so the generic Auth.all() merge doesn't leave an invalid provider - await Auth.remove(ProviderID.make("altimate-backend")) + await Auth.remove(ProviderID.make("altimate-backend")).catch(() => {}) return { autoload: false } } } @@ -222,7 +222,7 @@ export namespace Provider { } } // Invalid key format — remove stale auth entry - await Auth.remove(ProviderID.make("altimate-backend")) + await Auth.remove(ProviderID.make("altimate-backend")).catch(() => {}) } return { autoload: false } }, diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts index ef3f30be1d..36fae2eb16 100644 --- a/packages/opencode/test/altimate/datamate.test.ts +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -191,6 +191,26 @@ describe("getCredentials", () => { ) }) + test("resolves empty-string env var without throwing", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.myaltimate.com", + altimateInstanceName: "${env:TEST_EMPTY_VAR}", + altimateApiKey: "key", + }), + ) + process.env.TEST_EMPTY_VAR = "" + try { + const creds = await AltimateApi.getCredentials() + expect(creds.altimateInstanceName).toBe("") + } finally { + delete process.env.TEST_EMPTY_VAR + } + }) + test("leaves literal values unchanged when no substitution syntax present", async () => { const altDir = path.join(testHome, ".altimate") await fsp.mkdir(altDir, { recursive: true })