From feb08184449dc99126cb75b4d12f11b7e324ed34 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 12 Jun 2026 18:31:06 +0200 Subject: [PATCH] feat(cli): self-provision pinned uv and defer MCP Python runtime install (#297) Fixes a production crash-loop (PostHog issue 019eb68e): ktx mcp start --foreground on a uv-less container eagerly installed the managed Python runtime at boot, failed, and was restarted by its supervisor every ~62s (122 exceptions from one install). - MCP server factory now wires a lazy semantic-layer compute port that defers the runtime install to the first call, mirroring the already-lazy SQL-analysis port; the server boots and serves non-Python tools without the runtime. - ktx no longer requires uv on PATH: it downloads its own pinned, sha256-verified uv build under the runtime root (KTX_RUNTIME_ROOT aware), always musl-static on Linux. PATH uv is never consulted. - uv is acquired before the version dir is wiped, so a failed download cannot destroy an existing runtime. - Acquisition failures (offline, intercepted download, unsupported platform) throw KtxExpectedError and stay out of Error Tracking; a missing binary inside a checksum-verified archive remains a plain Error. - scripts/refresh-uv-manifest.mjs regenerates the pinned manifest (packages/cli/src/managed-uv-release.ts) on uv bumps. - Setup consent prompt now discloses the uv download; docs updated. --- .../content/docs/cli-reference/ktx-admin.mdx | 5 + .../content/docs/cli-reference/ktx-mcp.mdx | 1 + packages/cli/src/managed-python-command.ts | 31 +- packages/cli/src/managed-python-runtime.ts | 199 +++++++++++-- packages/cli/src/managed-uv-release.ts | 26 ++ packages/cli/src/mcp-server-factory.ts | 4 +- .../cli/test/managed-python-command.test.ts | 98 ++++++- .../cli/test/managed-python-runtime.test.ts | 265 ++++++++++++++---- packages/cli/test/mcp-server-factory.test.ts | 10 +- scripts/refresh-uv-manifest.mjs | 95 +++++++ scripts/refresh-uv-manifest.test.mjs | 69 +++++ 11 files changed, 731 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/managed-uv-release.ts create mode 100644 scripts/refresh-uv-manifest.mjs create mode 100644 scripts/refresh-uv-manifest.test.mjs diff --git a/docs-site/content/docs/cli-reference/ktx-admin.mdx b/docs-site/content/docs/cli-reference/ktx-admin.mdx index 54ac5f84..031c05ce 100644 --- a/docs-site/content/docs/cli-reference/ktx-admin.mdx +++ b/docs-site/content/docs/cli-reference/ktx-admin.mdx @@ -48,6 +48,11 @@ directory. Use it from any directory to generate editor or agent schema files. | `stop` | Stop the **ktx** daemon | | `status` | Show managed Python runtime status and readiness checks | +`install` is self-contained: **ktx** downloads its own pinned, checksum-verified +`uv` build under the runtime root and uses it to provision Python and the +runtime wheel. Nothing needs to be installed on `PATH` first; the host only +needs network access to `github.com` during the first install. + ## `admin runtime` Options | Flag | Description | Default | diff --git a/docs-site/content/docs/cli-reference/ktx-mcp.mdx b/docs-site/content/docs/cli-reference/ktx-mcp.mdx index 79c6c949..54f666f5 100644 --- a/docs-site/content/docs/cli-reference/ktx-mcp.mdx +++ b/docs-site/content/docs/cli-reference/ktx-mcp.mdx @@ -68,3 +68,4 @@ hosts and origins for browser clients. | No **ktx** project found | Current directory has no `ktx.yaml` and `KTX_PROJECT_DIR` is unset | Run from a **ktx** project or pass `--project-dir ` | | Non-loopback host rejected | The server needs token auth before binding beyond localhost | Pass `--token ` or set `KTX_MCP_TOKEN` | | Client cannot connect | Host, port, token, allowed host, or allowed origin does not match the client | Check `ktx mcp status`, then restart with explicit `--host`, `--port`, `--allowed-host`, and `--allowed-origin` values | +| A Python-backed tool reports a runtime install failure | A tool that needs the managed Python runtime (metric compute, query-history SQL analysis) ran on a host that cannot reach `github.com` to download the pinned `uv` and Python | The server still starts and serves catalog and search tools. Restore network access and retry, or pre-build the runtime where network is available: `ktx admin runtime install --yes` | diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index caa300ca..2fb2f37c 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -62,7 +62,7 @@ export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string function installPrompt(feature: KtxRuntimeFeature): string { const label = feature === 'local-embeddings' ? 'local embeddings Python runtime' : 'core Python runtime'; - return `ktx needs to install the ${label}. This downloads Python dependencies with uv. Continue?`; + return `ktx needs to install the ${label}. This downloads a pinned, checksum-verified uv build, Python, and dependencies. Continue?`; } function runtimeRequiredMessage(feature: KtxRuntimeFeature): string { @@ -144,3 +144,32 @@ export async function createManagedPythonSemanticLayerComputePort( ...(projectId ? { projectId } : {}), }); } + +/** + * Defers the managed-runtime install to the first semantic-layer call so a + * long-lived server (the MCP server) can start and serve context tools that + * need no Python even when uv is absent. Caches on success only, so a runtime + * installed mid-session is picked up on the next call. + */ +export function createLazyManagedPythonSemanticLayerComputePort( + options: ManagedPythonSemanticLayerComputeOptions, +): KtxSemanticLayerComputePort { + let cached: KtxSemanticLayerComputePort | undefined; + const resolve = async (): Promise => { + if (!cached) { + cached = await createManagedPythonSemanticLayerComputePort(options); + } + return cached; + }; + return { + async query(input) { + return (await resolve()).query(input); + }, + async validateSources(input) { + return (await resolve()).validateSources(input); + }, + async generateSources(input) { + return (await resolve()).generateSources(input); + }, + }; +} diff --git a/packages/cli/src/managed-python-runtime.ts b/packages/cli/src/managed-python-runtime.ts index b65f7fb3..263140aa 100644 --- a/packages/cli/src/managed-python-runtime.ts +++ b/packages/cli/src/managed-python-runtime.ts @@ -1,12 +1,19 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { access, appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, appendFile, chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { strFromU8, unzipSync } from 'fflate'; +import { gunzipSync, strFromU8, unzipSync } from 'fflate'; import { z } from 'zod'; +import { KtxExpectedError } from './errors.js'; +import { + MANAGED_UV_ARTIFACTS, + MANAGED_UV_VERSION, + type ManagedUvArtifact, + type ManagedUvPlatformKey, +} from './managed-uv-release.js'; const execFileAsync = promisify(execFile); @@ -96,6 +103,7 @@ export interface ManagedPythonRuntimeInstallOptions extends ManagedPythonRuntime features: KtxRuntimeFeature[]; force?: boolean; exec?: ManagedPythonRuntimeExec; + fetchUvArtifact?: ManagedUvFetchArtifact; } export interface ManagedPythonRuntimeInstallResult { @@ -122,9 +130,29 @@ export interface ManagedPythonRuntimeDoctorCheck { fix?: string; } +export type ManagedUvFetchArtifact = (url: string) => Promise; + /** @internal */ -export const MISSING_UV_RUNTIME_INSTALL_MESSAGE = - 'uv is required to install the ktx Python runtime. ktx does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx admin runtime install --yes'; +export interface ManagedUvRelease { + version: string; + artifacts: Partial>; +} + +const PINNED_UV_RELEASE: ManagedUvRelease = { + version: MANAGED_UV_VERSION, + artifacts: MANAGED_UV_ARTIFACTS, +}; + +/** @internal */ +export interface EnsureManagedUvOptions { + platform?: NodeJS.Platform; + arch?: string; + env?: NodeJS.ProcessEnv; + homeDir?: string; + runtimeRoot?: string; + fetchArtifact?: ManagedUvFetchArtifact; + release?: ManagedUvRelease; +} function defaultAssetDir(): string { return fileURLToPath(new URL('../assets/python/', import.meta.url)); @@ -347,12 +375,145 @@ function managedRuntimeUvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...baseEnv, UV_NO_CONFIG: '1' }; } -async function ensureUv(exec: ManagedPythonRuntimeExec, env?: NodeJS.ProcessEnv): Promise { +function managedUvBinaryName(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'uv.exe' : 'uv'; +} + +/** @internal */ +export function managedUvPath(options: EnsureManagedUvOptions = {}): string { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ env, homeDir }); + const version = (options.release ?? PINNED_UV_RELEASE).version; + return join(runtimeRoot, 'uv', version, managedUvBinaryName(platform)); +} + +async function defaultFetchUvArtifact(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +function readTarField(block: Uint8Array, start: number, length: number): string { + const field = block.subarray(start, start + length); + const end = field.indexOf(0); + return strFromU8(end < 0 ? field : field.subarray(0, end)); +} + +function findTarEntry(archive: Uint8Array, matches: (name: string) => boolean): Uint8Array | undefined { + let offset = 0; + while (offset + 512 <= archive.length) { + const block = archive.subarray(offset, offset + 512); + const name = readTarField(block, 0, 100); + if (!name) { + return undefined; + } + const size = Number.parseInt(readTarField(block, 124, 12).trim() || '0', 8); + if (matches(name)) { + return archive.subarray(offset + 512, offset + 512 + size); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + return undefined; +} + +function extractUvFromArchive(input: { file: string; contents: Uint8Array; binaryName: string }): Uint8Array { + const entry = input.file.endsWith('.zip') + ? unzipSync(input.contents)[input.binaryName] + : findTarEntry(gunzipSync(input.contents), (name) => name === input.binaryName || name.endsWith(`/${input.binaryName}`)); + if (!entry) { + throw new Error(`uv archive ${input.file} is missing the ${input.binaryName} binary`); + } + return entry; +} + +/** + * ktx provisions its own pinned uv under the runtime root; uv on PATH is never + * consulted, so runtime installs behave identically on every machine. All + * failures here are environment outcomes (offline host, intercepting proxy, + * unsupported platform) and stay out of Error Tracking via KtxExpectedError — + * except a pin/layout mismatch inside a checksum-verified archive, which is a + * ktx release fault and must reach Error Tracking. + * @internal + */ +export async function ensureManagedUv(options: EnsureManagedUvOptions = {}): Promise { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const release = options.release ?? PINNED_UV_RELEASE; + const binaryName = managedUvBinaryName(platform); + const uvPath = managedUvPath(options); + if (await pathExists(uvPath)) { + return uvPath; + } + + const artifact = release.artifacts[`${platform}-${arch}` as ManagedUvPlatformKey]; + if (!artifact) { + throw new KtxExpectedError( + `ktx does not bundle uv for ${platform}-${arch}. Place a uv ${release.version} binary at ${uvPath} and retry: ktx admin runtime install --yes`, + ); + } + + const url = `https://github.com/astral-sh/uv/releases/download/${release.version}/${artifact.file}`; + let contents: Uint8Array; try { - const result = await exec('uv', ['--version'], { env }); - return result.stdout.trim() || 'uv available'; - } catch { - throw new Error(MISSING_UV_RUNTIME_INSTALL_MESSAGE); + contents = await (options.fetchArtifact ?? defaultFetchUvArtifact)(url); + } catch (error) { + throw new KtxExpectedError( + `ktx could not download uv ${release.version} (required to install the ktx Python runtime). ` + + 'Check network access to github.com and retry: ktx admin runtime install --yes. ' + + `Air-gapped hosts: place the uv binary at ${uvPath}.`, + { cause: error }, + ); + } + + const sha256 = createHash('sha256').update(contents).digest('hex'); + if (sha256 !== artifact.sha256) { + throw new KtxExpectedError( + `Downloaded uv ${release.version} failed checksum verification (a proxy or captive portal may have altered the download). Retry: ktx admin runtime install --yes`, + ); + } + + const binary = extractUvFromArchive({ file: artifact.file, contents, binaryName }); + await mkdir(dirname(uvPath), { recursive: true }); + const stagedPath = `${uvPath}.${process.pid}.download`; + await writeFile(stagedPath, binary); + await chmod(stagedPath, 0o755); + try { + await rename(stagedPath, uvPath); + } catch (error) { + // On Windows a concurrent install may have won the rename; the binary at + // uvPath is checksum-pinned identical, so reuse it. + await rm(stagedPath, { force: true }); + if (!(await pathExists(uvPath))) { + throw error; + } + } + return uvPath; +} + +async function ensureUv(input: { + exec: ManagedPythonRuntimeExec; + uvEnv: NodeJS.ProcessEnv; + options: ManagedPythonRuntimeLayoutOptions & { fetchUvArtifact?: ManagedUvFetchArtifact }; +}): Promise<{ uvPath: string; version: string }> { + const uvPath = await ensureManagedUv({ + platform: input.options.platform, + env: input.options.env, + homeDir: input.options.homeDir, + runtimeRoot: input.options.runtimeRoot, + fetchArtifact: input.options.fetchUvArtifact, + }); + try { + const result = await input.exec(uvPath, ['--version'], { env: input.uvEnv }); + return { uvPath, version: result.stdout.trim() || `uv ${MANAGED_UV_VERSION}` }; + } catch (error) { + throw new KtxExpectedError( + `Managed uv at ${uvPath} failed to run. Delete it and retry: ktx admin runtime install --yes`, + { cause: error }, + ); } } @@ -377,21 +538,23 @@ export async function installManagedPythonRuntime( return { status: 'ready', layout, asset, manifest: existing }; } + // uv is acquired before the version dir is wiped, so a failed acquisition + // never destroys a previously installed runtime. + const { uvPath } = await ensureUv({ exec, uvEnv, options }); await rm(layout.versionDir, { recursive: true, force: true }); await mkdir(layout.versionDir, { recursive: true }); await writeFile(layout.installLogPath, ''); - await ensureUv(exec, uvEnv); await runLogged({ exec, logPath: layout.installLogPath, - command: 'uv', + command: uvPath, args: ['python', 'install', asset.requiresPython.minimumVersion], env: uvEnv, }); await runLogged({ exec, logPath: layout.installLogPath, - command: 'uv', + command: uvPath, args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir], env: uvEnv, }); @@ -399,7 +562,7 @@ export async function installManagedPythonRuntime( await runLogged({ exec, logPath: layout.installLogPath, - command: 'uv', + command: uvPath, args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec], env: uvEnv, }); @@ -462,20 +625,20 @@ function check( } export async function doctorManagedPythonRuntime( - options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec }, + options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec; fetchUvArtifact?: ManagedUvFetchArtifact }, ): Promise { const exec = options.exec ?? defaultExec; const checks: ManagedPythonRuntimeDoctorCheck[] = []; try { - const version = await ensureUv(exec, managedRuntimeUvEnv(options.env ?? process.env)); - checks.push(check('pass', { id: 'uv', label: 'uv', detail: version })); + const uv = await ensureUv({ exec, uvEnv: managedRuntimeUvEnv(options.env ?? process.env), options }); + checks.push(check('pass', { id: 'uv', label: 'uv', detail: `${uv.version} (managed: ${uv.uvPath})` })); } catch (error) { checks.push( check('fail', { id: 'uv', label: 'uv', detail: error instanceof Error ? error.message : String(error), - fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes', + fix: 'Check network access to github.com and run: ktx admin runtime install --yes', }), ); } diff --git a/packages/cli/src/managed-uv-release.ts b/packages/cli/src/managed-uv-release.ts new file mode 100644 index 00000000..22e861c0 --- /dev/null +++ b/packages/cli/src/managed-uv-release.ts @@ -0,0 +1,26 @@ +// Generated by scripts/refresh-uv-manifest.mjs. Do not edit by hand. +// Regenerate with: node scripts/refresh-uv-manifest.mjs [] + +export type ManagedUvPlatformKey = + | 'darwin-arm64' + | 'darwin-x64' + | 'linux-arm64' + | 'linux-x64' + | 'win32-arm64' + | 'win32-x64'; + +export interface ManagedUvArtifact { + file: string; + sha256: string; +} + +export const MANAGED_UV_VERSION = '0.11.21'; + +export const MANAGED_UV_ARTIFACTS: Record = { + 'darwin-arm64': { file: 'uv-aarch64-apple-darwin.tar.gz', sha256: '1f921d491ba5ffeea774eb04d6681ecee379101341cbb1500394993b541bf3f4' }, // pragma: allowlist secret + 'darwin-x64': { file: 'uv-x86_64-apple-darwin.tar.gz', sha256: 'f3c8e5708a84b920c18b691214d54d2b0da6b984789caae95d47c95120cb7765' }, // pragma: allowlist secret + 'linux-arm64': { file: 'uv-aarch64-unknown-linux-musl.tar.gz', sha256: 'e71badaed2a2c3a404a0a00974b51c7ed5f5bc7be947916846005b739c68a5a2' }, // pragma: allowlist secret + 'linux-x64': { file: 'uv-x86_64-unknown-linux-musl.tar.gz', sha256: '9dadff5b9e7b1d2d011e41852a1cbca713d9d5d88194f2eb6bd240fa4fb0a719' }, // pragma: allowlist secret + 'win32-arm64': { file: 'uv-aarch64-pc-windows-msvc.zip', sha256: '74e443f8004022dde57a1bd0d10c097830f9ea8feb4ec927db52cd5d805c2f48' }, // pragma: allowlist secret + 'win32-x64': { file: 'uv-x86_64-pc-windows-msvc.zip', sha256: 'ace861f360c6de2babedc1607d0f454b6b09a820dbc8182dc15af927e4df9589' }, // pragma: allowlist secret +}; diff --git a/packages/cli/src/mcp-server-factory.ts b/packages/cli/src/mcp-server-factory.ts index fbecccd8..84a00253 100644 --- a/packages/cli/src/mcp-server-factory.ts +++ b/packages/cli/src/mcp-server-factory.ts @@ -8,7 +8,7 @@ import type { KtxCliIo } from './cli-runtime.js'; import { resolveProjectEmbeddingProvider } from './embedding-resolution.js'; import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; -import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js'; +import { createLazyManagedPythonSemanticLayerComputePort } from './managed-python-command.js'; import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js'; function noopMcpIo(): KtxCliIo { @@ -26,7 +26,7 @@ export async function createKtxMcpServerFactory(input: { }): Promise<() => McpServer> { const io = input.io ?? noopMcpIo(); const queryExecutor = createKtxCliIngestQueryExecutor(input.project); - const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({ + const semanticLayerCompute = createLazyManagedPythonSemanticLayerComputePort({ cliVersion: input.cliVersion, installPolicy: 'auto', io, diff --git a/packages/cli/test/managed-python-command.test.ts b/packages/cli/test/managed-python-command.test.ts index a782ae43..05356b59 100644 --- a/packages/cli/test/managed-python-command.test.ts +++ b/packages/cli/test/managed-python-command.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import { KtxExpectedError } from '../src/errors.js'; import { + createLazyManagedPythonSemanticLayerComputePort, createManagedPythonSemanticLayerComputePort, ensureManagedPythonCommandRuntime, managedRuntimeInstallCommand, @@ -274,7 +276,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => { }); expect(confirmInstall).toHaveBeenCalledWith( - 'ktx needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?', + 'ktx needs to install the core Python runtime. This downloads a pinned, checksum-verified uv build, Python, and dependencies. Continue?', io.io, ); expect(installRuntime).toHaveBeenCalledWith({ @@ -306,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => { ).resolves.toBe(compute); expect(confirmInstall).toHaveBeenCalledWith( - 'ktx needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?', + 'ktx needs to install the core Python runtime. This downloads a pinned, checksum-verified uv build, Python, and dependencies. Continue?', io.io, ); expect(events).toContainEqual('start:Installing ktx Python runtime (core) with uv...'); @@ -328,3 +330,95 @@ describe('createManagedPythonSemanticLayerComputePort', () => { ).rejects.toThrow('ktx Python runtime installation was cancelled'); }); }); + +describe('createLazyManagedPythonSemanticLayerComputePort', () => { + it('does not touch the managed runtime at construction, so a server starts without uv', async () => { + const io = makeIo(); + const readStatus = vi.fn(async () => missingStatus()); + const installRuntime = vi.fn(async (): Promise => { + throw new KtxExpectedError('uv missing'); + }); + const createPythonCompute = vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })); + + const port = createLazyManagedPythonSemanticLayerComputePort({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: io.io, + readStatus, + installRuntime, + createPythonCompute, + }); + + expect(readStatus).not.toHaveBeenCalled(); + expect(installRuntime).not.toHaveBeenCalled(); + expect(createPythonCompute).not.toHaveBeenCalled(); + + await expect(port.query({ sources: [], query: {} as never, dialect: 'postgres' })).rejects.toBeInstanceOf( + KtxExpectedError, + ); + expect(installRuntime).toHaveBeenCalledTimes(1); + }); + + it('resolves the runtime once and reuses it across calls', async () => { + const io = makeIo(); + const readStatus = vi.fn(async () => readyStatus()); + const compute = { + query: vi.fn(), + validateSources: vi.fn(), + generateSources: vi.fn(), + }; + const createPythonCompute = vi.fn(() => compute); + + const port = createLazyManagedPythonSemanticLayerComputePort({ + cliVersion: '0.2.0', + installPolicy: 'never', + io: io.io, + readStatus, + installRuntime: vi.fn(), + createPythonCompute, + }); + + await port.query({ sources: [], query: {} as never, dialect: 'postgres' }); + await port.validateSources({ sources: [], dialect: 'postgres' }); + + expect(readStatus).toHaveBeenCalledTimes(1); + expect(createPythonCompute).toHaveBeenCalledTimes(1); + expect(compute.query).toHaveBeenCalledTimes(1); + expect(compute.validateSources).toHaveBeenCalledTimes(1); + }); + + it('retries the runtime resolution after a failed attempt', async () => { + const io = makeIo(); + const compute = { + query: vi.fn(), + validateSources: vi.fn(), + generateSources: vi.fn(), + }; + const createPythonCompute = vi.fn(() => compute); + let attempt = 0; + const installRuntime = vi.fn(async (): Promise => { + attempt += 1; + if (attempt === 1) { + throw new KtxExpectedError('uv missing'); + } + return installResult(); + }); + + const port = createLazyManagedPythonSemanticLayerComputePort({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: io.io, + readStatus: vi.fn(async () => missingStatus()), + installRuntime, + createPythonCompute, + }); + + await expect(port.query({ sources: [], query: {} as never, dialect: 'postgres' })).rejects.toBeInstanceOf( + KtxExpectedError, + ); + await port.query({ sources: [], query: {} as never, dialect: 'postgres' }); + + expect(installRuntime).toHaveBeenCalledTimes(2); + expect(compute.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/test/managed-python-runtime.test.ts b/packages/cli/test/managed-python-runtime.test.ts index 143802ad..8070c7f2 100644 --- a/packages/cli/test/managed-python-runtime.test.ts +++ b/packages/cli/test/managed-python-runtime.test.ts @@ -1,19 +1,61 @@ import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { strToU8, zipSync } from 'fflate'; +import { dirname, join } from 'node:path'; +import { gzipSync, strToU8, zipSync } from 'fflate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { KtxExpectedError } from '../src/errors.js'; import { - MISSING_UV_RUNTIME_INSTALL_MESSAGE, doctorManagedPythonRuntime, + ensureManagedUv, installManagedPythonRuntime, managedPythonDaemonLayout, managedPythonRuntimeLayout, + managedUvPath, readManagedPythonRuntimeStatus, verifyRuntimeAsset, type ManagedPythonRuntimeExec, + type ManagedUvRelease, } from '../src/managed-python-runtime.js'; +import type { ManagedUvPlatformKey } from '../src/managed-uv-release.js'; + +async function placeFakeUv(runtimeRoot: string): Promise { + const uvPath = managedUvPath({ runtimeRoot }); + await mkdir(dirname(uvPath), { recursive: true }); + await writeFile(uvPath, '#!/bin/sh\n'); + return uvPath; +} + +function tarball(entries: Record): Uint8Array { + const blocks: Uint8Array[] = []; + for (const [name, data] of Object.entries(entries)) { + const header = new Uint8Array(512); + header.set(strToU8(name), 0); + header.set(strToU8('0000755\0'), 100); + header.set(strToU8(`${data.length.toString(8).padStart(11, '0')}\0`), 124); + blocks.push(header); + const padded = new Uint8Array(Math.ceil(data.length / 512) * 512); + padded.set(data); + blocks.push(padded); + } + blocks.push(new Uint8Array(1024)); + const out = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0)); + let offset = 0; + for (const block of blocks) { + out.set(block, offset); + offset += block.length; + } + return out; +} + +function releaseFor(file: string, contents: Uint8Array, key: ManagedUvPlatformKey): ManagedUvRelease { + return { + version: '9.9.9-test', + artifacts: { + [key]: { file, sha256: createHash('sha256').update(contents).digest('hex') }, + }, + }; +} function runtimeWheelContents(input: { label?: string; requiresPython?: string | null } = {}): Buffer { const label = input.label ?? 'runtime-wheel'; @@ -246,10 +288,11 @@ describe('installManagedPythonRuntime', () => { it('creates a venv, installs the core wheel, and writes a manifest', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const commands: Array<{ command: string; args: string[] }> = []; const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { commands.push({ command, args }); - return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; }); const result = await installManagedPythonRuntime({ @@ -262,11 +305,11 @@ describe('installManagedPythonRuntime', () => { expect(result.status).toBe('installed'); expect(commands).toEqual([ - { command: 'uv', args: ['--version'] }, - { command: 'uv', args: ['python', 'install', '3.13'] }, - { command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] }, + { command: uvPath, args: ['--version'] }, + { command: uvPath, args: ['python', 'install', '3.13'] }, + { command: uvPath, args: ['venv', '--python', '3.13', result.layout.venvDir] }, { - command: 'uv', + command: uvPath, args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath], }, ]); @@ -283,10 +326,11 @@ describe('installManagedPythonRuntime', () => { it('disables repo uv config for managed runtime uv commands', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => { commands.push({ command, args, env: options?.env }); - return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' }; + return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' }; }); await installManagedPythonRuntime({ @@ -299,19 +343,20 @@ describe('installManagedPythonRuntime', () => { }); expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([ - ['uv', '--version', '1', '/opt/homebrew/bin'], - ['uv', 'python', '1', '/opt/homebrew/bin'], - ['uv', 'venv', '1', '/opt/homebrew/bin'], - ['uv', 'pip', '1', '/opt/homebrew/bin'], + [uvPath, '--version', '1', '/opt/homebrew/bin'], + [uvPath, 'python', '1', '/opt/homebrew/bin'], + [uvPath, 'venv', '1', '/opt/homebrew/bin'], + [uvPath, 'pip', '1', '/opt/homebrew/bin'], ]); }); it('installs the local-embeddings extra when requested', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const commands: Array<{ command: string; args: string[] }> = []; const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { commands.push({ command, args }); - return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; }); const result = await installManagedPythonRuntime({ @@ -323,38 +368,72 @@ describe('installManagedPythonRuntime', () => { }); expect(commands.at(-1)).toEqual({ - command: 'uv', + command: uvPath, args: ['pip', 'install', '--python', result.layout.pythonPath, `${result.asset.wheelPath}[local-embeddings]`], }); const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { features: string[] }; expect(manifest.features).toEqual(['core', 'local-embeddings']); }); - it('fails with the hard-prerequisite message when uv is missing', async () => { + it('attempts the pinned uv download from github.com and rejects checksum mismatches', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); - const commands: Array<{ command: string; args: string[] }> = []; - const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { - commands.push({ command, args }); - throw new Error('spawn uv ENOENT'); + const runtimeRoot = join(tempDir, 'runtime'); + const archive = gzipSync(tarball({ 'uv-test/uv': strToU8('#!/bin/sh\necho uv\n') })); + const fetchUvArtifact = vi.fn(async () => archive); + const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: 'uv 9.9.9\n', stderr: '' })); + + const error = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot, + assetDir, + features: ['core'], + exec, + fetchUvArtifact, + }).catch((caught: unknown) => caught); + + expect(fetchUvArtifact).toHaveBeenCalledTimes(1); + expect(fetchUvArtifact).toHaveBeenCalledWith( + expect.stringMatching(/^https:\/\/github\.com\/astral-sh\/uv\/releases\/download\//), + ); + expect(error).toBeInstanceOf(KtxExpectedError); + expect((error as Error).message).toContain('failed checksum verification'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('fails with download guidance and preserves the existing runtime when uv cannot be fetched', async () => { + const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const runtimeRoot = join(tempDir, 'runtime'); + const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: '', stderr: '' })); + const fetchUvArtifact = vi.fn(async () => { + throw new Error('getaddrinfo ENOTFOUND github.com'); }); + const survivingRuntimeFile = join(runtimeRoot, '0.2.0', 'install.log'); + await mkdir(dirname(survivingRuntimeFile), { recursive: true }); + await writeFile(survivingRuntimeFile, 'stale runtime contents\n'); - await expect( - installManagedPythonRuntime({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), - assetDir, - features: ['core'], - exec, - }), - ).rejects.toThrow(MISSING_UV_RUNTIME_INSTALL_MESSAGE); + const error = await installManagedPythonRuntime({ + cliVersion: '0.2.0', + runtimeRoot, + assetDir, + features: ['core'], + exec, + fetchUvArtifact, + }).catch((caught: unknown) => caught); - expect(commands).toEqual([{ command: 'uv', args: ['--version'] }]); + // KtxExpectedError keeps this user-environment outcome out of Error Tracking. + expect(error).toBeInstanceOf(KtxExpectedError); + expect((error as Error).message).toContain('could not download uv'); + expect((error as Error).message).toContain('ktx admin runtime install --yes'); + expect(exec).not.toHaveBeenCalled(); + // A failed uv acquisition must not wipe whatever runtime is already on disk. + await expect(readFile(survivingRuntimeFile, 'utf8')).resolves.toContain('stale'); }); it('reuses an existing compatible runtime when force is false', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ - stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '', })); @@ -383,14 +462,15 @@ describe('installManagedPythonRuntime', () => { it('keeps failed install logs in the versioned runtime directory', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => { - if (command === 'uv' && args[0] === 'venv') { + if (command === uvPath && args[0] === 'venv') { throw Object.assign(new Error('uv venv failed'), { stdout: 'creating\n', stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\n', }); } - return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; + return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' }; }); await expect( @@ -404,11 +484,98 @@ describe('installManagedPythonRuntime', () => { ).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/); const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8'); - expect(log).toContain('$ uv venv --python 3.13'); + expect(log).toContain(`$ ${uvPath} venv --python 3.13`); expect(log).toContain('current Python version (3.12.3) does not satisfy Python>=3.13'); }); }); +describe('ensureManagedUv', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-managed-uv-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('downloads, verifies, and extracts uv from a tar.gz artifact, then reuses the cached binary', async () => { + const binary = strToU8('#!/bin/sh\necho uv\n'); + const archive = gzipSync(tarball({ 'uv-test/': new Uint8Array(0), 'uv-test/uvx': strToU8('x'), 'uv-test/uv': binary })); + const release = releaseFor('uv-test.tar.gz', archive, 'linux-x64'); + const fetchArtifact = vi.fn(async () => archive); + + const uvPath = await ensureManagedUv({ + platform: 'linux', + arch: 'x64', + runtimeRoot: join(tempDir, 'runtime'), + fetchArtifact, + release, + }); + + expect(uvPath).toBe(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv')); + await expect(readFile(uvPath, 'utf8')).resolves.toBe('#!/bin/sh\necho uv\n'); + + const again = await ensureManagedUv({ + platform: 'linux', + arch: 'x64', + runtimeRoot: join(tempDir, 'runtime'), + fetchArtifact, + release, + }); + expect(again).toBe(uvPath); + expect(fetchArtifact).toHaveBeenCalledTimes(1); + }); + + it('extracts uv.exe from a zip artifact on Windows', async () => { + const archive = zipSync({ 'uv.exe': strToU8('MZ-uv'), 'uvx.exe': strToU8('MZ-uvx') }); + const release = releaseFor('uv-test.zip', archive, 'win32-x64'); + + const uvPath = await ensureManagedUv({ + platform: 'win32', + arch: 'x64', + runtimeRoot: join(tempDir, 'runtime'), + fetchArtifact: vi.fn(async () => archive), + release, + }); + + expect(uvPath).toBe(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv.exe')); + await expect(readFile(uvPath, 'utf8')).resolves.toBe('MZ-uv'); + }); + + it('rejects an artifact whose checksum does not match the pin', async () => { + const archive = gzipSync(tarball({ 'uv-test/uv': strToU8('uv') })); + const release = releaseFor('uv-test.tar.gz', archive, 'linux-x64'); + release.artifacts['linux-x64']!.sha256 = 'b'.repeat(64); + + const error = await ensureManagedUv({ + platform: 'linux', + arch: 'x64', + runtimeRoot: join(tempDir, 'runtime'), + fetchArtifact: vi.fn(async () => archive), + release, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(KtxExpectedError); + expect((error as Error).message).toContain('failed checksum verification'); + }); + + it('fails with manual-placement guidance on platforms without a pinned artifact', async () => { + const error = await ensureManagedUv({ + platform: 'sunos', + arch: 'x64', + runtimeRoot: join(tempDir, 'runtime'), + fetchArtifact: vi.fn(), + release: { version: '9.9.9-test', artifacts: {} }, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(KtxExpectedError); + expect((error as Error).message).toContain('does not bundle uv for sunos-x64'); + expect((error as Error).message).toContain(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv')); + }); +}); + describe('readManagedPythonRuntimeStatus', () => { let tempDir: string; @@ -433,8 +600,9 @@ describe('readManagedPythonRuntimeStatus', () => { it('reports ready when manifest and executables exist', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ - stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '', })); const install = await installManagedPythonRuntime({ @@ -460,8 +628,9 @@ describe('readManagedPythonRuntimeStatus', () => { it('reports broken when an executable is missing', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ - stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '', })); await installManagedPythonRuntime({ @@ -496,8 +665,9 @@ describe('doctorManagedPythonRuntime', () => { it('checks uv, bundled assets, and installed runtime status', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); + const uvPath = await placeFakeUv(join(tempDir, 'runtime')); const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({ - stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', + stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '', })); @@ -513,28 +683,27 @@ describe('doctorManagedPythonRuntime', () => { ['asset', 'pass'], ['runtime', 'fail'], ]); + expect(checks[0]?.detail).toBe(`uv 0.9.5 (managed: ${uvPath})`); expect(checks[2]?.fix).toBe('Run: ktx admin runtime install --yes'); }); - it('reports uv as a hard prerequisite when uv is missing', async () => { + it('fails the uv check with download guidance when uv cannot be acquired', async () => { const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' }); - const exec: ManagedPythonRuntimeExec = vi.fn(async () => { - throw new Error('spawn uv ENOENT'); - }); + const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: '', stderr: '' })); const checks = await doctorManagedPythonRuntime({ cliVersion: '0.2.0', runtimeRoot: join(tempDir, 'runtime'), assetDir, exec, + fetchUvArtifact: vi.fn(async () => { + throw new Error('getaddrinfo ENOTFOUND github.com'); + }), }); - expect(checks[0]).toEqual({ - id: 'uv', - label: 'uv', - status: 'fail', - detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE, - fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes', - }); + expect(checks[0]?.id).toBe('uv'); + expect(checks[0]?.status).toBe('fail'); + expect(checks[0]?.detail).toContain('could not download uv'); + expect(checks[0]?.fix).toBe('Check network access to github.com and run: ktx admin runtime install --yes'); }); }); diff --git a/packages/cli/test/mcp-server-factory.test.ts b/packages/cli/test/mcp-server-factory.test.ts index e43fcb49..eb378bcf 100644 --- a/packages/cli/test/mcp-server-factory.test.ts +++ b/packages/cli/test/mcp-server-factory.test.ts @@ -4,6 +4,7 @@ import { createLocalProjectMcpContextPorts } from '../src/context/mcp/local-proj import { createLocalProjectMemoryIngest } from '../src/context/memory/local-memory.js'; import { resolveProjectEmbeddingProvider } from '../src/embedding-resolution.js'; import { createKtxCliScanConnector } from '../src/local-scan-connectors.js'; +import { createLazyManagedPythonSemanticLayerComputePort } from '../src/managed-python-command.js'; import { createKtxMcpServerFactory } from '../src/mcp-server-factory.js'; type FakeEmbeddingProvider = { @@ -62,7 +63,7 @@ vi.mock('../src/local-scan-connectors.js', () => ({ })); vi.mock('../src/managed-python-command.js', () => ({ - createManagedPythonSemanticLayerComputePort: vi.fn(async () => mocks.semanticLayerCompute), + createLazyManagedPythonSemanticLayerComputePort: vi.fn(() => mocks.semanticLayerCompute), })); vi.mock('../src/managed-python-http.js', () => ({ @@ -124,6 +125,13 @@ describe('createKtxMcpServerFactory', () => { expect(provider.embed).toHaveBeenCalledWith('gross revenue'); expect(provider.embedMany).toHaveBeenCalledWith(['gross revenue']); expect(createKtxCliScanConnector).toHaveBeenCalledWith(project, 'warehouse'); + // The server must wire the lazy compute port so startup never blocks on (or + // fails over) a missing managed Python runtime / uv. + expect(createLazyManagedPythonSemanticLayerComputePort).toHaveBeenCalledWith({ + cliVersion: '0.5.0', + installPolicy: 'auto', + io, + }); expect(contextOptions).toMatchObject({ queryExecutor: mocks.queryExecutor, semanticLayerCompute: mocks.semanticLayerCompute, diff --git a/scripts/refresh-uv-manifest.mjs b/scripts/refresh-uv-manifest.mjs new file mode 100644 index 00000000..aaac0b47 --- /dev/null +++ b/scripts/refresh-uv-manifest.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const manifestModulePath = path.join(scriptDir, '..', 'packages', 'cli', 'src', 'managed-uv-release.ts'); + +// Linux always uses the musl-static build: it runs on both glibc and musl +// distributions, so the CLI never has to detect libc at runtime. +const PLATFORM_ARTIFACTS = { + 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', + 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', + 'linux-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', + 'linux-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', + 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', + 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', +}; + +function parseSha256File(contents, file) { + const hash = contents.trim().split(/\s+/)[0]?.replace(/^\*/, ''); + if (!/^[a-f0-9]{64}$/.test(hash ?? '')) { + throw new Error(`Unexpected sha256 file contents for ${file}: ${contents.slice(0, 120)}`); + } + return hash; +} + +export function renderUvReleaseModule(version, artifacts) { + const keys = Object.keys(PLATFORM_ARTIFACTS); + for (const key of keys) { + if (!artifacts[key]?.file || !artifacts[key]?.sha256) { + throw new Error(`Missing artifact entry for ${key}`); + } + } + const entries = keys + .map( + (key) => + ` '${key}': { file: '${artifacts[key].file}', sha256: '${artifacts[key].sha256}' }, // pragma: allowlist secret`, + ) + .join('\n'); + return `// Generated by scripts/refresh-uv-manifest.mjs. Do not edit by hand. +// Regenerate with: node scripts/refresh-uv-manifest.mjs [] + +export type ManagedUvPlatformKey = +${keys.map((key) => ` | '${key}'`).join('\n')}; + +export interface ManagedUvArtifact { + file: string; + sha256: string; +} + +export const MANAGED_UV_VERSION = '${version}'; + +export const MANAGED_UV_ARTIFACTS: Record = { +${entries} +}; +`; +} + +export async function refreshUvManifest(options = {}) { + const fetchImpl = options.fetch ?? fetch; + const writeFile = options.writeFile ?? writeFileSync; + const log = options.log ?? console.log; + const outputPath = options.outputPath ?? manifestModulePath; + + let version = options.version; + if (!version) { + const response = await fetchImpl('https://api.github.com/repos/astral-sh/uv/releases/latest'); + if (!response.ok) { + throw new Error(`Failed to resolve latest uv release: HTTP ${response.status}`); + } + version = (await response.json()).tag_name; + } + + const artifacts = {}; + for (const [key, file] of Object.entries(PLATFORM_ARTIFACTS)) { + const url = `https://github.com/astral-sh/uv/releases/download/${version}/${file}.sha256`; + const response = await fetchImpl(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); + } + artifacts[key] = { file, sha256: parseSha256File(await response.text(), file) }; + } + + writeFile(outputPath, renderUvReleaseModule(version, artifacts)); + log(`Pinned uv ${version} into ${outputPath}`); + return { version, artifacts }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + refreshUvManifest({ version: process.argv[2] }).catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/refresh-uv-manifest.test.mjs b/scripts/refresh-uv-manifest.test.mjs new file mode 100644 index 00000000..3275f86a --- /dev/null +++ b/scripts/refresh-uv-manifest.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { refreshUvManifest, renderUvReleaseModule } from './refresh-uv-manifest.mjs'; + +const ALL_KEYS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-arm64', 'win32-x64']; + +function artifactsFixture() { + return Object.fromEntries( + ALL_KEYS.map((key, index) => [key, { file: `uv-${key}.tar.gz`, sha256: String(index).repeat(64).slice(0, 64) }]), + ); +} + +describe('renderUvReleaseModule', () => { + it('renders a typed module with every platform key and the pinned version', () => { + const module = renderUvReleaseModule('1.2.3', artifactsFixture()); + + assert.match(module, /MANAGED_UV_VERSION = '1\.2\.3'/); + assert.match(module, /Generated by scripts\/refresh-uv-manifest\.mjs/); + for (const key of ALL_KEYS) { + assert.match(module, new RegExp(`'${key}': \\{ file: 'uv-${key}\\.tar\\.gz', sha256: '[a-f0-9]{64}' \\}`)); + } + }); + + it('rejects an incomplete platform map', () => { + const artifacts = artifactsFixture(); + delete artifacts['win32-arm64']; + assert.throws(() => renderUvReleaseModule('1.2.3', artifacts), /Missing artifact entry for win32-arm64/); + }); +}); + +describe('refreshUvManifest', () => { + it('fetches per-artifact sha256 files and writes the module', async () => { + const written = []; + const fetched = []; + const fetchStub = async (url) => { + fetched.push(url); + return { + ok: true, + text: async () => `${'a'.repeat(64)} *${url.split('/').at(-1).replace('.sha256', '')}\n`, + }; + }; + + const result = await refreshUvManifest({ + version: '1.2.3', + fetch: fetchStub, + writeFile: (path, contents) => written.push({ path, contents }), + log: () => {}, + outputPath: '/tmp/managed-uv-release.ts', + }); + + assert.equal(result.version, '1.2.3'); + assert.equal(fetched.length, ALL_KEYS.length); + assert.ok(fetched.every((url) => url.endsWith('.sha256') && url.includes('/download/1.2.3/'))); + assert.equal(written.length, 1); + assert.match(written[0].contents, /MANAGED_UV_VERSION = '1\.2\.3'/); + }); + + it('rejects malformed sha256 file contents', async () => { + await assert.rejects( + refreshUvManifest({ + version: '1.2.3', + fetch: async () => ({ ok: true, text: async () => 'proxy login' }), + writeFile: () => {}, + log: () => {}, + }), + /Unexpected sha256 file contents/, + ); + }); +});