fix(cli): install managed runtime with required Python

This commit is contained in:
Andrey Avtomonov 2026-05-19 18:12:35 +02:00
parent 7cec0041eb
commit d1e496faa0
8 changed files with 347 additions and 39 deletions

View file

@ -99,6 +99,7 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
asset: {
manifest: installedManifest.asset,
wheelPath: '/assets/python/kaelio_ktx-0.2.0-py3-none-any.whl',
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
},
manifest: installedManifest,
};

View file

@ -79,6 +79,7 @@ function installResult(root: string, features: Array<'core' | 'local-embeddings'
asset: {
manifest: manifest(root, features).asset,
wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
},
manifest: manifest(root, features),
};

View file

@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { strToU8, zipSync } from 'fflate';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MISSING_UV_RUNTIME_INSTALL_MESSAGE,
@ -14,10 +15,33 @@ import {
type ManagedPythonRuntimeExec,
} from './managed-python-runtime.js';
async function writeAsset(root: string, contents = 'wheel-bytes') {
function runtimeWheelContents(input: { label?: string; requiresPython?: string | null } = {}): Buffer {
const label = input.label ?? 'runtime-wheel';
const requiresPython = input.requiresPython === null ? [] : [`Requires-Python: ${input.requiresPython ?? '>=3.13'}`];
return Buffer.from(
zipSync({
'kaelio_ktx-0.1.0.dist-info/METADATA': strToU8(
[
'Metadata-Version: 2.4',
'Name: kaelio-ktx',
'Version: 0.1.0',
...requiresPython,
`Summary: ${label}`,
'',
].join('\n'),
),
}),
);
}
async function writeAsset(
root: string,
options: { label?: string; requiresPython?: string | null; contents?: Buffer } = {},
) {
const assetDir = join(root, 'assets', 'python');
await mkdir(assetDir, { recursive: true });
const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl');
const contents = options.contents ?? runtimeWheelContents(options);
await writeFile(wheelPath, contents);
await writeFile(
join(assetDir, 'manifest.json'),
@ -30,7 +54,7 @@ async function writeAsset(root: string, contents = 'wheel-bytes') {
wheel: {
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
sha256: createHash('sha256').update(contents).digest('hex'),
bytes: Buffer.byteLength(contents),
bytes: contents.byteLength,
},
},
null,
@ -145,17 +169,18 @@ describe('verifyRuntimeAsset', () => {
});
it('reads the manifest and verifies the wheel checksum', async () => {
const { assetDir, wheelPath } = await writeAsset(tempDir, 'valid-wheel');
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'valid-wheel' });
const asset = await verifyRuntimeAsset({ assetDir });
expect(asset.manifest.distributionName).toBe('kaelio-ktx');
expect(asset.manifest.normalizedName).toBe('kaelio_ktx');
expect(asset.wheelPath).toBe(wheelPath);
expect(asset.requiresPython).toEqual({ specifier: '>=3.13', minimumVersion: '3.13' });
});
it('rejects a wheel whose checksum does not match the manifest', async () => {
const { assetDir, wheelPath } = await writeAsset(tempDir, 'original');
const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'original' });
await writeFile(wheelPath, 'tampered');
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
@ -164,7 +189,7 @@ describe('verifyRuntimeAsset', () => {
});
it('rejects an unsafe wheel filename in the manifest', async () => {
const { assetDir } = await writeAsset(tempDir, 'valid-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'valid-wheel' });
await writeFile(
join(assetDir, 'manifest.json'),
`${JSON.stringify({
@ -190,6 +215,22 @@ describe('verifyRuntimeAsset', () => {
/Missing bundled Python runtime manifest.*pnpm run artifacts:build/s,
);
});
it('rejects a bundled wheel without Requires-Python metadata', async () => {
const { assetDir } = await writeAsset(tempDir, { requiresPython: null });
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
/Bundled Python runtime wheel metadata is missing Requires-Python/,
);
});
it('rejects a bundled wheel without a supported minimum Python version', async () => {
const { assetDir } = await writeAsset(tempDir, { requiresPython: '<4' });
await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(
/Unsupported bundled Python runtime Requires-Python: <4/,
);
});
});
describe('installManagedPythonRuntime', () => {
@ -204,7 +245,7 @@ describe('installManagedPythonRuntime', () => {
});
it('creates a venv, installs the core wheel, and writes a manifest', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const commands: Array<{ command: string; args: string[] }> = [];
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
commands.push({ command, args });
@ -222,7 +263,8 @@ describe('installManagedPythonRuntime', () => {
expect(result.status).toBe('installed');
expect(commands).toEqual([
{ command: 'uv', args: ['--version'] },
{ command: 'uv', args: ['venv', result.layout.venvDir] },
{ command: 'uv', args: ['python', 'install', '3.13'] },
{ command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] },
{
command: 'uv',
args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
@ -240,7 +282,7 @@ describe('installManagedPythonRuntime', () => {
});
it('disables repo uv config for managed runtime uv commands', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const commands: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args, options) => {
commands.push({ command, args, env: options?.env });
@ -258,13 +300,14 @@ describe('installManagedPythonRuntime', () => {
expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
['uv', '--version', '1', '/opt/homebrew/bin'],
['uv', 'python', '1', '/opt/homebrew/bin'],
['uv', 'venv', '1', '/opt/homebrew/bin'],
['uv', 'pip', '1', '/opt/homebrew/bin'],
]);
});
it('installs the local-embeddings extra when requested', async () => {
const { assetDir } = await writeAsset(tempDir, 'embedding-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
const commands: Array<{ command: string; args: string[] }> = [];
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
commands.push({ command, args });
@ -288,7 +331,7 @@ describe('installManagedPythonRuntime', () => {
});
it('fails with the hard-prerequisite message when uv is missing', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const commands: Array<{ command: string; args: string[] }> = [];
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
commands.push({ command, args });
@ -309,7 +352,7 @@ describe('installManagedPythonRuntime', () => {
});
it('reuses an existing compatible runtime when force is false', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
stderr: '',
@ -335,14 +378,17 @@ describe('installManagedPythonRuntime', () => {
});
expect(second.status).toBe('ready');
expect(exec).toHaveBeenCalledTimes(3);
expect(exec).toHaveBeenCalledTimes(4);
});
it('keeps failed install logs in the versioned runtime directory', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => {
if (command === 'uv' && args[0] === 'venv') {
throw Object.assign(new Error('uv venv failed'), { stdout: 'creating\n', stderr: 'bad python\n' });
throw Object.assign(new Error('uv venv failed'), {
stdout: 'creating\n',
stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\n',
});
}
return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
});
@ -355,11 +401,11 @@ describe('installManagedPythonRuntime', () => {
features: ['core'],
exec,
}),
).rejects.toThrow(/Python runtime install failed/);
).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/);
const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8');
expect(log).toContain('$ uv venv');
expect(log).toContain('bad python');
expect(log).toContain('$ uv venv --python 3.13');
expect(log).toContain('current Python version (3.12.3) does not satisfy Python>=3.13');
});
});
@ -386,7 +432,7 @@ describe('readManagedPythonRuntimeStatus', () => {
});
it('reports ready when manifest and executables exist', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
stderr: '',
@ -413,7 +459,7 @@ describe('readManagedPythonRuntimeStatus', () => {
});
it('reports broken when an executable is missing', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
stderr: '',
@ -449,7 +495,7 @@ describe('doctorManagedPythonRuntime', () => {
});
it('checks uv, bundled assets, and installed runtime status', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async (command, args) => ({
stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
stderr: '',
@ -471,7 +517,7 @@ describe('doctorManagedPythonRuntime', () => {
});
it('reports uv as a hard prerequisite when uv is missing', async () => {
const { assetDir } = await writeAsset(tempDir, 'core-wheel');
const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
const exec: ManagedPythonRuntimeExec = vi.fn(async () => {
throw new Error('spawn uv ENOENT');
});

View file

@ -5,6 +5,7 @@ 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);
@ -78,6 +79,10 @@ export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
export interface ManagedRuntimeAsset {
manifest: KtxRuntimeAssetManifest;
wheelPath: string;
requiresPython: {
specifier: string;
minimumVersion: string;
};
}
export type ManagedPythonRuntimeExec = (
@ -196,6 +201,40 @@ 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]}`,
};
}
export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<ManagedRuntimeAsset> {
const manifestPath = join(input.assetDir, 'manifest.json');
let manifestData: unknown;
@ -221,7 +260,7 @@ export async function verifyRuntimeAsset(input: { assetDir: string }): Promise<M
if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) {
throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`);
}
return { manifest, wheelPath };
return { manifest, wheelPath, requiresPython: parseRequiresPythonFromWheel({ wheelPath, contents: wheel }) };
}
function normalizeFeatures(features: KtxRuntimeFeature[]): KtxRuntimeFeature[] {
@ -262,6 +301,14 @@ function errorOutput(error: unknown): { stdout: string; stderr: string } {
};
}
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;
@ -288,7 +335,7 @@ async function runLogged(input: {
if (output.stderr) {
await appendFile(input.logPath, output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
}
throw new Error(`Python runtime install failed. Install log: ${input.logPath}`);
throw new Error(installFailureMessage({ logPath: input.logPath, stdout: output.stdout, stderr: output.stderr }));
}
}
@ -334,7 +381,14 @@ export async function installManagedPythonRuntime(
exec,
logPath: layout.installLogPath,
command: 'uv',
args: ['venv', layout.venvDir],
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;

View file

@ -52,6 +52,7 @@ describe('runKtxRuntime', () => {
},
asset: {
wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl',
requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
manifest: {
schemaVersion: 1,
distributionName: 'kaelio-ktx',

View file

@ -29,8 +29,18 @@ export interface KtxSetupProjectArgs {
allowBack?: boolean;
}
export type KtxSetupCreatedProjectCleanup =
| { kind: 'remove-project-dir'; projectDir: string }
| { kind: 'remove-ktx-scaffold'; projectDir: string };
export type KtxSetupProjectResult =
| { status: 'ready'; projectDir: string; project: KtxLocalProject; confirmedCreation?: boolean }
| {
status: 'ready';
projectDir: string;
project: KtxLocalProject;
confirmedCreation?: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
}
| { status: 'back'; projectDir: string }
| { status: 'cancelled'; projectDir: string }
| { status: 'missing-input'; projectDir: string };
@ -49,7 +59,12 @@ export interface KtxSetupProjectDeps {
}
type PromptProjectDirResult =
| { status: 'selected'; projectDir: string; confirmedCreation: boolean }
| {
status: 'selected';
projectDir: string;
confirmedCreation: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
}
| { status: 'cancelled'; projectDir: string }
| { status: 'missing-input'; projectDir: string }
| { status: 'back'; projectDir: string };
@ -92,12 +107,29 @@ async function existingFolderState(
}
type ConfirmProjectDirResult =
| { status: 'confirmed'; confirmedCreation: boolean }
| {
status: 'confirmed';
confirmedCreation: boolean;
createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
}
| { status: 'choose-another' }
| { status: 'back' }
| { status: 'cancelled' }
| { status: 'not-directory' };
function cleanupForFolderState(
projectDir: string,
state: Awaited<ReturnType<typeof existingFolderState>>,
): KtxSetupCreatedProjectCleanup | undefined {
if (state === 'missing') {
return { kind: 'remove-project-dir', projectDir };
}
if (state === 'empty-directory') {
return { kind: 'remove-ktx-scaffold', projectDir };
}
return undefined;
}
async function confirmProjectDir(
selectedDir: string,
io: KtxCliIo,
@ -137,7 +169,7 @@ async function confirmProjectDir(
if (action === 'choose-another') return { status: 'choose-another' };
if (action === 'back') return { status: 'back' };
if (action !== 'create') return { status: 'cancelled' };
return { status: 'confirmed', confirmedCreation: true };
return { status: 'confirmed', confirmedCreation: true, createdProjectCleanup: cleanupForFolderState(selectedDir, state) };
}
async function normalizeSetupGitignore(projectDir: string): Promise<void> {
@ -220,10 +252,28 @@ async function promptForNewProjectDir(
if (confirmed.status === 'choose-another') continue;
if (confirmed.status === 'back') return { status: 'back', projectDir };
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation };
return {
status: 'selected',
projectDir: selectedDir,
confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
};
}
}
async function createProjectWithCleanup(
projectDir: string,
deps: KtxSetupProjectDeps,
): Promise<{ project: KtxLocalProject; createdProjectCleanup?: KtxSetupCreatedProjectCleanup }> {
const state = await existingFolderState(projectDir);
const project = await createProject(projectDir, deps);
const createdProjectCleanup = cleanupForFolderState(projectDir, state);
return {
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
export async function runKtxSetupProjectStep(
args: KtxSetupProjectArgs,
io: KtxCliIo,
@ -244,9 +294,14 @@ export async function runKtxSetupProjectStep(
}
if (args.mode === 'new') {
const project = await createProject(projectDir, deps);
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
printProjectSummary(io, projectDir);
return { status: 'ready', projectDir, project };
return {
status: 'ready',
projectDir,
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
if (args.mode === 'prompt-new') {
@ -277,6 +332,7 @@ export async function runKtxSetupProjectStep(
projectDir: selected.projectDir,
project,
confirmedCreation: selected.confirmedCreation,
...(selected.createdProjectCleanup ? { createdProjectCleanup: selected.createdProjectCleanup } : {}),
};
}
@ -291,9 +347,14 @@ export async function runKtxSetupProjectStep(
io.stderr.write('Missing setup choice: pass --new or --yes to create a project in non-interactive setup.\n');
return { status: 'missing-input', projectDir };
}
const project = await createProject(projectDir, deps);
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
printProjectSummary(io, projectDir);
return { status: 'ready', projectDir, project };
return {
status: 'ready',
projectDir,
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
if (!io.stdout.isTTY && !deps.prompts) {
@ -332,9 +393,14 @@ export async function runKtxSetupProjectStep(
}
if (choice === 'current') {
const project = await createProject(projectDir, deps);
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
printProjectSummary(io, projectDir);
return { status: 'ready', projectDir, project };
return {
status: 'ready',
projectDir,
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
if (choice === 'new-default') {
@ -349,6 +415,7 @@ export async function runKtxSetupProjectStep(
projectDir: defaultProjectDir,
project,
confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
};
}
@ -372,7 +439,13 @@ export async function runKtxSetupProjectStep(
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
const project = await createProject(customDir, deps);
printProjectSummary(io, customDir);
return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation };
return {
status: 'ready',
projectDir: customDir,
project,
confirmedCreation: confirmed.confirmedCreation,
...(confirmed.createdProjectCleanup ? { createdProjectCleanup: confirmed.createdProjectCleanup } : {}),
};
}
prompts.cancel('Setup cancelled.');

View file

@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
@ -563,6 +563,112 @@ describe('setup status', () => {
expect(testIo.stderr()).toBe('');
});
it('removes a newly created missing project directory when a later runtime step fails', async () => {
const projectDir = join(tempDir, 'missing-project');
const testIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir,
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
databaseSchemas: [],
skipDatabases: true,
skipSources: true,
},
testIo.io,
{
model: async () => ({ status: 'skipped', projectDir }),
embeddings: async () => ({ status: 'skipped', projectDir }),
databases: async () => ({ status: 'skipped', projectDir }),
sources: async () => ({ status: 'skipped', projectDir }),
runtime: async () => ({ status: 'failed', projectDir, requirements: { features: ['core'], requirements: [] } }),
},
),
).resolves.toBe(1);
await expect(stat(projectDir)).rejects.toThrow();
});
it('removes KTX scaffold files from an initially empty project directory when runtime setup fails', async () => {
const testIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
databaseSchemas: [],
skipDatabases: true,
skipSources: true,
},
testIo.io,
{
model: async () => ({ status: 'skipped', projectDir: tempDir }),
embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
databases: async () => ({ status: 'skipped', projectDir: tempDir }),
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
},
),
).resolves.toBe(1);
await expect(stat(tempDir)).resolves.toBeDefined();
expect(await readdir(tempDir)).toEqual([]);
});
it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => {
await writeFile(join(tempDir, 'notes.txt'), 'keep me\n', 'utf-8');
const testIo = makeIo();
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
databaseSchemas: [],
skipDatabases: true,
skipSources: true,
},
testIo.io,
{
model: async () => ({ status: 'skipped', projectDir: tempDir }),
embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
databases: async () => ({ status: 'skipped', projectDir: tempDir }),
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
},
),
).resolves.toBe(1);
await expect(readFile(join(tempDir, 'notes.txt'), 'utf-8')).resolves.toBe('keep me\n');
await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
});
it('shows demo near the bottom of the first setup intent menu before project creation', async () => {
const testIo = makeIo();
const select = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => {

View file

@ -1,4 +1,5 @@
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import {
@ -33,7 +34,11 @@ import {
isKtxSetupLlmConfigReady,
runKtxSetupAnthropicModelStep,
} from './setup-models.js';
import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
import {
type KtxSetupCreatedProjectCleanup,
type KtxSetupProjectDeps,
runKtxSetupProjectStep,
} from './setup-project.js';
import {
isKtxPreAgentSetupReady,
isKtxSetupReady,
@ -503,6 +508,23 @@ async function commitSetupConfigChanges(projectDir: string): Promise<void> {
await project.git.commitFile('ktx.yaml', 'setup: update KTX project config', 'ktx setup', 'setup@ktx.local');
}
const KTX_SETUP_SCAFFOLD_PATHS = ['ktx.yaml', '.ktx', 'wiki', 'semantic-layer', 'raw-sources', '.git'];
async function cleanupCreatedProjectScaffold(cleanup: KtxSetupCreatedProjectCleanup | undefined): Promise<void> {
if (!cleanup) {
return;
}
if (cleanup.kind === 'remove-project-dir') {
await rm(cleanup.projectDir, { recursive: true, force: true });
return;
}
await Promise.all(
KTX_SETUP_SCAFFOLD_PATHS.map((relativePath) =>
rm(join(cleanup.projectDir, relativePath), { recursive: true, force: true }),
),
);
}
export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
try {
return await runKtxSetupInner(args, io, deps);
@ -771,7 +793,11 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
}
}
if (stepResult.status === 'failed' || stepResult.status === 'missing-input') {
if (stepResult.status === 'failed') {
await cleanupCreatedProjectScaffold(projectResult.createdProjectCleanup);
return 1;
}
if (stepResult.status === 'missing-input') {
return 1;
}
if (stepResult.status === 'back') {