From 01987000909d7c800747270e59eb82c891055d6a Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 29 Aug 2026 18:09:21 -0400 Subject: [PATCH] fix(binary-manager): resolve Dolt home dir + download correctly on Windows 'deltix init' failed on Windows with 'Executable not found in /run/user/1000/fnm_multishells/47653_1787712485619/bin:/home/sammy/.cargo/bin:/home/sammy/.nvm/versions/node/v20.18.1/bin:/home/sammy/.dotnet:/home/sammy/.console-ninja/.bin:/home/sammy/.local/share/pnpm:/home/sammy/.bun/bin:/home/sammy/.deno/bin:/usr/share/archcraft/scripts:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/share/dotnet:/home/sammy/.dotnet/tools:/var/lib/flatpak/exports/bin:/usr/lib/jvm/default/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/usr/lib/rustup/bin:/var/lib/snapd/snap/bin:/home/sammy/Android/Sdk/emulator:/home/sammy/Android/Sdk/platform-tools:/home/sammy/.turso:/usr/local/go/bin:/home/sammy/go/bin: .deltix\bin\dolt-2.3.1\bin\dolt'. Two root causes: - defaultHomeDir() used process.env.HOME, which is undefined on Windows (it uses USERPROFILE), so the install path came out RELATIVE. Now uses os.homedir() -> absolute C:\Users\\.deltix. - binary-manager only supported darwin/linux tarballs. Added win32: dolt-windows-.zip, extracted via 'tar -xf' (bsdtar on Win10+), binary named dolt.exe, findDoltExecutable looks for dolt.exe. Also made the local workflow resilient: - 'deltix init' no longer hard-fails if Dolt can't be resolved yet (first-run download needs network): it still binds the project and warns that 'deltix start' will initialize the engine. - 'deltix start' now ensures the local Dolt repo exists (idempotent) before serving it. extractTarGz -> extractArchive (auto-detects gzip/zip). 113 unit tests pass, build + lint clean. --- src/cli/index.ts | 25 ++++++++--- .../binary-manager/binary-manager.service.ts | 31 +++++++++---- src/contexts/binary-manager/download.ts | 17 +++---- src/contexts/binary-manager/tar-extract.ts | 45 +++++++++---------- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 78813d6..6d3021d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -927,13 +927,20 @@ async function runInit(args: string[]): Promise { } try { const project = await createLocalProjectService().init(process.cwd(), repo); - // Create the local Dolt repo now (the "git init" moment), so the folder is - // a working versioned checkout before `deltix start` ever runs. - const { BinaryManager } = await import('../contexts/binary-manager'); - await new VersioningLocalService({ - homeDir: process.env.DELTIX_HOME ?? join(homedir(), '.deltix'), - binaryManager: new BinaryManager(), - }).initLocalRepo({ repo: project.config.repo, projectRoot: project.root }); + // Create the local Dolt repo (the "git init" moment). If the Dolt binary + // can't be resolved yet (e.g. first-run download needs network), don't + // fail the bind — `deltix start` will initialize the repo then. + try { + const { BinaryManager } = await import('../contexts/binary-manager'); + await new VersioningLocalService({ + homeDir: process.env.DELTIX_HOME ?? join(homedir(), '.deltix'), + binaryManager: new BinaryManager(), + }).initLocalRepo({ repo: project.config.repo, projectRoot: project.root }); + } catch (err) { + printInfo( + `Project bound, but the local Dolt engine wasn't created yet (${String(err)}). \`deltix start\` will initialize it.`, + ); + } printSuccess(`Initialized Deltix project in ${project.root}`, { repo, config: project.configPath, @@ -1023,6 +1030,10 @@ async function runStart(args: string[]): Promise { const identity = await resolveServerIdentity(repoArg); if (!identity) return 1; try { + // Ensure the local Dolt repo exists (idempotent) before serving it, so + // `start` works even if `init` deferred repo creation. + const local = await newLocalService(); + await local.initLocalRepo(identity); const state = await createMysqlEmbeddedService().start(identity); printSuccess(`Local Dolt SQL server started for ${identity.repo}`, { host: '127.0.0.1', diff --git a/src/contexts/binary-manager/binary-manager.service.ts b/src/contexts/binary-manager/binary-manager.service.ts index 0b8c15c..51603d1 100644 --- a/src/contexts/binary-manager/binary-manager.service.ts +++ b/src/contexts/binary-manager/binary-manager.service.ts @@ -23,7 +23,7 @@ import { createHash } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { runCommand, whichBinary } from '../../acl/dolt-exec'; import { loadEnv } from '../../shared/env'; @@ -31,13 +31,16 @@ import { createGitHubReleaseDownloader, type DoltDownloader } from './download'; export const DOLT_VERSION = '2.3.1'; +/** Host OSes with an official Dolt release we know how to fetch. */ +type DoltOs = 'darwin' | 'linux' | 'win32'; + export interface BinaryManagerDeps { /** Root state dir; defaults to `~/.deltix` (or `DELTIX_HOME`). */ homeDir?: string; /** Overrides `DELTIX_DOLT_BIN_PATH` (and the env var). */ explicitBinPath?: string; /** Overrides OS auto-detection (test/CI). */ - os?: 'darwin' | 'linux'; + os?: DoltOs; /** Overrides arch auto-detection (test/CI). */ arch?: 'arm64' | 'amd64'; downloader?: DoltDownloader; @@ -84,7 +87,8 @@ export class BinaryManager { /** Absolute path to the install'd dolt executable for a version. */ binaryPath(version: string): string { - return join(this.versionDir(version), 'bin', 'dolt'); + const exe = process.platform === 'win32' ? 'dolt.exe' : 'dolt'; + return join(this.versionDir(version), 'bin', exe); } /** Returns the installed binary path if present and digest-verified. */ @@ -121,11 +125,12 @@ export class BinaryManager { ); try { const stagedBin = await downloader.download(url, stageDir); - const dest = join(binDir, 'dolt'); + const exe = process.platform === 'win32' ? 'dolt.exe' : 'dolt'; + const dest = join(binDir, exe); const tmpDest = join(binDir, `.dolt.tmp-${Math.random().toString(36).slice(2)}`); await copyFile(stagedBin, tmpDest); try { - await chmod(tmpDest, 0o755); + await chmod(tmpDest, 0o755).catch(() => {}); await rename(tmpDest, dest); } catch (err) { await rm(tmpDest, { force: true }); @@ -141,8 +146,10 @@ export class BinaryManager { } } -function defaultOs(): 'darwin' | 'linux' { - return process.platform === 'darwin' ? 'darwin' : 'linux'; +function defaultOs(): DoltOs { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; } function defaultArch(): 'arm64' | 'amd64' { @@ -150,7 +157,10 @@ function defaultArch(): 'arm64' | 'amd64' { } function defaultHomeDir(): string { - return join(process.env.HOME ?? '', '.deltix'); + // Use os.homedir() — on Windows `process.env.HOME` is typically undefined + // (the platform uses USERPROFILE), which previously produced a *relative* + // `.deltix/...` path and made the resolved binary unfindable. + return join(homedir(), '.deltix'); } async function findOnPath(version: string): Promise { @@ -179,7 +189,10 @@ function sha256File(path: string): Promise { } export function doltReleaseUrl(version: string, os: string, arch: string): string { - const platform = os === 'darwin' ? 'darwin' : 'linux'; const a = arch === 'arm64' ? 'arm64' : 'amd64'; + if (os === 'win32') { + return `https://github.com/dolthub/dolt/releases/download/v${version}/dolt-windows-${a}.zip`; + } + const platform = os === 'darwin' ? 'darwin' : 'linux'; return `https://github.com/dolthub/dolt/releases/download/v${version}/dolt-${platform}-${a}.tar.gz`; } diff --git a/src/contexts/binary-manager/download.ts b/src/contexts/binary-manager/download.ts index 9c63b7b..e8135b1 100644 --- a/src/contexts/binary-manager/download.ts +++ b/src/contexts/binary-manager/download.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { extractTarGz, findDoltExecutable } from './tar-extract'; +import { extractArchive, findDoltExecutable } from './tar-extract'; export interface DoltDownloader { /** @@ -39,23 +39,24 @@ export function createGitHubReleaseDownloader(): DoltDownloader { throw new Error('Download of Dolt failed: empty response body'); } - const tarballPath = join( + const isZip = url.endsWith('.zip'); + const archivePath = join( tmpdir(), - `deltix-dolt-${process.pid}-${Math.random().toString(36).slice(2)}.tar.gz`, + `deltix-dolt-${process.pid}-${Math.random().toString(36).slice(2)}.${isZip ? 'zip' : 'tar.gz'}`, ); await mkdir(destDir, { recursive: true }); try { - await pipeline(Readable.fromWeb(response.body as never), createWriteStream(tarballPath)); - await extractTarGz(tarballPath, destDir); + await pipeline(Readable.fromWeb(response.body as never), createWriteStream(archivePath)); + await extractArchive(archivePath, destDir); const binary = await findDoltExecutable(destDir); if (!binary) { - throw new Error('Downloaded Dolt tarball contained no dolt executable'); + throw new Error('Downloaded Dolt archive contained no dolt executable'); } - await chmod(binary, 0o755); + await chmod(binary, 0o755).catch(() => {}); return binary; } finally { - await rm(tarballPath, { force: true }).catch(() => {}); + await rm(archivePath, { force: true }).catch(() => {}); } }, }; diff --git a/src/contexts/binary-manager/tar-extract.ts b/src/contexts/binary-manager/tar-extract.ts index 8e33ad3..7a978a6 100644 --- a/src/contexts/binary-manager/tar-extract.ts +++ b/src/contexts/binary-manager/tar-extract.ts @@ -13,46 +13,45 @@ import { join } from 'node:path'; import { runCommand, whichBinary } from '../../acl/dolt-exec'; /** - * Extracts the gzip'd tarball at `tarballPath` into `destDir` using the - * platform `tar`. Throws when tar is unavailable or the extraction fails. + * Extracts the Dolt release archive (`.tar.gz` on unix, `.zip` on Windows) + * into `destDir` using the platform `tar`. `-xf` lets the tool auto-detect the + * format: GNU tar handles the gzip tarball on Linux/macOS; the bsdtar shipped + * with Windows 10+ also expands the `.zip`. Throws when tar is missing or + * extraction fails. */ -export async function extractTarGz(tarballPath: string, destDir: string): Promise { +export async function extractArchive(archivePath: string, destDir: string): Promise { await mkdir(destDir, { recursive: true }); const tar = await whichBinary('tar'); if (!tar) { throw new Error('No `tar` binary available to extract the Dolt archive'); } - const result = await runCommand(tar, ['-xzf', tarballPath, '-C', destDir]); + const result = await runCommand(tar, ['-xf', archivePath, '-C', destDir]); if (result.exitCode !== 0) { - throw new Error(`Failed to extract Dolt tarball: ${result.stderr.trim() || 'tar error'}`); + throw new Error(`Failed to extract Dolt archive: ${result.stderr.trim() || 'tar error'}`); } } /** Walks `root` and returns the path of a `dolt` executable if found. */ export async function findDoltExecutable(root: string): Promise { - // Common layout of official release tarballs: `dolt/bin/dolt` (darwin) or - // `dolt-linux-amd64/bin/dolt`. Prefer well-known paths, then fall back to - // a recursive scan so we stay robust to future layout changes. - const expected = [ - join(root, 'dolt', 'bin', 'dolt'), - join( - root, - `dolt-${process.platform === 'darwin' ? 'darwin' : 'linux'}${archSuffix()}`, - 'bin', - 'dolt', - ), - ]; + const exe = process.platform === 'win32' ? 'dolt.exe' : 'dolt'; + const dirName = archiveDirName(); + // Common layouts: `dolt/bin/dolt`, `dolt--/bin/dolt`, and the + // Windows zip's `dolt-windows-amd64\bin\dolt.exe`. Prefer known paths, then + // fall back to a recursive scan so we stay robust to layout changes. + const expected = [join(root, 'dolt', 'bin', exe), join(root, dirName, 'bin', exe)]; for (const candidate of expected) { if (existsSync(candidate)) return candidate; } - return (await scanForDolt(root)) ?? null; + return (await scanForDolt(root, exe)) ?? null; } -function archSuffix(): string { - return process.arch === 'arm64' ? '-arm64' : '-amd64'; +function archiveDirName(): string { + const os = + process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux'; + return `dolt-${os}${process.arch === 'arm64' ? '-arm64' : '-amd64'}`; } -async function scanForDolt(dir: string): Promise { +async function scanForDolt(dir: string, exe: string): Promise { let entries: Awaited> | undefined; try { entries = await readdir(dir, { withFileTypes: true }); @@ -62,9 +61,9 @@ async function scanForDolt(dir: string): Promise { for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { - const found = await scanForDolt(full); + const found = await scanForDolt(full, exe); if (found) return found; - } else if (entry.isFile() && entry.name === 'dolt') { + } else if (entry.isFile() && entry.name === exe) { return full; } }