diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/src/managed-python-command.test.ts index a63f162e..767d8dd1 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/src/managed-python-command.test.ts @@ -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, }; diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/src/managed-python-daemon.test.ts index 09e45fd3..b02149c2 100644 --- a/packages/cli/src/managed-python-daemon.test.ts +++ b/packages/cli/src/managed-python-daemon.test.ts @@ -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), }; diff --git a/packages/cli/src/managed-python-runtime.test.ts b/packages/cli/src/managed-python-runtime.test.ts index 540df619..13b97a45 100644 --- a/packages/cli/src/managed-python-runtime.test.ts +++ b/packages/cli/src/managed-python-runtime.test.ts @@ -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'); }); diff --git a/packages/cli/src/managed-python-runtime.ts b/packages/cli/src/managed-python-runtime.ts index 4e3af013..88b0fa2b 100644 --- a/packages/cli/src/managed-python-runtime.ts +++ b/packages/cli/src/managed-python-runtime.ts @@ -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; + 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 { const manifestPath = join(input.assetDir, 'manifest.json'); let manifestData: unknown; @@ -221,7 +260,7 @@ export async function verifyRuntimeAsset(input: { assetDir: string }): Promise 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; diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 5af457de..01a529e7 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -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', diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index 23884b0c..2af3a35a 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -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>, +): 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 { @@ -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.'); diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index 60118207..110c0b1b 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -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 }> }) => { diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index b06c9198..3ef5ae21 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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 { 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 { + 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 { 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') {