From e28b10454a42972cee06f8f0789176fa0fe3aa8b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 14:35:55 +0200 Subject: [PATCH] feat(cli): friendly missing-project status and per-project daemon state (#87) - Block project-aware commands when ktx.yaml is absent and render a friendly "run ktx setup" message (plain or JSON) instead of leaking ENOENT or "Project: ..." noise. - Make ktx status project detect the missing config and emit the same message via a shared renderMissingProjectMessage helper. - Move the managed Python daemon state, stdout, and stderr files out of the shared runtime root into {projectDir}/.ktx/runtime so multiple projects no longer share a single daemon record. - Simplify the runtime install root to ~/.ktx/runtime on every platform and split the daemon-specific paths into managedPythonDaemonLayout, threading projectDir through start, stop, and stop-all paths. --- packages/cli/src/cli-program.ts | 74 +++++++++++- packages/cli/src/commands/runtime-commands.ts | 8 +- packages/cli/src/doctor.test.ts | 44 +++++++ packages/cli/src/doctor.ts | 47 ++++++++ packages/cli/src/index.test.ts | 21 ++-- packages/cli/src/ingest.test.ts | 1 + packages/cli/src/ingest.ts | 1 + .../cli/src/managed-local-embeddings.test.ts | 19 ++- packages/cli/src/managed-local-embeddings.ts | 3 + .../cli/src/managed-python-command.test.ts | 3 - .../cli/src/managed-python-daemon.test.ts | 108 +++++++----------- packages/cli/src/managed-python-daemon.ts | 98 +++++++--------- packages/cli/src/managed-python-http.test.ts | 3 + packages/cli/src/managed-python-http.ts | 3 + .../cli/src/managed-python-runtime.test.ts | 94 +++++++++------ packages/cli/src/managed-python-runtime.ts | 37 ++++-- packages/cli/src/project-dir.test.ts | 53 ++++++--- packages/cli/src/runtime.test.ts | 63 +++++----- packages/cli/src/runtime.ts | 14 ++- packages/cli/src/scan.test.ts | 1 + packages/cli/src/scan.ts | 1 + packages/cli/src/setup-embeddings.test.ts | 1 + packages/cli/src/setup-embeddings.ts | 1 + 23 files changed, 450 insertions(+), 248 deletions(-) diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 93f31ae9..cfbc86b0 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { Command, InvalidArgumentError } from '@commander-js/extra-typings'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; @@ -7,6 +9,7 @@ import { registerSetupCommands } from './commands/setup-commands.js'; import { registerSlCommands } from './commands/sl-commands.js'; import { registerStatusCommands } from './commands/status-commands.js'; import { registerDevCommands } from './dev.js'; +import { renderMissingProjectMessage } from './doctor.js'; import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js'; import { profileMark, profileSpan } from './startup-profile.js'; @@ -53,6 +56,22 @@ type CommandPathNode = CommandWithGlobalOptions & { }; const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']); +const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']); +const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']); + +class KtxProjectMissingAbortError extends Error { + readonly isKtxProjectMissingAbort = true; + constructor() { + super('ktx project missing'); + } +} + +function isKtxProjectMissingAbortError(error: unknown): error is KtxProjectMissingAbortError { + return ( + error instanceof KtxProjectMissingAbortError || + (typeof error === 'object' && error !== null && (error as { isKtxProjectMissingAbort?: unknown }).isKtxProjectMissingAbort === true) + ); +} const REMOVED_COMMAND_PATHS = new Set([ 'scan', 'wiki read', @@ -257,11 +276,60 @@ function writeDebug(io: KtxCliIo, commandContext: CommandWithGlobalOptions, comm io.stderr.write(`[debug] dispatch=${command}\n`); } +function ktxYamlExists(projectDir: string): boolean { + return existsSync(join(projectDir, 'ktx.yaml')); +} + +function commandRendersMissingProjectMessage(path: string[]): boolean { + if (!isProjectAwareCommand(path)) { + return false; + } + const pathKey = path.join(' '); + const rootCommand = path[1]; + if (rootCommand !== undefined && COMMANDS_THAT_CREATE_PROJECT.has(rootCommand)) { + return false; + } + if (COMMANDS_THAT_CREATE_PROJECT.has(pathKey)) { + return false; + } + return true; +} + +function requiresExistingProject(path: string[]): boolean { + if (!commandRendersMissingProjectMessage(path)) { + return false; + } + const rootCommand = path[1]; + if (rootCommand !== undefined && COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING.has(rootCommand)) { + return false; + } + return true; +} + function writeProjectDir(io: KtxCliIo, commandContext: CommandPathNode): void { if (!shouldPrintProjectDir(commandContext)) { return; } - io.stderr.write(`Project: ${resolveCommandProjectDir(commandContext)}\n`); + const projectDir = resolveCommandProjectDir(commandContext); + if (commandRendersMissingProjectMessage(commandPath(commandContext)) && !ktxYamlExists(projectDir)) { + return; + } + io.stderr.write(`Project: ${projectDir}\n`); +} + +function ensureProjectAvailable(io: KtxCliIo, command: CommandPathNode): void { + const path = commandPath(command); + if (!requiresExistingProject(path)) { + return; + } + const projectDir = resolveCommandProjectDir(command); + if (ktxYamlExists(projectDir)) { + return; + } + const options = commandOptions(command); + const outputMode: 'plain' | 'json' = options.json === true ? 'json' : 'plain'; + renderMissingProjectMessage(projectDir, outputMode, io); + throw new KtxProjectMissingAbortError(); } function formatCliError(error: unknown): string { @@ -346,6 +414,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); program.hook('preAction', (_thisCommand, actionCommand) => { writeProjectDir(options.io, actionCommand as CommandPathNode); + ensureProjectAvailable(options.io, actionCommand as CommandPathNode); }); const context: KtxCliCommandContext = { @@ -429,6 +498,9 @@ export async function runCommanderKtxCli( try { await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' })); } catch (error) { + if (isKtxProjectMissingAbortError(error)) { + return 1; + } if (isCommanderExit(error)) { return error.exitCode === 0 ? 0 : 1; } diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts index cf0abb42..12af875c 100644 --- a/packages/cli/src/commands/runtime-commands.ts +++ b/packages/cli/src/commands/runtime-commands.ts @@ -1,5 +1,5 @@ import { type Command, Option } from '@commander-js/extra-typings'; -import type { KtxCliCommandContext } from '../cli-program.js'; +import { resolveCommandProjectDir, type CommandWithGlobalOptions, type KtxCliCommandContext } from '../cli-program.js'; import type { KtxRuntimeArgs } from '../runtime.js'; type RuntimeFeature = Extract['feature']; @@ -41,10 +41,11 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand .description('Start the KTX-managed Python HTTP daemon') .addOption(createRuntimeFeatureOption()) .option('--force', 'Restart even when a matching daemon is already running', false) - .action(async (options: { feature: RuntimeFeature; force?: boolean }) => { + .action(async (options: { feature: RuntimeFeature; force?: boolean }, command: CommandWithGlobalOptions) => { await runRuntimeArgs(context, { command: 'start', cliVersion: context.packageInfo.version, + projectDir: resolveCommandProjectDir(command), feature: options.feature, force: options.force === true, }); @@ -54,10 +55,11 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand .command('stop') .description('Stop the KTX-managed Python HTTP daemon') .option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false) - .action(async (options: { all?: boolean }) => { + .action(async (options: { all?: boolean }, command: CommandWithGlobalOptions) => { await runRuntimeArgs(context, { command: 'stop', cliVersion: context.packageInfo.version, + projectDir: resolveCommandProjectDir(command), all: options.all === true, }); }); diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts index b89e52d3..22c6878d 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/src/doctor.test.ts @@ -280,6 +280,50 @@ describe('runKtxDoctor', () => { }); }); + it('prints a friendly message when ktx.yaml is missing at the project dir', async () => { + const originalEnvProjectDir = process.env.KTX_PROJECT_DIR; + process.env.KTX_PROJECT_DIR = tempDir; + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(1); + + const out = testIo.stdout(); + expect(out).toContain('KTX status'); + expect(out).toContain('No KTX project here yet.'); + expect(out).toContain('ktx setup'); + expect(out).toContain('KTX_PROJECT_DIR'); + expect(out).not.toContain('ENOENT'); + expect(testIo.stderr()).toBe(''); + + if (originalEnvProjectDir === undefined) { + delete process.env.KTX_PROJECT_DIR; + } else { + process.env.KTX_PROJECT_DIR = originalEnvProjectDir; + } + }); + + it('emits a structured JSON error when ktx.yaml is missing and JSON output is requested', async () => { + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(1); + + const parsed = JSON.parse(testIo.stdout()) as { error: string; projectDir: string }; + expect(parsed.error).toBe('missing_project'); + expect(parsed.projectDir).toBe(tempDir); + }); + it('runs project checks against a valid ktx.yaml', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret await writeFile( diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index c4928e5c..c0ff7ba7 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -450,6 +450,48 @@ function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: io.stdout.write(renderPlainReport(report, options)); } +export function renderMissingProjectMessage( + projectDir: string, + outputMode: KtxDoctorOutputMode, + io: KtxDoctorIo, +): void { + if (outputMode === 'json') { + io.stdout.write( + `${JSON.stringify( + { + error: 'missing_project', + projectDir, + message: `No ktx.yaml found in ${projectDir}`, + }, + null, + 2, + )}\n`, + ); + return; + } + + const useColor = shouldUseColor(io); + const dim = (text: string) => styleDim(useColor, text); + const bold = (text: string) => styleBold(useColor, text); + const abbreviated = abbreviateHome(projectDir) ?? projectDir; + const envProjectDir = process.env.KTX_PROJECT_DIR; + + const lines: string[] = []; + lines.push(`${bold('KTX status')} ${dim('ยท')} ${abbreviated}`); + lines.push(''); + lines.push(` No KTX project here yet. ${dim('(ktx.yaml not found)')}`); + lines.push(''); + lines.push(` Run ${bold('ktx setup')} to create one.`); + if (envProjectDir !== undefined) { + lines.push(` ${dim(`Or unset KTX_PROJECT_DIR (currently ${envProjectDir}) to use a different directory.`)}`); + } else { + lines.push(` ${dim('Or pass --project-dir to point at an existing project.')}`); + } + lines.push(''); + + io.stdout.write(lines.join('\n')); +} + export async function runKtxDoctor( args: KtxDoctorArgs, io: KtxDoctorIo = process, @@ -460,6 +502,11 @@ export async function runKtxDoctor( const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks()); if (args.command === 'project') { + const configPath = join(args.projectDir, 'ktx.yaml'); + if (!(await defaultPathExists(configPath))) { + renderMissingProjectMessage(args.projectDir, args.outputMode, io); + return 1; + } const { loadKtxProject } = await import('@ktx/context/project'); const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js'); const project = await loadKtxProject({ projectDir: args.projectDir }); diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 35c425c0..fff5fb09 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -102,6 +102,7 @@ describe('runKtxCli', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-')); + await writeFile(join(tempDir, 'ktx.yaml'), 'project: cli-dispatch-fixture\n', 'utf-8'); }); afterEach(async () => { @@ -272,6 +273,7 @@ describe('runKtxCli', () => { { command: 'start', cliVersion: '0.0.0-private', + projectDir: expect.any(String), feature: 'local-embeddings', force: true, }, @@ -282,6 +284,7 @@ describe('runKtxCli', () => { { command: 'stop', cliVersion: '0.0.0-private', + projectDir: expect.any(String), all: false, }, stopIo.io, @@ -291,6 +294,7 @@ describe('runKtxCli', () => { { command: 'stop', cliVersion: '0.0.0-private', + projectDir: expect.any(String), all: true, }, stopAllIo.io, @@ -656,7 +660,7 @@ describe('runKtxCli', () => { const publicIngest = vi.fn().mockResolvedValue(0); await expect( - runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse', '--fast', '--no-input'], testIo.io, { + runKtxCli(['--project-dir', tempDir, 'ingest', 'warehouse', '--fast', '--no-input'], testIo.io, { publicIngest, }), ).resolves.toBe(0); @@ -664,7 +668,7 @@ describe('runKtxCli', () => { expect(publicIngest).toHaveBeenCalledWith( { command: 'run', - projectDir: '/tmp/project', + projectDir: tempDir, targetConnectionId: 'warehouse', all: false, json: false, @@ -676,7 +680,7 @@ describe('runKtxCli', () => { }, testIo.io, ); - expect(testIo.stderr()).toBe('Project: /tmp/project\n'); + expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`); }); it('routes public ingest --all --deep with JSON output', async () => { @@ -684,7 +688,7 @@ describe('runKtxCli', () => { const publicIngest = vi.fn().mockResolvedValue(0); await expect( - runKtxCli(['--project-dir', '/tmp/project', 'ingest', '--all', '--deep', '--json'], testIo.io, { + runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--deep', '--json'], testIo.io, { publicIngest, }), ).resolves.toBe(0); @@ -692,7 +696,7 @@ describe('runKtxCli', () => { expect(publicIngest).toHaveBeenCalledWith( { command: 'run', - projectDir: '/tmp/project', + projectDir: tempDir, all: true, json: true, inputMode: 'auto', @@ -727,7 +731,7 @@ describe('runKtxCli', () => { const publicIngest = vi.fn(async () => 0); await expect( - runKtxCli(['--project-dir', '/tmp/project', 'ingest', connectionId, '--no-input'], testIo.io, { + runKtxCli(['--project-dir', tempDir, 'ingest', connectionId, '--no-input'], testIo.io, { publicIngest, }), ).resolves.toBe(0); @@ -735,7 +739,7 @@ describe('runKtxCli', () => { expect(publicIngest).toHaveBeenCalledWith( { command: 'run', - projectDir: '/tmp/project', + projectDir: tempDir, targetConnectionId: connectionId, all: false, json: false, @@ -1478,6 +1482,7 @@ describe('runKtxCli', () => { it('dispatches public connection subcommands through the existing connection implementation', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-')); + await writeFile(join(tempDir, 'ktx.yaml'), 'project: connection-dispatch\n', 'utf-8'); const connection = vi.fn(async () => 0); await expect( diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 5384ef78..55e7007c 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -1132,6 +1132,7 @@ describe('runKtxIngest', () => { const expectedManagedDaemon = { cliVersion: '0.2.0', + projectDir, installPolicy: 'auto', io: io.io, }; diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index c508c5cf..d602833c 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -539,6 +539,7 @@ function managedDaemonOptionsForIngestRun( } return { cliVersion: args.cliVersion, + projectDir: args.projectDir, installPolicy: args.runtimeInstallPolicy, io, }; diff --git a/packages/cli/src/managed-local-embeddings.test.ts b/packages/cli/src/managed-local-embeddings.test.ts index f0cb5a2f..cbb9b5f1 100644 --- a/packages/cli/src/managed-local-embeddings.test.ts +++ b/packages/cli/src/managed-local-embeddings.test.ts @@ -45,9 +45,6 @@ function runtime(): ManagedPythonCommandRuntime { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }, manifest: { schemaVersion: 1, @@ -77,7 +74,14 @@ function runtime(): ManagedPythonCommandRuntime { function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDaemonStartResult { return { status, - layout: runtime().layout, + layout: { + ...runtime().layout, + projectDir: '/work/proj', + daemonStateDir: '/work/proj/.ktx/runtime', + daemonStatePath: '/work/proj/.ktx/runtime/daemon.json', + daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log', + daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log', + }, baseUrl: 'http://127.0.0.1:61234', state: { schemaVersion: 1, @@ -87,8 +91,8 @@ function daemonResult(status: 'started' | 'reused' = 'reused'): ManagedPythonDae version: '0.2.0', features: ['core', 'local-embeddings'], startedAt: '2026-05-11T00:00:00.000Z', - stdoutLog: '/runtime/0.2.0/daemon.stdout.log', - stderrLog: '/runtime/0.2.0/daemon.stderr.log', + stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log', + stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log', }, }; } @@ -138,6 +142,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => { await expect( ensureManagedLocalEmbeddingsDaemon({ cliVersion: '0.2.0', + projectDir: '/work/proj', installPolicy: 'auto', io: io.io, ensureRuntime, @@ -158,6 +163,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => { }); expect(startDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0', + projectDir: '/work/proj', features: ['local-embeddings'], force: false, }); @@ -169,6 +175,7 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => { await ensureManagedLocalEmbeddingsDaemon({ cliVersion: '0.2.0', + projectDir: '/work/proj', installPolicy: 'prompt', io: io.io, ensureRuntime: vi.fn(async () => runtime()), diff --git a/packages/cli/src/managed-local-embeddings.ts b/packages/cli/src/managed-local-embeddings.ts index e47d605c..8c383ef5 100644 --- a/packages/cli/src/managed-local-embeddings.ts +++ b/packages/cli/src/managed-local-embeddings.ts @@ -19,6 +19,7 @@ export interface ManagedLocalEmbeddingsDaemon { export interface ManagedLocalEmbeddingsOptions { cliVersion: string; + projectDir: string; installPolicy: KtxManagedPythonInstallPolicy; io: KtxCliIo; ensureRuntime?: (options: { @@ -29,6 +30,7 @@ export interface ManagedLocalEmbeddingsOptions { }) => Promise; startDaemon?: (options: { cliVersion: string; + projectDir: string; features: ['local-embeddings']; force: boolean; }) => Promise; @@ -79,6 +81,7 @@ export async function ensureManagedLocalEmbeddingsDaemon( }); const daemon = await startDaemon({ cliVersion: options.cliVersion, + projectDir: options.projectDir, features: ['local-embeddings'], force: false, }); diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/src/managed-python-command.test.ts index f2ab3399..abde67f1 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/src/managed-python-command.test.ts @@ -45,9 +45,6 @@ function layout(): ManagedPythonRuntimeLayout { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }; } diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/src/managed-python-daemon.test.ts index ffa69972..09e45fd3 100644 --- a/packages/cli/src/managed-python-daemon.test.ts +++ b/packages/cli/src/managed-python-daemon.test.ts @@ -15,11 +15,13 @@ import { } from './managed-python-daemon.js'; import type { InstalledKtxRuntimeManifest, + ManagedPythonDaemonLayout, ManagedPythonRuntimeInstallResult, ManagedPythonRuntimeLayout, } from './managed-python-runtime.js'; -function layout(root: string): ManagedPythonRuntimeLayout { +function layout(root: string): ManagedPythonDaemonLayout { + const projectDir = join(root, 'project'); return { cliVersion: '0.2.0', runtimeRoot: join(root, 'runtime'), @@ -31,12 +33,19 @@ function layout(root: string): ManagedPythonRuntimeLayout { assetManifestPath: join(root, 'assets', 'python', 'manifest.json'), pythonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'python'), daemonPath: join(root, 'runtime', '0.2.0', '.venv', 'bin', 'ktx-daemon'), - daemonStatePath: join(root, 'runtime', '0.2.0', 'daemon.json'), - daemonStdoutPath: join(root, 'runtime', '0.2.0', 'daemon.stdout.log'), - daemonStderrPath: join(root, 'runtime', '0.2.0', 'daemon.stderr.log'), + projectDir, + daemonStateDir: join(projectDir, '.ktx', 'runtime'), + daemonStatePath: join(projectDir, '.ktx', 'runtime', 'daemon.json'), + daemonStdoutPath: join(projectDir, '.ktx', 'runtime', 'daemon.stdout.log'), + daemonStderrPath: join(projectDir, '.ktx', 'runtime', 'daemon.stderr.log'), }; } +function installLayout(root: string): ManagedPythonRuntimeLayout { + const { projectDir: _projectDir, daemonStateDir: _d, daemonStatePath: _ds, daemonStdoutPath: _so, daemonStderrPath: _se, ...rest } = layout(root); + return rest; +} + function manifest(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): InstalledKtxRuntimeManifest { const runtimeLayout = layout(root); return { @@ -66,7 +75,7 @@ function manifest(root: string, features: Array<'core' | 'local-embeddings'> = [ function installResult(root: string, features: Array<'core' | 'local-embeddings'> = ['core']): ManagedPythonRuntimeInstallResult { return { status: 'ready', - layout: layout(root), + layout: installLayout(root), asset: { manifest: manifest(root, features).asset, wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'), @@ -107,22 +116,12 @@ function runningState(root: string, overrides: Partial }; } -function daemonStatePath(root: string, version: string): string { - return join(root, 'runtime', version, 'daemon.json'); -} - -function runningStateForVersion( - root: string, - version: string, - overrides: Partial = {}, -): ManagedPythonDaemonState { +function daemonOptionsBase(root: string) { return { - ...runningState(root), - version, - stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'), - stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'), - ...overrides, - }; + cliVersion: '0.2.0', + projectDir: layout(root).projectDir, + runtimeRoot: join(root, 'runtime'), + } as const; } describe('managed Python daemon lifecycle', () => { @@ -138,8 +137,7 @@ describe('managed Python daemon lifecycle', () => { it('reports stopped when no daemon state exists', async () => { const status = await readManagedPythonDaemonStatus({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), processAlive: vi.fn(() => false), fetch: makeFetch(), }); @@ -153,8 +151,7 @@ describe('managed Python daemon lifecycle', () => { const installRuntime = vi.fn(async () => installResult(tempDir)); const result = await startManagedPythonDaemon({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), features: ['core'], installRuntime, spawnDaemon, @@ -204,8 +201,7 @@ describe('managed Python daemon lifecycle', () => { }); const result = await startManagedPythonDaemon({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), features: ['core'], installRuntime, spawnDaemon, @@ -226,13 +222,12 @@ describe('managed Python daemon lifecycle', () => { }); it('reuses a healthy daemon with the requested feature set', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); const spawnDaemon = makeSpawn(9999); const result = await startManagedPythonDaemon({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), features: ['core'], installRuntime: vi.fn(async () => installResult(tempDir)), spawnDaemon, @@ -247,15 +242,14 @@ describe('managed Python daemon lifecycle', () => { }); it('starts a fresh daemon when the previous state is stale', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile( layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir, { version: '0.1.0' }), null, 2)}\n`, ); const result = await startManagedPythonDaemon({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), features: ['core'], installRuntime: vi.fn(async () => installResult(tempDir)), spawnDaemon: makeSpawn(6666), @@ -276,13 +270,12 @@ describe('managed Python daemon lifecycle', () => { }); it('stops a recorded daemon and removes the state file', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); const killProcess = vi.fn(); const result = await stopManagedPythonDaemon({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), processAlive: vi.fn(() => true), killProcess, }); @@ -292,25 +285,16 @@ describe('managed Python daemon lifecycle', () => { await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow(); }); - it('stops all recorded daemon states across runtime versions and removes state files', async () => { - await mkdir(join(tempDir, 'runtime', '0.1.0'), { recursive: true }); - await mkdir(join(tempDir, 'runtime', '0.2.0'), { recursive: true }); - await writeFile( - daemonStatePath(tempDir, '0.1.0'), - `${JSON.stringify(runningStateForVersion(tempDir, '0.1.0', { pid: 1111, port: 61111 }), null, 2)}\n`, - ); - await writeFile( - daemonStatePath(tempDir, '0.2.0'), - `${JSON.stringify(runningStateForVersion(tempDir, '0.2.0', { pid: 2222, port: 62222 }), null, 2)}\n`, - ); - const alive = new Set([1111, 2222]); + it('stops the recorded daemon for this project and removes the state file', async () => { + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); + await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); + const alive = new Set([4242]); const killProcess = vi.fn((pid: number) => { alive.delete(pid); }); const result = await stopAllManagedPythonDaemons({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), listProcesses: vi.fn(async () => []), processAlive: vi.fn((pid) => alive.has(pid)), killProcess, @@ -318,20 +302,17 @@ describe('managed Python daemon lifecycle', () => { }); expect(result.failed).toHaveLength(0); - expect(result.stopped.map((entry) => entry.pid).sort()).toEqual([1111, 2222]); - expect(killProcess).toHaveBeenCalledWith(1111, 'SIGTERM'); - expect(killProcess).toHaveBeenCalledWith(2222, 'SIGTERM'); - await expect(readFile(daemonStatePath(tempDir, '0.1.0'), 'utf8')).rejects.toThrow(); - await expect(readFile(daemonStatePath(tempDir, '0.2.0'), 'utf8')).rejects.toThrow(); + expect(result.stopped.map((entry) => entry.pid)).toEqual([4242]); + expect(killProcess).toHaveBeenCalledWith(4242, 'SIGTERM'); + await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow(); }); it('removes stale state when the recorded daemon process is no longer alive', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); const result = await stopAllManagedPythonDaemons({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), listProcesses: vi.fn(async () => []), processAlive: vi.fn(() => false), killProcess: vi.fn(), @@ -344,7 +325,7 @@ describe('managed Python daemon lifecycle', () => { }); it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); const alive = new Set([4242]); const killProcess = vi.fn((pid: number) => { @@ -352,8 +333,7 @@ describe('managed Python daemon lifecycle', () => { }); const result = await stopAllManagedPythonDaemons({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), listProcesses: vi.fn(async (): Promise => [ { pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' }, ]), @@ -378,8 +358,7 @@ describe('managed Python daemon lifecycle', () => { }); const result = await stopAllManagedPythonDaemons({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), listProcesses: vi.fn(async (): Promise => [ { pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' }, { pid: 4444, command: 'node server.js --port 8765' }, @@ -404,12 +383,11 @@ describe('managed Python daemon lifecycle', () => { }); it('reports a failed stop when TERM and KILL leave a daemon running', async () => { - await mkdir(layout(tempDir).versionDir, { recursive: true }); + await mkdir(layout(tempDir).daemonStateDir, { recursive: true }); await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`); const result = await stopAllManagedPythonDaemons({ - cliVersion: '0.2.0', - runtimeRoot: join(tempDir, 'runtime'), + ...daemonOptionsBase(tempDir), listProcesses: vi.fn(async () => []), processAlive: vi.fn(() => true), killProcess: vi.fn(), diff --git a/packages/cli/src/managed-python-daemon.ts b/packages/cli/src/managed-python-daemon.ts index b99de581..76740554 100644 --- a/packages/cli/src/managed-python-daemon.ts +++ b/packages/cli/src/managed-python-daemon.ts @@ -1,19 +1,18 @@ import { execFile, spawn } from 'node:child_process'; -import { mkdir, open, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; -import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; import { z } from 'zod'; import { installManagedPythonRuntime, - managedPythonRuntimeLayout, + managedPythonDaemonLayout, runtimeFeatureSchema, type KtxRuntimeFeature, + type ManagedPythonDaemonLayout, + type ManagedPythonDaemonLayoutOptions, type ManagedPythonRuntimeInstallOptions, type ManagedPythonRuntimeInstallResult, - type ManagedPythonRuntimeLayout, - type ManagedPythonRuntimeLayoutOptions, } from './managed-python-runtime.js'; export interface ManagedPythonDaemonState { @@ -29,20 +28,20 @@ export interface ManagedPythonDaemonState { } export type ManagedPythonDaemonStatus = - | { kind: 'stopped'; detail: string; layout: ManagedPythonRuntimeLayout } - | { kind: 'running'; detail: string; layout: ManagedPythonRuntimeLayout; state: ManagedPythonDaemonState; baseUrl: string } - | { kind: 'stale'; detail: string; layout: ManagedPythonRuntimeLayout; state?: ManagedPythonDaemonState }; + | { kind: 'stopped'; detail: string; layout: ManagedPythonDaemonLayout } + | { kind: 'running'; detail: string; layout: ManagedPythonDaemonLayout; state: ManagedPythonDaemonState; baseUrl: string } + | { kind: 'stale'; detail: string; layout: ManagedPythonDaemonLayout; state?: ManagedPythonDaemonState }; export interface ManagedPythonDaemonStartResult { status: 'started' | 'reused'; - layout: ManagedPythonRuntimeLayout; + layout: ManagedPythonDaemonLayout; state: ManagedPythonDaemonState; baseUrl: string; } export interface ManagedPythonDaemonStopResult { status: 'stopped' | 'already-stopped'; - layout: ManagedPythonRuntimeLayout; + layout: ManagedPythonDaemonLayout; state?: ManagedPythonDaemonState; } @@ -68,7 +67,6 @@ export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonSt } export interface ManagedPythonDaemonStopAllResult { - runtimeRoot: string; stopped: ManagedPythonDaemonStopAllEntry[]; stale: ManagedPythonDaemonStopAllEntry[]; failed: ManagedPythonDaemonStopAllFailure[]; @@ -101,7 +99,7 @@ export type ManagedPythonDaemonFetch = ( export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void; -export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions { +export interface ManagedPythonDaemonStartOptions extends ManagedPythonDaemonLayoutOptions { features: KtxRuntimeFeature[]; force?: boolean; installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; @@ -115,17 +113,17 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay pollIntervalMs?: number; } -export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLayoutOptions { +export interface ManagedPythonDaemonStatusOptions extends ManagedPythonDaemonLayoutOptions { fetch?: ManagedPythonDaemonFetch; processAlive?: (pid: number) => boolean; } -export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions { +export interface ManagedPythonDaemonStopOptions extends ManagedPythonDaemonLayoutOptions { processAlive?: (pid: number) => boolean; killProcess?: ManagedPythonDaemonKillProcess; } -export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions { +export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonDaemonLayoutOptions { listProcesses?: () => Promise; processAlive?: (pid: number) => boolean; killProcess?: ManagedPythonDaemonKillProcess; @@ -242,7 +240,7 @@ async function healthOk(input: { export async function readManagedPythonDaemonStatus( options: ManagedPythonDaemonStatusOptions, ): Promise { - const layout = managedPythonRuntimeLayout(options); + const layout = managedPythonDaemonLayout(options); let state: ManagedPythonDaemonState | undefined; try { state = await readState(layout.daemonStatePath); @@ -329,12 +327,12 @@ async function waitForHealth(input: { throw new Error(`KTX Python daemon failed to start: ${lastDetail}. stderr: ${input.state.stderrLog}`); } -async function removeState(layout: ManagedPythonRuntimeLayout): Promise { +async function removeState(layout: ManagedPythonDaemonLayout): Promise { await rm(layout.daemonStatePath, { force: true }); } async function stopRecordedDaemon(input: { - layout: ManagedPythonRuntimeLayout; + layout: ManagedPythonDaemonLayout; state: ManagedPythonDaemonState; processAlive: (pid: number) => boolean; killProcess: ManagedPythonDaemonKillProcess; @@ -345,10 +343,6 @@ async function stopRecordedDaemon(input: { await removeState(input.layout); } -function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string { - return managedPythonRuntimeLayout(options).runtimeRoot; -} - async function removeStatePaths(paths: string[]): Promise { await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true }))); } @@ -410,42 +404,26 @@ async function probeCandidateHealth( } } -async function readStateCandidates(runtimeRoot: string): Promise { - let entries; +async function readStateCandidates(statePath: string): Promise { + let state: ManagedPythonDaemonState | undefined; try { - entries = await readdir(runtimeRoot, { withFileTypes: true }); - } catch (error) { - const code = (error as { code?: unknown }).code; - if (code === 'ENOENT') { - return []; - } - throw error; + state = await readState(statePath); + } catch { + return []; } - const candidates: ManagedPythonDaemonStopCandidate[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - const statePath = join(runtimeRoot, entry.name, 'daemon.json'); - let state: ManagedPythonDaemonState | undefined; - try { - state = await readState(statePath); - } catch { - continue; - } - if (!state) { - continue; - } - candidates.push({ + if (!state) { + return []; + } + return [ + { pid: state.pid, source: 'state', host: state.host, port: state.port, version: state.version, statePaths: [statePath], - }); - } - return candidates; + }, + ]; } function tokenizeCommand(command: string): string[] { @@ -638,12 +616,12 @@ async function waitUntilStopped(input: { async function discoverStopAllCandidates( options: ManagedPythonDaemonStopAllOptions, ): Promise<{ - runtimeRoot: string; + layout: ManagedPythonDaemonLayout; candidates: ManagedPythonDaemonStopCandidate[]; scanErrors: string[]; }> { - const runtimeRoot = runtimeRootForStopAll(options); - const stateCandidates = await readStateCandidates(runtimeRoot); + const layout = managedPythonDaemonLayout(options); + const stateCandidates = await readStateCandidates(layout.daemonStatePath); const scanErrors: string[] = []; let processCandidates: ManagedPythonDaemonStopCandidate[] = []; try { @@ -656,7 +634,7 @@ async function discoverStopAllCandidates( scanErrors.push(error instanceof Error ? error.message : String(error)); } return { - runtimeRoot, + layout, candidates: mergeCandidates([...stateCandidates, ...processCandidates]), scanErrors, }; @@ -674,13 +652,18 @@ export async function startManagedPythonDaemon( ...(options.env !== undefined ? { env: options.env } : {}), ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}), }; - const layout = managedPythonRuntimeLayout({ cliVersion: options.cliVersion, ...layoutOverrides }); + const layout = managedPythonDaemonLayout({ + cliVersion: options.cliVersion, + projectDir: options.projectDir, + ...layoutOverrides, + }); const processAlive = options.processAlive ?? defaultProcessAlive; const killProcess = options.killProcess ?? defaultKillProcess; const fetchImpl = options.fetch ?? defaultFetch; const status = await readManagedPythonDaemonStatus({ cliVersion: options.cliVersion, + projectDir: options.projectDir, ...layoutOverrides, fetch: fetchImpl, processAlive, @@ -701,7 +684,7 @@ export async function startManagedPythonDaemon( force: false, }); - await mkdir(layout.versionDir, { recursive: true }); + await mkdir(layout.daemonStateDir, { recursive: true }); const stdout = await open(layout.daemonStdoutPath, 'a'); const stderr = await open(layout.daemonStderrPath, 'a'); try { @@ -752,7 +735,7 @@ export async function startManagedPythonDaemon( export async function stopManagedPythonDaemon( options: ManagedPythonDaemonStopOptions, ): Promise { - const layout = managedPythonRuntimeLayout(options); + const layout = managedPythonDaemonLayout(options); const state = await readState(layout.daemonStatePath); if (!state) { return { status: 'already-stopped', layout }; @@ -818,7 +801,6 @@ export async function stopAllManagedPythonDaemons( } return { - runtimeRoot: discovery.runtimeRoot, stopped, stale, failed, diff --git a/packages/cli/src/managed-python-http.test.ts b/packages/cli/src/managed-python-http.test.ts index 7bab7ea5..84d70cf8 100644 --- a/packages/cli/src/managed-python-http.test.ts +++ b/packages/cli/src/managed-python-http.test.ts @@ -33,6 +33,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => { })); const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({ cliVersion: '0.2.0', + projectDir: '/work/proj', installPolicy: 'auto', io: testIo.io, ensureRuntime, @@ -52,6 +53,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => { expect(startDaemon).toHaveBeenCalledTimes(1); expect(startDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0', + projectDir: '/work/proj', features: ['core'], force: false, }); @@ -72,6 +74,7 @@ describe('createManagedPythonDaemonBaseUrlResolver', () => { })); const resolveBaseUrl = createManagedPythonDaemonBaseUrlResolver({ cliVersion: '0.2.0', + projectDir: '/work/proj', installPolicy: 'never', io: testIo.io, ensureRuntime, diff --git a/packages/cli/src/managed-python-http.ts b/packages/cli/src/managed-python-http.ts index 1cd1f7d1..8496a9c9 100644 --- a/packages/cli/src/managed-python-http.ts +++ b/packages/cli/src/managed-python-http.ts @@ -34,6 +34,7 @@ export type ManagedPythonHttpPostJson = ( export interface ManagedPythonCoreDaemonOptions { cliVersion: string; + projectDir: string; installPolicy: KtxManagedPythonInstallPolicy; io: KtxCliIo; ensureRuntime?: (options: { @@ -44,6 +45,7 @@ export interface ManagedPythonCoreDaemonOptions { }) => Promise; startDaemon?: (options: { cliVersion: string; + projectDir: string; features: ['core']; force: false; }) => Promise; @@ -135,6 +137,7 @@ export function createManagedPythonDaemonBaseUrlResolver( }); const daemon = await startDaemon({ cliVersion: options.cliVersion, + projectDir: options.projectDir, features: ['core'], force: false, }); diff --git a/packages/cli/src/managed-python-runtime.test.ts b/packages/cli/src/managed-python-runtime.test.ts index 63755ad1..540df619 100644 --- a/packages/cli/src/managed-python-runtime.test.ts +++ b/packages/cli/src/managed-python-runtime.test.ts @@ -7,6 +7,7 @@ import { MISSING_UV_RUNTIME_INSTALL_MESSAGE, doctorManagedPythonRuntime, installManagedPythonRuntime, + managedPythonDaemonLayout, managedPythonRuntimeLayout, readManagedPythonRuntimeStatus, verifyRuntimeAsset, @@ -40,7 +41,7 @@ async function writeAsset(root: string, contents = 'wheel-bytes') { } describe('managedPythonRuntimeLayout', () => { - it('uses the macOS application-support runtime root', () => { + it('uses ~/.ktx/runtime as the runtime root on macOS', () => { const layout = managedPythonRuntimeLayout({ cliVersion: '0.2.0', platform: 'darwin', @@ -49,28 +50,42 @@ describe('managedPythonRuntimeLayout', () => { assetDir: '/repo/packages/cli/assets/python', }); - expect(layout.runtimeRoot).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime'); - expect(layout.versionDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0'); - expect(layout.venvDir).toBe('/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv'); - expect(layout.pythonPath).toBe( - '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/python', - ); - expect(layout.daemonPath).toBe( - '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/.venv/bin/ktx-daemon', - ); - expect(layout.daemonStatePath).toBe( - '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.json', - ); - expect(layout.daemonStdoutPath).toBe( - '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.stdout.log', - ); - expect(layout.daemonStderrPath).toBe( - '/Users/alex/Library/Application Support/kaelio/ktx/runtime/0.2.0/daemon.stderr.log', - ); + expect(layout.runtimeRoot).toBe('/Users/alex/.ktx/runtime'); + expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0'); + expect(layout.venvDir).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv'); + expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python'); + expect(layout.daemonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/ktx-daemon'); expect(layout.assetManifestPath).toBe('/repo/packages/cli/assets/python/manifest.json'); }); - it('honors KTX_RUNTIME_ROOT before platform defaults', () => { + it('uses ~/.ktx/runtime on Linux too', () => { + const layout = managedPythonRuntimeLayout({ + cliVersion: '0.2.0', + platform: 'linux', + env: {}, + homeDir: '/home/alex', + assetDir: '/repo/packages/cli/assets/python', + }); + + expect(layout.runtimeRoot).toBe('/home/alex/.ktx/runtime'); + expect(layout.versionDir).toBe('/home/alex/.ktx/runtime/0.2.0'); + }); + + it('uses Scripts/*.exe layout on Windows under ~/.ktx/runtime', () => { + const layout = managedPythonRuntimeLayout({ + cliVersion: '0.2.0', + platform: 'win32', + env: {}, + homeDir: 'C:\\Users\\Alex', + assetDir: 'C:\\repo\\packages\\cli\\assets\\python', + }); + + expect(layout.runtimeRoot).toBe('C:\\Users\\Alex/.ktx/runtime'); + expect(layout.pythonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/python.exe'); + expect(layout.daemonPath).toBe('C:\\Users\\Alex/.ktx/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe'); + }); + + it('honors KTX_RUNTIME_ROOT before the default ~/.ktx/runtime', () => { const layout = managedPythonRuntimeLayout({ cliVersion: '0.2.0', platform: 'darwin', @@ -82,32 +97,39 @@ describe('managedPythonRuntimeLayout', () => { expect(layout.runtimeRoot).toBe('/tmp/ktx-runtime'); expect(layout.versionDir).toBe('/tmp/ktx-runtime/0.2.0'); }); +}); - it('honors XDG_DATA_HOME on Linux', () => { - const layout = managedPythonRuntimeLayout({ +describe('managedPythonDaemonLayout', () => { + it('places daemon state, stdout, and stderr under {projectDir}/.ktx/runtime', () => { + const layout = managedPythonDaemonLayout({ cliVersion: '0.2.0', - platform: 'linux', - env: { XDG_DATA_HOME: '/var/xdg' }, - homeDir: '/home/alex', + projectDir: '/work/orbit-analytics', + platform: 'darwin', + env: {}, + homeDir: '/Users/alex', assetDir: '/repo/packages/cli/assets/python', }); - expect(layout.runtimeRoot).toBe('/var/xdg/kaelio/ktx/runtime'); - expect(layout.versionDir).toBe('/var/xdg/kaelio/ktx/runtime/0.2.0'); + expect(layout.projectDir).toBe('/work/orbit-analytics'); + expect(layout.daemonStateDir).toBe('/work/orbit-analytics/.ktx/runtime'); + expect(layout.daemonStatePath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.json'); + expect(layout.daemonStdoutPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stdout.log'); + expect(layout.daemonStderrPath).toBe('/work/orbit-analytics/.ktx/runtime/daemon.stderr.log'); }); - it('uses LocalAppData on Windows', () => { - const layout = managedPythonRuntimeLayout({ + it('keeps install paths under the global runtime root regardless of projectDir', () => { + const layout = managedPythonDaemonLayout({ cliVersion: '0.2.0', - platform: 'win32', - env: { LOCALAPPDATA: 'C:\\Users\\Alex\\AppData\\Local' }, - homeDir: 'C:\\Users\\Alex', - assetDir: 'C:\\repo\\packages\\cli\\assets\\python', + projectDir: '/work/orbit-analytics', + platform: 'darwin', + env: {}, + homeDir: '/Users/alex', + assetDir: '/repo/packages/cli/assets/python', }); - expect(layout.runtimeRoot).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime'); - expect(layout.pythonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/python.exe'); - expect(layout.daemonPath).toBe('C:\\Users\\Alex\\AppData\\Local/Kaelio/KTX/runtime/0.2.0/.venv/Scripts/ktx-daemon.exe'); + expect(layout.runtimeRoot).toBe('/Users/alex/.ktx/runtime'); + expect(layout.versionDir).toBe('/Users/alex/.ktx/runtime/0.2.0'); + expect(layout.pythonPath).toBe('/Users/alex/.ktx/runtime/0.2.0/.venv/bin/python'); }); }); diff --git a/packages/cli/src/managed-python-runtime.ts b/packages/cli/src/managed-python-runtime.ts index 563b62f7..4e3af013 100644 --- a/packages/cli/src/managed-python-runtime.ts +++ b/packages/cli/src/managed-python-runtime.ts @@ -61,6 +61,15 @@ export interface ManagedPythonRuntimeLayout { 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; @@ -114,17 +123,11 @@ function defaultAssetDir(): string { return fileURLToPath(new URL('../assets/python/', import.meta.url)); } -function runtimeRootFor(input: Required>): string { +function runtimeRootFor(input: { env: NodeJS.ProcessEnv; homeDir: string }): string { if (input.env.KTX_RUNTIME_ROOT) { return input.env.KTX_RUNTIME_ROOT; } - if (input.platform === 'darwin') { - return join(input.homeDir, 'Library', 'Application Support', 'kaelio', 'ktx', 'runtime'); - } - if (input.platform === 'win32') { - return join(input.env.LOCALAPPDATA ?? join(input.homeDir, 'AppData', 'Local'), 'Kaelio', 'KTX', 'runtime'); - } - return join(input.env.XDG_DATA_HOME ?? join(input.homeDir, '.local', 'share'), 'kaelio', 'ktx', 'runtime'); + return join(input.homeDir, '.ktx', 'runtime'); } function executablePath(venvDir: string, platform: NodeJS.Platform, name: string): string { @@ -138,7 +141,7 @@ export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOp const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const homeDir = options.homeDir ?? homedir(); - const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ platform, env, homeDir }); + const runtimeRoot = options.runtimeRoot ?? runtimeRootFor({ env, homeDir }); const versionDir = join(runtimeRoot, options.cliVersion); const venvDir = join(versionDir, '.venv'); const assetDir = options.assetDir ?? defaultAssetDir(); @@ -154,9 +157,19 @@ export function managedPythonRuntimeLayout(options: ManagedPythonRuntimeLayoutOp assetManifestPath: join(assetDir, 'manifest.json'), pythonPath: executablePath(venvDir, platform, 'python'), daemonPath: executablePath(venvDir, platform, 'ktx-daemon'), - daemonStatePath: join(versionDir, 'daemon.json'), - daemonStdoutPath: join(versionDir, 'daemon.stdout.log'), - daemonStderrPath: join(versionDir, 'daemon.stderr.log'), + }; +} + +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'), }; } diff --git a/packages/cli/src/project-dir.test.ts b/packages/cli/src/project-dir.test.ts index 7d25e56d..b5006bcc 100644 --- a/packages/cli/src/project-dir.test.ts +++ b/packages/cli/src/project-dir.test.ts @@ -1,6 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxCli, type KtxCliDeps } from './index.js'; +async function makeFixtureProject(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + await writeFile(join(dir, 'ktx.yaml'), 'project: project-dir-fixture\n', 'utf-8'); + return dir; +} + function makeIo() { let stdout = ''; let stderr = ''; @@ -23,12 +32,22 @@ function makeIo() { } describe('project directory defaults', () => { - afterEach(() => { + let envProjectDir: string; + let explicitProjectDir: string; + + beforeEach(async () => { + envProjectDir = await makeFixtureProject('ktx-env-project-'); + explicitProjectDir = await makeFixtureProject('ktx-explicit-project-'); + }); + + afterEach(async () => { delete process.env.KTX_PROJECT_DIR; + await rm(envProjectDir, { recursive: true, force: true }); + await rm(explicitProjectDir, { recursive: true, force: true }); }); it('uses KTX_PROJECT_DIR when Commander-dispatched commands omit --project-dir', async () => { - process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project'; + process.env.KTX_PROJECT_DIR = envProjectDir; const connection = vi.fn(async () => 0); const doctor = vi.fn(async () => 0); @@ -45,26 +64,26 @@ describe('project directory defaults', () => { { argv: ['connection', 'list'], spy: connection, - expected: { command: 'list', projectDir: '/tmp/ktx-env-project' }, - expectedStderr: 'Project: /tmp/ktx-env-project\n', + expected: { command: 'list', projectDir: envProjectDir }, + expectedStderr: `Project: ${envProjectDir}\n`, }, { argv: ['status', '--no-input'], spy: doctor, - expected: { command: 'project', projectDir: '/tmp/ktx-env-project' }, - expectedStderr: 'Project: /tmp/ktx-env-project\n', + expected: { command: 'project', projectDir: envProjectDir }, + expectedStderr: `Project: ${envProjectDir}\n`, }, { argv: ['setup', '--no-input'], spy: setup, - expected: { command: 'run', projectDir: '/tmp/ktx-env-project' }, + expected: { command: 'run', projectDir: envProjectDir }, expectedStderr: '', }, { argv: ['ingest', 'warehouse', '--no-input'], spy: publicIngest, - expected: { command: 'run', projectDir: '/tmp/ktx-env-project', targetConnectionId: 'warehouse' }, - expectedStderr: 'Project: /tmp/ktx-env-project\n', + expected: { command: 'run', projectDir: envProjectDir, targetConnectionId: 'warehouse' }, + expectedStderr: `Project: ${envProjectDir}\n`, }, ]; @@ -77,35 +96,35 @@ describe('project directory defaults', () => { }); it('lets explicit global --project-dir override KTX_PROJECT_DIR before and after nested commands', async () => { - process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project'; + process.env.KTX_PROJECT_DIR = envProjectDir; const publicIngest = vi.fn(async () => 0); const beforeCommandIo = makeIo(); const afterCommandIo = makeIo(); await expect( - runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'ingest', 'warehouse', '--no-input'], beforeCommandIo.io, { + runKtxCli(['--project-dir', explicitProjectDir, 'ingest', 'warehouse', '--no-input'], beforeCommandIo.io, { publicIngest, }), ).resolves.toBe(0); await expect( - runKtxCli(['ingest', 'warehouse', '--project-dir=/tmp/ktx-explicit-project', '--no-input'], afterCommandIo.io, { + runKtxCli(['ingest', 'warehouse', `--project-dir=${explicitProjectDir}`, '--no-input'], afterCommandIo.io, { publicIngest, }), ).resolves.toBe(0); expect(publicIngest).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }), + expect.objectContaining({ command: 'run', projectDir: explicitProjectDir }), beforeCommandIo.io, ); expect(publicIngest).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }), + expect.objectContaining({ command: 'run', projectDir: explicitProjectDir }), afterCommandIo.io, ); - expect(beforeCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n'); - expect(afterCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n'); + expect(beforeCommandIo.stderr()).toBe(`Project: ${explicitProjectDir}\n`); + expect(afterCommandIo.stderr()).toBe(`Project: ${explicitProjectDir}\n`); }); it('uses nearest ancestor containing ktx.yaml when no explicit or environment project-dir exists', async () => { diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index a147f966..5af457de 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -49,9 +49,6 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }, asset: { wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl', @@ -128,9 +125,11 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', + projectDir: '/work/proj', + daemonStateDir: '/work/proj/.ktx/runtime', + daemonStatePath: '/work/proj/.ktx/runtime/daemon.json', + daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log', + daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log', }, state: { schemaVersion: 1, @@ -140,15 +139,15 @@ describe('runKtxRuntime', () => { version: '0.2.0', features: ['core', 'local-embeddings'], startedAt: '2026-05-11T00:00:00.000Z', - stdoutLog: '/runtime/0.2.0/daemon.stdout.log', - stderrLog: '/runtime/0.2.0/daemon.stderr.log', + stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log', + stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log', }, })), }; await expect( runKtxRuntime( - { command: 'start', cliVersion: '0.2.0', feature: 'local-embeddings', force: true }, + { command: 'start', cliVersion: '0.2.0', projectDir: '/work/proj', feature: 'local-embeddings', force: true }, io.io, deps, ), @@ -156,6 +155,7 @@ describe('runKtxRuntime', () => { expect(deps.startDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0', + projectDir: '/work/proj', features: ['local-embeddings'], force: true, }); @@ -163,7 +163,7 @@ describe('runKtxRuntime', () => { expect(io.stdout()).toContain('url: http://127.0.0.1:61234'); expect(io.stdout()).toContain('pid: 4242'); expect(io.stdout()).toContain('features: core, local-embeddings'); - expect(io.stdout()).toContain('stderr: /runtime/0.2.0/daemon.stderr.log'); + expect(io.stdout()).toContain('stderr: /work/proj/.ktx/runtime/daemon.stderr.log'); }); it('stops the managed Python daemon', async () => { @@ -182,9 +182,11 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', + projectDir: '/work/proj', + daemonStateDir: '/work/proj/.ktx/runtime', + daemonStatePath: '/work/proj/.ktx/runtime/daemon.json', + daemonStdoutPath: '/work/proj/.ktx/runtime/daemon.stdout.log', + daemonStderrPath: '/work/proj/.ktx/runtime/daemon.stderr.log', }, state: { schemaVersion: 1, @@ -194,15 +196,17 @@ describe('runKtxRuntime', () => { version: '0.2.0', features: ['core'], startedAt: '2026-05-11T00:00:00.000Z', - stdoutLog: '/runtime/0.2.0/daemon.stdout.log', - stderrLog: '/runtime/0.2.0/daemon.stderr.log', + stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log', + stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log', }, })), }; - await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0); + await expect( + runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: false }, io.io, deps), + ).resolves.toBe(0); - expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' }); + expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0', projectDir: '/work/proj' }); expect(io.stdout()).toContain('Stopped KTX Python daemon'); expect(io.stdout()).toContain('pid: 4242'); }); @@ -211,9 +215,8 @@ describe('runKtxRuntime', () => { const io = makeIo(); const deps: KtxRuntimeDeps = { stopAllDaemons: vi.fn(async (): Promise => ({ - runtimeRoot: '/runtime', stopped: [ - { pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] }, + { pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/work/proj/.ktx/runtime/daemon.json'] }, { pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] }, ], stale: [], @@ -222,9 +225,11 @@ describe('runKtxRuntime', () => { })), }; - await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0); + await expect( + runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: true }, io.io, deps), + ).resolves.toBe(0); - expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' }); + expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0', projectDir: '/work/proj' }); expect(io.stdout()).toContain('Stopped 2 KTX Python daemons'); expect(io.stdout()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234'); expect(io.stdout()).toContain('pid: 5252 source: process url: http://127.0.0.1:8765'); @@ -234,7 +239,6 @@ describe('runKtxRuntime', () => { const io = makeIo(); const deps: KtxRuntimeDeps = { stopAllDaemons: vi.fn(async (): Promise => ({ - runtimeRoot: '/runtime', stopped: [], stale: [], failed: [ @@ -242,7 +246,7 @@ describe('runKtxRuntime', () => { pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', - statePaths: ['/runtime/0.2.0/daemon.json'], + statePaths: ['/work/proj/.ktx/runtime/daemon.json'], detail: 'Process still running after SIGKILL', }, ], @@ -250,7 +254,9 @@ describe('runKtxRuntime', () => { })), }; - await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(1); + await expect( + runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', projectDir: '/work/proj', all: true }, io.io, deps), + ).resolves.toBe(1); expect(io.stderr()).toContain('Stopped 0 KTX Python daemons; failed 1'); expect(io.stderr()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234'); @@ -274,9 +280,6 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }, })), doctorRuntime: vi.fn(async (): Promise => [ @@ -325,9 +328,6 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }, manifest: { schemaVersion: 1, @@ -386,9 +386,6 @@ describe('runKtxRuntime', () => { assetManifestPath: '/assets/python/manifest.json', pythonPath: '/runtime/0.2.0/.venv/bin/python', daemonPath: '/runtime/0.2.0/.venv/bin/ktx-daemon', - daemonStatePath: '/runtime/0.2.0/daemon.json', - daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log', - daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log', }, manifest: { schemaVersion: 1, diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index e64efd40..5630163f 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -21,19 +21,20 @@ import { export type KtxRuntimeArgs = | { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } - | { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean } - | { command: 'stop'; cliVersion: string; all: boolean } + | { command: 'start'; cliVersion: string; projectDir: string; feature: KtxRuntimeFeature; force: boolean } + | { command: 'stop'; cliVersion: string; projectDir: string; all: boolean } | { command: 'status'; cliVersion: string; json: boolean }; export interface KtxRuntimeDeps { installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; startDaemon?: (options: { cliVersion: string; + projectDir: string; features: KtxRuntimeFeature[]; force?: boolean; }) => Promise; - stopDaemon?: (options: { cliVersion: string }) => Promise; - stopAllDaemons?: (options: { cliVersion: string }) => Promise; + stopDaemon?: (options: { cliVersion: string; projectDir: string }) => Promise; + stopAllDaemons?: (options: { cliVersion: string; projectDir: string }) => Promise; readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; } @@ -174,6 +175,7 @@ export async function runKtxRuntime( const startDaemon = deps.startDaemon ?? startManagedPythonDaemon; const result = await startDaemon({ cliVersion: args.cliVersion, + projectDir: args.projectDir, features: [args.feature], force: args.force, }); @@ -183,11 +185,11 @@ export async function runKtxRuntime( if (args.command === 'stop') { if (args.all) { const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons; - const result = await stopAllDaemons({ cliVersion: args.cliVersion }); + const result = await stopAllDaemons({ cliVersion: args.cliVersion, projectDir: args.projectDir }); return writeDaemonStopAll(io, result); } else { const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon; - const result = await stopDaemon({ cliVersion: args.cliVersion }); + const result = await stopDaemon({ cliVersion: args.cliVersion, projectDir: args.projectDir }); writeDaemonStop(io, result); return 0; } diff --git a/packages/cli/src/scan.test.ts b/packages/cli/src/scan.test.ts index c4cbaf70..487bd935 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/src/scan.test.ts @@ -413,6 +413,7 @@ describe('runKtxScan', () => { expect(createLocalIngestAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir }), { managedDaemon: { cliVersion: '0.2.0', + projectDir: tempDir, installPolicy: 'auto', io: io.io, }, diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts index ef5679cc..ce68cfca 100644 --- a/packages/cli/src/scan.ts +++ b/packages/cli/src/scan.ts @@ -139,6 +139,7 @@ function managedDaemonOptionsForScanRun(args: Extract { expect(result.status).toBe('ready'); expect(ensureLocalEmbeddings).toHaveBeenCalledWith({ cliVersion: '0.2.0', + projectDir: tempDir, installPolicy: 'auto', io: io.io, }); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index d9b43a75..4e92670c 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -401,6 +401,7 @@ export async function runKtxSetupEmbeddingsStep( try { managedLocalEmbeddings = await ensureLocalEmbeddings({ cliVersion: args.cliVersion, + projectDir: args.projectDir, installPolicy: args.runtimeInstallPolicy, io, });