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`
`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 |
|------|-------------|---------|
| `--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 |
|----------|----------|------------------|
| 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 |
| 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
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
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
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';
const ESC = String.fromCharCode(0x1b);
export interface KtxCliSpinner {
start(message: string): void;
message(message: string): void;
@ -7,6 +9,10 @@ export interface KtxCliSpinner {
error(message: string): void;
}
export interface KtxCliSpinnerIo {
stderr: { write(chunk: string): void };
}
export interface KtxCliPromptAdapter {
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
cancel(message: string): void;
@ -31,6 +37,31 @@ export function createClackSpinner(): KtxCliSpinner {
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 {
return {
async confirm(options) {

View file

@ -11,7 +11,13 @@ function stubIo(): KtxCliIo {
}
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', () => {

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_INDEPENDENT_DEV_COMMANDS = new Set(['runtime', 'schema']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
@ -172,7 +173,7 @@ function isProjectAwareCommand(path: string[]): boolean {
const rootCommand = path[1];
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);
}

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
createManagedPythonSemanticLayerComputePort,
ensureManagedPythonCommandRuntime,
managedRuntimeInstallCommand,
runtimeInstallPolicyFromFlags,
} 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', () => {
it('prints the exact command for each managed runtime feature', () => {
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
@ -128,6 +140,51 @@ describe('runtimeInstallPolicyFromFlags', () => {
});
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 () => {
const io = makeIo();
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 () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createPythonCompute = vi.fn(() => compute);
const installRuntime = vi.fn(async () => installResult());
@ -182,6 +240,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
createPythonCompute,
spinner,
}),
).resolves.toBe(compute);
@ -190,12 +249,15 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'],
force: false,
});
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv');
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
expect(events).toEqual([
'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 () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const confirmInstall = vi.fn(async () => true);
const installRuntime = vi.fn(async () => installResult());
@ -207,6 +269,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime,
createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })),
confirmInstall,
spinner,
});
expect(confirmInstall).toHaveBeenCalledWith(
@ -218,10 +281,12 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'],
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 () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult());
const confirmInstall = vi.fn(async () => true);
@ -235,6 +300,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime,
confirmInstall,
createPythonCompute: () => compute,
spinner,
}),
).resolves.toBe(compute);
@ -242,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
'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...');
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
});
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 type { KtxCliIo } from './cli-runtime.js';
import { createClackPromptAdapter } from './clack.js';
import { createClackPromptAdapter, createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import {
installManagedPythonRuntime,
readManagedPythonRuntimeStatus,
@ -37,6 +37,7 @@ export interface ManagedPythonCommandDeps {
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
spinner?: () => KtxCliSpinner;
}
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 installed = await installRuntime({
cliVersion: options.cliVersion,
features: [feature],
force: false,
});
options.io.stderr.write(`KTX Python runtime ready: ${installed.layout.versionDir}\n`);
return { layout: installed.layout, manifest: installed.manifest };
const progress = (options.spinner ?? (() => createStaticCliSpinner(options.io)))();
progress.start(`Installing KTX Python runtime (${feature}) with uv...`);
try {
const installed = await installRuntime({
cliVersion: options.cliVersion,
features: [feature],
force: false,
});
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(
@ -122,6 +129,7 @@ export async function createManagedPythonSemanticLayerComputePort(
...(options.readStatus ? { readStatus: options.readStatus } : {}),
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
...(options.spinner ? { spinner: options.spinner } : {}),
});
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
return createPythonCompute({

View file

@ -11,7 +11,13 @@ function silentIo(): KtxCliIo {
}
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 {

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(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
expect(spinnerEvents).toContainEqual(
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
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 () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
@ -213,9 +234,7 @@ describe('setup embeddings step', () => {
);
await vi.waitFor(() => {
expect(spinnerEvents).toContainEqual(
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
});
expect(resolveHealthCheck).toBeDefined();

View file

@ -10,7 +10,7 @@ import {
} from '@ktx/context/project';
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
import { createClackSpinner, type KtxCliSpinner } from './clack.js';
import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import {
ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig,
@ -316,10 +316,7 @@ async function promptAfterLocalEmbeddingFailure(
function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string {
if (backend === LOCAL_EMBEDDING_BACKEND) {
return [
`Testing local sentence-transformers embeddings (${model}, ${dimensions} dimensions).`,
'First run may take up to 60 seconds.',
].join(' ');
return `Testing local embeddings (${model})`;
}
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
}
@ -424,7 +421,7 @@ export async function runKtxSetupEmbeddingsStep(
dimensions,
credentialValue,
});
const healthSpinner = (deps.spinner ?? createClackSpinner)();
const healthSpinner = (deps.spinner ?? (() => createStaticCliSpinner(io)))();
const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions));
let health: KtxEmbeddingHealthCheckResult;
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 result = await runKtxSetupAnthropicModelStep(
@ -177,10 +177,12 @@ describe('setup Anthropic model step', () => {
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
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: 'back', label: 'Back' },
]),
],
}),
);
});

View file

@ -509,12 +509,12 @@ async function chooseBackend(
}
const choice = await prompts.select({
message: 'Which LLM provider should KTX use?',
options: [
{ value: 'anthropic', label: 'Anthropic API' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'claude-code', label: 'Local Claude Code session' },
{ value: 'back', label: 'Back' },
],
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: 'back', label: 'Back' },
],
});
if (choice === '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 { join, relative, resolve } from 'node:path';
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 {
cloneOrPull,
@ -620,7 +623,10 @@ async function defaultValidateLookml(connection: KtxProjectConnectionConfig): Pr
}
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);
await client.retrieveBotUser();
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,
lookerCredentialsFromLocalConnection,
metabaseRuntimeConfigFromLocalConnection,
planMetabaseFanoutChildren,
seedLocalMappingStateFromKtxYaml,
validateLookerMappings,
validateMappingPhysicalMatch,
@ -198,6 +199,14 @@ export async function runKtxSourceMapping(
}
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) => {
if (!row.targetConnectionId) {
return [];

View file

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

View file

@ -1,6 +1,28 @@
"""Portable compute package for KTX."""
PACKAGE_NAME = "ktx-daemon"
VERSION = "0.1.0"
from collections.abc import Callable
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.responses import Response
from ktx_daemon import VERSION
from ktx_daemon.code_execution import (
ExecuteCodeRequest,
ExecuteCodeResponse,
@ -84,7 +85,7 @@ def create_app(
app = FastAPI(
title="KTX Daemon",
description="Stateless portable compute server for KTX.",
version="0.1.0",
version=VERSION,
)
@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:
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]
name = "ktx-sl"
version = "0.1.0"
version = "0.0.0+private"
description = "Agent-first semantic layer engine with aggregate locality"
readme = "README.md"
requires-python = ">=3.13"

View file

@ -6,11 +6,13 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { publicPythonRuntimePackageVersion } from './public-npm-release-metadata.mjs';
const execFileAsync = promisify(execFile);
export const RUNTIME_WHEEL_DISTRIBUTION_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() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');

View file

@ -48,11 +48,11 @@ describe('runtimeWheelLayout', () => {
});
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();
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, /packages = \["semantic_layer", "ktx_daemon"\]/);
assert.match(pyproject, /\[project\.optional-dependencies\]/);
@ -110,6 +110,6 @@ describe('runtimeWheelBuildCommand', () => {
cwd: '/repo/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 identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-sl/plans/', 'python/ktx-sl/openspec/'];
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$/,
];
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];
@ -87,7 +88,10 @@ function scansForLlmBoundaries(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) {

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/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', () => {
@ -79,7 +80,9 @@ describe('scanFileContent', () => {
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/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('python/ktx-daemon/src/ktx_daemon/__init__.py', `${name}-ktx`).length, 0);
});
it('allows clean source files and clean runtime prompt assets', () => {

View file

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

View file

@ -9,6 +9,8 @@ export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']);
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-]+)*)?$/;
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() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -29,6 +31,30 @@ export function assertPublicNpmPackageVersion(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) {
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
throw new Error(`Invalid public npm release tag: ${tag}`);
@ -51,3 +77,7 @@ export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {
export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
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],
`${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) {

4
uv.lock generated
View file

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