diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts new file mode 100644 index 00000000..c80d78a2 --- /dev/null +++ b/packages/cli/src/runtime.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts new file mode 100644 index 00000000..61ba203d --- /dev/null +++ b/packages/cli/src/runtime.ts @@ -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; + readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; + doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; + pruneRuntime?: (options: { + cliVersion: string; + runtimeRoot: string; + dryRun?: boolean; + }) => Promise; +} + +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 { + 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; + } +}