ktx/packages/cli/src/managed-python-runtime.ts
Andrey Avtomonov 2366b00301
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm

* refactor(workspace): rewrite @ktx/llm imports to relative paths

* refactor(workspace): fold internal packages into cli

* chore(workspace): gate dead-code with knip production mode

Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.

* refactor(cli): delete internal barrel index.ts files

The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).

This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
  (the published package entry).
- Rewrites ~270 source/test files to import each name directly from
  the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
  `create-warehouse-verification-tools.ts` (the function it defined
  locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
  the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
  live-database/extracted-schema, live-database/structural-sync,
  relationship-* feedback/review chain) plus their tests and a
  cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
  (notion-client, connector barrels in scan/local-scan-connectors
  tests) to mock the source files instead.
- Points the maintainer benchmark script
  (`scripts/relationship-benchmark-report.mjs`) at source files
  instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
  production entries only for the benchmark code reached via dist by
  the maintainer script.

Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.

`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.

* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly

Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.

Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.

* docs: align "agent clients" and "data agents" terminology

Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.

Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.

* refactor(release): single source of truth for package version

Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.

Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.

- update-public-release-version.mjs rewrites both Python pyproject.toml
  files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
  normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
  @semantic-release/git assets so the release commit back to main
  carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
  replaced with "?? getKtxCliPackageInfo().version", and
  createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
  always reflects the most recent release; no sentinel pin to
  maintain.

Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.

* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime

Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.

* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal

Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.

* fix(cli): use real package metadata in print-command-tree

The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.

* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts

Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.

Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00

507 lines
17 KiB
TypeScript

import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
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';
import { promisify } from 'node:util';
import { strFromU8, unzipSync } from 'fflate';
import { z } from 'zod';
const execFileAsync = promisify(execFile);
export const runtimeFeatureSchema = z.enum(['core', 'local-embeddings']);
export type KtxRuntimeFeature = z.infer<typeof runtimeFeatureSchema>;
const runtimeAssetManifestSchema = z.object({
schemaVersion: z.literal(1),
distributionName: z.literal('kaelio-ktx'),
normalizedName: z.literal('kaelio_ktx'),
version: z.string().min(1),
wheel: z.object({
file: z.string().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
bytes: z.number().int().nonnegative(),
}),
});
type KtxRuntimeAssetManifest = z.infer<typeof runtimeAssetManifestSchema>;
const installedRuntimeManifestSchema = z.object({
schemaVersion: z.literal(1),
cliVersion: z.string().min(1),
installedAt: z.string().min(1),
asset: runtimeAssetManifestSchema,
features: z.array(runtimeFeatureSchema).min(1),
python: z.object({
executable: z.string().min(1),
daemonExecutable: z.string().min(1),
}),
installLog: z.string().min(1),
});
export type InstalledKtxRuntimeManifest = z.infer<typeof installedRuntimeManifestSchema>;
export interface ManagedPythonRuntimeLayoutOptions {
cliVersion: string;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
homeDir?: string;
runtimeRoot?: string;
assetDir?: string;
}
export interface ManagedPythonRuntimeLayout {
cliVersion: string;
runtimeRoot: string;
versionDir: string;
venvDir: string;
manifestPath: string;
installLogPath: string;
assetDir: string;
assetManifestPath: string;
pythonPath: string;
daemonPath: string;
}
export interface ManagedPythonDaemonLayoutOptions extends ManagedPythonRuntimeLayoutOptions {
projectDir: string;
}
export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
projectDir: string;
daemonStateDir: string;
daemonStatePath: string;
daemonStdoutPath: string;
daemonStderrPath: string;
}
/** @internal */
export interface ManagedRuntimeAsset {
manifest: KtxRuntimeAssetManifest;
wheelPath: string;
requiresPython: {
specifier: string;
minimumVersion: string;
};
}
export type ManagedPythonRuntimeExec = (
command: string,
args: string[],
options?: { cwd?: string; env?: NodeJS.ProcessEnv },
) => Promise<{ stdout: string; stderr: string }>;
export interface ManagedPythonRuntimeInstallOptions extends ManagedPythonRuntimeLayoutOptions {
features: KtxRuntimeFeature[];
force?: boolean;
exec?: ManagedPythonRuntimeExec;
}
export interface ManagedPythonRuntimeInstallResult {
status: 'ready' | 'installed';
layout: ManagedPythonRuntimeLayout;
asset: ManagedRuntimeAsset;
manifest: InstalledKtxRuntimeManifest;
}
type ManagedPythonRuntimeStatusKind = 'missing' | 'ready' | 'mismatched' | 'broken';
export interface ManagedPythonRuntimeStatus {
kind: ManagedPythonRuntimeStatusKind;
detail: string;
layout: ManagedPythonRuntimeLayout;
manifest?: InstalledKtxRuntimeManifest;
}
export interface ManagedPythonRuntimeDoctorCheck {
id: 'uv' | 'asset' | 'runtime';
label: string;
status: 'pass' | 'fail';
detail: string;
fix?: string;
}
/** @internal */
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 admin runtime install --yes';
function defaultAssetDir(): string {
return fileURLToPath(new URL('../assets/python/', import.meta.url));
}
function runtimeRootFor(input: { env: NodeJS.ProcessEnv; homeDir: string }): string {
if (input.env.KTX_RUNTIME_ROOT) {
return input.env.KTX_RUNTIME_ROOT;
}
return join(input.homeDir, '.ktx', 'runtime');
}
function executablePath(venvDir: string, platform: NodeJS.Platform, name: string): string {
if (platform === 'win32') {
return join(venvDir, 'Scripts', `${name}.exe`);
}
return join(venvDir, 'bin', name);
}
/** @internal */
export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOptions): ManagedPythonRuntimeLayout {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ env, homeDir });
const versionDir = join(runtimeRoot, options.cliVersion);
const venvDir = join(versionDir, '.venv');
const assetDir = options.assetDir ?? defaultAssetDir();
return {
cliVersion: options.cliVersion,
runtimeRoot,
versionDir,
venvDir,
manifestPath: join(versionDir, 'manifest.json'),
installLogPath: join(versionDir, 'install.log'),
assetDir,
assetManifestPath: join(assetDir, 'manifest.json'),
pythonPath: executablePath(venvDir, platform, 'python'),
daemonPath: executablePath(venvDir, platform, 'ktx-daemon'),
};
}
export function managedPythonDaemonLayout(options: ManagedPythonDaemonLayoutOptions): ManagedPythonDaemonLayout {
const runtime = managedPythonRuntimeLayout(options);
const daemonStateDir = join(options.projectDir, '.ktx', 'runtime');
return {
...runtime,
projectDir: options.projectDir,
daemonStateDir,
daemonStatePath: join(daemonStateDir, 'daemon.json'),
daemonStdoutPath: join(daemonStateDir, 'daemon.stdout.log'),
daemonStderrPath: join(daemonStateDir, 'daemon.stderr.log'),
};
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function assertSafeWheelFilename(file: string): void {
if (file !== basename(file) || file.includes('/') || file.includes('\\')) {
throw new Error(`Unsafe runtime wheel filename in bundled manifest: ${file}`);
}
}
async function readJsonFile(path: string): Promise<unknown> {
return JSON.parse(await readFile(path, 'utf8')) as unknown;
}
function isErrnoException(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
function parseRequiresPythonFromWheel(input: { wheelPath: string; contents: Buffer }): ManagedRuntimeAsset['requiresPython'] {
let files: Record<string, Uint8Array>;
try {
files = unzipSync(new Uint8Array(input.contents));
} catch (error) {
throw new Error(
`Unable to read bundled Python runtime wheel metadata: ${error instanceof Error ? error.message : String(error)}`,
);
}
const metadataEntry = Object.entries(files).find(([path]) => path.endsWith('.dist-info/METADATA'));
if (!metadataEntry) {
throw new Error(`Bundled Python runtime wheel metadata is missing: ${input.wheelPath}`);
}
const metadata = strFromU8(metadataEntry[1]);
const requiresPython = metadata
.split(/\r?\n/)
.map((line) => line.match(/^Requires-Python:\s*(.+)\s*$/i)?.[1]?.trim())
.find((value): value is string => typeof value === 'string' && value.length > 0);
if (!requiresPython) {
throw new Error('Bundled Python runtime wheel metadata is missing Requires-Python');
}
const minimumMatch = requiresPython.match(/(?:^|[,\s])>=\s*([0-9]+)\.([0-9]+)(?:\.[0-9]+)?\b/);
if (!minimumMatch) {
throw new Error(`Unsupported bundled Python runtime Requires-Python: ${requiresPython}`);
}
return {
specifier: requiresPython,
minimumVersion: `${minimumMatch[1]}.${minimumMatch[2]}`,
};
}
/** @internal */
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
const manifestPath = join(input.assetDir, 'manifest.json');
let manifestData: unknown;
try {
manifestData = await readJsonFile(manifestPath);
} catch (error) {
if (isErrnoException(error, 'ENOENT')) {
throw new Error(
[
`Missing bundled Python runtime manifest: ${manifestPath}`,
'In a source checkout, build the local runtime assets with: pnpm run artifacts:build',
'Then retry the runtime-backed KTX command.',
].join('\n'),
);
}
throw error;
}
const manifest = runtimeAssetManifestSchema.parse(manifestData);
assertSafeWheelFilename(manifest.wheel.file);
const wheelPath = join(input.assetDir, manifest.wheel.file);
const wheel = await readFile(wheelPath);
const sha256 = createHash('sha256').update(wheel).digest('hex');
if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) {
throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`);
}
return { manifest, wheelPath, requiresPython: parseRequiresPythonFromWheel({ wheelPath, contents: wheel }) };
}
function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
const requested = new Set<KtxRuntimeFeature>(['core', ...features]);
return runtimeFeatureSchema.options.filter((feature) => requested.has(feature));
}
async function readInstalledManifest(path: string): Promise<InstalledKtxRuntimeManifest | undefined> {
if (!(await pathExists(path))) {
return undefined;
}
return installedRuntimeManifestSchema.parse(await readJsonFile(path));
}
function hasFeatures(manifest: InstalledKtxRuntimeManifest, features: KtxRuntimeFeature[]): boolean {
return normalizeFeatures(features).every((feature) => manifest.features.includes(feature));
}
async function defaultExec(
command: string,
args: string[],
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
): Promise<{ stdout: string; stderr: string }> {
const result = await execFileAsync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 20,
});
return { stdout: result.stdout, stderr: result.stderr };
}
function errorOutput(error: unknown): { stdout: string; stderr: string } {
const value = error as { stdout?: unknown; stderr?: unknown };
return {
stdout: typeof value.stdout === 'string' ? value.stdout : '',
stderr: typeof value.stderr === 'string' ? value.stderr : '',
};
}
function installFailureMessage(input: { logPath: string; stdout: string; stderr: string }): string {
const output = [input.stderr.trim(), input.stdout.trim()].filter((part) => part.length > 0).join('\n');
if (!output) {
return `Python runtime install failed. Install log: ${input.logPath}`;
}
return `Python runtime install failed.\n${output}\nInstall log: ${input.logPath}`;
}
async function runLogged(input: {
exec: ManagedPythonRuntimeExec;
logPath: string;
command: string;
args: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ stdout: string; stderr: string }> {
await appendFile(input.logPath, `$ ${input.command} ${input.args.join(' ')}\n`);
try {
const result = await input.exec(input.command, input.args, { cwd: input.cwd, env: input.env });
if (result.stdout) {
await appendFile(input.logPath, result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
}
if (result.stderr) {
await appendFile(input.logPath, result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
}
return result;
} catch (error) {
const output = errorOutput(error);
if (output.stdout) {
await appendFile(input.logPath, output.stdout.endsWith('\n') ? output.stdout : `${output.stdout}\n`);
}
if (output.stderr) {
await appendFile(input.logPath, output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
}
throw new Error(installFailureMessage({ logPath: input.logPath, stdout: output.stdout, stderr: output.stderr }));
}
}
function managedRuntimeUvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...baseEnv, UV_NO_CONFIG: '1' };
}
async function ensureUv(exec: ManagedPythonRuntimeExec, env?: NodeJS.ProcessEnv): Promise<string> {
try {
const result = await exec('uv', ['--version'], { env });
return result.stdout.trim() || 'uv available';
} catch {
throw new Error(MISSING_UV_RUNTIME_INSTALL_MESSAGE);
}
}
export async function installManagedPythonRuntime(
options: ManagedPythonRuntimeInstallOptions,
): Promise<ManagedPythonRuntimeInstallResult> {
const layout = managedPythonRuntimeLayout(options);
const exec = options.exec ?? defaultExec;
const features = normalizeFeatures(options.features);
const asset = await verifyRuntimeAsset({ assetDir: layout.assetDir });
const uvEnv = managedRuntimeUvEnv(options.env ?? process.env);
const existing = await readInstalledManifest(layout.manifestPath);
if (
options.force !== true &&
existing &&
existing.cliVersion === options.cliVersion &&
existing.asset.wheel.sha256 === asset.manifest.wheel.sha256 &&
hasFeatures(existing, features) &&
(await pathExists(existing.python.executable)) &&
(await pathExists(existing.python.daemonExecutable))
) {
return { status: 'ready', layout, asset, manifest: existing };
}
await rm(layout.versionDir, { recursive: true, force: true });
await mkdir(layout.versionDir, { recursive: true });
await writeFile(layout.installLogPath, '');
await ensureUv(exec, uvEnv);
await runLogged({
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['python', 'install', asset.requiresPython.minimumVersion],
env: uvEnv,
});
await runLogged({
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir],
env: uvEnv,
});
const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath;
await runLogged({
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['pip', 'install', '--python', layout.pythonPath, wheelSpec],
env: uvEnv,
});
const manifest: InstalledKtxRuntimeManifest = {
schemaVersion: 1,
cliVersion: options.cliVersion,
installedAt: new Date().toISOString(),
asset: asset.manifest,
features,
python: {
executable: layout.pythonPath,
daemonExecutable: layout.daemonPath,
},
installLog: layout.installLogPath,
};
await writeFile(layout.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
return { status: 'installed', layout, asset, manifest };
}
export async function readManagedPythonRuntimeStatus(
options: ManagedPythonRuntimeLayoutOptions,
): Promise<ManagedPythonRuntimeStatus> {
const layout = managedPythonRuntimeLayout(options);
let manifest: InstalledKtxRuntimeManifest | undefined;
try {
manifest = await readInstalledManifest(layout.manifestPath);
} catch (error) {
return {
kind: 'broken',
detail: `Runtime manifest is invalid: ${error instanceof Error ? error.message : String(error)}`,
layout,
};
}
if (!manifest) {
return { kind: 'missing', detail: `No runtime manifest at ${layout.manifestPath}`, layout };
}
if (manifest.cliVersion !== options.cliVersion) {
return {
kind: 'mismatched',
detail: `Runtime is for CLI ${manifest.cliVersion}, current CLI is ${options.cliVersion}`,
layout,
manifest,
};
}
if (!(await pathExists(manifest.python.executable))) {
return { kind: 'broken', detail: `Missing Python executable: ${manifest.python.executable}`, layout, manifest };
}
if (!(await pathExists(manifest.python.daemonExecutable))) {
return { kind: 'broken', detail: `Missing ktx-daemon executable: ${manifest.python.daemonExecutable}`, layout, manifest };
}
return { kind: 'ready', detail: `Runtime ready at ${layout.versionDir}`, layout, manifest };
}
function check(
status: ManagedPythonRuntimeDoctorCheck['status'],
input: Omit<ManagedPythonRuntimeDoctorCheck, 'status'>,
): ManagedPythonRuntimeDoctorCheck {
return { status, ...input };
}
export async function doctorManagedPythonRuntime(
options: ManagedPythonRuntimeLayoutOptions & { exec?: ManagedPythonRuntimeExec },
): Promise<ManagedPythonRuntimeDoctorCheck[]> {
const exec = options.exec ?? defaultExec;
const checks: ManagedPythonRuntimeDoctorCheck[] = [];
try {
const version = await ensureUv(exec, managedRuntimeUvEnv(options.env ?? process.env));
checks.push(check('pass', { id: 'uv', label: 'uv', detail: version }));
} catch (error) {
checks.push(
check('fail', {
id: 'uv',
label: 'uv',
detail: error instanceof Error ? error.message : String(error),
fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes',
}),
);
}
try {
const asset = await verifyRuntimeAsset({ assetDir: managedPythonRuntimeLayout(options).assetDir });
checks.push(check('pass', { id: 'asset', label: 'Bundled Python wheel', detail: asset.wheelPath }));
} catch (error) {
checks.push(
check('fail', {
id: 'asset',
label: 'Bundled Python wheel',
detail: error instanceof Error ? error.message : String(error),
fix: 'Run: pnpm run artifacts:check',
}),
);
}
const status = await readManagedPythonRuntimeStatus(options);
checks.push(
check(status.kind === 'ready' ? 'pass' : 'fail', {
id: 'runtime',
label: 'Managed Python runtime',
detail: status.detail,
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx admin runtime install --yes' }),
}),
);
return checks;
}