diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index e7083df9..fc24f1e7 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -1,4 +1,4 @@ -import { spinner } from '@clack/prompts'; +import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts'; export interface KtxCliSpinner { start(message: string): void; @@ -6,6 +6,62 @@ export interface KtxCliSpinner { error(message: string): void; } +export interface KtxCliPromptAdapter { + confirm(options: { message: string; initialValue?: boolean }): Promise; + cancel(message: string): void; + log: { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + success(message: string): void; + step(message: string): void; + }; + spinner(): KtxCliSpinner; +} + +export class KtxCliPromptCancelledError extends Error { + constructor(message = 'Operation cancelled.') { + super(message); + this.name = 'KtxCliPromptCancelledError'; + } +} + export function createClackSpinner(): KtxCliSpinner { return spinner(); } + +export function createClackPromptAdapter(): KtxCliPromptAdapter { + return { + async confirm(options) { + const value = await confirm(options); + if (isCancel(value)) { + cancel('Operation cancelled.'); + throw new KtxCliPromptCancelledError(); + } + return value; + }, + cancel(message) { + cancel(message); + }, + log: { + info(message) { + log.info(message); + }, + warn(message) { + log.warn(message); + }, + error(message) { + log.error(message); + }, + success(message) { + log.success(message); + }, + step(message) { + log.step(message); + }, + }, + spinner() { + return createClackSpinner(); + }, + }; +} diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/src/managed-python-command.test.ts index d081c320..3dbf315a 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/src/managed-python-command.test.ts @@ -214,6 +214,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => { expect(confirmInstall).toHaveBeenCalledWith( 'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?', + io.io, ); expect(installRuntime).toHaveBeenCalledWith({ cliVersion: '0.2.0', @@ -221,4 +222,45 @@ describe('createManagedPythonSemanticLayerComputePort', () => { force: false, }); }); + + it('uses injected runtime confirmation instead of reading process TTY directly', async () => { + const io = makeIo(); + const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }; + const installRuntime = vi.fn(async (): Promise => installResult()); + const confirmInstall = vi.fn(async () => true); + + await expect( + createManagedPythonSemanticLayerComputePort({ + cliVersion: '0.2.0', + installPolicy: 'prompt', + io: io.io, + readStatus: async () => missingStatus(), + installRuntime, + confirmInstall, + createPythonCompute: () => compute, + }), + ).resolves.toBe(compute); + + expect(confirmInstall).toHaveBeenCalledWith( + 'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?', + io.io, + ); + expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv...'); + }); + + it('can decide default runtime prompting from injected io capabilities', async () => { + const io = makeIo(); + Object.assign(io.io.stdout, { isTTY: false }); + + await expect( + createManagedPythonSemanticLayerComputePort({ + cliVersion: '0.2.0', + installPolicy: 'prompt', + io: io.io, + readStatus: async () => missingStatus(), + installRuntime: vi.fn(), + createPythonCompute: () => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }), + }), + ).rejects.toThrow('KTX Python runtime installation was cancelled'); + }); }); diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index 0a8a193c..ce7afe7b 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -1,6 +1,6 @@ -import { cancel, confirm, isCancel } from '@clack/prompts'; import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon'; import type { KtxCliIo } from './cli-runtime.js'; +import { createClackPromptAdapter } from './clack.js'; import { installManagedPythonRuntime, readManagedPythonRuntimeStatus, @@ -36,7 +36,7 @@ export interface ManagedPythonCommandRuntime { export interface ManagedPythonCommandDeps { readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; - confirmInstall?: (message: string) => Promise; + confirmInstall?: (message: string, io: KtxCliIo) => Promise; } export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps { @@ -69,16 +69,12 @@ function hasFeature(manifest: InstalledKtxRuntimeManifest, feature: KtxRuntimeFe return manifest.features.includes(feature); } -async function defaultConfirmInstall(message: string): Promise { - if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) { +async function defaultConfirmInstall(message: string, io: KtxCliIo): Promise { + if (io.stdout.isTTY !== true) { return false; } - const response = await confirm({ message, initialValue: true }); - if (isCancel(response)) { - cancel('Runtime installation cancelled.'); - return false; - } - return response === true; + const prompts = createClackPromptAdapter(); + return await prompts.confirm({ message, initialValue: true }); } export async function ensureManagedPythonCommandRuntime( @@ -99,7 +95,7 @@ export async function ensureManagedPythonCommandRuntime( if (options.installPolicy === 'prompt') { const confirmInstall = options.confirmInstall ?? defaultConfirmInstall; - const confirmed = await confirmInstall(installPrompt(feature)); + const confirmed = await confirmInstall(installPrompt(feature), options.io); if (!confirmed) { throw new Error(`KTX Python runtime installation was cancelled. Run: ${managedRuntimeInstallCommand(feature)}`); }