fix(cli): clean up dev runtime commands (#59)

This commit is contained in:
Andrey Avtomonov 2026-05-13 12:28:24 +02:00 committed by GitHub
parent b9e0a746af
commit eaaabb361e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 127 additions and 218 deletions

View file

@ -18,7 +18,7 @@ async function runRuntimeArgs(context: KtxCliCommandContext, args: KtxRuntimeArg
export function registerRuntimeCommands(program: Command, context: KtxCliCommandContext): void {
const runtime = program
.command('runtime')
.description('Install, inspect, and prune the KTX-managed Python runtime')
.description('Install, start, stop, and inspect the KTX-managed Python runtime')
.showHelpAfterError();
runtime
@ -64,7 +64,7 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
runtime
.command('status')
.description('Show managed Python runtime status')
.description('Show managed Python runtime status and readiness checks')
.option('--json', 'Print JSON output', false)
.action(async (options: { json?: boolean }) => {
await runRuntimeArgs(context, {
@ -73,18 +73,4 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
json: options.json === true,
});
});
runtime
.command('prune')
.description('Remove stale managed Python runtimes for older CLI versions')
.option('--dry-run', 'List stale runtimes without deleting them', false)
.option('--yes', 'Confirm deletion of stale runtime directories', false)
.action(async (options: { dryRun?: boolean; yes?: boolean }) => {
await runRuntimeArgs(context, {
command: 'prune',
cliVersion: context.packageInfo.version,
dryRun: options.dryRun === true,
yes: options.yes === true,
});
});
}

View file

@ -106,6 +106,7 @@ describe('dev Commander tree', () => {
for (const argv of [
['dev', 'doctor', 'setup'],
['dev', 'runtime', 'doctor'],
['dev', 'runtime', 'prune', '--dry-run'],
['dev', 'scan', 'warehouse'],
['dev', 'ingest', 'run'],
['dev', 'mapping', 'list'],
@ -126,7 +127,7 @@ describe('dev Commander tree', () => {
it.each([
{
argv: ['dev', 'runtime', '--help'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'prune'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status'],
},
{
argv: ['scan', '--help'],
@ -147,6 +148,10 @@ describe('dev Commander tree', () => {
for (const text of expected) {
expect(io.stdout()).toContain(text);
}
if (argv.join(' ') === 'dev runtime --help') {
expect(io.stdout()).not.toContain('prune');
expect(io.stdout()).not.toContain('doctor');
}
expect(io.stderr()).toBe('');
expect(doctor).not.toHaveBeenCalled();
expect(ingest).not.toHaveBeenCalled();

View file

@ -159,7 +159,7 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(1);
expect(runtime).toHaveBeenNthCalledWith(
1,
@ -208,19 +208,11 @@ describe('runKtxCli', () => {
},
statusIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
6,
{
command: 'prune',
cliVersion: '0.0.0-private',
dryRun: true,
yes: false,
},
pruneIo.io,
);
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, pruneIo]) {
expect(runtime).toHaveBeenCalledTimes(5);
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo]) {
expect(io.stderr()).toBe('');
}
expect(pruneIo.stderr()).toMatch(/unknown command|error:/);
});
it('prints the resolved project directory for ordinary project commands', async () => {

View file

@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -8,7 +8,6 @@ import {
doctorManagedPythonRuntime,
installManagedPythonRuntime,
managedPythonRuntimeLayout,
pruneManagedPythonRuntimes,
readManagedPythonRuntimeStatus,
verifyRuntimeAsset,
type ManagedPythonRuntimeExec,
@ -471,41 +470,3 @@ describe('doctorManagedPythonRuntime', () => {
});
});
});
describe('pruneManagedPythonRuntimes', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-prune-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('removes stale version directories and keeps the current version', async () => {
const runtimeRoot = join(tempDir, 'runtime');
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
await writeFile(join(runtimeRoot, 'README.txt'), 'not a runtime directory\n');
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot });
expect(result.removed).toEqual([join(runtimeRoot, '0.1.0')]);
expect(result.kept).toEqual([join(runtimeRoot, '0.2.0')]);
await expect(stat(join(runtimeRoot, '0.1.0'))).rejects.toThrow();
expect(await readdir(runtimeRoot)).toEqual(['0.2.0', 'README.txt']);
});
it('supports dry-run without deleting stale directories', async () => {
const runtimeRoot = join(tempDir, 'runtime');
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot, dryRun: true });
expect(result.removed).toEqual([]);
expect(result.stale).toEqual([join(runtimeRoot, '0.1.0')]);
expect(await readdir(runtimeRoot)).toEqual(['0.1.0', '0.2.0']);
});
});

View file

@ -1,6 +1,6 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { access, appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { access, appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -107,13 +107,6 @@ export interface ManagedPythonRuntimeDoctorCheck {
fix?: string;
}
export interface ManagedPythonRuntimePruneResult {
runtimeRoot: string;
stale: string[];
kept: string[];
removed: string[];
}
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
'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 dev runtime install --yes';
@ -441,36 +434,3 @@ export async function doctorManagedPythonRuntime(
);
return checks;
}
export async function pruneManagedPythonRuntimes(options: {
cliVersion: string;
runtimeRoot: string;
dryRun?: boolean;
}): Promise<ManagedPythonRuntimePruneResult> {
if (!(await pathExists(options.runtimeRoot))) {
return { runtimeRoot: options.runtimeRoot, stale: [], kept: [], removed: [] };
}
const entries = await readdir(options.runtimeRoot);
const stale: string[] = [];
const kept: string[] = [];
for (const entry of entries) {
const path = join(options.runtimeRoot, entry);
const info = await stat(path);
if (!info.isDirectory()) {
continue;
}
if (entry === options.cliVersion) {
kept.push(path);
} else {
stale.push(path);
}
}
const removed: string[] = [];
if (options.dryRun !== true) {
for (const path of stale) {
await rm(path, { recursive: true, force: true });
removed.push(path);
}
}
return { runtimeRoot: options.runtimeRoot, stale, kept, removed };
}

View file

@ -5,6 +5,7 @@ import type {
ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
import type {
ManagedPythonRuntimeDoctorCheck,
ManagedPythonRuntimeInstallResult,
ManagedPythonRuntimeStatus,
} from './managed-python-runtime.js';
@ -256,7 +257,7 @@ describe('runKtxRuntime', () => {
expect(io.stderr()).toContain('process scan: ps failed');
});
it('prints runtime status as JSON', async () => {
it('prints runtime status and doctor checks as JSON with doctor-style exit status', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
@ -278,38 +279,41 @@ describe('runKtxRuntime', () => {
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
},
})),
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
{ id: 'asset', label: 'Bundled Python wheel', status: 'pass', detail: '/assets/python/runtime.whl' },
{
id: 'runtime',
label: 'Managed Python runtime',
status: 'fail',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
fix: 'Run: ktx dev runtime install --yes',
},
]),
};
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(0);
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(1);
expect(JSON.parse(io.stdout())).toMatchObject({
kind: 'missing',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
layout: { runtimeRoot: '/runtime' },
checks: [
{ id: 'uv', status: 'pass' },
{ id: 'asset', status: 'pass' },
{ id: 'runtime', status: 'fail' },
],
});
expect(deps.readStatus).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(deps.doctorRuntime).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
});
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 () => {
it('prints runtime status and doctor checks in plain output', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
kind: 'missing',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
kind: 'ready',
detail: 'Runtime ready at /runtime/0.2.0',
layout: {
cliVersion: '0.2.0',
runtimeRoot: '/runtime',
@ -325,19 +329,43 @@ describe('runKtxRuntime', () => {
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
},
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'],
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',
},
})),
pruneRuntime: vi.fn(async () => ({
runtimeRoot: '/runtime',
stale: ['/runtime/0.1.0'],
kept: ['/runtime/0.2.0'],
removed: [],
})),
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
{ id: 'asset', label: 'Bundled Python wheel', status: 'pass', detail: '/assets/python/runtime.whl' },
{ id: 'runtime', label: 'Managed Python runtime', status: 'pass', detail: 'Runtime ready at /runtime/0.2.0' },
]),
};
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: true, yes: false }, io.io, deps))
.resolves.toBe(0);
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(0);
expect(io.stdout()).toContain('Stale KTX Python runtimes');
expect(io.stdout()).toContain('/runtime/0.1.0');
expect(io.stdout()).toContain('KTX Python runtime');
expect(io.stdout()).toContain('status: ready');
expect(io.stdout()).toContain('KTX Python runtime checks');
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
expect(io.stdout()).toContain('PASS Managed Python runtime: Runtime ready at /runtime/0.2.0');
expect(io.stderr()).toBe('');
});
});

View file

@ -8,14 +8,14 @@ import {
type ManagedPythonDaemonStopResult,
} from './managed-python-daemon.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';
@ -23,8 +23,7 @@ export type KtxRuntimeArgs =
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'stop'; cliVersion: string; all: boolean }
| { command: 'status'; cliVersion: string; json: boolean }
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
| { command: 'status'; cliVersion: string; json: boolean };
export interface KtxRuntimeDeps {
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
@ -36,11 +35,7 @@ export interface KtxRuntimeDeps {
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
pruneRuntime?: (options: {
cliVersion: string;
runtimeRoot: string;
dryRun?: boolean;
}) => Promise<ManagedPythonRuntimePruneResult>;
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
}
function writeJson(io: KtxCliIo, value: unknown): void {
@ -145,17 +140,20 @@ function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
}
}
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`);
function writeRuntimeChecks(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
io.stdout.write('KTX Python runtime checks\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 hasRuntimeCheckFailures(checks: ManagedPythonRuntimeDoctorCheck[]): boolean {
return checks.some((check) => check.status === 'fail');
}
export async function runKtxRuntime(
args: KtxRuntimeArgs,
io: KtxCliIo = process,
@ -196,27 +194,19 @@ export async function runKtxRuntime(
}
if (args.command === 'status') {
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
const status = await readStatus({ cliVersion: args.cliVersion });
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
if (args.json) {
writeJson(io, status);
writeJson(io, { ...status, checks });
} else {
writeStatus(io, status);
writeRuntimeChecks(io, checks);
}
return 0;
return hasRuntimeCheckFailures(checks) ? 1 : 0;
}
if (!args.dryRun && !args.yes) {
io.stderr.write('Refusing to prune without --yes. Preview with: ktx dev 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;
const _exhaustive: never = args;
return _exhaustive;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;