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
|
|
@ -1,19 +1,61 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { gzipSync, strToU8, zipSync } from 'fflate';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KtxExpectedError } from '../src/errors.js';
|
||||
import {
|
||||
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
||||
doctorManagedPythonRuntime,
|
||||
ensureManagedUv,
|
||||
installManagedPythonRuntime,
|
||||
managedPythonDaemonLayout,
|
||||
managedPythonRuntimeLayout,
|
||||
managedUvPath,
|
||||
readManagedPythonRuntimeStatus,
|
||||
verifyRuntimeAsset,
|
||||
type ManagedPythonRuntimeExec,
|
||||
type ManagedUvRelease,
|
||||
} from '../src/managed-python-runtime.js';
|
||||
import type { ManagedUvPlatformKey } from '../src/managed-uv-release.js';
|
||||
|
||||
async function placeFakeUv(runtimeRoot: string): Promise<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';
|
||||
|
|
@ -246,10 +288,11 @@ describe('installManagedPythonRuntime', () => {
|
|||
|
||||
it('creates a venv, installs the core wheel, and writes a manifest', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
const result = await installManagedPythonRuntime({
|
||||
|
|
@ -262,11 +305,11 @@ describe('installManagedPythonRuntime', () => {
|
|||
|
||||
expect(result.status).toBe('installed');
|
||||
expect(commands).toEqual([
|
||||
{ command: 'uv', args: ['--version'] },
|
||||
{ command: 'uv', args: ['python', 'install', '3.13'] },
|
||||
{ command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
||||
{ command: uvPath, args: ['--version'] },
|
||||
{ command: uvPath, args: ['python', 'install', '3.13'] },
|
||||
{ command: uvPath, args: ['venv', '--python', '3.13', result.layout.venvDir] },
|
||||
{
|
||||
command: 'uv',
|
||||
command: uvPath,
|
||||
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
|
||||
},
|
||||
]);
|
||||
|
|
@ -283,10 +326,11 @@ describe('installManagedPythonRuntime', () => {
|
|||
|
||||
it('disables repo uv config for managed runtime uv commands', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
|
||||
commands.push({ command, args, env: options?.env });
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' };
|
||||
return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.11.13\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
await installManagedPythonRuntime({
|
||||
|
|
@ -299,19 +343,20 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
|
||||
['uv', '--version', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'python', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'venv', '1', '/opt/homebrew/bin'],
|
||||
['uv', 'pip', '1', '/opt/homebrew/bin'],
|
||||
[uvPath, '--version', '1', '/opt/homebrew/bin'],
|
||||
[uvPath, 'python', '1', '/opt/homebrew/bin'],
|
||||
[uvPath, 'venv', '1', '/opt/homebrew/bin'],
|
||||
[uvPath, 'pip', '1', '/opt/homebrew/bin'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('installs the local-embeddings extra when requested', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
const result = await installManagedPythonRuntime({
|
||||
|
|
@ -323,38 +368,72 @@ describe('installManagedPythonRuntime', () => {
|
|||
});
|
||||
|
||||
expect(commands.at(-1)).toEqual({
|
||||
command: 'uv',
|
||||
command: uvPath,
|
||||
args: ['pip', 'install', '--python', result.layout.pythonPath, `${result.asset.wheelPath}[local-embeddings]`],
|
||||
});
|
||||
const manifest = JSON.parse(await readFile(result.layout.manifestPath, 'utf8')) as { features: string[] };
|
||||
expect(manifest.features).toEqual(['core', 'local-embeddings']);
|
||||
});
|
||||
|
||||
it('fails with the hard-prerequisite message when uv is missing', async () => {
|
||||
it('attempts the pinned uv download from github.com and rejects checksum mismatches', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
throw new Error('spawn uv ENOENT');
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
const archive = gzipSync(tarball({ 'uv-test/uv': strToU8('#!/bin/sh\necho uv\n') }));
|
||||
const fetchUvArtifact = vi.fn(async () => archive);
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: 'uv 9.9.9\n', stderr: '' }));
|
||||
|
||||
const error = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot,
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
fetchUvArtifact,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(fetchUvArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUvArtifact).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^https:\/\/github\.com\/astral-sh\/uv\/releases\/download\//),
|
||||
);
|
||||
expect(error).toBeInstanceOf(KtxExpectedError);
|
||||
expect((error as Error).message).toContain('failed checksum verification');
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with download guidance and preserves the existing runtime when uv cannot be fetched', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: '', stderr: '' }));
|
||||
const fetchUvArtifact = vi.fn(async () => {
|
||||
throw new Error('getaddrinfo ENOTFOUND github.com');
|
||||
});
|
||||
const survivingRuntimeFile = join(runtimeRoot, '0.2.0', 'install.log');
|
||||
await mkdir(dirname(survivingRuntimeFile), { recursive: true });
|
||||
await writeFile(survivingRuntimeFile, 'stale runtime contents\n');
|
||||
|
||||
await expect(
|
||||
installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
}),
|
||||
).rejects.toThrow(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
|
||||
const error = await installManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot,
|
||||
assetDir,
|
||||
features: ['core'],
|
||||
exec,
|
||||
fetchUvArtifact,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(commands).toEqual([{ command: 'uv', args: ['--version'] }]);
|
||||
// KtxExpectedError keeps this user-environment outcome out of Error Tracking.
|
||||
expect(error).toBeInstanceOf(KtxExpectedError);
|
||||
expect((error as Error).message).toContain('could not download uv');
|
||||
expect((error as Error).message).toContain('ktx admin runtime install --yes');
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
// A failed uv acquisition must not wipe whatever runtime is already on disk.
|
||||
await expect(readFile(survivingRuntimeFile, 'utf8')).resolves.toContain('stale');
|
||||
});
|
||||
|
||||
it('reuses an existing compatible runtime when force is false', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
|
||||
|
|
@ -383,14 +462,15 @@ describe('installManagedPythonRuntime', () => {
|
|||
|
||||
it('keeps failed install logs in the versioned runtime directory', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
|
||||
if (command === 'uv' && args[0] === 'venv') {
|
||||
if (command === uvPath && args[0] === 'venv') {
|
||||
throw Object.assign(new Error('uv venv failed'), {
|
||||
stdout: 'creating\n',
|
||||
stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\n',
|
||||
});
|
||||
}
|
||||
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
return { stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -404,11 +484,98 @@ describe('installManagedPythonRuntime', () => {
|
|||
).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/);
|
||||
|
||||
const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8');
|
||||
expect(log).toContain('$ uv venv --python 3.13');
|
||||
expect(log).toContain(`$ ${uvPath} venv --python 3.13`);
|
||||
expect(log).toContain('current Python version (3.12.3) does not satisfy Python>=3.13');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureManagedUv', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-managed-uv-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('downloads, verifies, and extracts uv from a tar.gz artifact, then reuses the cached binary', async () => {
|
||||
const binary = strToU8('#!/bin/sh\necho uv\n');
|
||||
const archive = gzipSync(tarball({ 'uv-test/': new Uint8Array(0), 'uv-test/uvx': strToU8('x'), 'uv-test/uv': binary }));
|
||||
const release = releaseFor('uv-test.tar.gz', archive, 'linux-x64');
|
||||
const fetchArtifact = vi.fn(async () => archive);
|
||||
|
||||
const uvPath = await ensureManagedUv({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
fetchArtifact,
|
||||
release,
|
||||
});
|
||||
|
||||
expect(uvPath).toBe(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv'));
|
||||
await expect(readFile(uvPath, 'utf8')).resolves.toBe('#!/bin/sh\necho uv\n');
|
||||
|
||||
const again = await ensureManagedUv({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
fetchArtifact,
|
||||
release,
|
||||
});
|
||||
expect(again).toBe(uvPath);
|
||||
expect(fetchArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('extracts uv.exe from a zip artifact on Windows', async () => {
|
||||
const archive = zipSync({ 'uv.exe': strToU8('MZ-uv'), 'uvx.exe': strToU8('MZ-uvx') });
|
||||
const release = releaseFor('uv-test.zip', archive, 'win32-x64');
|
||||
|
||||
const uvPath = await ensureManagedUv({
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
fetchArtifact: vi.fn(async () => archive),
|
||||
release,
|
||||
});
|
||||
|
||||
expect(uvPath).toBe(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv.exe'));
|
||||
await expect(readFile(uvPath, 'utf8')).resolves.toBe('MZ-uv');
|
||||
});
|
||||
|
||||
it('rejects an artifact whose checksum does not match the pin', async () => {
|
||||
const archive = gzipSync(tarball({ 'uv-test/uv': strToU8('uv') }));
|
||||
const release = releaseFor('uv-test.tar.gz', archive, 'linux-x64');
|
||||
release.artifacts['linux-x64']!.sha256 = 'b'.repeat(64);
|
||||
|
||||
const error = await ensureManagedUv({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
fetchArtifact: vi.fn(async () => archive),
|
||||
release,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(KtxExpectedError);
|
||||
expect((error as Error).message).toContain('failed checksum verification');
|
||||
});
|
||||
|
||||
it('fails with manual-placement guidance on platforms without a pinned artifact', async () => {
|
||||
const error = await ensureManagedUv({
|
||||
platform: 'sunos',
|
||||
arch: 'x64',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
fetchArtifact: vi.fn(),
|
||||
release: { version: '9.9.9-test', artifacts: {} },
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(KtxExpectedError);
|
||||
expect((error as Error).message).toContain('does not bundle uv for sunos-x64');
|
||||
expect((error as Error).message).toContain(join(tempDir, 'runtime', 'uv', '9.9.9-test', 'uv'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('readManagedPythonRuntimeStatus', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -433,8 +600,9 @@ describe('readManagedPythonRuntimeStatus', () => {
|
|||
|
||||
it('reports ready when manifest and executables exist', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
const install = await installManagedPythonRuntime({
|
||||
|
|
@ -460,8 +628,9 @@ describe('readManagedPythonRuntimeStatus', () => {
|
|||
|
||||
it('reports broken when an executable is missing', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
await installManagedPythonRuntime({
|
||||
|
|
@ -496,8 +665,9 @@ describe('doctorManagedPythonRuntime', () => {
|
|||
|
||||
it('checks uv, bundled assets, and installed runtime status', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const uvPath = await placeFakeUv(join(tempDir, 'runtime'));
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
|
||||
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stdout: command === uvPath && args[0] === '--version' ? 'uv 0.9.5\n' : '',
|
||||
stderr: '',
|
||||
}));
|
||||
|
||||
|
|
@ -513,28 +683,27 @@ describe('doctorManagedPythonRuntime', () => {
|
|||
['asset', 'pass'],
|
||||
['runtime', 'fail'],
|
||||
]);
|
||||
expect(checks[0]?.detail).toBe(`uv 0.9.5 (managed: ${uvPath})`);
|
||||
expect(checks[2]?.fix).toBe('Run: ktx admin runtime install --yes');
|
||||
});
|
||||
|
||||
it('reports uv as a hard prerequisite when uv is missing', async () => {
|
||||
it('fails the uv check with download guidance when uv cannot be acquired', async () => {
|
||||
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => {
|
||||
throw new Error('spawn uv ENOENT');
|
||||
});
|
||||
const exec: ManagedPythonRuntimeExec = vi.fn(async () => ({ stdout: '', stderr: '' }));
|
||||
|
||||
const checks = await doctorManagedPythonRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
assetDir,
|
||||
exec,
|
||||
fetchUvArtifact: vi.fn(async () => {
|
||||
throw new Error('getaddrinfo ENOTFOUND github.com');
|
||||
}),
|
||||
});
|
||||
|
||||
expect(checks[0]).toEqual({
|
||||
id: 'uv',
|
||||
label: 'uv',
|
||||
status: 'fail',
|
||||
detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE,
|
||||
fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes',
|
||||
});
|
||||
expect(checks[0]?.id).toBe('uv');
|
||||
expect(checks[0]?.status).toBe('fail');
|
||||
expect(checks[0]?.detail).toContain('could not download uv');
|
||||
expect(checks[0]?.fix).toBe('Check network access to github.com and run: ktx admin runtime install --yes');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue