mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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.
709 lines
26 KiB
TypeScript
709 lines
26 KiB
TypeScript
import { createHash } from 'node:crypto';
|
||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
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 {
|
||
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<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 {
|
||
const label = input.label ?? 'runtime-wheel';
|
||
const requiresPython = input.requiresPython === null ? [] : [`Requires-Python: ${input.requiresPython ?? '>=3.13'}`];
|
||
return Buffer.from(
|
||
zipSync({
|
||
'kaelio_ktx-0.1.0.dist-info/METADATA': strToU8(
|
||
[
|
||
'Metadata-Version: 2.4',
|
||
'Name: kaelio-ktx',
|
||
'Version: 0.1.0',
|
||
...requiresPython,
|
||
`Summary: ${label}`,
|
||
'',
|
||
].join('\n'),
|
||
),
|
||
}),
|
||
);
|
||
}
|
||
|
||
async function writeAsset(
|
||
root: string,
|
||
options: { label?: string; requiresPython?: string | null; contents?: Buffer } = {},
|
||
) {
|
||
const assetDir = join(root, 'assets', 'python');
|
||
await mkdir(assetDir, { recursive: true });
|
||
const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl');
|
||
const contents = options.contents ?? runtimeWheelContents(options);
|
||
await writeFile(wheelPath, contents);
|
||
await writeFile(
|
||
join(assetDir, 'manifest.json'),
|
||
`${JSON.stringify(
|
||
{
|
||
schemaVersion: 1,
|
||
distributionName: 'kaelio-ktx',
|
||
normalizedName: 'kaelio_ktx',
|
||
version: '0.1.0',
|
||
wheel: {
|
||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||
bytes: contents.byteLength,
|
||
},
|
||
},
|
||
null,
|
||
2,
|
||
)}\n`,
|
||
);
|
||
return { assetDir, wheelPath };
|
||
}
|
||
|
||
describe('managedPythonRuntimeLayout', () => {
|
||
it('uses ~/.ktx/runtime as the runtime root on macOS', () => {
|
||
const layout = managedPythonRuntimeLayout({
|
||
cliVersion: '0.2.0',
|
||
platform: 'darwin',
|
||
env: {},
|
||
homeDir: '/Users/alex',
|
||
assetDir: '/repo/packages/cli/assets/python',
|
||
});
|
||
|
||
expect(layout.runtimeRoot).toBe('/Users/alex/.ktx/runtime');
|
||
expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0');
|
||
expect(layout.venvDir).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv');
|
||
expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python');
|
||
expect(layout.daemonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/ktx-daemon');
|
||
expect(layout.assetManifestPath).toBe('/repo/packages/cli/assets/python/manifest.json');
|
||
});
|
||
|
||
it('uses ~/.ktx/runtime on Linux too', () => {
|
||
const layout = managedPythonRuntimeLayout({
|
||
cliVersion: '0.2.0',
|
||
platform: 'linux',
|
||
env: {},
|
||
homeDir: '/home/alex',
|
||
assetDir: '/repo/packages/cli/assets/python',
|
||
});
|
||
|
||
expect(layout.runtimeRoot).toBe('/home/alex/.ktx/runtime');
|
||
expect(layout.versionDir).toBe('/home/alex/.ktx/runtime/0.2.0');
|
||
});
|
||
|
||
it('uses Scripts/*.exe layout on Windows under ~/.ktx/runtime', () => {
|
||
const layout = managedPythonRuntimeLayout({
|
||
cliVersion: '0.2.0',
|
||
platform: 'win32',
|
||
env: {},
|
||
homeDir: 'C:\\Users\\Alex',
|
||
assetDir: 'C:\\repo\\packages\\cli\\assets\\python',
|
||
});
|
||
|
||
expect(layout.runtimeRoot).toBe('C:\\Users\\Alex/.ktx/runtime');
|
||
expect(layout.pythonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/python.exe');
|
||
expect(layout.daemonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe');
|
||
});
|
||
|
||
it('honors KTX_RUNTIME_ROOT before the default ~/.ktx/runtime', () => {
|
||
const layout = managedPythonRuntimeLayout({
|
||
cliVersion: '0.2.0',
|
||
platform: 'darwin',
|
||
env: { KTX_RUNTIME_ROOT: '/tmp/ktx-runtime' },
|
||
homeDir: '/Users/alex',
|
||
assetDir: '/repo/packages/cli/assets/python',
|
||
});
|
||
|
||
expect(layout.runtimeRoot).toBe('/tmp/ktx-runtime');
|
||
expect(layout.versionDir).toBe('/tmp/ktx-runtime/0.2.0');
|
||
});
|
||
});
|
||
|
||
describe('managedPythonDaemonLayout', () => {
|
||
it('places daemon state, stdout, and stderr under {projectDir}/.ktx/runtime', () => {
|
||
const layout = managedPythonDaemonLayout({
|
||
cliVersion: '0.2.0',
|
||
projectDir: '/work/orbit-analytics',
|
||
platform: 'darwin',
|
||
env: {},
|
||
homeDir: '/Users/alex',
|
||
assetDir: '/repo/packages/cli/assets/python',
|
||
});
|
||
|
||
expect(layout.projectDir).toBe('/work/orbit-analytics');
|
||
expect(layout.daemonStateDir).toBe('/work/orbit-analytics/.ktx/runtime');
|
||
expect(layout.daemonStatePath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.json');
|
||
expect(layout.daemonStdoutPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stdout.log');
|
||
expect(layout.daemonStderrPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stderr.log');
|
||
});
|
||
|
||
it('keeps install paths under the global runtime root regardless of projectDir', () => {
|
||
const layout = managedPythonDaemonLayout({
|
||
cliVersion: '0.2.0',
|
||
projectDir: '/work/orbit-analytics',
|
||
platform: 'darwin',
|
||
env: {},
|
||
homeDir: '/Users/alex',
|
||
assetDir: '/repo/packages/cli/assets/python',
|
||
});
|
||
|
||
expect(layout.runtimeRoot).toBe('/Users/alex/.ktx/runtime');
|
||
expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0');
|
||
expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python');
|
||
});
|
||
});
|
||
|
||
describe('verifyRuntimeAsset', () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-asset-'));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('reads the manifest and verifies the wheel checksum', async () => {
|
||
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'valid-wheel' });
|
||
|
||
const asset = await verifyRuntimeAsset({ assetDir });
|
||
|
||
expect(asset.manifest.distributionName).toBe('kaelio-ktx');
|
||
expect(asset.manifest.normalizedName).toBe('kaelio_ktx');
|
||
expect(asset.wheelPath).toBe(wheelPath);
|
||
expect(asset.requiresPython).toEqual({ specifier: '>=3.13', minimumVersion: '3.13' });
|
||
});
|
||
|
||
it('rejects a wheel whose checksum does not match the manifest', async () => {
|
||
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'original' });
|
||
await writeFile(wheelPath, 'tampered');
|
||
|
||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||
/Bundled Python runtime wheel checksum mismatch/,
|
||
);
|
||
});
|
||
|
||
it('rejects an unsafe wheel filename in the manifest', async () => {
|
||
const { assetDir } = await writeAsset(tempDir, { label: 'valid-wheel' });
|
||
await writeFile(
|
||
join(assetDir, 'manifest.json'),
|
||
`${JSON.stringify({
|
||
schemaVersion: 1,
|
||
distributionName: 'kaelio-ktx',
|
||
normalizedName: 'kaelio_ktx',
|
||
version: '0.1.0',
|
||
wheel: {
|
||
file: '../kaelio_ktx-0.1.0-py3-none-any.whl',
|
||
sha256: 'a'.repeat(64),
|
||
bytes: 1,
|
||
},
|
||
})}\n`,
|
||
);
|
||
|
||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Unsafe runtime wheel filename/);
|
||
});
|
||
|
||
it('reports the source-checkout artifact command when the bundled manifest is missing', async () => {
|
||
const assetDir = join(tempDir, 'packages', 'cli', 'assets', 'python');
|
||
|
||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||
/Missing bundled Python runtime manifest.*pnpm run artifacts:build/s,
|
||
);
|
||
});
|
||
|
||
it('rejects a bundled wheel without Requires-Python metadata', async () => {
|
||
const { assetDir } = await writeAsset(tempDir, { requiresPython: null });
|
||
|
||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||
/Bundled Python runtime wheel metadata is missing Requires-Python/,
|
||
);
|
||
});
|
||
|
||
it('rejects a bundled wheel without a supported minimum Python version', async () => {
|
||
const { assetDir } = await writeAsset(tempDir, { requiresPython: '<4' });
|
||
|
||
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
|
||
/Unsupported bundled Python runtime Requires-Python: <4/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('installManagedPythonRuntime', () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-install-'));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||
});
|
||
|
||
const result = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
|
||
expect(result.status).toBe('installed');
|
||
expect(commands).toEqual([
|
||
{ command: uvPath, args: ['--version'] },
|
||
{ command: uvPath, args: ['python', 'install', '3.13'] },
|
||
{ command: uvPath, args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
||
{
|
||
command: uvPath,
|
||
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
|
||
},
|
||
]);
|
||
const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as {
|
||
cliVersion: string;
|
||
features: string[];
|
||
python: { executable: string; daemonExecutable: string };
|
||
};
|
||
expect(manifest.cliVersion).toBe('0.2.0');
|
||
expect(manifest.features).toEqual(['core']);
|
||
expect(manifest.python.executable).toBe(result.layout.pythonPath);
|
||
expect(manifest.python.daemonExecutable).toBe(result.layout.daemonPath);
|
||
});
|
||
|
||
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 === uvPath && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' };
|
||
});
|
||
|
||
await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
env: { PATH: '/opt/homebrew/bin', UV_NO_CONFIG: '0' },
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
|
||
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
|
||
[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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||
});
|
||
|
||
const result = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['local-embeddings'],
|
||
exec,
|
||
});
|
||
|
||
expect(commands.at(-1)).toEqual({
|
||
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('attempts the pinned uv download from github.com and rejects checksum mismatches', async () => {
|
||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||
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');
|
||
|
||
const error = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot,
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
fetchUvArtifact,
|
||
}).catch((caught: unknown) => caught);
|
||
|
||
// 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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||
stderr: '',
|
||
}));
|
||
|
||
const first = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
await mkdir(join(first.layout.venvDir, 'bin'), { recursive: true });
|
||
await writeFile(first.layout.pythonPath, '#!/usr/bin/env python\n');
|
||
await writeFile(first.layout.daemonPath, '#!/usr/bin/env python\n');
|
||
|
||
const second = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
|
||
expect(second.status).toBe('ready');
|
||
expect(exec).toHaveBeenCalledTimes(4);
|
||
});
|
||
|
||
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 === 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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||
});
|
||
|
||
await expect(
|
||
installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
}),
|
||
).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(`$ ${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;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-status-'));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('reports missing before install', async () => {
|
||
const status = await readManagedPythonRuntimeStatus({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir: join(tempDir, 'assets', 'python'),
|
||
});
|
||
|
||
expect(status.kind).toBe('missing');
|
||
expect(status.detail).toContain('No runtime manifest');
|
||
});
|
||
|
||
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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||
stderr: '',
|
||
}));
|
||
const install = await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
await mkdir(join(install.layout.venvDir, 'bin'), { recursive: true });
|
||
await writeFile(install.layout.pythonPath, '#!/usr/bin/env python\n');
|
||
await writeFile(install.layout.daemonPath, '#!/usr/bin/env python\n');
|
||
|
||
const status = await readManagedPythonRuntimeStatus({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
});
|
||
|
||
expect(status.kind).toBe('ready');
|
||
expect(status.manifest?.features).toEqual(['core']);
|
||
});
|
||
|
||
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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||
stderr: '',
|
||
}));
|
||
await installManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
features: ['core'],
|
||
exec,
|
||
});
|
||
|
||
const status = await readManagedPythonRuntimeStatus({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
});
|
||
|
||
expect(status.kind).toBe('broken');
|
||
expect(status.detail).toContain('Missing Python executable');
|
||
});
|
||
});
|
||
|
||
describe('doctorManagedPythonRuntime', () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(async () => {
|
||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-doctor-'));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
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 === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||
stderr: '',
|
||
}));
|
||
|
||
const checks = await doctorManagedPythonRuntime({
|
||
cliVersion: '0.2.0',
|
||
runtimeRoot: join(tempDir, 'runtime'),
|
||
assetDir,
|
||
exec,
|
||
});
|
||
|
||
expect(checks.map((check) => [check.id, check.status])).toEqual([
|
||
['uv', 'pass'],
|
||
['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('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 () => ({ 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]?.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');
|
||
});
|
||
});
|