mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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.
This commit is contained in:
parent
663eaff940
commit
feb0818444
11 changed files with 731 additions and 72 deletions
|
|
@ -48,6 +48,11 @@ directory. Use it from any directory to generate editor or agent schema files.
|
||||||
| `stop` | Stop the **ktx** daemon |
|
| `stop` | Stop the **ktx** daemon |
|
||||||
| `status` | Show managed Python runtime status and readiness checks |
|
| `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
|
## `admin runtime` Options
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|
|
|
||||||
|
|
@ -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 <path>` |
|
| 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 <path>` |
|
||||||
| Non-loopback host rejected | The server needs token auth before binding beyond localhost | Pass `--token <token>` or set `KTX_MCP_TOKEN` |
|
| Non-loopback host rejected | The server needs token auth before binding beyond localhost | Pass `--token <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 |
|
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string
|
||||||
|
|
||||||
function installPrompt(feature: KtxRuntimeFeature): string {
|
function installPrompt(feature: KtxRuntimeFeature): string {
|
||||||
const label = feature === 'local-embeddings' ? 'local embeddings Python runtime' : 'core Python runtime';
|
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 {
|
function runtimeRequiredMessage(feature: KtxRuntimeFeature): string {
|
||||||
|
|
@ -144,3 +144,32 @@ export async function createManagedPythonSemanticLayerComputePort(
|
||||||
...(projectId ? { projectId } : {}),
|
...(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<KtxSemanticLayerComputePort> => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
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 { homedir } from 'node:os';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { strFromU8, unzipSync } from 'fflate';
|
import { gunzipSync, strFromU8, unzipSync } from 'fflate';
|
||||||
import { z } from 'zod';
|
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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|
@ -96,6 +103,7 @@ export interface ManagedPythonRuntimeInstallOptions extends ManagedPythonRuntime
|
||||||
features: KtxRuntimeFeature[];
|
features: KtxRuntimeFeature[];
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
exec?: ManagedPythonRuntimeExec;
|
exec?: ManagedPythonRuntimeExec;
|
||||||
|
fetchUvArtifact?: ManagedUvFetchArtifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManagedPythonRuntimeInstallResult {
|
export interface ManagedPythonRuntimeInstallResult {
|
||||||
|
|
@ -122,9 +130,29 @@ export interface ManagedPythonRuntimeDoctorCheck {
|
||||||
fix?: string;
|
fix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ManagedUvFetchArtifact = (url: string) => Promise<Uint8Array>;
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
|
export interface ManagedUvRelease {
|
||||||
'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';
|
version: string;
|
||||||
|
artifacts: Partial<Record<ManagedUvPlatformKey, ManagedUvArtifact>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function defaultAssetDir(): string {
|
||||||
return fileURLToPath(new URL('../assets/python/', import.meta.url));
|
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' };
|
return { ...baseEnv, UV_NO_CONFIG: '1' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUv(exec: ManagedPythonRuntimeExec, env?: NodeJS.ProcessEnv): Promise<string> {
|
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<Uint8Array> {
|
||||||
|
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<string> {
|
||||||
|
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 {
|
try {
|
||||||
const result = await exec('uv', ['--version'], { env });
|
contents = await (options.fetchArtifact ?? defaultFetchUvArtifact)(url);
|
||||||
return result.stdout.trim() || 'uv available';
|
} catch (error) {
|
||||||
} catch {
|
throw new KtxExpectedError(
|
||||||
throw new Error(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
|
`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 };
|
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 rm(layout.versionDir, { recursive: true, force: true });
|
||||||
await mkdir(layout.versionDir, { recursive: true });
|
await mkdir(layout.versionDir, { recursive: true });
|
||||||
await writeFile(layout.installLogPath, '');
|
await writeFile(layout.installLogPath, '');
|
||||||
await ensureUv(exec, uvEnv);
|
|
||||||
await runLogged({
|
await runLogged({
|
||||||
exec,
|
exec,
|
||||||
logPath: layout.installLogPath,
|
logPath: layout.installLogPath,
|
||||||
command: 'uv',
|
command: uvPath,
|
||||||
args: ['python', 'install', asset.requiresPython.minimumVersion],
|
args: ['python', 'install', asset.requiresPython.minimumVersion],
|
||||||
env: uvEnv,
|
env: uvEnv,
|
||||||
});
|
});
|
||||||
await runLogged({
|
await runLogged({
|
||||||
exec,
|
exec,
|
||||||
logPath: layout.installLogPath,
|
logPath: layout.installLogPath,
|
||||||
command: 'uv',
|
command: uvPath,
|
||||||
args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir],
|
args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir],
|
||||||
env: uvEnv,
|
env: uvEnv,
|
||||||
});
|
});
|
||||||
|
|
@ -399,7 +562,7 @@ export async function installManagedPythonRuntime(
|
||||||
await runLogged({
|
await runLogged({
|
||||||
exec,
|
exec,
|
||||||
logPath: layout.installLogPath,
|
logPath: layout.installLogPath,
|
||||||
command: 'uv',
|
command: uvPath,
|
||||||
args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec],
|
args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec],
|
||||||
env: uvEnv,
|
env: uvEnv,
|
||||||
});
|
});
|
||||||
|
|
@ -462,20 +625,20 @@ function check(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doctorManagedPythonRuntime(
|
export async function doctorManagedPythonRuntime(
|
||||||
options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec },
|
options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec; fetchUvArtifact?: ManagedUvFetchArtifact },
|
||||||
): Promise<ManagedPythonRuntimeDoctorCheck[]> {
|
): Promise<ManagedPythonRuntimeDoctorCheck[]> {
|
||||||
const exec = options.exec ?? defaultExec;
|
const exec = options.exec ?? defaultExec;
|
||||||
const checks: ManagedPythonRuntimeDoctorCheck[] = [];
|
const checks: ManagedPythonRuntimeDoctorCheck[] = [];
|
||||||
try {
|
try {
|
||||||
const version = await ensureUv(exec, managedRuntimeUvEnv(options.env ?? process.env));
|
const uv = await ensureUv({ exec, uvEnv: managedRuntimeUvEnv(options.env ?? process.env), options });
|
||||||
checks.push(check('pass', { id: 'uv', label: 'uv', detail: version }));
|
checks.push(check('pass', { id: 'uv', label: 'uv', detail: `${uv.version} (managed: ${uv.uvPath})` }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
checks.push(
|
checks.push(
|
||||||
check('fail', {
|
check('fail', {
|
||||||
id: 'uv',
|
id: 'uv',
|
||||||
label: 'uv',
|
label: 'uv',
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
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',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
packages/cli/src/managed-uv-release.ts
Normal file
26
packages/cli/src/managed-uv-release.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Generated by scripts/refresh-uv-manifest.mjs. Do not edit by hand.
|
||||||
|
// Regenerate with: node scripts/refresh-uv-manifest.mjs [<uv-version>]
|
||||||
|
|
||||||
|
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<ManagedUvPlatformKey, ManagedUvArtifact> = {
|
||||||
|
'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
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,7 @@ import type { KtxCliIo } from './cli-runtime.js';
|
||||||
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
|
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
|
||||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||||
import { createKtxCliScanConnector } from './local-scan-connectors.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';
|
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
|
||||||
|
|
||||||
function noopMcpIo(): KtxCliIo {
|
function noopMcpIo(): KtxCliIo {
|
||||||
|
|
@ -26,7 +26,7 @@ export async function createKtxMcpServerFactory(input: {
|
||||||
}): Promise<() => McpServer> {
|
}): Promise<() => McpServer> {
|
||||||
const io = input.io ?? noopMcpIo();
|
const io = input.io ?? noopMcpIo();
|
||||||
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
|
const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
|
||||||
const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
|
const semanticLayerCompute = createLazyManagedPythonSemanticLayerComputePort({
|
||||||
cliVersion: input.cliVersion,
|
cliVersion: input.cliVersion,
|
||||||
installPolicy: 'auto',
|
installPolicy: 'auto',
|
||||||
io,
|
io,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { KtxExpectedError } from '../src/errors.js';
|
||||||
import {
|
import {
|
||||||
|
createLazyManagedPythonSemanticLayerComputePort,
|
||||||
createManagedPythonSemanticLayerComputePort,
|
createManagedPythonSemanticLayerComputePort,
|
||||||
ensureManagedPythonCommandRuntime,
|
ensureManagedPythonCommandRuntime,
|
||||||
managedRuntimeInstallCommand,
|
managedRuntimeInstallCommand,
|
||||||
|
|
@ -274,7 +276,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(confirmInstall).toHaveBeenCalledWith(
|
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,
|
io.io,
|
||||||
);
|
);
|
||||||
expect(installRuntime).toHaveBeenCalledWith({
|
expect(installRuntime).toHaveBeenCalledWith({
|
||||||
|
|
@ -306,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
||||||
).resolves.toBe(compute);
|
).resolves.toBe(compute);
|
||||||
|
|
||||||
expect(confirmInstall).toHaveBeenCalledWith(
|
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,
|
io.io,
|
||||||
);
|
);
|
||||||
expect(events).toContainEqual('start:Installing ktx Python runtime (core) with uv...');
|
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');
|
).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<ManagedPythonRuntimeInstallResult> => {
|
||||||
|
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<ManagedPythonRuntimeInstallResult> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,61 @@
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { strToU8, zipSync } from 'fflate';
|
import { gzipSync, strToU8, zipSync } from 'fflate';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { KtxExpectedError } from '../src/errors.js';
|
||||||
import {
|
import {
|
||||||
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
|
||||||
doctorManagedPythonRuntime,
|
doctorManagedPythonRuntime,
|
||||||
|
ensureManagedUv,
|
||||||
installManagedPythonRuntime,
|
installManagedPythonRuntime,
|
||||||
managedPythonDaemonLayout,
|
managedPythonDaemonLayout,
|
||||||
managedPythonRuntimeLayout,
|
managedPythonRuntimeLayout,
|
||||||
|
managedUvPath,
|
||||||
readManagedPythonRuntimeStatus,
|
readManagedPythonRuntimeStatus,
|
||||||
verifyRuntimeAsset,
|
verifyRuntimeAsset,
|
||||||
type ManagedPythonRuntimeExec,
|
type ManagedPythonRuntimeExec,
|
||||||
|
type ManagedUvRelease,
|
||||||
} from '../src/managed-python-runtime.js';
|
} from '../src/managed-python-runtime.js';
|
||||||
|
import type { ManagedUvPlatformKey } from '../src/managed-uv-release.js';
|
||||||
|
|
||||||
|
async function placeFakeUv(runtimeRoot: string): Promise<string> {
|
||||||
|
const uvPath = managedUvPath({ runtimeRoot });
|
||||||
|
await mkdir(dirname(uvPath), { recursive: true });
|
||||||
|
await writeFile(uvPath, '#!/bin/sh\n');
|
||||||
|
return uvPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarball(entries: Record<string, Uint8Array>): 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 {
|
function runtimeWheelContents(input: { label?: string; requiresPython?: string | null } = {}): Buffer {
|
||||||
const label = input.label ?? 'runtime-wheel';
|
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 () => {
|
it('creates a venv, installs the core wheel, and writes a manifest', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const commands: Array<{ command: string; args: string[] }> = [];
|
const commands: Array<{ command: string; args: string[] }> = [];
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||||
commands.push({ 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({
|
const result = await installManagedPythonRuntime({
|
||||||
|
|
@ -262,11 +305,11 @@ describe('installManagedPythonRuntime', () => {
|
||||||
|
|
||||||
expect(result.status).toBe('installed');
|
expect(result.status).toBe('installed');
|
||||||
expect(commands).toEqual([
|
expect(commands).toEqual([
|
||||||
{ command: 'uv', args: ['--version'] },
|
{ command: uvPath, args: ['--version'] },
|
||||||
{ command: 'uv', args: ['python', 'install', '3.13'] },
|
{ command: uvPath, args: ['python', 'install', '3.13'] },
|
||||||
{ command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
{ command: uvPath, args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
||||||
{
|
{
|
||||||
command: 'uv',
|
command: uvPath,
|
||||||
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
|
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 () => {
|
it('disables repo uv config for managed runtime uv commands', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
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 commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
|
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
|
||||||
commands.push({ command, args, env: options?.env });
|
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({
|
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([
|
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
|
||||||
['uv', '--version', '1', '/opt/homebrew/bin'],
|
[uvPath, '--version', '1', '/opt/homebrew/bin'],
|
||||||
['uv', 'python', '1', '/opt/homebrew/bin'],
|
[uvPath, 'python', '1', '/opt/homebrew/bin'],
|
||||||
['uv', 'venv', '1', '/opt/homebrew/bin'],
|
[uvPath, 'venv', '1', '/opt/homebrew/bin'],
|
||||||
['uv', 'pip', '1', '/opt/homebrew/bin'],
|
[uvPath, 'pip', '1', '/opt/homebrew/bin'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('installs the local-embeddings extra when requested', async () => {
|
it('installs the local-embeddings extra when requested', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const commands: Array<{ command: string; args: string[] }> = [];
|
const commands: Array<{ command: string; args: string[] }> = [];
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||||
commands.push({ 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({
|
const result = await installManagedPythonRuntime({
|
||||||
|
|
@ -323,38 +368,72 @@ describe('installManagedPythonRuntime', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(commands.at(-1)).toEqual({
|
expect(commands.at(-1)).toEqual({
|
||||||
command: 'uv',
|
command: uvPath,
|
||||||
args: ['pip', 'install', '--python', result.layout.pythonPath, `${result.asset.wheelPath}[local-embeddings]`],
|
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[] };
|
const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { features: string[] };
|
||||||
expect(manifest.features).toEqual(['core', 'local-embeddings']);
|
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 { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
const commands: Array<{ command: string; args: string[] }> = [];
|
const runtimeRoot = join(tempDir, 'runtime');
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
const archive = gzipSync(tarball({ 'uv-test/uv': strToU8('#!/bin/sh\necho uv\n') }));
|
||||||
commands.push({ command, args });
|
const fetchUvArtifact = vi.fn(async () => archive);
|
||||||
throw new Error('spawn uv ENOENT');
|
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(
|
const error = await installManagedPythonRuntime({
|
||||||
installManagedPythonRuntime({
|
cliVersion: '0.2.0',
|
||||||
cliVersion: '0.2.0',
|
runtimeRoot,
|
||||||
runtimeRoot: join(tempDir, 'runtime'),
|
assetDir,
|
||||||
assetDir,
|
features: ['core'],
|
||||||
features: ['core'],
|
exec,
|
||||||
exec,
|
fetchUvArtifact,
|
||||||
}),
|
}).catch((caught: unknown) => caught);
|
||||||
).rejects.toThrow(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
|
|
||||||
|
|
||||||
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 () => {
|
it('reuses an existing compatible runtime when force is false', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
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: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -383,14 +462,15 @@ describe('installManagedPythonRuntime', () => {
|
||||||
|
|
||||||
it('keeps failed install logs in the versioned runtime directory', async () => {
|
it('keeps failed install logs in the versioned runtime directory', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
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'), {
|
throw Object.assign(new Error('uv venv failed'), {
|
||||||
stdout: 'creating\n',
|
stdout: 'creating\n',
|
||||||
stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\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(
|
await expect(
|
||||||
|
|
@ -404,11 +484,98 @@ describe('installManagedPythonRuntime', () => {
|
||||||
).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/);
|
).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');
|
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');
|
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', () => {
|
describe('readManagedPythonRuntimeStatus', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
|
|
@ -433,8 +600,9 @@ describe('readManagedPythonRuntimeStatus', () => {
|
||||||
|
|
||||||
it('reports ready when manifest and executables exist', async () => {
|
it('reports ready when manifest and executables exist', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
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: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
const install = await installManagedPythonRuntime({
|
const install = await installManagedPythonRuntime({
|
||||||
|
|
@ -460,8 +628,9 @@ describe('readManagedPythonRuntimeStatus', () => {
|
||||||
|
|
||||||
it('reports broken when an executable is missing', async () => {
|
it('reports broken when an executable is missing', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
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: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
await installManagedPythonRuntime({
|
await installManagedPythonRuntime({
|
||||||
|
|
@ -496,8 +665,9 @@ describe('doctorManagedPythonRuntime', () => {
|
||||||
|
|
||||||
it('checks uv, bundled assets, and installed runtime status', async () => {
|
it('checks uv, bundled assets, and installed runtime status', async () => {
|
||||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
|
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
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: '',
|
stderr: '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -513,28 +683,27 @@ describe('doctorManagedPythonRuntime', () => {
|
||||||
['asset', 'pass'],
|
['asset', 'pass'],
|
||||||
['runtime', 'fail'],
|
['runtime', 'fail'],
|
||||||
]);
|
]);
|
||||||
|
expect(checks[0]?.detail).toBe(`uv 0.9.5 (managed: ${uvPath})`);
|
||||||
expect(checks[2]?.fix).toBe('Run: ktx admin runtime install --yes');
|
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 { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => {
|
const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: '', stderr: '' }));
|
||||||
throw new Error('spawn uv ENOENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
const checks = await doctorManagedPythonRuntime({
|
const checks = await doctorManagedPythonRuntime({
|
||||||
cliVersion: '0.2.0',
|
cliVersion: '0.2.0',
|
||||||
runtimeRoot: join(tempDir, 'runtime'),
|
runtimeRoot: join(tempDir, 'runtime'),
|
||||||
assetDir,
|
assetDir,
|
||||||
exec,
|
exec,
|
||||||
|
fetchUvArtifact: vi.fn(async () => {
|
||||||
|
throw new Error('getaddrinfo ENOTFOUND github.com');
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checks[0]).toEqual({
|
expect(checks[0]?.id).toBe('uv');
|
||||||
id: 'uv',
|
expect(checks[0]?.status).toBe('fail');
|
||||||
label: 'uv',
|
expect(checks[0]?.detail).toContain('could not download uv');
|
||||||
status: 'fail',
|
expect(checks[0]?.fix).toBe('Check network access to github.com and run: ktx admin runtime install --yes');
|
||||||
detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
|
||||||
fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { createLocalProjectMcpContextPorts } from '../src/context/mcp/local-proj
|
||||||
import { createLocalProjectMemoryIngest } from '../src/context/memory/local-memory.js';
|
import { createLocalProjectMemoryIngest } from '../src/context/memory/local-memory.js';
|
||||||
import { resolveProjectEmbeddingProvider } from '../src/embedding-resolution.js';
|
import { resolveProjectEmbeddingProvider } from '../src/embedding-resolution.js';
|
||||||
import { createKtxCliScanConnector } from '../src/local-scan-connectors.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';
|
import { createKtxMcpServerFactory } from '../src/mcp-server-factory.js';
|
||||||
|
|
||||||
type FakeEmbeddingProvider = {
|
type FakeEmbeddingProvider = {
|
||||||
|
|
@ -62,7 +63,7 @@ vi.mock('../src/local-scan-connectors.js', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../src/managed-python-command.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', () => ({
|
vi.mock('../src/managed-python-http.js', () => ({
|
||||||
|
|
@ -124,6 +125,13 @@ describe('createKtxMcpServerFactory', () => {
|
||||||
expect(provider.embed).toHaveBeenCalledWith('gross revenue');
|
expect(provider.embed).toHaveBeenCalledWith('gross revenue');
|
||||||
expect(provider.embedMany).toHaveBeenCalledWith(['gross revenue']);
|
expect(provider.embedMany).toHaveBeenCalledWith(['gross revenue']);
|
||||||
expect(createKtxCliScanConnector).toHaveBeenCalledWith(project, 'warehouse');
|
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({
|
expect(contextOptions).toMatchObject({
|
||||||
queryExecutor: mocks.queryExecutor,
|
queryExecutor: mocks.queryExecutor,
|
||||||
semanticLayerCompute: mocks.semanticLayerCompute,
|
semanticLayerCompute: mocks.semanticLayerCompute,
|
||||||
|
|
|
||||||
95
scripts/refresh-uv-manifest.mjs
Normal file
95
scripts/refresh-uv-manifest.mjs
Normal file
|
|
@ -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 [<uv-version>]
|
||||||
|
|
||||||
|
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<ManagedUvPlatformKey, ManagedUvArtifact> = {
|
||||||
|
${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);
|
||||||
|
});
|
||||||
|
}
|
||||||
69
scripts/refresh-uv-manifest.test.mjs
Normal file
69
scripts/refresh-uv-manifest.test.mjs
Normal file
|
|
@ -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 () => '<html>proxy login</html>' }),
|
||||||
|
writeFile: () => {},
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/Unexpected sha256 file contents/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue