mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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:
parent
33a142f769
commit
d1c84e5564
35 changed files with 671 additions and 90 deletions
|
|
@ -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 | — |
|
||||
|
|
|
|||
|
|
@ -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:`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
55
packages/cli/src/release-version.ts
Normal file
55
packages/cli/src/release-version.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
137
packages/cli/src/setup-sources-notion.test.ts
Normal file
137
packages/cli/src/setup-sources-notion.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
85
packages/cli/src/source-mapping.test.ts
Normal file
85
packages/cli/src/source-mapping.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)), '..');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
26
scripts/public-npm-release-metadata.test.mjs
Normal file
26
scripts/public-npm-release-metadata.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
4
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue