mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* 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.
507 lines
17 KiB
TypeScript
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;
|
|
}
|