fix: derive runtime versions from release metadata

This commit is contained in:
Andrey Avtomonov 2026-05-17 19:00:34 +02:00
parent 1c30abc51d
commit 8aea27bfbe
18 changed files with 231 additions and 50 deletions

View file

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

View file

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

View file

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

View file

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

View file

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