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..c44c567ae5 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,92 @@ 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 + } +} + +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: project-local .venv → VIRTUAL_ENV → CONDA_PREFIX → which/where python + */ +export function discoverPython(projectRoot: string): string { + // 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 pythonBins) { + const py = join(projectRoot, venvDir, VENV_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 pythonBins) { + const py = join(virtualEnv, VENV_BIN, bin) + if (existsSync(py)) return py + } + } + + // 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 = isWindows ? join(condaPrefix, bin) : join(condaPrefix, VENV_BIN, bin) + if (existsSync(py)) return py + } + } + + // 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 { + // `where` on Windows may return multiple lines — take the first + const first = execFileSync(whichCmd, [cmd], { encoding: "utf-8", timeout: 5_000 }).trim().split(/\r?\n/)[0] + if (first) return first + } catch {} + } + return isWindows ? "python.exe" : "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)) { + 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() + 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..b13adaaf01 100644 --- a/packages/dbt-tools/src/dbt-resolve.ts +++ b/packages/dbt-tools/src/dbt-resolve.ts @@ -27,7 +27,13 @@ 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/ +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). */ @@ -43,13 +49,14 @@ 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 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,7 +72,7 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD // 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 @@ -73,17 +80,21 @@ 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 {} } - // 3. 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") - candidates.push({ path: localDbt, source: `${venvDir}/ in project root`, binDir: join(projectRoot, venvDir, "bin") }) + const localDbt = join(projectRoot, venvDir, VENV_BIN, `dbt${EXE}`) + candidates.push({ + path: localDbt, + source: `${venvDir}/ in project root`, + binDir: join(projectRoot, venvDir, VENV_BIN), + }) } } @@ -91,9 +102,9 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD 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), }) } @@ -101,67 +112,84 @@ export function resolveDbt(pythonPath?: string, projectRoot?: string): ResolvedD 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 } - // 6. 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 } - } - // 7. 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 {} + } } - // 8. `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 {} // 9. 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)" }, - ] + 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) }) } @@ -173,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)" } } /** @@ -184,7 +212,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, PATH: `${resolved.binDir}${delimiter}${process.env.PATH}` } : process.env const out = execFileSync(resolved.path, ["--version"], { @@ -214,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) diff --git a/packages/dbt-tools/test/config.test.ts b/packages/dbt-tools/test/config.test.ts index 2689375e26..a7b5c008eb 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 @@ -19,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 () => { @@ -51,4 +58,137 @@ 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 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() + 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("falls back to project-local .venv/bin/python3", async () => { + const { discoverPython } = await import("../src/config") + + const binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + 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 binDir = join(dir, ".venv", "bin") + await mkdir(binDir, { recursive: true }) + // Only create python3, not python + await writeFile(join(binDir, "python3"), "#!/bin/sh") + + 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 ec93cd297d..6554a95b24 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 {} }) // --------------------------------------------------------------------------- @@ -218,7 +222,7 @@ describe("poetry (in-project)", () => { }) // --------------------------------------------------------------------------- -// Scenario 8: ALTIMATE_DBT_PATH override +// Scenario: ALTIMATE_DBT_PATH override // --------------------------------------------------------------------------- describe("explicit override", () => { test("ALTIMATE_DBT_PATH takes highest priority", () => {