fix: improve setup wizard behavior (#127)

* fix: improve setup wizard behavior

* fix: derive runtime versions from release metadata

* test: validate metabase source mapping requirements

* Fix boundary check release identifiers
This commit is contained in:
Andrey Avtomonov 2026-05-17 19:15:09 +02:00 committed by GitHub
parent 33a142f769
commit d1c84e5564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 671 additions and 90 deletions

View file

@ -31,6 +31,9 @@ ktx dev <subcommand> [options]
## `dev schema` ## `dev schema`
`ktx dev schema` does not require a `ktx.yaml` file or a configured project
directory. Use it from any directory to generate editor or agent schema files.
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `--output <file>` | Write the schema to a file instead of stdout | — | | `--output <file>` | Write the schema to a file instead of stdout | — |

View file

@ -63,9 +63,9 @@ Setup supports three LLM provider paths:
| Provider | Use when | Credential model | | Provider | Use when | Credential model |
|----------|----------|------------------| |----------|----------|------------------|
| Anthropic API | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret | | Claude subscription (Pro/Max) | You want KTX to use your local Claude Code session | Claude Code local authentication |
| Anthropic API key | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret |
| Google Vertex AI for Anthropic Claude | Your organization runs Claude through Google Cloud | Application Default Credentials plus Vertex project and location | | Google Vertex AI for Anthropic Claude | Your organization runs Claude through Google Cloud | Application Default Credentials plus Vertex project and location |
| Claude Code | You want KTX to use your local Claude Code session | Claude Code local authentication |
For Anthropic API, setup can read the key from the environment or save a pasted For Anthropic API, setup can read the key from the environment or save a pasted
key to `.ktx/secrets/anthropic-api-key`. `ktx.yaml` stores an `env:` or `file:` key to `.ktx/secrets/anthropic-api-key`. `ktx.yaml` stores an `env:` or `file:`

View file

@ -100,6 +100,12 @@ The artifact packaging and readiness scripts read `publicNpmPackageVersion`
from `release-policy.json`, so manual version edits in build scripts aren't from `release-policy.json`, so manual version edits in build scripts aren't
needed for rc releases. needed for rc releases.
The bundled Python runtime wheel also derives its version from
`publicNpmPackageVersion`. Stable npm versions are reused as-is, and rc
versions are normalized to Python's version format. For example,
`0.1.0-rc.2` becomes `0.1.0rc2` in the `kaelio-ktx` wheel filename and wheel
metadata.
## npm authentication ## npm authentication
The release workflow publishes through npm Trusted Publishing. It doesn't use The release workflow publishes through npm Trusted Publishing. It doesn't use

View file

@ -1,5 +1,7 @@
import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts'; import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
const ESC = String.fromCharCode(0x1b);
export interface KtxCliSpinner { export interface KtxCliSpinner {
start(message: string): void; start(message: string): void;
message(message: string): void; message(message: string): void;
@ -7,6 +9,10 @@ export interface KtxCliSpinner {
error(message: string): void; error(message: string): void;
} }
export interface KtxCliSpinnerIo {
stderr: { write(chunk: string): void };
}
export interface KtxCliPromptAdapter { export interface KtxCliPromptAdapter {
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>; confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
cancel(message: string): void; cancel(message: string): void;
@ -31,6 +37,31 @@ export function createClackSpinner(): KtxCliSpinner {
return spinner(); return spinner();
} }
function magenta(text: string): string {
return `${ESC}[35m${text}${ESC}[39m`;
}
function red(text: string): string {
return `${ESC}[31m${text}${ESC}[39m`;
}
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
return {
start(message) {
io.stderr.write(`${magenta('◐')} ${message}\n`);
},
message(message) {
io.stderr.write(`${magenta('│')} ${message}\n`);
},
stop(message) {
io.stderr.write(`${magenta('◇')} ${message}\n`);
},
error(message) {
io.stderr.write(`${red('■')} ${message}\n`);
},
};
}
export function createClackPromptAdapter(): KtxCliPromptAdapter { export function createClackPromptAdapter(): KtxCliPromptAdapter {
return { return {
async confirm(options) { async confirm(options) {

View file

@ -11,7 +11,13 @@ function stubIo(): KtxCliIo {
} }
function stubPackageInfo(): KtxCliPackageInfo { function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }; return {
name: '@ktx/cli',
version: '0.0.0-test',
packageVersion: '0.0.0-private',
runtimeVersion: '0.0.0-test',
contextPackageName: '@ktx/context',
};
} }
describe('buildKtxProgram', () => { describe('buildKtxProgram', () => {

View file

@ -58,6 +58,7 @@ type CommandPathNode = CommandWithGlobalOptions & {
}; };
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'sql', 'status', 'mcp']); const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'sql', 'status', 'mcp']);
const PROJECT_INDEPENDENT_DEV_COMMANDS = new Set(['runtime', 'schema']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']); const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']); const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']); const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
@ -172,7 +173,7 @@ function isProjectAwareCommand(path: string[]): boolean {
const rootCommand = path[1]; const rootCommand = path[1];
if (rootCommand === 'dev') { if (rootCommand === 'dev') {
return path[2] !== undefined && path[2] !== 'runtime'; return path[2] !== undefined && !PROJECT_INDEPENDENT_DEV_COMMANDS.has(path[2]);
} }
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand); return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
} }

View file

@ -10,6 +10,7 @@ import type { KtxSlArgs } from './sl.js';
import type { KtxSqlArgs } from './sql.js'; import type { KtxSqlArgs } from './sql.js';
import { profileMark, profileSpan } from './startup-profile.js'; import { profileMark, profileSpan } from './startup-profile.js';
import type { KtxTextIngestArgs } from './text-ingest.js'; import type { KtxTextIngestArgs } from './text-ingest.js';
import { resolveKtxRuntimeVersion } from './release-version.js';
profileMark('module:cli-runtime'); profileMark('module:cli-runtime');
@ -18,6 +19,8 @@ const requirePackageJson = createRequire(import.meta.url);
export interface KtxCliPackageInfo { export interface KtxCliPackageInfo {
name: string; name: string;
version: string; version: string;
packageVersion: string;
runtimeVersion: string;
contextPackageName: '@ktx/context'; contextPackageName: '@ktx/context';
} }
@ -61,9 +64,16 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
throw new Error('Invalid KTX CLI package metadata'); throw new Error('Invalid KTX CLI package metadata');
} }
const runtimeVersion = resolveKtxRuntimeVersion({
packageName: packageJson.name,
packageVersion: packageJson.version,
});
return { return {
name: packageJson.name, name: packageJson.name,
version: packageJson.version, version: runtimeVersion,
packageVersion: packageJson.version,
runtimeVersion,
contextPackageName: '@ktx/context', contextPackageName: '@ktx/context',
}; };
} }

View file

@ -102,6 +102,35 @@ describe('dev Commander tree', () => {
} }
}); });
it('prints config schema without requiring a KTX project directory', async () => {
const { mkdtemp, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-schema-'));
const missingProjectDir = join(tempDir, 'missing-project');
const originalProjectDir = process.env.KTX_PROJECT_DIR;
const testIo = makeIo();
try {
process.env.KTX_PROJECT_DIR = missingProjectDir;
await expect(runKtxCli(['dev', 'schema'], testIo.io)).resolves.toBe(0);
expect(JSON.parse(testIo.stdout())).toMatchObject({
title: 'ktx.yaml',
type: 'object',
});
expect(testIo.stderr()).toBe('');
} finally {
if (originalProjectDir === undefined) {
delete process.env.KTX_PROJECT_DIR;
} else {
process.env.KTX_PROJECT_DIR = originalProjectDir;
}
await rm(tempDir, { recursive: true, force: true });
}
});
it('rejects removed dev command groups', async () => { it('rejects removed dev command groups', async () => {
for (const argv of [ for (const argv of [
['dev', 'doctor', 'setup'], ['dev', 'doctor', 'setup'],

View file

@ -45,7 +45,9 @@ describe('getKtxCliPackageInfo', () => {
it('identifies the CLI package and its context dependency', () => { it('identifies the CLI package and its context dependency', () => {
expect(getKtxCliPackageInfo()).toEqual({ expect(getKtxCliPackageInfo()).toEqual({
name: '@ktx/cli', name: '@ktx/cli',
version: '0.0.0-private', version: '0.1.0-rc.1',
packageVersion: '0.0.0-private',
runtimeVersion: '0.1.0-rc.1',
contextPackageName: '@ktx/context', contextPackageName: '@ktx/context',
}); });
}); });
@ -68,6 +70,8 @@ describe('getKtxCliPackageInfo', () => {
).toEqual({ ).toEqual({
name: '@kaelio/ktx', name: '@kaelio/ktx',
version: '0.1.0', version: '0.1.0',
packageVersion: '0.1.0',
runtimeVersion: '0.1.0',
contextPackageName: '@ktx/context', contextPackageName: '@ktx/context',
}); });
}); });
@ -114,7 +118,7 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['--version'], testIo.io)).resolves.toBe(0); await expect(runKtxCli(['--version'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toBe('@ktx/cli 0.0.0-private\n'); expect(testIo.stdout()).toBe('@ktx/cli 0.1.0-rc.1\n');
expect(testIo.stderr()).toBe(''); expect(testIo.stderr()).toBe('');
}); });
@ -252,7 +256,7 @@ describe('runKtxCli', () => {
expect(listIo.stderr()).toContain("unknown option '--query'"); expect(listIo.stderr()).toContain("unknown option '--query'");
}); });
it('routes runtime management commands with the CLI package version', async () => { it('routes runtime management commands with the release runtime version', async () => {
const runtime = vi.fn(async () => 0); const runtime = vi.fn(async () => 0);
const installIo = makeIo(); const installIo = makeIo();
const startIo = makeIo(); const startIo = makeIo();
@ -278,7 +282,7 @@ describe('runKtxCli', () => {
1, 1,
{ {
command: 'install', command: 'install',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
feature: 'local-embeddings', feature: 'local-embeddings',
force: true, force: true,
}, },
@ -288,7 +292,7 @@ describe('runKtxCli', () => {
2, 2,
{ {
command: 'start', command: 'start',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String), projectDir: expect.any(String),
feature: 'local-embeddings', feature: 'local-embeddings',
force: true, force: true,
@ -299,7 +303,7 @@ describe('runKtxCli', () => {
3, 3,
{ {
command: 'stop', command: 'stop',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String), projectDir: expect.any(String),
all: false, all: false,
}, },
@ -309,7 +313,7 @@ describe('runKtxCli', () => {
4, 4,
{ {
command: 'stop', command: 'stop',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String), projectDir: expect.any(String),
all: true, all: true,
}, },
@ -319,7 +323,7 @@ describe('runKtxCli', () => {
5, 5,
{ {
command: 'status', command: 'status',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
json: true, json: true,
}, },
statusIo.io, statusIo.io,
@ -392,7 +396,7 @@ describe('runKtxCli', () => {
expect.objectContaining({ expect.objectContaining({
command: 'query', command: 'query',
projectDir: tempDir, projectDir: tempDir,
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'prompt', runtimeInstallPolicy: 'prompt',
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }), query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
}), }),
@ -407,7 +411,7 @@ describe('runKtxCli', () => {
).resolves.toBe(0); ).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith( expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'auto', runtimeInstallPolicy: 'auto',
}), }),
autoIo.io, autoIo.io,
@ -423,7 +427,7 @@ describe('runKtxCli', () => {
).resolves.toBe(0); ).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith( expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never', runtimeInstallPolicy: 'never',
}), }),
noInputIo.io, noInputIo.io,
@ -562,7 +566,7 @@ describe('runKtxCli', () => {
skipAgents: false, skipAgents: false,
inputMode: 'auto', inputMode: 'auto',
yes: false, yes: false,
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
skipLlm: false, skipLlm: false,
skipEmbeddings: false, skipEmbeddings: false,
databaseSchemas: [], databaseSchemas: [],
@ -692,7 +696,7 @@ describe('runKtxCli', () => {
inputMode: 'disabled', inputMode: 'disabled',
depth: 'fast', depth: 'fast',
queryHistory: 'default', queryHistory: 'default',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never', runtimeInstallPolicy: 'never',
}, },
testIo.io, testIo.io,
@ -719,7 +723,7 @@ describe('runKtxCli', () => {
inputMode: 'auto', inputMode: 'auto',
depth: 'deep', depth: 'deep',
queryHistory: 'default', queryHistory: 'default',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'prompt', runtimeInstallPolicy: 'prompt',
}, },
testIo.io, testIo.io,
@ -796,7 +800,7 @@ describe('runKtxCli', () => {
json: false, json: false,
inputMode: 'disabled', inputMode: 'disabled',
queryHistory: 'default', queryHistory: 'default',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never', runtimeInstallPolicy: 'never',
}, },
testIo.io, testIo.io,
@ -1074,7 +1078,7 @@ describe('runKtxCli', () => {
command: 'run', command: 'run',
projectDir: tempDir, projectDir: tempDir,
inputMode: 'disabled', inputMode: 'disabled',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6', anthropicModel: 'claude-sonnet-4-6',
skipLlm: false, skipLlm: false,
@ -1113,7 +1117,7 @@ describe('runKtxCli', () => {
command: 'run', command: 'run',
projectDir: tempDir, projectDir: tempDir,
inputMode: 'disabled', inputMode: 'disabled',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
llmBackend: 'vertex', llmBackend: 'vertex',
vertexProject: 'local-gcp-project', vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5', vertexLocation: 'us-east5',
@ -1150,7 +1154,7 @@ describe('runKtxCli', () => {
command: 'run', command: 'run',
projectDir: tempDir, projectDir: tempDir,
inputMode: 'disabled', inputMode: 'disabled',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
llmBackend: 'claude-code', llmBackend: 'claude-code',
llmModel: 'opus', llmModel: 'opus',
skipLlm: false, skipLlm: false,
@ -1258,7 +1262,7 @@ describe('runKtxCli', () => {
projectDir: '/tmp/project', projectDir: '/tmp/project',
inputMode: 'disabled', inputMode: 'disabled',
yes: true, yes: true,
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
skipLlm: true, skipLlm: true,
skipEmbeddings: true, skipEmbeddings: true,
databaseDrivers: ['postgres'], databaseDrivers: ['postgres'],
@ -1576,7 +1580,7 @@ describe('runKtxCli', () => {
queryFile: '/tmp/query.json', queryFile: '/tmp/query.json',
execute: false, execute: false,
format: 'json', format: 'json',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'auto', runtimeInstallPolicy: 'auto',
}, },
autoIo.io, autoIo.io,
@ -1590,7 +1594,7 @@ describe('runKtxCli', () => {
queryFile: '/tmp/query.json', queryFile: '/tmp/query.json',
execute: false, execute: false,
format: 'json', format: 'json',
cliVersion: '0.0.0-private', cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never', runtimeInstallPolicy: 'never',
}, },
neverIo.io, neverIo.io,

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import {
createManagedPythonSemanticLayerComputePort, createManagedPythonSemanticLayerComputePort,
ensureManagedPythonCommandRuntime,
managedRuntimeInstallCommand, managedRuntimeInstallCommand,
runtimeInstallPolicyFromFlags, runtimeInstallPolicyFromFlags,
} from './managed-python-command.js'; } from './managed-python-command.js';
@ -103,6 +104,17 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
}; };
} }
function makeSpinnerEvents() {
const events: string[] = [];
const spinner = vi.fn(() => ({
start: (msg: string) => events.push(`start:${msg}`),
message: (msg: string) => events.push(`message:${msg}`),
stop: (msg: string) => events.push(`stop:${msg}`),
error: (msg: string) => events.push(`error:${msg}`),
}));
return { events, spinner };
}
describe('managedRuntimeInstallCommand', () => { describe('managedRuntimeInstallCommand', () => {
it('prints the exact command for each managed runtime feature', () => { it('prints the exact command for each managed runtime feature', () => {
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes'); expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
@ -128,6 +140,51 @@ describe('runtimeInstallPolicyFromFlags', () => {
}); });
describe('createManagedPythonSemanticLayerComputePort', () => { describe('createManagedPythonSemanticLayerComputePort', () => {
it('uses non-animated runtime setup status by default', async () => {
const io = makeIo();
await expect(
ensureManagedPythonCommandRuntime({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
feature: 'local-embeddings',
}),
).resolves.toMatchObject({
layout: { versionDir: '/runtime/0.2.0' },
});
expect(io.stderr()).toContain('Installing KTX Python runtime (local-embeddings) with uv...');
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
expect(io.stderr().match(/Installing KTX Python runtime/g)).toHaveLength(1);
});
it('shows runtime installation progress with the CLI spinner', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const options = {
cliVersion: '0.2.0',
installPolicy: 'auto' as const,
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
feature: 'local-embeddings' as const,
spinner,
};
await expect(ensureManagedPythonCommandRuntime(options)).resolves.toMatchObject({
layout: { versionDir: '/runtime/0.2.0' },
});
expect(events).toEqual([
'start:Installing KTX Python runtime (local-embeddings) with uv...',
'stop:KTX Python runtime ready: /runtime/0.2.0',
]);
});
it('uses the managed ktx-daemon executable when the runtime is ready', async () => { it('uses the managed ktx-daemon executable when the runtime is ready', async () => {
const io = makeIo(); const io = makeIo();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }; const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
@ -170,6 +227,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
it('installs the core runtime without prompting when policy is auto', async () => { it('installs the core runtime without prompting when policy is auto', async () => {
const io = makeIo(); const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }; const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createPythonCompute = vi.fn(() => compute); const createPythonCompute = vi.fn(() => compute);
const installRuntime = vi.fn(async () => installResult()); const installRuntime = vi.fn(async () => installResult());
@ -182,6 +240,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
readStatus: vi.fn(async () => missingStatus()), readStatus: vi.fn(async () => missingStatus()),
installRuntime, installRuntime,
createPythonCompute, createPythonCompute,
spinner,
}), }),
).resolves.toBe(compute); ).resolves.toBe(compute);
@ -190,12 +249,15 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'], features: ['core'],
force: false, force: false,
}); });
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv'); expect(events).toEqual([
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0'); 'start:Installing KTX Python runtime (core) with uv...',
'stop:KTX Python runtime ready: /runtime/0.2.0',
]);
}); });
it('prompts before installing when policy is prompt', async () => { it('prompts before installing when policy is prompt', async () => {
const io = makeIo(); const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const confirmInstall = vi.fn(async () => true); const confirmInstall = vi.fn(async () => true);
const installRuntime = vi.fn(async () => installResult()); const installRuntime = vi.fn(async () => installResult());
@ -207,6 +269,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime, installRuntime,
createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })), createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })),
confirmInstall, confirmInstall,
spinner,
}); });
expect(confirmInstall).toHaveBeenCalledWith( expect(confirmInstall).toHaveBeenCalledWith(
@ -218,10 +281,12 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'], features: ['core'],
force: false, force: false,
}); });
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
}); });
it('uses injected runtime confirmation instead of reading process TTY directly', async () => { it('uses injected runtime confirmation instead of reading process TTY directly', async () => {
const io = makeIo(); const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() }; const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult()); const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult());
const confirmInstall = vi.fn(async () => true); const confirmInstall = vi.fn(async () => true);
@ -235,6 +300,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime, installRuntime,
confirmInstall, confirmInstall,
createPythonCompute: () => compute, createPythonCompute: () => compute,
spinner,
}), }),
).resolves.toBe(compute); ).resolves.toBe(compute);
@ -242,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?', 'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
io.io, io.io,
); );
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv...'); expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
}); });
it('can decide default runtime prompting from injected io capabilities', async () => { it('can decide default runtime prompting from injected io capabilities', async () => {

View file

@ -1,6 +1,6 @@
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon'; import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { createClackPromptAdapter } from './clack.js'; import { createClackPromptAdapter, createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import { import {
installManagedPythonRuntime, installManagedPythonRuntime,
readManagedPythonRuntimeStatus, readManagedPythonRuntimeStatus,
@ -37,6 +37,7 @@ export interface ManagedPythonCommandDeps {
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>; readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>; installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>; confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
spinner?: () => KtxCliSpinner;
} }
export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps { export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps {
@ -101,14 +102,20 @@ export async function ensureManagedPythonCommandRuntime(
} }
} }
options.io.stderr.write(`Installing KTX Python runtime (${feature}) with uv...\n`); const progress = (options.spinner ?? (() => createStaticCliSpinner(options.io)))();
const installed = await installRuntime({ progress.start(`Installing KTX Python runtime (${feature}) with uv...`);
cliVersion: options.cliVersion, try {
features: [feature], const installed = await installRuntime({
force: false, cliVersion: options.cliVersion,
}); features: [feature],
options.io.stderr.write(`KTX Python runtime ready: ${installed.layout.versionDir}\n`); force: false,
return { layout: installed.layout, manifest: installed.manifest }; });
progress.stop(`KTX Python runtime ready: ${installed.layout.versionDir}`);
return { layout: installed.layout, manifest: installed.manifest };
} catch (error) {
progress.error(`KTX Python runtime install failed: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
} }
export async function createManagedPythonSemanticLayerComputePort( export async function createManagedPythonSemanticLayerComputePort(
@ -122,6 +129,7 @@ export async function createManagedPythonSemanticLayerComputePort(
...(options.readStatus ? { readStatus: options.readStatus } : {}), ...(options.readStatus ? { readStatus: options.readStatus } : {}),
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}), ...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}), ...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
...(options.spinner ? { spinner: options.spinner } : {}),
}); });
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort; const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
return createPythonCompute({ return createPythonCompute({

View file

@ -11,7 +11,13 @@ function silentIo(): KtxCliIo {
} }
function stubPackageInfo(): KtxCliPackageInfo { function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' }; return {
name: '@ktx/cli',
version: '0.0.0-docs',
packageVersion: '0.0.0-private',
runtimeVersion: '0.0.0-docs',
contextPackageName: '@ktx/context',
};
} }
export function renderKtxCommandTree(): string { export function renderKtxCommandTree(): string {

View file

@ -0,0 +1,55 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, parse } from 'node:path';
import { fileURLToPath } from 'node:url';
const semverPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function assertReleaseVersion(value: unknown, source: string): string {
if (typeof value !== 'string' || !semverPattern.test(value)) {
throw new Error(`Invalid KTX release version in ${source}`);
}
return value;
}
function findReleasePolicyPath(startDir: string): string | undefined {
let current = startDir;
const root = parse(current).root;
while (true) {
const candidate = join(current, 'release-policy.json');
if (existsSync(candidate)) {
return candidate;
}
if (current === root) {
return undefined;
}
current = dirname(current);
}
}
function readSourceReleaseVersion(startDir = dirname(fileURLToPath(import.meta.url))): string | undefined {
const policyPath = findReleasePolicyPath(startDir);
if (!policyPath) {
return undefined;
}
const policy = JSON.parse(readFileSync(policyPath, 'utf8')) as unknown;
if (!isPlainObject(policy)) {
throw new Error(`Invalid KTX release policy: ${policyPath}`);
}
return assertReleaseVersion(policy.publicNpmPackageVersion, policyPath);
}
export function resolveKtxRuntimeVersion(input: {
packageName: string;
packageVersion: string;
startDir?: string;
}): string {
if (input.packageName === '@kaelio/ktx') {
return assertReleaseVersion(input.packageVersion, `${input.packageName}/package.json`);
}
return readSourceReleaseVersion(input.startDir) ?? input.packageVersion;
}

View file

@ -176,12 +176,33 @@ describe('setup embeddings step', () => {
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings); expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
expect(spinnerEvents).toContainEqual( expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
expect(io.stdout()).toContain('Embeddings ready: yes'); expect(io.stdout()).toContain('Embeddings ready: yes');
}); });
it('uses a short non-animated local embeddings health-check status by default', async () => {
const io = makeIo();
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
const result = await runKtxSetupEmbeddingsStep(
{
projectDir: tempDir,
inputMode: 'auto',
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
skipEmbeddings: false,
},
io.io,
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
);
expect(result.status).toBe('ready');
expect(io.stderr()).toContain('Testing local embeddings (all-MiniLM-L6-v2)');
expect(io.stderr()).not.toContain('First run may take up to 60 seconds');
expect(io.stderr().match(/Testing local embeddings/g)).toHaveLength(1);
});
it('shows live progress while local sentence-transformers embeddings are being tested', async () => { it('shows live progress while local sentence-transformers embeddings are being tested', async () => {
const io = makeIo(); const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] }); const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
@ -213,9 +234,7 @@ describe('setup embeddings step', () => {
); );
await vi.waitFor(() => { await vi.waitFor(() => {
expect(spinnerEvents).toContainEqual( expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
}); });
expect(resolveHealthCheck).toBeDefined(); expect(resolveHealthCheck).toBeDefined();

View file

@ -10,7 +10,7 @@ import {
} from '@ktx/context/project'; } from '@ktx/context/project';
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm'; import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js'; import type { KtxCliIo } from './cli-runtime.js';
import { createClackSpinner, type KtxCliSpinner } from './clack.js'; import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import { import {
ensureManagedLocalEmbeddingsDaemon, ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig, managedLocalEmbeddingHealthConfig,
@ -316,10 +316,7 @@ async function promptAfterLocalEmbeddingFailure(
function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string { function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string {
if (backend === LOCAL_EMBEDDING_BACKEND) { if (backend === LOCAL_EMBEDDING_BACKEND) {
return [ return `Testing local embeddings (${model})`;
`Testing local sentence-transformers embeddings (${model}, ${dimensions} dimensions).`,
'First run may take up to 60 seconds.',
].join(' ');
} }
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`; return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
} }
@ -424,7 +421,7 @@ export async function runKtxSetupEmbeddingsStep(
dimensions, dimensions,
credentialValue, credentialValue,
}); });
const healthSpinner = (deps.spinner ?? createClackSpinner)(); const healthSpinner = (deps.spinner ?? (() => createStaticCliSpinner(io)))();
const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions)); const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions));
let health: KtxEmbeddingHealthCheckResult; let health: KtxEmbeddingHealthCheckResult;
try { try {

View file

@ -164,7 +164,7 @@ describe('setup Anthropic model step', () => {
); );
}); });
it('offers Vertex AI as an Anthropic model provider option', async () => { it('offers Anthropic provider paths in the preferred order', async () => {
const prompts = makePromptAdapter({ providerChoice: 'back' }); const prompts = makePromptAdapter({ providerChoice: 'back' });
const result = await runKtxSetupAnthropicModelStep( const result = await runKtxSetupAnthropicModelStep(
@ -177,10 +177,12 @@ describe('setup Anthropic model step', () => {
expect(prompts.select).toHaveBeenCalledWith( expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
message: expect.stringContaining('Which LLM provider should KTX use?'), message: expect.stringContaining('Which LLM provider should KTX use?'),
options: expect.arrayContaining([ options: [
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
{ value: 'anthropic', label: 'Anthropic API key' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' }, { value: 'back', label: 'Back' },
]), ],
}), }),
); );
}); });

View file

@ -509,12 +509,12 @@ async function chooseBackend(
} }
const choice = await prompts.select({ const choice = await prompts.select({
message: 'Which LLM provider should KTX use?', message: 'Which LLM provider should KTX use?',
options: [ options: [
{ value: 'anthropic', label: 'Anthropic API' }, { value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'anthropic', label: 'Anthropic API key' },
{ value: 'claude-code', label: 'Local Claude Code session' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' }, { value: 'back', label: 'Back' },
], ],
}); });
if (choice === 'back') { if (choice === 'back') {
return { status: 'back' }; return { status: 'back' };

View file

@ -0,0 +1,137 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
initKtxProject,
type KtxProjectConnectionConfig,
parseKtxProjectConfig,
serializeKtxProjectConfig,
} from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
runKtxSetupSourcesStep,
type KtxSetupSourcesPromptAdapter,
} from './setup-sources.js';
const notionMocks = vi.hoisted(() => ({
tokens: [] as string[],
retrieveBotUser: vi.fn(async () => ({ name: 'Docs Bot' })),
retrievePage: vi.fn(async () => ({ id: 'page-1' })),
}));
vi.mock('@ktx/context/ingest', async (importOriginal) => {
const actual = await importOriginal<typeof import('@ktx/context/ingest')>();
return {
...actual,
NotionClient: vi.fn().mockImplementation(function NotionClient(token: string) {
notionMocks.tokens.push(token);
return {
retrieveBotUser: notionMocks.retrieveBotUser,
retrievePage: notionMocks.retrievePage,
};
}),
};
});
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: true,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function prompts(values: { multiselect?: string[][]; select?: string[] }): KtxSetupSourcesPromptAdapter {
const multiselectValues = [...(values.multiselect ?? [])];
const selectValues = [...(values.select ?? [])];
return {
multiselect: vi.fn(async () => multiselectValues.shift() ?? []),
select: vi.fn(async () => selectValues.shift() ?? 'back'),
text: vi.fn(async () => ''),
password: vi.fn(async () => undefined),
cancel: vi.fn(),
log: vi.fn(),
};
}
describe('setup sources Notion validation', () => {
let tempDir: string;
let projectDir: string;
beforeEach(async () => {
notionMocks.tokens.length = 0;
notionMocks.retrieveBotUser.mockClear();
notionMocks.retrievePage.mockClear();
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-notion-'));
projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
async function readConfig() {
return parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
}
async function writeConfigConnection(connectionId: string, connection: KtxProjectConnectionConfig) {
const config = await readConfig();
await writeFile(
join(projectDir, 'ktx.yaml'),
serializeKtxProjectConfig({
...config,
connections: {
...config.connections,
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
[connectionId]: connection,
},
setup: {
...config.setup,
database_connection_ids: ['warehouse'],
},
}),
'utf-8',
);
}
it('validates an existing Notion source that uses an inline auth token', async () => {
await writeConfigConnection('notion', {
driver: 'notion',
auth_token: 'ntn_inline_token',
crawl_mode: 'all_accessible',
});
const io = makeIo();
await expect(
runKtxSetupSourcesStep(
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
io.io,
{
prompts: prompts({
multiselect: [['notion']],
select: ['existing:notion'],
}),
},
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['notion'] });
expect(notionMocks.tokens).toEqual(['ntn_inline_token']);
expect(notionMocks.retrieveBotUser).toHaveBeenCalledOnce();
expect(io.stderr()).toBe('');
});
});

View file

@ -2,7 +2,10 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, relative, resolve } from 'node:path'; import { join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections'; import {
localConnectionTypeForConfig,
resolveNotionConnectionAuthToken,
} from '@ktx/context/connections';
import { resolveKtxConfigReference } from '@ktx/context/core'; import { resolveKtxConfigReference } from '@ktx/context/core';
import { import {
cloneOrPull, cloneOrPull,
@ -620,7 +623,10 @@ async function defaultValidateLookml(connection: KtxProjectConnectionConfig): Pr
} }
async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> { async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
const token = await resolveNotionAuthToken(String(connection.auth_token_ref)); const token = await resolveNotionConnectionAuthToken({
auth_token: stringField(connection.auth_token) ?? null,
auth_token_ref: stringField(connection.auth_token_ref) ?? null,
});
const client: NotionApi = new NotionClient(token); const client: NotionApi = new NotionClient(token);
await client.retrieveBotUser(); await client.retrieveBotUser();
const roots = Array.isArray(connection.root_page_ids) const roots = Array.isArray(connection.root_page_ids)

View file

@ -0,0 +1,85 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { KtxCliIo } from './cli-runtime.js';
import { runKtxSourceMapping } from './source-mapping.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
} satisfies KtxCliIo,
stdout: () => stdout,
stderr: () => stderr,
};
}
describe('source mapping commands', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-source-mapping-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
async function writeConfig(metabaseMappings: string[]): Promise<void> {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' metabase:',
' driver: metabase',
' api_url: https://metabase.example.com',
...metabaseMappings,
'',
].join('\n'),
'utf-8',
);
}
it('fails Metabase validation when no sync-enabled target mapping exists', async () => {
await writeConfig([]);
const io = makeIo();
await expect(
runKtxSourceMapping({ command: 'validate', projectDir: tempDir, connectionId: 'metabase' }, io.io),
).resolves.toBe(1);
expect(io.stderr()).toContain('no sync-enabled mappings with a target connection for Metabase connection metabase');
});
it('passes Metabase validation when a sync-enabled target mapping exists', async () => {
await writeConfig([
' mappings:',
' databaseMappings:',
' "3": warehouse',
' syncEnabled:',
' "3": true',
]);
const io = makeIo();
await expect(
runKtxSourceMapping({ command: 'validate', projectDir: tempDir, connectionId: 'metabase' }, io.io),
).resolves.toBe(0);
expect(io.stdout()).toContain('Mapping validation passed: metabase');
});
});

View file

@ -12,6 +12,7 @@ import {
discoverMetabaseDatabases, discoverMetabaseDatabases,
lookerCredentialsFromLocalConnection, lookerCredentialsFromLocalConnection,
metabaseRuntimeConfigFromLocalConnection, metabaseRuntimeConfigFromLocalConnection,
planMetabaseFanoutChildren,
seedLocalMappingStateFromKtxYaml, seedLocalMappingStateFromKtxYaml,
validateLookerMappings, validateLookerMappings,
validateMappingPhysicalMatch, validateMappingPhysicalMatch,
@ -198,6 +199,14 @@ export async function runKtxSourceMapping(
} }
const rows = await store.listDatabaseMappings(args.connectionId); const rows = await store.listDatabaseMappings(args.connectionId);
planMetabaseFanoutChildren({
metabaseConnectionId: args.connectionId,
mappings: rows.map((row) => ({
metabaseDatabaseId: row.metabaseDatabaseId,
targetConnectionId: row.targetConnectionId,
syncEnabled: row.syncEnabled,
})),
});
const failures = rows.flatMap((row) => { const failures = rows.flatMap((row) => {
if (!row.targetConnectionId) { if (!row.targetConnectionId) {
return []; return [];

View file

@ -1,6 +1,6 @@
[project] [project]
name = "ktx-daemon" name = "ktx-daemon"
version = "0.1.0" version = "0.0.0+private"
description = "Portable compute package for KTX semantic-layer operations" description = "Portable compute package for KTX semantic-layer operations"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"

View file

@ -1,6 +1,28 @@
"""Portable compute package for KTX.""" """Portable compute package for KTX."""
PACKAGE_NAME = "ktx-daemon" from collections.abc import Callable
VERSION = "0.1.0" from importlib.metadata import PackageNotFoundError, version
__all__ = ["PACKAGE_NAME", "VERSION"] PACKAGE_NAME = "ktx-daemon"
RUNTIME_DISTRIBUTION_NAME = "kaelio-ktx"
def resolve_package_version(
version_loader: Callable[[str], str] = version,
) -> str:
for distribution_name in (RUNTIME_DISTRIBUTION_NAME, PACKAGE_NAME):
try:
return version_loader(distribution_name)
except PackageNotFoundError:
continue
return "0.0.0+local"
VERSION = resolve_package_version()
__all__ = [
"PACKAGE_NAME",
"RUNTIME_DISTRIBUTION_NAME",
"VERSION",
"resolve_package_version",
]

View file

@ -10,6 +10,7 @@ from typing import Any
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from ktx_daemon import VERSION
from ktx_daemon.code_execution import ( from ktx_daemon.code_execution import (
ExecuteCodeRequest, ExecuteCodeRequest,
ExecuteCodeResponse, ExecuteCodeResponse,
@ -84,7 +85,7 @@ def create_app(
app = FastAPI( app = FastAPI(
title="KTX Daemon", title="KTX Daemon",
description="Stateless portable compute server for KTX.", description="Stateless portable compute server for KTX.",
version="0.1.0", version=VERSION,
) )
@app.get("/health") @app.get("/health")

View file

@ -1,6 +1,19 @@
from ktx_daemon import PACKAGE_NAME, VERSION from ktx_daemon import PACKAGE_NAME, VERSION, resolve_package_version
def test_package_metadata() -> None: def test_package_metadata() -> None:
assert PACKAGE_NAME == "ktx-daemon" assert PACKAGE_NAME == "ktx-daemon"
assert VERSION == "0.1.0" assert VERSION == resolve_package_version()
def test_package_version_prefers_bundled_runtime_distribution() -> None:
calls: list[str] = []
def fake_version(distribution_name: str) -> str:
calls.append(distribution_name)
if distribution_name == "kaelio-ktx":
return "0.1.0rc1"
raise AssertionError(f"unexpected distribution lookup: {distribution_name}")
assert resolve_package_version(version_loader=fake_version) == "0.1.0rc1"
assert calls == ["kaelio-ktx"]

View file

@ -1,6 +1,6 @@
[project] [project]
name = "ktx-sl" name = "ktx-sl"
version = "0.1.0" version = "0.0.0+private"
description = "Agent-first semantic layer engine with aggregate locality" description = "Agent-first semantic layer engine with aggregate locality"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"

View file

@ -6,11 +6,13 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { publicPythonRuntimePackageVersion } from './public-npm-release-metadata.mjs';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export const RUNTIME_WHEEL_DISTRIBUTION_NAME = 'kaelio-ktx'; export const RUNTIME_WHEEL_DISTRIBUTION_NAME = 'kaelio-ktx';
export const RUNTIME_WHEEL_NORMALIZED_NAME = 'kaelio_ktx'; export const RUNTIME_WHEEL_NORMALIZED_NAME = 'kaelio_ktx';
export const RUNTIME_WHEEL_PACKAGE_VERSION = '0.1.0'; export const RUNTIME_WHEEL_PACKAGE_VERSION = publicPythonRuntimePackageVersion();
function scriptRootDir() { function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..'); return resolve(dirname(fileURLToPath(import.meta.url)), '..');

View file

@ -48,11 +48,11 @@ describe('runtimeWheelLayout', () => {
}); });
describe('runtimeWheelPyproject', () => { describe('runtimeWheelPyproject', () => {
it('describes one kaelio-ktx wheel with lazy local embeddings', () => { it('describes one kaelio-ktx wheel with the release-derived Python version and lazy local embeddings', () => {
const pyproject = runtimeWheelPyproject(); const pyproject = runtimeWheelPyproject();
assert.match(pyproject, /name = "kaelio-ktx"/); assert.match(pyproject, /name = "kaelio-ktx"/);
assert.match(pyproject, /version = "0\.1\.0"/); assert.match(pyproject, /version = "0\.1\.0rc1"/);
assert.match(pyproject, /ktx-daemon = "ktx_daemon\.__main__:main"/); assert.match(pyproject, /ktx-daemon = "ktx_daemon\.__main__:main"/);
assert.match(pyproject, /packages = \["semantic_layer", "ktx_daemon"\]/); assert.match(pyproject, /packages = \["semantic_layer", "ktx_daemon"\]/);
assert.match(pyproject, /\[project\.optional-dependencies\]/); assert.match(pyproject, /\[project\.optional-dependencies\]/);
@ -110,6 +110,6 @@ describe('runtimeWheelBuildCommand', () => {
cwd: '/repo/ktx', cwd: '/repo/ktx',
}); });
assert.equal(RUNTIME_WHEEL_DISTRIBUTION_NAME, 'kaelio-ktx'); assert.equal(RUNTIME_WHEEL_DISTRIBUTION_NAME, 'kaelio-ktx');
assert.equal(RUNTIME_WHEEL_PACKAGE_VERSION, '0.1.0'); assert.equal(RUNTIME_WHEEL_PACKAGE_VERSION, '0.1.0rc1');
}); });
}); });

View file

@ -8,7 +8,8 @@ const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.
const runtimeAssetPatterns = [/^packages\/[^/]+\/prompts\/.+\.md$/, /^packages\/[^/]+\/skills\/.+\.md$/]; const runtimeAssetPatterns = [/^packages\/[^/]+\/prompts\/.+\.md$/, /^packages\/[^/]+\/skills\/.+\.md$/];
const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-sl/plans/', 'python/ktx-sl/openspec/']; const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-sl/plans/', 'python/ktx-sl/openspec/'];
const identifierAllowPatterns = [ const identifierAllowPatterns = [
/^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|runtime)(?:\.test)?\.ts$/, /^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|release-version|runtime)(?:\.test)?\.ts$/,
/^python\/ktx-daemon\/src\/ktx_daemon\/__init__\.py$/,
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/, /^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
]; ];
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_']; const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];
@ -87,7 +88,10 @@ function scansForLlmBoundaries(relativePath) {
} }
function isTestSource(relativePath) { function isTestSource(relativePath) {
return /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(relativePath); return (
/(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(relativePath) ||
/(?:^|\/)tests\/(?:.+\/)?(?:test_[^/]+|[^/]+_test)\.py$/.test(relativePath)
);
} }
function scansForContextProductionLlmBoundaries(relativePath) { function scansForContextProductionLlmBoundaries(relativePath) {

View file

@ -70,6 +70,7 @@ describe('scanFileContent', () => {
assert.equal(scanFileContent('packages/cli/src/setup.test.ts', `project: ${name}-dev`).length, 0); assert.equal(scanFileContent('packages/cli/src/setup.test.ts', `project: ${name}-dev`).length, 0);
assert.equal(scanFileContent('packages/context/src/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0); assert.equal(scanFileContent('packages/context/src/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0);
assert.equal(scanFileContent('python/ktx-daemon/tests/test_package.py', `${name}-ktx`).length, 0);
}); });
it('allows public package identifiers in release packaging and managed runtime source', () => { it('allows public package identifiers in release packaging and managed runtime source', () => {
@ -79,7 +80,9 @@ describe('scanFileContent', () => {
assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0); assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0);
assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0); assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0); assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('packages/cli/src/release-version.ts', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0); assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0);
assert.equal(scanFileContent('python/ktx-daemon/src/ktx_daemon/__init__.py', `${name}-ktx`).length, 0);
}); });
it('allows clean source files and clean runtime prompt assets', () => { it('allows clean source files and clean runtime prompt assets', () => {

View file

@ -82,7 +82,7 @@ async function writeUploadableArtifactFixtures(layout) {
`${packageInfo.name}-tarball`, `${packageInfo.name}-tarball`,
]), ]),
[ [
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
'kaelio-ktx-runtime-wheel', 'kaelio-ktx-runtime-wheel',
], ],
]); ]);
@ -139,7 +139,7 @@ describe('packageReleaseMetadata', () => {
ecosystem: 'python', ecosystem: 'python',
packageName: 'kaelio-ktx', packageName: 'kaelio-ktx',
packageRoot: 'python/runtime-wheel', packageRoot: 'python/runtime-wheel',
packageVersion: '0.1.0', packageVersion: '0.1.0rc1',
private: false, private: false,
releaseMode: 'ci-artifact-only', releaseMode: 'ci-artifact-only',
}, },
@ -154,10 +154,10 @@ describe('findPythonArtifacts', () => {
it('finds the bundled runtime wheel only', async () => { it('finds the bundled runtime wheel only', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-test-')); const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-test-'));
try { try {
await writeFile(join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'), ''); await writeFile(join(root, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'), '');
assert.deepEqual(await findPythonArtifacts(root), { assert.deepEqual(await findPythonArtifacts(root), {
runtimeWheel: join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'), runtimeWheel: join(root, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
}); });
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
@ -210,7 +210,7 @@ describe('artifact manifest', () => {
ecosystem: 'python', ecosystem: 'python',
packageName: 'kaelio-ktx', packageName: 'kaelio-ktx',
packageRoot: 'python/runtime-wheel', packageRoot: 'python/runtime-wheel',
packageVersion: '0.1.0', packageVersion: '0.1.0rc1',
private: false, private: false,
releaseMode: 'ci-artifact-only', releaseMode: 'ci-artifact-only',
}, },
@ -252,8 +252,8 @@ describe('artifact manifest', () => {
artifactKind: 'wheel', artifactKind: 'wheel',
ecosystem: 'python', ecosystem: 'python',
packageName: 'kaelio-ktx', packageName: 'kaelio-ktx',
packageVersion: '0.1.0', packageVersion: '0.1.0rc1',
path: 'python/kaelio_ktx-0.1.0-py3-none-any.whl', path: 'python/kaelio_ktx-0.1.0rc1-py3-none-any.whl',
}, },
], ],
); );
@ -362,17 +362,17 @@ describe('copyRuntimeWheelAssets', () => {
try { try {
await mkdir(layout.pythonDir, { recursive: true }); await mkdir(layout.pythonDir, { recursive: true });
await writeFile( await writeFile(
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
'kaelio-ktx-runtime-wheel', 'kaelio-ktx-runtime-wheel',
); );
const assets = await copyRuntimeWheelAssets(layout, { const assets = await copyRuntimeWheelAssets(layout, {
runtimeWheel: join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), runtimeWheel: join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
}); });
assert.equal( assert.equal(
assets.wheelPath, assets.wheelPath,
join(root, 'packages', 'cli', 'assets', 'python', 'kaelio_ktx-0.1.0-py3-none-any.whl'), join(root, 'packages', 'cli', 'assets', 'python', 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
); );
assert.equal( assert.equal(
assets.manifestPath, assets.manifestPath,
@ -385,7 +385,7 @@ describe('copyRuntimeWheelAssets', () => {
normalizedName: RUNTIME_WHEEL_NORMALIZED_NAME, normalizedName: RUNTIME_WHEEL_NORMALIZED_NAME,
version: RUNTIME_WHEEL_PACKAGE_VERSION, version: RUNTIME_WHEEL_PACKAGE_VERSION,
wheel: { wheel: {
file: 'kaelio_ktx-0.1.0-py3-none-any.whl', file: 'kaelio_ktx-0.1.0rc1-py3-none-any.whl',
sha256: createHash('sha256') sha256: createHash('sha256')
.update('kaelio-ktx-runtime-wheel') .update('kaelio-ktx-runtime-wheel')
.digest('hex'), .digest('hex'),

View file

@ -9,6 +9,8 @@ export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']);
const SEMVER_PATTERN = const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const SEMVER_PARTS_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function scriptRootDir() { function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..'); return resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -29,6 +31,30 @@ export function assertPublicNpmPackageVersion(version) {
return version; return version;
} }
export function publicNpmPackageVersionToPythonVersion(version) {
const safeVersion = assertPublicNpmPackageVersion(version);
const match = SEMVER_PARTS_PATTERN.exec(safeVersion);
if (!match) {
throw new Error(`Invalid public npm package version: ${version}`);
}
const [, major, minor, patch, prerelease, buildMetadata] = match;
if (buildMetadata) {
throw new Error(`Unsupported public npm build metadata for Python runtime version: ${safeVersion}`);
}
const baseVersion = `${major}.${minor}.${patch}`;
if (!prerelease) {
return baseVersion;
}
const rcMatch = /^rc\.([1-9]\d*|0)$/.exec(prerelease);
if (!rcMatch) {
throw new Error(`Unsupported public npm prerelease for Python runtime version: ${safeVersion}`);
}
return `${baseVersion}rc${rcMatch[1]}`;
}
export function assertPublicNpmReleaseTag(tag) { export function assertPublicNpmReleaseTag(tag) {
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) { if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
throw new Error(`Invalid public npm release tag: ${tag}`); throw new Error(`Invalid public npm release tag: ${tag}`);
@ -51,3 +77,7 @@ export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {
export function publicNpmPackageVersion(rootDir = scriptRootDir()) { export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
return readPublicNpmReleaseMetadata(rootDir).version; return readPublicNpmReleaseMetadata(rootDir).version;
} }
export function publicPythonRuntimePackageVersion(rootDir = scriptRootDir()) {
return publicNpmPackageVersionToPythonVersion(publicNpmPackageVersion(rootDir));
}

View file

@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { publicNpmPackageVersionToPythonVersion } from './public-npm-release-metadata.mjs';
describe('publicNpmPackageVersionToPythonVersion', () => {
it('keeps stable public npm versions unchanged for Python wheels', () => {
assert.equal(publicNpmPackageVersionToPythonVersion('1.2.3'), '1.2.3');
});
it('converts semantic-release rc versions to PEP 440 rc versions', () => {
assert.equal(publicNpmPackageVersionToPythonVersion('0.1.0-rc.1'), '0.1.0rc1');
assert.equal(publicNpmPackageVersionToPythonVersion('2.0.0-rc.12'), '2.0.0rc12');
});
it('rejects unsupported prerelease and build metadata forms', () => {
assert.throws(
() => publicNpmPackageVersionToPythonVersion('1.2.3-beta.1'),
/Unsupported public npm prerelease for Python runtime version/,
);
assert.throws(
() => publicNpmPackageVersionToPythonVersion('1.2.3+build.1'),
/Unsupported public npm build metadata for Python runtime version/,
);
});
});

View file

@ -37,7 +37,7 @@ async function writeUploadableArtifactFixtures(layout) {
layout.npmTarballs[packageInfo.name], layout.npmTarballs[packageInfo.name],
`${packageInfo.name}-tarball`, `${packageInfo.name}-tarball`,
]), ]),
[join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), 'kaelio-ktx-runtime-wheel'], [join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'), 'kaelio-ktx-runtime-wheel'],
]); ]);
for (const [path, contents] of fileContents) { for (const [path, contents] of fileContents) {

4
uv.lock generated
View file

@ -440,7 +440,7 @@ wheels = [
[[package]] [[package]]
name = "ktx-daemon" name = "ktx-daemon"
version = "0.1.0" version = "0.0.0+private"
source = { editable = "python/ktx-daemon" } source = { editable = "python/ktx-daemon" }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
@ -495,7 +495,7 @@ dev = [
[[package]] [[package]]
name = "ktx-sl" name = "ktx-sl"
version = "0.1.0" version = "0.0.0+private"
source = { editable = "python/ktx-sl" } source = { editable = "python/ktx-sl" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },