From b4b402baf4a5cade5b3fb9f73144d9d3ba0f967b Mon Sep 17 00:00:00 2001 From: Michiel De Smet Date: Wed, 25 Mar 2026 11:49:07 -0700 Subject: [PATCH 1/5] feat(dbt-tools): auto-discover config, expose ALTIMATE_CODE_* env vars for vscode integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.ts: add findProjectRoot() and discoverPython() (exported), update read() to auto-discover projectRoot and pythonPath at runtime when no config file exists — removes requirement to run `altimate-dbt init` before using any command - discoverPython() prioritises ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server) over project-local venvs; tries python3 before python in each candidate location - dbt-resolve.ts: add tier 2 (ALTIMATE_CODE_PYTHON_PATH sibling) and tier 6 (ALTIMATE_CODE_VIRTUAL_ENV) so the dbt binary is found when altimate serve is spawned with a vscode-activated Python environment - init.ts: remove duplicated find()/python() helpers; import from config.ts - tests: add config.test.ts coverage for auto-discovery, findProjectRoot, discoverPython; add dbt-resolve.test.ts scenarios for ALTIMATE_CODE_PYTHON_PATH and ALTIMATE_CODE_VIRTUAL_ENV priority Co-Authored-By: Claude Sonnet 4.6 --- packages/dbt-tools/src/commands/init.ts | 61 +------- packages/dbt-tools/src/config.ts | 82 ++++++++++- packages/dbt-tools/src/dbt-resolve.ts | 43 ++++-- packages/dbt-tools/test/config.test.ts | 149 +++++++++++++++++++- packages/dbt-tools/test/dbt-resolve.test.ts | 100 +++++++++++++ 5 files changed, 360 insertions(+), 75 deletions(-) diff --git a/packages/dbt-tools/src/commands/init.ts b/packages/dbt-tools/src/commands/init.ts index 28a2eed10f..34eeb8e69d 100644 --- a/packages/dbt-tools/src/commands/init.ts +++ b/packages/dbt-tools/src/commands/init.ts @@ -1,74 +1,21 @@ -import { join, resolve } from "path" +import { resolve, join } from "path" import { existsSync } from "fs" -import { execFileSync } from "child_process" -import { write, type Config } from "../config" +import { write, findProjectRoot, discoverPython, type Config } from "../config" import { all } from "../check" -function find(start: string): string | null { - let dir = resolve(start) - while (true) { - if (existsSync(join(dir, "dbt_project.yml"))) return dir - const parent = resolve(dir, "..") - if (parent === dir) return null - dir = parent - } -} - -/** - * Discover the Python binary, checking multiple environment managers. - * - * Priority: - * 1. Project-local .venv/bin/python (uv, pdm, venv, poetry in-project) - * 2. VIRTUAL_ENV/bin/python (activated venv) - * 3. CONDA_PREFIX/bin/python (conda) - * 4. `which python3` / `which python` (system PATH) - * 5. Fallback "python3" (hope for the best) - */ -function python(projectRoot?: string): string { - // Check project-local venvs first (most reliable for dbt projects) - if (projectRoot) { - for (const venvDir of [".venv", "venv", "env"]) { - const py = join(projectRoot, venvDir, "bin", "python") - if (existsSync(py)) return py - } - } - - // Check VIRTUAL_ENV (set by activate scripts) - const virtualEnv = process.env.VIRTUAL_ENV - if (virtualEnv) { - const py = join(virtualEnv, "bin", "python") - if (existsSync(py)) return py - } - - // Check CONDA_PREFIX (set by conda activate) - const condaPrefix = process.env.CONDA_PREFIX - if (condaPrefix) { - const py = join(condaPrefix, "bin", "python") - if (existsSync(py)) return py - } - - // Fall back to PATH-based discovery - for (const cmd of ["python3", "python"]) { - try { - return execFileSync("which", [cmd], { encoding: "utf-8" }).trim() - } catch {} - } - return "python3" -} - export async function init(args: string[]) { const idx = args.indexOf("--project-root") const root = idx >= 0 ? args[idx + 1] : undefined const pidx = args.indexOf("--python-path") const py = pidx >= 0 ? args[pidx + 1] : undefined - const project = root ? resolve(root) : find(process.cwd()) + const project = root ? resolve(root) : findProjectRoot(process.cwd()) if (!project) return { error: "No dbt_project.yml found. Use --project-root to specify." } if (!existsSync(join(project, "dbt_project.yml"))) return { error: `No dbt_project.yml in ${project}` } const cfg: Config = { projectRoot: project, - pythonPath: py ?? python(project), + pythonPath: py ?? discoverPython(project), dbtIntegration: "corecommand", queryLimit: 500, } diff --git a/packages/dbt-tools/src/config.ts b/packages/dbt-tools/src/config.ts index 07fdc0e3c9..6db3e54ab2 100644 --- a/packages/dbt-tools/src/config.ts +++ b/packages/dbt-tools/src/config.ts @@ -1,7 +1,8 @@ import { homedir } from "os" -import { join } from "path" +import { join, resolve } from "path" import { readFile, writeFile, mkdir } from "fs/promises" import { existsSync } from "fs" +import { execFileSync } from "child_process" type Config = { projectRoot: string @@ -18,11 +19,84 @@ function configPath() { return join(configDir(), "dbt.json") } +/** + * Walk up from `start` to find the nearest directory containing dbt_project.yml. + * Returns null if none found. + */ +export function findProjectRoot(start = process.cwd()): string | null { + let dir = resolve(start) + while (true) { + if (existsSync(join(dir, "dbt_project.yml"))) return dir + const parent = resolve(dir, "..") + if (parent === dir) return null + dir = parent + } +} + +/** + * Discover the Python binary for a given project root. + * Priority: ALTIMATE_CODE_VIRTUAL_ENV → project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which python3 + */ +export function discoverPython(projectRoot: string): string { + // ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server — explicit user selection wins) + const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + if (altVenv) { + for (const bin of ["python3", "python"]) { + const py = join(altVenv, "bin", bin) + if (existsSync(py)) return py + } + } + + // Project-local venvs (uv, pdm, venv, poetry in-project, rye) + for (const venvDir of [".venv", "venv", "env"]) { + for (const bin of ["python3", "python"]) { + const py = join(projectRoot, venvDir, "bin", bin) + if (existsSync(py)) return py + } + } + + // VIRTUAL_ENV (set by activate scripts) + const virtualEnv = process.env.VIRTUAL_ENV + if (virtualEnv) { + for (const bin of ["python3", "python"]) { + const py = join(virtualEnv, "bin", bin) + if (existsSync(py)) return py + } + } + + // CONDA_PREFIX + const condaPrefix = process.env.CONDA_PREFIX + if (condaPrefix) { + for (const bin of ["python3", "python"]) { + const py = join(condaPrefix, "bin", bin) + if (existsSync(py)) return py + } + } + + // PATH-based discovery + for (const cmd of ["python3", "python"]) { + try { + return execFileSync("which", [cmd], { encoding: "utf-8" }).trim() + } catch {} + } + return "python3" +} + async function read(): Promise { const p = configPath() - if (!existsSync(p)) return null - const raw = await readFile(p, "utf-8") - return JSON.parse(raw) as Config + if (existsSync(p)) { + const raw = await readFile(p, "utf-8") + return JSON.parse(raw) as Config + } + // No config file — auto-discover from cwd so `altimate-dbt init` isn't required + const projectRoot = findProjectRoot() + if (!projectRoot) return null + return { + projectRoot, + pythonPath: discoverPython(projectRoot), + dbtIntegration: "corecommand", + queryLimit: 500, + } } async function write(cfg: Config) { diff --git a/packages/dbt-tools/src/dbt-resolve.ts b/packages/dbt-tools/src/dbt-resolve.ts index 9eb0ea46e8..3446a7e76b 100644 --- a/packages/dbt-tools/src/dbt-resolve.ts +++ b/packages/dbt-tools/src/dbt-resolve.ts @@ -43,13 +43,15 @@ export interface ResolvedDbt { * * Priority: * 1. ALTIMATE_DBT_PATH env var (explicit user override) - * 2. Sibling of configured pythonPath (same venv/bin) - * 3. Project-local .venv/bin/dbt (uv, pdm, venv, rye, poetry in-project) - * 4. CONDA_PREFIX/bin/dbt (conda environments) - * 5. VIRTUAL_ENV/bin/dbt (activated venv) - * 6. Pyenv real path resolution (follow shims) - * 7. `which dbt` on current PATH - * 8. Common known locations (~/.local/bin/dbt for pipx, etc.) + * 2. Sibling of ALTIMATE_CODE_PYTHON_PATH (set by vscode-altimate-mcp-server) + * 3. Sibling of configured pythonPath (same venv/bin) + * 4. Project-local .venv/bin/dbt (uv, pdm, venv, rye, poetry in-project) + * 5. CONDA_PREFIX/bin/dbt (conda environments) + * 6. ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt (set by vscode-altimate-mcp-server) + * 7. VIRTUAL_ENV/bin/dbt (activated venv) + * 8. Pyenv real path resolution (follow shims) + * 9. `which dbt` on current PATH + * 10. Common known locations (~/.local/bin/dbt for pipx, etc.) * * Each candidate is validated by checking it exists and is executable. */ @@ -62,7 +64,14 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD candidates.push({ path: envOverride, source: "ALTIMATE_DBT_PATH env var" }) } - // 2. Sibling of configured pythonPath (most common: venv, conda, pyenv real path) + // 2. Sibling of ALTIMATE_CODE_PYTHON_PATH (injected by vscode-altimate-mcp-server) + const altPython = process.env.ALTIMATE_CODE_PYTHON_PATH + if (altPython) { + const binDir = dirname(altPython) + candidates.push({ path: join(binDir, "dbt"), source: "sibling of ALTIMATE_CODE_PYTHON_PATH", binDir }) + } + + // 3. Sibling of configured pythonPath (most common: venv, conda, pyenv real path) if (pythonPath && existsSync(pythonPath)) { const binDir = dirname(pythonPath) const siblingDbt = join(binDir, "dbt") @@ -87,7 +96,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } } - // 4. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) + // 5. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) const condaPrefix = process.env.CONDA_PREFIX if (condaPrefix) { candidates.push({ @@ -97,7 +106,17 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD }) } - // 5. VIRTUAL_ENV (set by venv/virtualenv activate scripts) + // 6. ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server, avoids conflicts with user's VIRTUAL_ENV) + const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + if (altVenv) { + candidates.push({ + path: join(altVenv, "bin", "dbt"), + source: `ALTIMATE_CODE_VIRTUAL_ENV (${altVenv})`, + binDir: join(altVenv, "bin"), + }) + } + + // 7. VIRTUAL_ENV (set by venv/virtualenv activate scripts) const virtualEnv = process.env.VIRTUAL_ENV if (virtualEnv) { candidates.push({ @@ -110,7 +129,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD // Helper: current process env (for subprocess calls that need to inherit it) const currentEnv = { ...process.env } - // 6. Pyenv: resolve through shim to real binary + // 8. Pyenv: resolve through shim to real binary const pyenvRoot = process.env.PYENV_ROOT ?? join(process.env.HOME ?? "", ".pyenv") if (existsSync(join(pyenvRoot, "shims", "dbt"))) { try { @@ -128,7 +147,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } } - // 7. asdf/mise shim resolution + // 9. asdf/mise shim resolution const asdfDataDir = process.env.ASDF_DATA_DIR ?? join(process.env.HOME ?? "", ".asdf") if (existsSync(join(asdfDataDir, "shims", "dbt"))) { try { diff --git a/packages/dbt-tools/test/config.test.ts b/packages/dbt-tools/test/config.test.ts index 2689375e26..5b93caf611 100644 --- a/packages/dbt-tools/test/config.test.ts +++ b/packages/dbt-tools/test/config.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { join } from "path" -import { mkdtemp, rm } from "fs/promises" -import { tmpdir, homedir } from "os" +import { mkdtemp, rm, mkdir, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { realpathSync } from "fs" describe("config", () => { let dir: string @@ -51,4 +52,148 @@ describe("config", () => { await write(cfg) expect(existsSync(join(dir, ".altimate-code", "dbt.json"))).toBe(true) }) + + test("read auto-discovers from cwd when no config file exists", async () => { + // Create a fake dbt project in the temp dir + await writeFile(join(dir, "dbt_project.yml"), "name: test") + // Create a fake python3 binary so discoverPython finds it + const binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + const origCwd = process.cwd() + process.chdir(dir) + try { + // Re-import to get fresh module state + const { read } = await import("../src/config") + const result = await read() + expect(result).not.toBeNull() + expect(realpathSync(result!.projectRoot)).toBe(realpathSync(dir)) + expect(result!.dbtIntegration).toBe("corecommand") + expect(result!.queryLimit).toBe(500) + } finally { + process.chdir(origCwd) + } + }) + + test("read returns null when no config file and no dbt_project.yml in cwd", async () => { + // dir has no dbt_project.yml and HOME has no config file + const origCwd = process.cwd() + process.chdir(dir) + try { + const { read } = await import("../src/config") + const result = await read() + expect(result).toBeNull() + } finally { + process.chdir(origCwd) + } + }) +}) + +// --------------------------------------------------------------------------- +// findProjectRoot +// --------------------------------------------------------------------------- +describe("findProjectRoot", () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dbt-find-root-")) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + test("returns directory containing dbt_project.yml", async () => { + const { findProjectRoot } = await import("../src/config") + await writeFile(join(dir, "dbt_project.yml"), "name: test") + expect(findProjectRoot(dir)).toBe(dir) + }) + + test("walks up from a subdirectory", async () => { + const { findProjectRoot } = await import("../src/config") + await writeFile(join(dir, "dbt_project.yml"), "name: test") + const sub = join(dir, "models", "staging") + await mkdir(sub, { recursive: true }) + expect(findProjectRoot(sub)).toBe(dir) + }) + + test("returns null when no dbt_project.yml found", async () => { + const { findProjectRoot } = await import("../src/config") + expect(findProjectRoot(dir)).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// discoverPython +// --------------------------------------------------------------------------- +describe("discoverPython", () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dbt-discover-python-")) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + test("ALTIMATE_CODE_VIRTUAL_ENV takes priority over project-local .venv", async () => { + const { discoverPython } = await import("../src/config") + + const altBin = join(dir, "alt-venv", "bin") + await mkdir(altBin, { recursive: true }) + await writeFile(join(altBin, "python3"), "#!/bin/sh") + + const localBin = join(dir, "project", ".venv", "bin") + await mkdir(localBin, { recursive: true }) + await writeFile(join(localBin, "python3"), "#!/bin/sh") + + const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV + process.env.ALTIMATE_CODE_VIRTUAL_ENV = join(dir, "alt-venv") + try { + const result = discoverPython(join(dir, "project")) + expect(result).toBe(join(altBin, "python3")) + } finally { + if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig + else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + } + }) + + test("falls back to project-local .venv/bin/python3", async () => { + const { discoverPython } = await import("../src/config") + + const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV + delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + + const binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + try { + const result = discoverPython(dir) + expect(result).toBe(join(binDir, "python3")) + } finally { + if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig + } + }) + + test("tries python3 before python in each location", async () => { + const { discoverPython } = await import("../src/config") + + const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV + delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + + const binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + // Only create python3, not python + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + try { + const result = discoverPython(dir) + expect(result).toBe(join(binDir, "python3")) + } finally { + if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig + } + }) }) diff --git a/packages/dbt-tools/test/dbt-resolve.test.ts b/packages/dbt-tools/test/dbt-resolve.test.ts index ec93cd297d..cbd2df38f8 100644 --- a/packages/dbt-tools/test/dbt-resolve.test.ts +++ b/packages/dbt-tools/test/dbt-resolve.test.ts @@ -217,6 +217,106 @@ describe("poetry (in-project)", () => { }) }) +// --------------------------------------------------------------------------- +// Scenario: ALTIMATE_CODE_PYTHON_PATH (injected by vscode-altimate-mcp-server) +// --------------------------------------------------------------------------- +describe("ALTIMATE_CODE_PYTHON_PATH", () => { + test("resolves dbt as sibling of ALTIMATE_CODE_PYTHON_PATH", () => { + const venvBin = join(tempDir, "vscode-venv", "bin") + mkdirSync(venvBin, { recursive: true }) + fakeDbt(venvBin) + + const origVal = process.env.ALTIMATE_CODE_PYTHON_PATH + process.env.ALTIMATE_CODE_PYTHON_PATH = join(venvBin, "python3") + + try { + const result = resolveDbt(undefined, undefined) + expect(result.path).toBe(join(venvBin, "dbt")) + expect(result.source).toContain("ALTIMATE_CODE_PYTHON_PATH") + } finally { + if (origVal !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origVal + else delete process.env.ALTIMATE_CODE_PYTHON_PATH + } + }) + + test("ALTIMATE_CODE_PYTHON_PATH loses to ALTIMATE_DBT_PATH", () => { + const venvBin = join(tempDir, "vscode-venv", "bin") + mkdirSync(venvBin, { recursive: true }) + fakeDbt(venvBin) + + const customDbt = join(tempDir, "custom-dbt") + writeFileSync(customDbt, "#!/bin/sh") + chmodSync(customDbt, 0o755) + + const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH + const origDbt = process.env.ALTIMATE_DBT_PATH + process.env.ALTIMATE_CODE_PYTHON_PATH = join(venvBin, "python3") + process.env.ALTIMATE_DBT_PATH = customDbt + + try { + const result = resolveDbt(undefined, undefined) + expect(result.path).toBe(customDbt) + expect(result.source).toContain("ALTIMATE_DBT_PATH") + } finally { + if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython + else delete process.env.ALTIMATE_CODE_PYTHON_PATH + if (origDbt !== undefined) process.env.ALTIMATE_DBT_PATH = origDbt + else delete process.env.ALTIMATE_DBT_PATH + } + }) +}) + +// --------------------------------------------------------------------------- +// Scenario: ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server) +// --------------------------------------------------------------------------- +describe("ALTIMATE_CODE_VIRTUAL_ENV", () => { + test("resolves dbt from ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt", () => { + const venvDir = join(tempDir, "vscode-venv") + const binDir = join(venvDir, "bin") + mkdirSync(binDir, { recursive: true }) + fakeDbt(binDir) + + const origVal = process.env.ALTIMATE_CODE_VIRTUAL_ENV + process.env.ALTIMATE_CODE_VIRTUAL_ENV = venvDir + + try { + const result = resolveDbt(undefined, undefined) + expect(result.path).toBe(join(binDir, "dbt")) + expect(result.source).toContain("ALTIMATE_CODE_VIRTUAL_ENV") + } finally { + if (origVal !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVal + else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + } + }) + + test("ALTIMATE_CODE_VIRTUAL_ENV loses to ALTIMATE_CODE_PYTHON_PATH sibling", () => { + const pythonBin = join(tempDir, "python-venv", "bin") + mkdirSync(pythonBin, { recursive: true }) + const pythonDbt = fakeDbt(pythonBin) + + const venvDir = join(tempDir, "vscode-venv") + const venvBin = join(venvDir, "bin") + mkdirSync(venvBin, { recursive: true }) + fakeDbt(venvBin) + + const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH + const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + process.env.ALTIMATE_CODE_PYTHON_PATH = join(pythonBin, "python3") + process.env.ALTIMATE_CODE_VIRTUAL_ENV = venvDir + + try { + const result = resolveDbt(undefined, undefined) + expect(result.path).toBe(pythonDbt) + expect(result.source).toContain("ALTIMATE_CODE_PYTHON_PATH") + } finally { + if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython + else delete process.env.ALTIMATE_CODE_PYTHON_PATH + if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv + else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + } + }) +}) + // --------------------------------------------------------------------------- // Scenario 8: ALTIMATE_DBT_PATH override // --------------------------------------------------------------------------- From fdb8519a1a28dc0800c85828f18ea3c34dd9f8ba Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 25 Mar 2026 15:35:23 -0700 Subject: [PATCH 2/5] fix(dbt-tools): address code review findings for config auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `ALTIMATE_CODE_PYTHON_PATH` as highest priority in `discoverPython()` so VS Code's explicit Python selection is not silently ignored - Align `ALTIMATE_CODE_VIRTUAL_ENV` priority: move to tier 4 in `resolveDbt()` (before project-local `.venv`) to match `discoverPython()` priority and prevent Python/dbt resolving from different environments - Add `JSON.parse` error handling in `read()` — malformed config file now falls back to auto-discovery instead of crashing - Fix stale inline comment numbering in `dbt-resolve.ts` (1-11) - Fix environment-dependent `read returns null` test by setting CWD to temp dir - Add tests: `ALTIMATE_CODE_PYTHON_PATH` priority, malformed config fallback - Run prettier on `dbt-resolve.ts` and `dbt-resolve.test.ts` Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/dbt-tools/src/config.ts | 14 +++- packages/dbt-tools/src/dbt-resolve.ts | 49 +++++++------ packages/dbt-tools/test/config.test.ts | 77 +++++++++++++++++++-- packages/dbt-tools/test/dbt-resolve.test.ts | 8 ++- 4 files changed, 113 insertions(+), 35 deletions(-) diff --git a/packages/dbt-tools/src/config.ts b/packages/dbt-tools/src/config.ts index 6db3e54ab2..ae796d6c56 100644 --- a/packages/dbt-tools/src/config.ts +++ b/packages/dbt-tools/src/config.ts @@ -35,9 +35,13 @@ export function findProjectRoot(start = process.cwd()): string | null { /** * Discover the Python binary for a given project root. - * Priority: ALTIMATE_CODE_VIRTUAL_ENV → project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which python3 + * Priority: ALTIMATE_CODE_PYTHON_PATH → ALTIMATE_CODE_VIRTUAL_ENV → project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which python3 */ export function discoverPython(projectRoot: string): string { + // ALTIMATE_CODE_PYTHON_PATH (explicit selection from vscode-altimate-mcp-server — highest priority) + const altPython = process.env.ALTIMATE_CODE_PYTHON_PATH + if (altPython && existsSync(altPython)) return altPython + // ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server — explicit user selection wins) const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV if (altVenv) { @@ -85,8 +89,12 @@ export function discoverPython(projectRoot: string): string { async function read(): Promise { const p = configPath() if (existsSync(p)) { - const raw = await readFile(p, "utf-8") - return JSON.parse(raw) as Config + try { + const raw = await readFile(p, "utf-8") + return JSON.parse(raw) as Config + } catch { + // Malformed config — fall through to auto-discovery + } } // No config file — auto-discover from cwd so `altimate-dbt init` isn't required const projectRoot = findProjectRoot() diff --git a/packages/dbt-tools/src/dbt-resolve.ts b/packages/dbt-tools/src/dbt-resolve.ts index 3446a7e76b..e5a874c9e3 100644 --- a/packages/dbt-tools/src/dbt-resolve.ts +++ b/packages/dbt-tools/src/dbt-resolve.ts @@ -45,13 +45,14 @@ export interface ResolvedDbt { * 1. ALTIMATE_DBT_PATH env var (explicit user override) * 2. Sibling of ALTIMATE_CODE_PYTHON_PATH (set by vscode-altimate-mcp-server) * 3. Sibling of configured pythonPath (same venv/bin) - * 4. Project-local .venv/bin/dbt (uv, pdm, venv, rye, poetry in-project) - * 5. CONDA_PREFIX/bin/dbt (conda environments) - * 6. ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt (set by vscode-altimate-mcp-server) + * 4. ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt (set by vscode-altimate-mcp-server) + * 5. Project-local .venv/bin/dbt (uv, pdm, venv, rye, poetry in-project) + * 6. CONDA_PREFIX/bin/dbt (conda environments) * 7. VIRTUAL_ENV/bin/dbt (activated venv) * 8. Pyenv real path resolution (follow shims) - * 9. `which dbt` on current PATH - * 10. Common known locations (~/.local/bin/dbt for pipx, etc.) + * 9. asdf/mise shim resolution + * 10. `which dbt` on current PATH + * 11. Common known locations (~/.local/bin/dbt for pipx, etc.) * * Each candidate is validated by checking it exists and is executable. */ @@ -88,15 +89,29 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } catch {} } - // 3. Project-local .venv/bin/dbt (uv, pdm, venv, poetry in-project, rye) + // 4. ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server, avoids conflicts with user's VIRTUAL_ENV) + const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + if (altVenv) { + candidates.push({ + path: join(altVenv, "bin", "dbt"), + source: `ALTIMATE_CODE_VIRTUAL_ENV (${altVenv})`, + binDir: join(altVenv, "bin"), + }) + } + + // 5. Project-local .venv/bin/dbt (uv, pdm, venv, poetry in-project, rye) if (projectRoot) { for (const venvDir of [".venv", "venv", "env"]) { const localDbt = join(projectRoot, venvDir, "bin", "dbt") - candidates.push({ path: localDbt, source: `${venvDir}/ in project root`, binDir: join(projectRoot, venvDir, "bin") }) + candidates.push({ + path: localDbt, + source: `${venvDir}/ in project root`, + binDir: join(projectRoot, venvDir, "bin"), + }) } } - // 5. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) + // 6. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) const condaPrefix = process.env.CONDA_PREFIX if (condaPrefix) { candidates.push({ @@ -106,16 +121,6 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD }) } - // 6. ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server, avoids conflicts with user's VIRTUAL_ENV) - const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - if (altVenv) { - candidates.push({ - path: join(altVenv, "bin", "dbt"), - source: `ALTIMATE_CODE_VIRTUAL_ENV (${altVenv})`, - binDir: join(altVenv, "bin"), - }) - } - // 7. VIRTUAL_ENV (set by venv/virtualenv activate scripts) const virtualEnv = process.env.VIRTUAL_ENV if (virtualEnv) { @@ -162,7 +167,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } catch {} } - // 8. `which dbt` on current PATH (catches pipx ~/.local/bin, system pip, homebrew, etc.) + // 10. `which dbt` on current PATH (catches pipx ~/.local/bin, system pip, homebrew, etc.) try { const whichDbt = execFileSync("which", ["dbt"], { encoding: "utf-8", @@ -174,7 +179,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } } catch {} - // 9. Common known locations (last resort) + // 11. Common known locations (last resort) const home = process.env.HOME ?? "" const knownPaths = [ { path: join(home, ".local", "bin", "dbt"), source: "~/.local/bin/dbt (pipx/user pip)" }, @@ -202,9 +207,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD */ export function validateDbt(resolved: ResolvedDbt): { version: string; isFusion: boolean } | null { try { - const env = resolved.binDir - ? { ...process.env, PATH: `${resolved.binDir}:${process.env.PATH}` } - : process.env + const env = resolved.binDir ? { ...process.env, PATH: `${resolved.binDir}:${process.env.PATH}` } : process.env const out = execFileSync(resolved.path, ["--version"], { encoding: "utf-8", diff --git a/packages/dbt-tools/test/config.test.ts b/packages/dbt-tools/test/config.test.ts index 5b93caf611..cbeab2b9bc 100644 --- a/packages/dbt-tools/test/config.test.ts +++ b/packages/dbt-tools/test/config.test.ts @@ -20,9 +20,15 @@ describe("config", () => { }) test("read returns null for missing file", async () => { - const { read } = await import("../src/config") - const result = await read() - expect(result).toBeNull() + const origCwd = process.cwd() + process.chdir(dir) + try { + const { read } = await import("../src/config") + const result = await read() + expect(result).toBeNull() + } finally { + process.chdir(origCwd) + } }) test("write and read round-trip", async () => { @@ -76,6 +82,31 @@ describe("config", () => { } }) + test("read falls back to auto-discovery on malformed config file", async () => { + const { read } = await import("../src/config") + // Write a malformed JSON config file + const configDir = join(dir, ".altimate-code") + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, "dbt.json"), "{ invalid json !!!") + + // Create a dbt project so auto-discovery has something to find + await writeFile(join(dir, "dbt_project.yml"), "name: test") + const binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + const origCwd = process.cwd() + process.chdir(dir) + try { + const result = await read() + // Should fall through to auto-discovery instead of crashing + expect(result).not.toBeNull() + expect(result!.dbtIntegration).toBe("corecommand") + } finally { + process.chdir(origCwd) + } + }) + test("read returns null when no config file and no dbt_project.yml in cwd", async () => { // dir has no dbt_project.yml and HOME has no config file const origCwd = process.cwd() @@ -138,6 +169,32 @@ describe("discoverPython", () => { await rm(dir, { recursive: true, force: true }) }) + test("ALTIMATE_CODE_PYTHON_PATH takes highest priority", async () => { + const { discoverPython } = await import("../src/config") + + const altPythonBin = join(dir, "alt-python", "bin") + await mkdir(altPythonBin, { recursive: true }) + await writeFile(join(altPythonBin, "python3"), "#!/bin/sh") + + const localBin = join(dir, "project", ".venv", "bin") + await mkdir(localBin, { recursive: true }) + await writeFile(join(localBin, "python3"), "#!/bin/sh") + + const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH + const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + process.env.ALTIMATE_CODE_PYTHON_PATH = join(altPythonBin, "python3") + process.env.ALTIMATE_CODE_VIRTUAL_ENV = join(dir, "project", ".venv") + try { + const result = discoverPython(join(dir, "project")) + expect(result).toBe(join(altPythonBin, "python3")) + } finally { + if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython + else delete process.env.ALTIMATE_CODE_PYTHON_PATH + if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv + else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + } + }) + test("ALTIMATE_CODE_VIRTUAL_ENV takes priority over project-local .venv", async () => { const { discoverPython } = await import("../src/config") @@ -163,8 +220,10 @@ describe("discoverPython", () => { test("falls back to project-local .venv/bin/python3", async () => { const { discoverPython } = await import("../src/config") - const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV + const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + delete process.env.ALTIMATE_CODE_PYTHON_PATH const binDir = join(dir, ".venv", "bin") await mkdir(binDir, { recursive: true }) @@ -174,15 +233,18 @@ describe("discoverPython", () => { const result = discoverPython(dir) expect(result).toBe(join(binDir, "python3")) } finally { - if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig + if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv + if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython } }) test("tries python3 before python in each location", async () => { const { discoverPython } = await import("../src/config") - const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV + const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV + const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH delete process.env.ALTIMATE_CODE_VIRTUAL_ENV + delete process.env.ALTIMATE_CODE_PYTHON_PATH const binDir = join(dir, ".venv", "bin") await mkdir(binDir, { recursive: true }) @@ -193,7 +255,8 @@ describe("discoverPython", () => { const result = discoverPython(dir) expect(result).toBe(join(binDir, "python3")) } finally { - if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig + if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv + if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython } }) }) diff --git a/packages/dbt-tools/test/dbt-resolve.test.ts b/packages/dbt-tools/test/dbt-resolve.test.ts index cbd2df38f8..42506200d9 100644 --- a/packages/dbt-tools/test/dbt-resolve.test.ts +++ b/packages/dbt-tools/test/dbt-resolve.test.ts @@ -27,7 +27,9 @@ function fakePython(dir: string): string { chmodSync(p, 0o755) // Also create python3 symlink const p3 = join(dir, "python3") - try { symlinkSync(p, p3) } catch {} + try { + symlinkSync(p, p3) + } catch {} return p } @@ -38,7 +40,9 @@ beforeEach(() => { }) afterEach(() => { - try { rmSync(tempDir, { recursive: true, force: true }) } catch {} + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} }) // --------------------------------------------------------------------------- From c209d46e8e77b2752bced2f19c0029b05d9e55b5 Mon Sep 17 00:00:00 2001 From: Michiel De Smet Date: Wed, 25 Mar 2026 18:14:26 -0700 Subject: [PATCH 3/5] fix(dbt-tools): remove ALTIMATE_CODE_* env vars and add Windows compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop ALTIMATE_CODE_PYTHON_PATH and ALTIMATE_CODE_VIRTUAL_ENV — dbt and Python will be on PATH anyway. Add Windows support: use Scripts/ instead of bin/, .exe suffix for binaries, where instead of which, skip Unix-only pyenv/asdf resolution, and add Windows-specific known fallback paths. Co-Authored-By: Claude Sonnet 4.6 --- packages/dbt-tools/src/config.ts | 45 +++--- packages/dbt-tools/src/dbt-resolve.ts | 166 ++++++++++---------- packages/dbt-tools/test/config.test.ts | 76 +-------- packages/dbt-tools/test/dbt-resolve.test.ts | 102 +----------- 4 files changed, 112 insertions(+), 277 deletions(-) diff --git a/packages/dbt-tools/src/config.ts b/packages/dbt-tools/src/config.ts index ae796d6c56..2616ea0937 100644 --- a/packages/dbt-tools/src/config.ts +++ b/packages/dbt-tools/src/config.ts @@ -33,28 +33,24 @@ export function findProjectRoot(start = process.cwd()): string | null { } } +const isWindows = process.platform === "win32" +// Windows venvs use Scripts/, Unix venvs use bin/ +const VENV_BIN = isWindows ? "Scripts" : "bin" +// Windows executables have .exe suffix +const EXE = isWindows ? ".exe" : "" + /** * Discover the Python binary for a given project root. - * Priority: ALTIMATE_CODE_PYTHON_PATH → ALTIMATE_CODE_VIRTUAL_ENV → project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which python3 + * Priority: project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which/where python */ export function discoverPython(projectRoot: string): string { - // ALTIMATE_CODE_PYTHON_PATH (explicit selection from vscode-altimate-mcp-server — highest priority) - const altPython = process.env.ALTIMATE_CODE_PYTHON_PATH - if (altPython && existsSync(altPython)) return altPython - - // ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server — explicit user selection wins) - const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - if (altVenv) { - for (const bin of ["python3", "python"]) { - const py = join(altVenv, "bin", bin) - if (existsSync(py)) return py - } - } + // Candidate Python binary names (python3 first on Unix; python.exe on Windows) + const pythonBins = isWindows ? ["python.exe", "python3.exe"] : ["python3", "python"] // Project-local venvs (uv, pdm, venv, poetry in-project, rye) for (const venvDir of [".venv", "venv", "env"]) { - for (const bin of ["python3", "python"]) { - const py = join(projectRoot, venvDir, "bin", bin) + for (const bin of pythonBins) { + const py = join(projectRoot, venvDir, VENV_BIN, bin) if (existsSync(py)) return py } } @@ -62,8 +58,8 @@ export function discoverPython(projectRoot: string): string { // VIRTUAL_ENV (set by activate scripts) const virtualEnv = process.env.VIRTUAL_ENV if (virtualEnv) { - for (const bin of ["python3", "python"]) { - const py = join(virtualEnv, "bin", bin) + for (const bin of pythonBins) { + const py = join(virtualEnv, VENV_BIN, bin) if (existsSync(py)) return py } } @@ -71,19 +67,22 @@ export function discoverPython(projectRoot: string): string { // CONDA_PREFIX const condaPrefix = process.env.CONDA_PREFIX if (condaPrefix) { - for (const bin of ["python3", "python"]) { - const py = join(condaPrefix, "bin", bin) + for (const bin of pythonBins) { + const py = join(condaPrefix, VENV_BIN, bin) if (existsSync(py)) return py } } - // PATH-based discovery - for (const cmd of ["python3", "python"]) { + // PATH-based discovery (`where` on Windows, `which` on Unix) + const whichCmd = isWindows ? "where" : "which" + const cmds = isWindows ? ["python.exe", "python3.exe", "python"] : ["python3", "python"] + for (const cmd of cmds) { try { - return execFileSync("which", [cmd], { encoding: "utf-8" }).trim() + // `where` on Windows may return multiple lines — take the first + return execFileSync(whichCmd, [cmd], { encoding: "utf-8" }).trim().split(/\r?\n/)[0] } catch {} } - return "python3" + return isWindows ? "python.exe" : "python3" } async function read(): Promise { diff --git a/packages/dbt-tools/src/dbt-resolve.ts b/packages/dbt-tools/src/dbt-resolve.ts index e5a874c9e3..4a45e6f44a 100644 --- a/packages/dbt-tools/src/dbt-resolve.ts +++ b/packages/dbt-tools/src/dbt-resolve.ts @@ -29,6 +29,12 @@ import { execFileSync } from "child_process" import { existsSync, realpathSync, readFileSync } from "fs" import { dirname, join } from "path" +const isWindows = process.platform === "win32" +// Windows venvs use Scripts/, Unix venvs use bin/ +const VENV_BIN = isWindows ? "Scripts" : "bin" +// Windows executables have .exe suffix +const EXE = isWindows ? ".exe" : "" + export interface ResolvedDbt { /** Absolute path to the dbt binary (or "dbt" if relying on PATH). */ path: string @@ -43,16 +49,14 @@ export interface ResolvedDbt { * * Priority: * 1. ALTIMATE_DBT_PATH env var (explicit user override) - * 2. Sibling of ALTIMATE_CODE_PYTHON_PATH (set by vscode-altimate-mcp-server) - * 3. Sibling of configured pythonPath (same venv/bin) - * 4. ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt (set by vscode-altimate-mcp-server) - * 5. Project-local .venv/bin/dbt (uv, pdm, venv, rye, poetry in-project) - * 6. CONDA_PREFIX/bin/dbt (conda environments) - * 7. VIRTUAL_ENV/bin/dbt (activated venv) - * 8. Pyenv real path resolution (follow shims) - * 9. asdf/mise shim resolution - * 10. `which dbt` on current PATH - * 11. Common known locations (~/.local/bin/dbt for pipx, etc.) + * 2. Sibling of configured pythonPath (same venv/Scripts or venv/bin) + * 3. Project-local .venv/bin/dbt or .venv/Scripts/dbt.exe + * 4. CONDA_PREFIX/bin/dbt or Scripts/dbt.exe (conda environments) + * 5. VIRTUAL_ENV/bin/dbt or Scripts/dbt.exe (activated venv) + * 6. Pyenv real path resolution — Unix only (follow shims) + * 7. asdf/mise shim resolution — Unix only + * 8. `which`/`where dbt` on current PATH + * 9. Common known locations (~/.local/bin/dbt for pipx, etc.) * * Each candidate is validated by checking it exists and is executable. */ @@ -65,17 +69,10 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD candidates.push({ path: envOverride, source: "ALTIMATE_DBT_PATH env var" }) } - // 2. Sibling of ALTIMATE_CODE_PYTHON_PATH (injected by vscode-altimate-mcp-server) - const altPython = process.env.ALTIMATE_CODE_PYTHON_PATH - if (altPython) { - const binDir = dirname(altPython) - candidates.push({ path: join(binDir, "dbt"), source: "sibling of ALTIMATE_CODE_PYTHON_PATH", binDir }) - } - - // 3. Sibling of configured pythonPath (most common: venv, conda, pyenv real path) + // 2. Sibling of configured pythonPath (most common: venv, conda, pyenv real path) if (pythonPath && existsSync(pythonPath)) { const binDir = dirname(pythonPath) - const siblingDbt = join(binDir, "dbt") + const siblingDbt = join(binDir, `dbt${EXE}`) candidates.push({ path: siblingDbt, source: `sibling of pythonPath (${pythonPath})`, binDir }) // If pythonPath is a symlink (e.g., pyenv shim), also check the real path @@ -83,109 +80,116 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD const realPython = realpathSync(pythonPath) if (realPython !== pythonPath) { const realBinDir = dirname(realPython) - const realDbt = join(realBinDir, "dbt") + const realDbt = join(realBinDir, `dbt${EXE}`) candidates.push({ path: realDbt, source: `real path of pythonPath (${realPython})`, binDir: realBinDir }) } } catch {} } - // 4. ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server, avoids conflicts with user's VIRTUAL_ENV) - const altVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - if (altVenv) { - candidates.push({ - path: join(altVenv, "bin", "dbt"), - source: `ALTIMATE_CODE_VIRTUAL_ENV (${altVenv})`, - binDir: join(altVenv, "bin"), - }) - } - - // 5. Project-local .venv/bin/dbt (uv, pdm, venv, poetry in-project, rye) + // 3. Project-local .venv/Scripts/dbt.exe (Windows) or .venv/bin/dbt (Unix) if (projectRoot) { for (const venvDir of [".venv", "venv", "env"]) { - const localDbt = join(projectRoot, venvDir, "bin", "dbt") + const localDbt = join(projectRoot, venvDir, VENV_BIN, `dbt${EXE}`) candidates.push({ path: localDbt, source: `${venvDir}/ in project root`, - binDir: join(projectRoot, venvDir, "bin"), + binDir: join(projectRoot, venvDir, VENV_BIN), }) } } - // 6. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) + // 4. CONDA_PREFIX (conda/mamba/micromamba — set after `conda activate`) const condaPrefix = process.env.CONDA_PREFIX if (condaPrefix) { candidates.push({ - path: join(condaPrefix, "bin", "dbt"), + path: join(condaPrefix, VENV_BIN, `dbt${EXE}`), source: `CONDA_PREFIX (${condaPrefix})`, - binDir: join(condaPrefix, "bin"), + binDir: join(condaPrefix, VENV_BIN), }) } - // 7. VIRTUAL_ENV (set by venv/virtualenv activate scripts) + // 5. VIRTUAL_ENV (set by venv/virtualenv activate scripts) const virtualEnv = process.env.VIRTUAL_ENV if (virtualEnv) { candidates.push({ - path: join(virtualEnv, "bin", "dbt"), + path: join(virtualEnv, VENV_BIN, `dbt${EXE}`), source: `VIRTUAL_ENV (${virtualEnv})`, - binDir: join(virtualEnv, "bin"), + binDir: join(virtualEnv, VENV_BIN), }) } // Helper: current process env (for subprocess calls that need to inherit it) const currentEnv = { ...process.env } - // 8. Pyenv: resolve through shim to real binary - const pyenvRoot = process.env.PYENV_ROOT ?? join(process.env.HOME ?? "", ".pyenv") - if (existsSync(join(pyenvRoot, "shims", "dbt"))) { - try { - // `pyenv which dbt` resolves the shim to the actual binary path - const realDbt = execFileSync("pyenv", ["which", "dbt"], { - encoding: "utf-8", - timeout: 5_000, - env: { ...currentEnv, PYENV_ROOT: pyenvRoot }, - }).trim() - if (realDbt) { - candidates.push({ path: realDbt, source: `pyenv which dbt`, binDir: dirname(realDbt) }) + if (!isWindows) { + // 6. Pyenv: resolve through shim to real binary (Unix only) + const pyenvRoot = process.env.PYENV_ROOT ?? join(process.env.HOME ?? "", ".pyenv") + if (existsSync(join(pyenvRoot, "shims", "dbt"))) { + try { + // `pyenv which dbt` resolves the shim to the actual binary path + const realDbt = execFileSync("pyenv", ["which", "dbt"], { + encoding: "utf-8", + timeout: 5_000, + env: { ...currentEnv, PYENV_ROOT: pyenvRoot }, + }).trim() + if (realDbt) { + candidates.push({ path: realDbt, source: `pyenv which dbt`, binDir: dirname(realDbt) }) + } + } catch { + // pyenv not functional — shim won't resolve } - } catch { - // pyenv not functional — shim won't resolve } - } - // 9. asdf/mise shim resolution - const asdfDataDir = process.env.ASDF_DATA_DIR ?? join(process.env.HOME ?? "", ".asdf") - if (existsSync(join(asdfDataDir, "shims", "dbt"))) { - try { - const realDbt = execFileSync("asdf", ["which", "dbt"], { - encoding: "utf-8", - timeout: 5_000, - env: currentEnv, - }).trim() - if (realDbt) { - candidates.push({ path: realDbt, source: `asdf which dbt`, binDir: dirname(realDbt) }) - } - } catch {} + // 7. asdf/mise shim resolution (Unix only) + const asdfDataDir = process.env.ASDF_DATA_DIR ?? join(process.env.HOME ?? "", ".asdf") + if (existsSync(join(asdfDataDir, "shims", "dbt"))) { + try { + const realDbt = execFileSync("asdf", ["which", "dbt"], { + encoding: "utf-8", + timeout: 5_000, + env: currentEnv, + }).trim() + if (realDbt) { + candidates.push({ path: realDbt, source: `asdf which dbt`, binDir: dirname(realDbt) }) + } + } catch {} + } } - // 10. `which dbt` on current PATH (catches pipx ~/.local/bin, system pip, homebrew, etc.) + // 8. `where dbt` (Windows) / `which dbt` (Unix) on current PATH + const whichCmd = isWindows ? "where" : "which" + const dbtCmd = `dbt${EXE}` try { - const whichDbt = execFileSync("which", ["dbt"], { + const found = execFileSync(whichCmd, [dbtCmd], { encoding: "utf-8", timeout: 5_000, env: currentEnv, - }).trim() - if (whichDbt) { - candidates.push({ path: whichDbt, source: `which dbt (PATH)`, binDir: dirname(whichDbt) }) + }) + .trim() + .split(/\r?\n/)[0] // `where` may return multiple lines — take the first + if (found) { + candidates.push({ path: found, source: `${whichCmd} dbt (PATH)`, binDir: dirname(found) }) } } catch {} - // 11. Common known locations (last resort) - const home = process.env.HOME ?? "" - const knownPaths = [ - { path: join(home, ".local", "bin", "dbt"), source: "~/.local/bin/dbt (pipx/user pip)" }, - { path: "/usr/local/bin/dbt", source: "/usr/local/bin/dbt (system pip)" }, - { path: "/opt/homebrew/bin/dbt", source: "/opt/homebrew/bin/dbt (homebrew, deprecated)" }, - ] + // 9. Common known locations (last resort) + const home = process.env.HOME ?? process.env.USERPROFILE ?? "" + const knownPaths = isWindows + ? [ + { + path: join(home, "AppData", "Roaming", "Python", "Scripts", "dbt.exe"), + source: "%APPDATA%/Python/Scripts/dbt.exe (user pip)", + }, + { + path: join(home, "AppData", "Local", "Programs", "Python", "Scripts", "dbt.exe"), + source: "%LOCALAPPDATA%/Programs/Python/Scripts/dbt.exe (system pip)", + }, + ] + : [ + { path: join(home, ".local", "bin", "dbt"), source: "~/.local/bin/dbt (pipx/user pip)" }, + { path: "/usr/local/bin/dbt", source: "/usr/local/bin/dbt (system pip)" }, + { path: "/opt/homebrew/bin/dbt", source: "/opt/homebrew/bin/dbt (homebrew, deprecated)" }, + ] for (const kp of knownPaths) { candidates.push({ ...kp, binDir: dirname(kp.path) }) } @@ -197,8 +201,8 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD } } - // Nothing found — return bare "dbt" and hope PATH has it - return { path: "dbt", source: "fallback (bare dbt on PATH)" } + // Nothing found — return bare "dbt" (or "dbt.exe") and hope PATH has it + return { path: `dbt${EXE}`, source: "fallback (bare dbt on PATH)" } } /** diff --git a/packages/dbt-tools/test/config.test.ts b/packages/dbt-tools/test/config.test.ts index cbeab2b9bc..a7b5c008eb 100644 --- a/packages/dbt-tools/test/config.test.ts +++ b/packages/dbt-tools/test/config.test.ts @@ -169,94 +169,26 @@ describe("discoverPython", () => { await rm(dir, { recursive: true, force: true }) }) - test("ALTIMATE_CODE_PYTHON_PATH takes highest priority", async () => { - const { discoverPython } = await import("../src/config") - - const altPythonBin = join(dir, "alt-python", "bin") - await mkdir(altPythonBin, { recursive: true }) - await writeFile(join(altPythonBin, "python3"), "#!/bin/sh") - - const localBin = join(dir, "project", ".venv", "bin") - await mkdir(localBin, { recursive: true }) - await writeFile(join(localBin, "python3"), "#!/bin/sh") - - const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH - const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - process.env.ALTIMATE_CODE_PYTHON_PATH = join(altPythonBin, "python3") - process.env.ALTIMATE_CODE_VIRTUAL_ENV = join(dir, "project", ".venv") - try { - const result = discoverPython(join(dir, "project")) - expect(result).toBe(join(altPythonBin, "python3")) - } finally { - if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython - else delete process.env.ALTIMATE_CODE_PYTHON_PATH - if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv - else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - } - }) - - test("ALTIMATE_CODE_VIRTUAL_ENV takes priority over project-local .venv", async () => { - const { discoverPython } = await import("../src/config") - - const altBin = join(dir, "alt-venv", "bin") - await mkdir(altBin, { recursive: true }) - await writeFile(join(altBin, "python3"), "#!/bin/sh") - - const localBin = join(dir, "project", ".venv", "bin") - await mkdir(localBin, { recursive: true }) - await writeFile(join(localBin, "python3"), "#!/bin/sh") - - const orig = process.env.ALTIMATE_CODE_VIRTUAL_ENV - process.env.ALTIMATE_CODE_VIRTUAL_ENV = join(dir, "alt-venv") - try { - const result = discoverPython(join(dir, "project")) - expect(result).toBe(join(altBin, "python3")) - } finally { - if (orig !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = orig - else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - } - }) - test("falls back to project-local .venv/bin/python3", async () => { const { discoverPython } = await import("../src/config") - const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH - delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - delete process.env.ALTIMATE_CODE_PYTHON_PATH - const binDir = join(dir, ".venv", "bin") await mkdir(binDir, { recursive: true }) await writeFile(join(binDir, "python3"), "#!/bin/sh") - try { - const result = discoverPython(dir) - expect(result).toBe(join(binDir, "python3")) - } finally { - if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv - if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython - } + const result = discoverPython(dir) + expect(result).toBe(join(binDir, "python3")) }) test("tries python3 before python in each location", async () => { const { discoverPython } = await import("../src/config") - const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH - delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - delete process.env.ALTIMATE_CODE_PYTHON_PATH - const binDir = join(dir, ".venv", "bin") await mkdir(binDir, { recursive: true }) // Only create python3, not python await writeFile(join(binDir, "python3"), "#!/bin/sh") - try { - const result = discoverPython(dir) - expect(result).toBe(join(binDir, "python3")) - } finally { - if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv - if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython - } + const result = discoverPython(dir) + expect(result).toBe(join(binDir, "python3")) }) }) diff --git a/packages/dbt-tools/test/dbt-resolve.test.ts b/packages/dbt-tools/test/dbt-resolve.test.ts index 42506200d9..6554a95b24 100644 --- a/packages/dbt-tools/test/dbt-resolve.test.ts +++ b/packages/dbt-tools/test/dbt-resolve.test.ts @@ -222,107 +222,7 @@ describe("poetry (in-project)", () => { }) // --------------------------------------------------------------------------- -// Scenario: ALTIMATE_CODE_PYTHON_PATH (injected by vscode-altimate-mcp-server) -// --------------------------------------------------------------------------- -describe("ALTIMATE_CODE_PYTHON_PATH", () => { - test("resolves dbt as sibling of ALTIMATE_CODE_PYTHON_PATH", () => { - const venvBin = join(tempDir, "vscode-venv", "bin") - mkdirSync(venvBin, { recursive: true }) - fakeDbt(venvBin) - - const origVal = process.env.ALTIMATE_CODE_PYTHON_PATH - process.env.ALTIMATE_CODE_PYTHON_PATH = join(venvBin, "python3") - - try { - const result = resolveDbt(undefined, undefined) - expect(result.path).toBe(join(venvBin, "dbt")) - expect(result.source).toContain("ALTIMATE_CODE_PYTHON_PATH") - } finally { - if (origVal !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origVal - else delete process.env.ALTIMATE_CODE_PYTHON_PATH - } - }) - - test("ALTIMATE_CODE_PYTHON_PATH loses to ALTIMATE_DBT_PATH", () => { - const venvBin = join(tempDir, "vscode-venv", "bin") - mkdirSync(venvBin, { recursive: true }) - fakeDbt(venvBin) - - const customDbt = join(tempDir, "custom-dbt") - writeFileSync(customDbt, "#!/bin/sh") - chmodSync(customDbt, 0o755) - - const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH - const origDbt = process.env.ALTIMATE_DBT_PATH - process.env.ALTIMATE_CODE_PYTHON_PATH = join(venvBin, "python3") - process.env.ALTIMATE_DBT_PATH = customDbt - - try { - const result = resolveDbt(undefined, undefined) - expect(result.path).toBe(customDbt) - expect(result.source).toContain("ALTIMATE_DBT_PATH") - } finally { - if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython - else delete process.env.ALTIMATE_CODE_PYTHON_PATH - if (origDbt !== undefined) process.env.ALTIMATE_DBT_PATH = origDbt - else delete process.env.ALTIMATE_DBT_PATH - } - }) -}) - -// --------------------------------------------------------------------------- -// Scenario: ALTIMATE_CODE_VIRTUAL_ENV (injected by vscode-altimate-mcp-server) -// --------------------------------------------------------------------------- -describe("ALTIMATE_CODE_VIRTUAL_ENV", () => { - test("resolves dbt from ALTIMATE_CODE_VIRTUAL_ENV/bin/dbt", () => { - const venvDir = join(tempDir, "vscode-venv") - const binDir = join(venvDir, "bin") - mkdirSync(binDir, { recursive: true }) - fakeDbt(binDir) - - const origVal = process.env.ALTIMATE_CODE_VIRTUAL_ENV - process.env.ALTIMATE_CODE_VIRTUAL_ENV = venvDir - - try { - const result = resolveDbt(undefined, undefined) - expect(result.path).toBe(join(binDir, "dbt")) - expect(result.source).toContain("ALTIMATE_CODE_VIRTUAL_ENV") - } finally { - if (origVal !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVal - else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - } - }) - - test("ALTIMATE_CODE_VIRTUAL_ENV loses to ALTIMATE_CODE_PYTHON_PATH sibling", () => { - const pythonBin = join(tempDir, "python-venv", "bin") - mkdirSync(pythonBin, { recursive: true }) - const pythonDbt = fakeDbt(pythonBin) - - const venvDir = join(tempDir, "vscode-venv") - const venvBin = join(venvDir, "bin") - mkdirSync(venvBin, { recursive: true }) - fakeDbt(venvBin) - - const origPython = process.env.ALTIMATE_CODE_PYTHON_PATH - const origVenv = process.env.ALTIMATE_CODE_VIRTUAL_ENV - process.env.ALTIMATE_CODE_PYTHON_PATH = join(pythonBin, "python3") - process.env.ALTIMATE_CODE_VIRTUAL_ENV = venvDir - - try { - const result = resolveDbt(undefined, undefined) - expect(result.path).toBe(pythonDbt) - expect(result.source).toContain("ALTIMATE_CODE_PYTHON_PATH") - } finally { - if (origPython !== undefined) process.env.ALTIMATE_CODE_PYTHON_PATH = origPython - else delete process.env.ALTIMATE_CODE_PYTHON_PATH - if (origVenv !== undefined) process.env.ALTIMATE_CODE_VIRTUAL_ENV = origVenv - else delete process.env.ALTIMATE_CODE_VIRTUAL_ENV - } - }) -}) - -// --------------------------------------------------------------------------- -// Scenario 8: ALTIMATE_DBT_PATH override +// Scenario: ALTIMATE_DBT_PATH override // --------------------------------------------------------------------------- describe("explicit override", () => { test("ALTIMATE_DBT_PATH takes highest priority", () => { From 492b8b82972f4e3873feded158dc8e486418656d Mon Sep 17 00:00:00 2001 From: Michiel De Smet Date: Wed, 25 Mar 2026 18:16:16 -0700 Subject: [PATCH 4/5] fix(dbt-tools): handle undefined from array index in discoverPython Co-Authored-By: Claude Sonnet 4.6 --- packages/dbt-tools/src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dbt-tools/src/config.ts b/packages/dbt-tools/src/config.ts index 2616ea0937..8b0a4857f9 100644 --- a/packages/dbt-tools/src/config.ts +++ b/packages/dbt-tools/src/config.ts @@ -79,7 +79,8 @@ export function discoverPython(projectRoot: string): string { for (const cmd of cmds) { try { // `where` on Windows may return multiple lines — take the first - return execFileSync(whichCmd, [cmd], { encoding: "utf-8" }).trim().split(/\r?\n/)[0] + const first = execFileSync(whichCmd, [cmd], { encoding: "utf-8" }).trim().split(/\r?\n/)[0] + if (first) return first } catch {} } return isWindows ? "python.exe" : "python3" From 04dfb315c5bed3b55f9b4e166d079957bb0c370c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 25 Mar 2026 22:35:36 -0700 Subject: [PATCH 5/5] fix(dbt-tools): fix PATH separator and Conda discovery for Windows - Use `path.delimiter` instead of hardcoded `:` in `validateDbt()` and `buildDbtEnv()` so PATH injection works on Windows (`;` separator) - Fix Conda Python discovery on Windows: check env root directly since Conda places `python.exe` at `%CONDA_PREFIX%\python.exe`, not in `Scripts/` - Add `timeout: 5_000` to `execFileSync` in `discoverPython()` to match the same pattern used in `dbt-resolve.ts` Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/dbt-tools/src/config.ts | 6 +++--- packages/dbt-tools/src/dbt-resolve.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/dbt-tools/src/config.ts b/packages/dbt-tools/src/config.ts index 8b0a4857f9..c44c567ae5 100644 --- a/packages/dbt-tools/src/config.ts +++ b/packages/dbt-tools/src/config.ts @@ -64,11 +64,11 @@ export function discoverPython(projectRoot: string): string { } } - // CONDA_PREFIX + // CONDA_PREFIX (Conda places python at env root on Windows, bin/ on Unix) const condaPrefix = process.env.CONDA_PREFIX if (condaPrefix) { for (const bin of pythonBins) { - const py = join(condaPrefix, VENV_BIN, bin) + const py = isWindows ? join(condaPrefix, bin) : join(condaPrefix, VENV_BIN, bin) if (existsSync(py)) return py } } @@ -79,7 +79,7 @@ export function discoverPython(projectRoot: string): string { for (const cmd of cmds) { try { // `where` on Windows may return multiple lines — take the first - const first = execFileSync(whichCmd, [cmd], { encoding: "utf-8" }).trim().split(/\r?\n/)[0] + const first = execFileSync(whichCmd, [cmd], { encoding: "utf-8", timeout: 5_000 }).trim().split(/\r?\n/)[0] if (first) return first } catch {} } diff --git a/packages/dbt-tools/src/dbt-resolve.ts b/packages/dbt-tools/src/dbt-resolve.ts index 4a45e6f44a..b13adaaf01 100644 --- a/packages/dbt-tools/src/dbt-resolve.ts +++ b/packages/dbt-tools/src/dbt-resolve.ts @@ -27,7 +27,7 @@ import { execFileSync } from "child_process" import { existsSync, realpathSync, readFileSync } from "fs" -import { dirname, join } from "path" +import { delimiter, dirname, join } from "path" const isWindows = process.platform === "win32" // Windows venvs use Scripts/, Unix venvs use bin/ @@ -211,7 +211,9 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD */ export function validateDbt(resolved: ResolvedDbt): { version: string; isFusion: boolean } | null { try { - const env = resolved.binDir ? { ...process.env, PATH: `${resolved.binDir}:${process.env.PATH}` } : process.env + const env = resolved.binDir + ? { ...process.env, PATH: `${resolved.binDir}${delimiter}${process.env.PATH}` } + : process.env const out = execFileSync(resolved.path, ["--version"], { encoding: "utf-8", @@ -240,7 +242,7 @@ export function validateDbt(resolved: ResolvedDbt): { version: string; isFusion: export function buildDbtEnv(resolved: ResolvedDbt): Record { const env = { ...process.env } if (resolved.binDir) { - env.PATH = `${resolved.binDir}:${env.PATH ?? ""}` + env.PATH = `${resolved.binDir}${delimiter}${env.PATH ?? ""}` } // Ensure DBT_PROFILES_DIR is set if we have a project root // (dbt looks in cwd for profiles.yml by default, but we may not be in the project dir)