diff --git a/docs-site/content/docs/cli-reference/ktx-dev.mdx b/docs-site/content/docs/cli-reference/ktx-dev.mdx index f124055a..50d4ea77 100644 --- a/docs-site/content/docs/cli-reference/ktx-dev.mdx +++ b/docs-site/content/docs/cli-reference/ktx-dev.mdx @@ -31,6 +31,9 @@ ktx dev [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 ` | Write the schema to a file instead of stdout | — | diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 09957e70..0118522c 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -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:` diff --git a/docs/release.md b/docs/release.md index 9131d57e..5e8a254b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index ad0dfd44..ce6f5872 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -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; 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) { diff --git a/packages/cli/src/cli-program.test.ts b/packages/cli/src/cli-program.test.ts index 79b0d23a..f0ac9595 100644 --- a/packages/cli/src/cli-program.test.ts +++ b/packages/cli/src/cli-program.test.ts @@ -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', () => { diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 971234c3..34b13854 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -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); } diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index b0fe8eb0..b8bc636d 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -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', }; } diff --git a/packages/cli/src/dev.test.ts b/packages/cli/src/dev.test.ts index bda96692..95adb20c 100644 --- a/packages/cli/src/dev.test.ts +++ b/packages/cli/src/dev.test.ts @@ -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'], diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 692c7cd0..a48da8d5 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -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, diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/src/managed-python-command.test.ts index abde67f1..a63f162e 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/src/managed-python-command.test.ts @@ -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 => 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 () => { diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index 097bb0ac..11e794ff 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -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; installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; confirmInstall?: (message: string, io: KtxCliIo) => Promise; + 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({ diff --git a/packages/cli/src/print-command-tree.ts b/packages/cli/src/print-command-tree.ts index 2ede889c..6c9de751 100644 --- a/packages/cli/src/print-command-tree.ts +++ b/packages/cli/src/print-command-tree.ts @@ -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 { diff --git a/packages/cli/src/release-version.ts b/packages/cli/src/release-version.ts new file mode 100644 index 00000000..77bcb833 --- /dev/null +++ b/packages/cli/src/release-version.ts @@ -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 { + 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; +} diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/src/setup-embeddings.test.ts index aadb8a9f..12ab947f 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/src/setup-embeddings.test.ts @@ -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(); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 4e92670c..3e2e47db 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -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 { diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index e997eb82..4c25d092 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -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' }, - ]), + ], }), ); }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index e8727f47..cd8aa848 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -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' }; diff --git a/packages/cli/src/setup-sources-notion.test.ts b/packages/cli/src/setup-sources-notion.test.ts new file mode 100644 index 00000000..b85eefff --- /dev/null +++ b/packages/cli/src/setup-sources-notion.test.ts @@ -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(); + 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(''); + }); +}); diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 7b4425f0..164ec2e8 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -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 { - 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) diff --git a/packages/cli/src/source-mapping.test.ts b/packages/cli/src/source-mapping.test.ts new file mode 100644 index 00000000..83f9496b --- /dev/null +++ b/packages/cli/src/source-mapping.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/cli/src/source-mapping.ts b/packages/cli/src/source-mapping.ts index 3f8e8782..13fa15fb 100644 --- a/packages/cli/src/source-mapping.ts +++ b/packages/cli/src/source-mapping.ts @@ -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 []; diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index 16437879..4f21f9de 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -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" diff --git a/python/ktx-daemon/src/ktx_daemon/__init__.py b/python/ktx-daemon/src/ktx_daemon/__init__.py index ddc5f6d9..d228ac79 100644 --- a/python/ktx-daemon/src/ktx_daemon/__init__.py +++ b/python/ktx-daemon/src/ktx_daemon/__init__.py @@ -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", +] diff --git a/python/ktx-daemon/src/ktx_daemon/app.py b/python/ktx-daemon/src/ktx_daemon/app.py index 0d7016dd..3208264c 100644 --- a/python/ktx-daemon/src/ktx_daemon/app.py +++ b/python/ktx-daemon/src/ktx_daemon/app.py @@ -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") diff --git a/python/ktx-daemon/tests/test_package.py b/python/ktx-daemon/tests/test_package.py index 3812b448..943a85a4 100644 --- a/python/ktx-daemon/tests/test_package.py +++ b/python/ktx-daemon/tests/test_package.py @@ -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"] diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index f3081327..51e97a83 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -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" diff --git a/scripts/build-python-runtime-wheel.mjs b/scripts/build-python-runtime-wheel.mjs index 9623b48a..46e30ce5 100644 --- a/scripts/build-python-runtime-wheel.mjs +++ b/scripts/build-python-runtime-wheel.mjs @@ -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)), '..'); diff --git a/scripts/build-python-runtime-wheel.test.mjs b/scripts/build-python-runtime-wheel.test.mjs index 2fbb3fbc..7fc3923b 100644 --- a/scripts/build-python-runtime-wheel.test.mjs +++ b/scripts/build-python-runtime-wheel.test.mjs @@ -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'); }); }); diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 12001ae5..f51fd223 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -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) { diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index 29ff2df2..952b5614 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -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', () => { diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index 95eb6c0c..bbffe06c 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -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'), diff --git a/scripts/public-npm-release-metadata.mjs b/scripts/public-npm-release-metadata.mjs index a8d4fa43..acc77c7e 100644 --- a/scripts/public-npm-release-metadata.mjs +++ b/scripts/public-npm-release-metadata.mjs @@ -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)); +} diff --git a/scripts/public-npm-release-metadata.test.mjs b/scripts/public-npm-release-metadata.test.mjs new file mode 100644 index 00000000..3f102ff1 --- /dev/null +++ b/scripts/public-npm-release-metadata.test.mjs @@ -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/, + ); + }); +}); diff --git a/scripts/release-readiness.test.mjs b/scripts/release-readiness.test.mjs index 38fcfe20..84170761 100644 --- a/scripts/release-readiness.test.mjs +++ b/scripts/release-readiness.test.mjs @@ -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) { diff --git a/uv.lock b/uv.lock index f3ffc767..2dc8b355 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },