feat: add runtime command runner

This commit is contained in:
Andrey Avtomonov 2026-05-11 10:07:38 +02:00
parent 46e1913355
commit 50811716c2
2 changed files with 336 additions and 0 deletions

View file

@ -0,0 +1,201 @@
import { describe, expect, it, vi } from 'vitest';
import { runKtxRuntime, type KtxRuntimeDeps } from './runtime.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
describe('runKtxRuntime', () => {
it('installs the requested runtime feature and prints the manifest path', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
installRuntime: vi.fn(async () => ({
status: 'installed',
layout: {
cliVersion: '0.2.0',
runtimeRoot: '/runtime',
versionDir: '/runtime/0.2.0',
venvDir: '/runtime/0.2.0/.venv',
manifestPath: '/runtime/0.2.0/manifest.json',
installLogPath: '/runtime/0.2.0/install.log',
assetDir: '/assets/python',
assetManifestPath: '/assets/python/manifest.json',
pythonPath: '/runtime/0.2.0/.venv/bin/python',
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
},
asset: {
wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl',
manifest: {
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: 10,
},
},
},
manifest: {
schemaVersion: 1,
cliVersion: '0.2.0',
installedAt: '2026-05-11T00:00:00.000Z',
asset: {
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: 10,
},
},
features: ['core', 'local-embeddings'],
python: {
executable: '/runtime/0.2.0/.venv/bin/python',
daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon',
},
installLog: '/runtime/0.2.0/install.log',
},
})),
};
await expect(
runKtxRuntime(
{ command: 'install', cliVersion: '0.2.0', feature: 'local-embeddings', force: true },
io.io,
deps,
),
).resolves.toBe(0);
expect(deps.installRuntime).toHaveBeenCalledWith({
cliVersion: '0.2.0',
features: ['local-embeddings'],
force: true,
});
expect(io.stdout()).toContain('Installed KTX Python runtime');
expect(io.stdout()).toContain('features: core, local-embeddings');
expect(io.stdout()).toContain('manifest: /runtime/0.2.0/manifest.json');
expect(io.stderr()).toBe('');
});
it('prints runtime status as JSON', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
readStatus: vi.fn(async () => ({
kind: 'missing',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
layout: {
cliVersion: '0.2.0',
runtimeRoot: '/runtime',
versionDir: '/runtime/0.2.0',
venvDir: '/runtime/0.2.0/.venv',
manifestPath: '/runtime/0.2.0/manifest.json',
installLogPath: '/runtime/0.2.0/install.log',
assetDir: '/assets/python',
assetManifestPath: '/assets/python/manifest.json',
pythonPath: '/runtime/0.2.0/.venv/bin/python',
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
},
})),
};
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toMatchObject({
kind: 'missing',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
layout: { runtimeRoot: '/runtime' },
});
});
it('returns failure for doctor when any check fails', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
doctorRuntime: vi.fn(async () => [
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
{
id: 'runtime',
label: 'Managed Python runtime',
status: 'fail',
detail: 'No runtime manifest',
fix: 'Run: ktx runtime install --yes',
},
]),
};
await expect(runKtxRuntime({ command: 'doctor', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(1);
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
expect(io.stdout()).toContain('FAIL Managed Python runtime: No runtime manifest');
expect(io.stdout()).toContain('Fix: Run: ktx runtime install --yes');
});
it('requires --yes before pruning stale runtime directories', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
pruneRuntime: vi.fn(async () => {
throw new Error('should not prune without --yes');
}),
};
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: false, yes: false }, io.io, deps))
.resolves.toBe(1);
expect(io.stderr()).toContain('Refusing to prune without --yes');
expect(deps.pruneRuntime).not.toHaveBeenCalled();
});
it('prints stale directories during prune dry-run', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
readStatus: vi.fn(async () => ({
kind: 'missing',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
layout: {
cliVersion: '0.2.0',
runtimeRoot: '/runtime',
versionDir: '/runtime/0.2.0',
venvDir: '/runtime/0.2.0/.venv',
manifestPath: '/runtime/0.2.0/manifest.json',
installLogPath: '/runtime/0.2.0/install.log',
assetDir: '/assets/python',
assetManifestPath: '/assets/python/manifest.json',
pythonPath: '/runtime/0.2.0/.venv/bin/python',
daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon',
},
})),
pruneRuntime: vi.fn(async () => ({
runtimeRoot: '/runtime',
stale: ['/runtime/0.1.0'],
kept: ['/runtime/0.2.0'],
removed: [],
})),
};
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: true, yes: false }, io.io, deps))
.resolves.toBe(0);
expect(io.stdout()).toContain('Stale KTX Python runtimes');
expect(io.stdout()).toContain('/runtime/0.1.0');
});
});

135
packages/cli/src/runtime.ts Normal file
View file

@ -0,0 +1,135 @@
import type { KtxCliIo } from './cli-runtime.js';
import {
doctorManagedPythonRuntime,
installManagedPythonRuntime,
pruneManagedPythonRuntimes,
readManagedPythonRuntimeStatus,
type KtxRuntimeFeature,
type ManagedPythonRuntimeDoctorCheck,
type ManagedPythonRuntimeInstallOptions,
type ManagedPythonRuntimeInstallResult,
type ManagedPythonRuntimeLayoutOptions,
type ManagedPythonRuntimePruneResult,
type ManagedPythonRuntimeStatus,
} from './managed-python-runtime.js';
export type KtxRuntimeArgs =
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'status'; cliVersion: string; json: boolean }
| { command: 'doctor'; cliVersion: string; json: boolean }
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
export interface KtxRuntimeDeps {
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
pruneRuntime?: (options: {
cliVersion: string;
runtimeRoot: string;
dryRun?: boolean;
}) => Promise<ManagedPythonRuntimePruneResult>;
}
function writeJson(io: KtxCliIo, value: unknown): void {
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeInstallResult(io: KtxCliIo, result: ManagedPythonRuntimeInstallResult): void {
const verb = result.status === 'ready' ? 'Using existing' : 'Installed';
io.stdout.write(`${verb} KTX Python runtime\n`);
io.stdout.write(`version: ${result.manifest.cliVersion}\n`);
io.stdout.write(`features: ${result.manifest.features.join(', ')}\n`);
io.stdout.write(`python: ${result.manifest.python.executable}\n`);
io.stdout.write(`daemon: ${result.manifest.python.daemonExecutable}\n`);
io.stdout.write(`manifest: ${result.layout.manifestPath}\n`);
io.stdout.write(`install log: ${result.layout.installLogPath}\n`);
}
function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
io.stdout.write('KTX Python runtime\n');
io.stdout.write(`status: ${status.kind}\n`);
io.stdout.write(`detail: ${status.detail}\n`);
io.stdout.write(`runtime root: ${status.layout.runtimeRoot}\n`);
io.stdout.write(`version dir: ${status.layout.versionDir}\n`);
if (status.manifest) {
io.stdout.write(`features: ${status.manifest.features.join(', ')}\n`);
io.stdout.write(`python: ${status.manifest.python.executable}\n`);
io.stdout.write(`daemon: ${status.manifest.python.daemonExecutable}\n`);
}
}
function writeDoctor(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
io.stdout.write('KTX Python runtime doctor\n');
for (const check of checks) {
io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`);
if (check.fix) {
io.stdout.write(` Fix: ${check.fix}\n`);
}
}
}
function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void {
if (result.stale.length === 0) {
io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`);
return;
}
io.stdout.write(dryRun ? 'Stale KTX Python runtimes\n' : 'Removed stale KTX Python runtimes\n');
for (const path of dryRun ? result.stale : result.removed) {
io.stdout.write(`${path}\n`);
}
}
export async function runKtxRuntime(
args: KtxRuntimeArgs,
io: KtxCliIo = process,
deps: KtxRuntimeDeps = {},
): Promise<number> {
try {
if (args.command === 'install') {
const installRuntime = deps.installRuntime ?? installManagedPythonRuntime;
const result = await installRuntime({
cliVersion: args.cliVersion,
features: [args.feature],
force: args.force,
});
writeInstallResult(io, result);
return 0;
}
if (args.command === 'status') {
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
const status = await readStatus({ cliVersion: args.cliVersion });
if (args.json) {
writeJson(io, status);
} else {
writeStatus(io, status);
}
return 0;
}
if (args.command === 'doctor') {
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
if (args.json) {
writeJson(io, { checks });
} else {
writeDoctor(io, checks);
}
return checks.some((check) => check.status === 'fail') ? 1 : 0;
}
if (!args.dryRun && !args.yes) {
io.stderr.write('Refusing to prune without --yes. Preview with: ktx runtime prune --dry-run\n');
return 1;
}
const status = await (deps.readStatus ?? readManagedPythonRuntimeStatus)({ cliVersion: args.cliVersion });
const pruneRuntime = deps.pruneRuntime ?? pruneManagedPythonRuntimes;
const result = await pruneRuntime({
cliVersion: args.cliVersion,
runtimeRoot: status.layout.runtimeRoot,
dryRun: args.dryRun,
});
writePrune(io, result, args.dryRun);
return 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}