mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design * build: add bundled python runtime wheel builder * build: make local embedding dependencies optional * build: bundle python runtime wheel in cli artifacts * build: track bundled python runtime release artifact * test: verify bundled python runtime wheel * docs: add plan for bundled python runtime wheel * test: cover managed python runtime lifecycle * feat: add managed python runtime installer * feat: add runtime command runner * feat: expose runtime management commands * test: verify managed python runtime commands * docs: add plan for managed python runtime installer * feat: add managed python command helper * feat: use managed runtime for sl query compute * feat: route sl query managed runtime policy * docs: add plan for managed runtime sl query integration * feat: add managed runtime daemon metadata * feat: manage python daemon lifecycle * feat: add runtime daemon start stop commands * fix: verify managed runtime daemon lifecycle * docs: add plan for managed runtime daemon lifecycle * feat: add managed local embeddings config marker * feat: add managed local embeddings daemon helper * feat: use managed runtime for local embedding setup * feat: pass managed runtime policy through setup * docs: add plan for managed local embeddings runtime * feat: read CLI package metadata dynamically * feat: assemble public kaelio ktx npm package * feat: release one public kaelio ktx npm artifact * test: cover public kaelio ktx package invocations * chore: verify public kaelio ktx package artifacts * docs: add plan for public kaelio ktx npm package * test: verify managed runtime in public package smoke * test: finalize managed runtime release smoke * docs: add plan for managed runtime release smoke * test: specify local embeddings release smoke * feat: add local embeddings runtime smoke * chore: register local embeddings smoke * fix: verify local embeddings smoke * fix: restore artifact smoke python env helper * docs: add plan for managed local embeddings release smoke * refactor: share managed runtime install policy parsing * feat: use managed runtime for agent semantic queries * feat: use managed runtime for MCP semantic compute * docs: add plan for managed agent and MCP semantic runtime * feat(cli): add managed daemon HTTP helpers * feat(cli): route local adapters through managed daemon * feat(cli): use managed daemon for ingest helpers * feat(cli): pass managed daemon options to scan * feat(context): pass MCP ingest pull config options * feat(cli): pass managed daemon options to serve ingest * test: verify managed local ingest daemon runtime * docs: add plan for managed local ingest daemon runtime * docs: align managed runtime examples * docs: add plan for managed runtime docs cleanup * test: cover published package runtime smoke commands * test: validate published package smoke outputs * docs: add plan for published package runtime smoke * build: stamp public npm package version * release: add npm public release policy * release: add guarded npm publish script * release: document public npm release handoff * docs: add plan for public npm release handoff * test: cover managed runtime prune in package smoke * docs: document managed runtime prune * docs: add plan for managed runtime prune smoke and docs * chore: encode uv runtime prerequisite policy * fix: clarify missing uv runtime error * docs: document uv runtime prerequisite * docs: add plan for uv runtime prerequisite contract * refactor: limit release artifacts to public package runtime * chore: align release policy with bundled runtime wheel * docs: describe single public runtime artifact surface * test: verify single public runtime artifact contract * docs: add plan for single public runtime artifact cleanup * fix: align local embeddings smoke with public version * docs: add plan for local embeddings smoke public version * release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag Publish target moves to the pre-release version 0.1.0-rc.0 under the next dist-tag so npm install @kaelio/ktx (which resolves to latest) does not pick up the soft-launch build. Users opt in via @kaelio/ktx@next. * Fix release script boundary checks * Remove PostHog from public package bundle
This commit is contained in:
parent
075764fe77
commit
9dad936ac7
99 changed files with 25375 additions and 1538 deletions
263
scripts/build-public-npm-package.mjs
Normal file
263
scripts/build-public-npm-package.mjs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
|
||||
export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0-rc.0';
|
||||
|
||||
export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
return `kaelio-ktx-${version}.tgz`;
|
||||
}
|
||||
|
||||
export const PUBLIC_BUNDLED_WORKSPACE_PACKAGES = [
|
||||
'@ktx/llm',
|
||||
'@ktx/context',
|
||||
'@ktx/connector-bigquery',
|
||||
'@ktx/connector-clickhouse',
|
||||
'@ktx/connector-mysql',
|
||||
'@ktx/connector-postgres',
|
||||
'@ktx/connector-snowflake',
|
||||
'@ktx/connector-sqlite',
|
||||
'@ktx/connector-sqlserver',
|
||||
];
|
||||
|
||||
export const PUBLIC_BUNDLED_WORKSPACE_PACKAGE_ROOTS = {
|
||||
'@ktx/llm': 'packages/llm',
|
||||
'@ktx/context': 'packages/context',
|
||||
'@ktx/connector-bigquery': 'packages/connector-bigquery',
|
||||
'@ktx/connector-clickhouse': 'packages/connector-clickhouse',
|
||||
'@ktx/connector-mysql': 'packages/connector-mysql',
|
||||
'@ktx/connector-postgres': 'packages/connector-postgres',
|
||||
'@ktx/connector-snowflake': 'packages/connector-snowflake',
|
||||
'@ktx/connector-sqlite': 'packages/connector-sqlite',
|
||||
'@ktx/connector-sqlserver': 'packages/connector-sqlserver',
|
||||
};
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
export function publicNpmPackageLayout(rootDir = scriptRootDir(), version = PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
return {
|
||||
rootDir,
|
||||
packageVersion: version,
|
||||
cliPackageRoot: join(rootDir, 'packages', 'cli'),
|
||||
packRoot: join(rootDir, 'dist', 'public-npm-package'),
|
||||
npmDir: join(rootDir, 'dist', 'artifacts', 'npm'),
|
||||
tarballPath: join(rootDir, 'dist', 'artifacts', 'npm', publicNpmPackageTarballName(version)),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function sortedObject(entries) {
|
||||
return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function isWorkspacePackageName(name) {
|
||||
return name.startsWith('@ktx/');
|
||||
}
|
||||
|
||||
function parseCaretVersion(value) {
|
||||
const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
|
||||
function compareParsedVersions(left, right) {
|
||||
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
||||
}
|
||||
|
||||
function mergeDependencyVersion(name, previous, next) {
|
||||
if (previous === next) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const previousCaret = parseCaretVersion(previous);
|
||||
const nextCaret = parseCaretVersion(next);
|
||||
if (previousCaret && nextCaret && previousCaret.major === nextCaret.major) {
|
||||
return compareParsedVersions(previousCaret, nextCaret) >= 0 ? previous : next;
|
||||
}
|
||||
|
||||
throw new Error(`Incompatible dependency versions for ${name}: ${previous} and ${next}`);
|
||||
}
|
||||
|
||||
export function collectPublicDependencies(packageJsons) {
|
||||
const dependencies = new Map();
|
||||
|
||||
for (const packageJson of packageJsons) {
|
||||
for (const [name, version] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
if (isWorkspacePackageName(name)) {
|
||||
continue;
|
||||
}
|
||||
const previous = dependencies.get(name);
|
||||
dependencies.set(name, previous ? mergeDependencyVersion(name, previous, version) : version);
|
||||
}
|
||||
}
|
||||
|
||||
return sortedObject(dependencies);
|
||||
}
|
||||
|
||||
export function publicNpmPackageJson(cliPackageJson, dependencies, version = PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
return {
|
||||
name: PUBLIC_NPM_PACKAGE_NAME,
|
||||
version,
|
||||
description: 'Standalone KTX context layer for database agents',
|
||||
private: false,
|
||||
type: 'module',
|
||||
engines: cliPackageJson.engines ?? { node: '>=22.0.0' },
|
||||
bin: { ktx: './dist/bin.js' },
|
||||
main: cliPackageJson.main ?? 'dist/index.js',
|
||||
types: cliPackageJson.types ?? 'dist/index.d.ts',
|
||||
exports: cliPackageJson.exports ?? {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
import: './dist/index.js',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
files: ['dist', 'assets'],
|
||||
dependencies,
|
||||
bundledDependencies: PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
|
||||
license: cliPackageJson.license ?? 'Apache-2.0',
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: 'git+https://github.com/kaelio/ktx.git',
|
||||
},
|
||||
bugs: {
|
||||
url: 'https://github.com/kaelio/ktx/issues',
|
||||
},
|
||||
homepage: 'https://github.com/kaelio/ktx#readme',
|
||||
};
|
||||
}
|
||||
|
||||
function bundledWorkspacePackageJson(packageJson) {
|
||||
const dependencies = Object.fromEntries(
|
||||
Object.entries(packageJson.dependencies ?? {}).filter(([name]) => !isWorkspacePackageName(name)),
|
||||
);
|
||||
|
||||
return {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version ?? PUBLIC_NPM_PACKAGE_VERSION,
|
||||
private: true,
|
||||
type: packageJson.type ?? 'module',
|
||||
main: packageJson.main,
|
||||
types: packageJson.types,
|
||||
exports: packageJson.exports,
|
||||
files: packageJson.files,
|
||||
dependencies: sortedObject(Object.entries(dependencies)),
|
||||
license: packageJson.license ?? 'Apache-2.0',
|
||||
};
|
||||
}
|
||||
|
||||
async function copyPackageFileEntries(sourceRoot, targetRoot, packageJson) {
|
||||
for (const entry of packageJson.files ?? ['dist']) {
|
||||
await cp(join(sourceRoot, entry), join(targetRoot, entry), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCliPackage(layout, cliPackageJson, dependencies) {
|
||||
await copyPackageFileEntries(layout.cliPackageRoot, layout.packRoot, cliPackageJson);
|
||||
await writeJson(
|
||||
join(layout.packRoot, 'package.json'),
|
||||
publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion),
|
||||
);
|
||||
}
|
||||
|
||||
async function copyBundledWorkspacePackage(rootDir, packageName, packageJson) {
|
||||
const packageRoot = PUBLIC_BUNDLED_WORKSPACE_PACKAGE_ROOTS[packageName];
|
||||
if (!packageRoot) {
|
||||
throw new Error(`Missing bundled workspace package root for ${packageName}`);
|
||||
}
|
||||
|
||||
const sourceRoot = join(rootDir, packageRoot);
|
||||
const targetRoot = join(rootDir, 'dist', 'public-npm-package', 'node_modules', ...packageName.split('/'));
|
||||
await mkdir(targetRoot, { recursive: true });
|
||||
await copyPackageFileEntries(sourceRoot, targetRoot, packageJson);
|
||||
await writeJson(join(targetRoot, 'package.json'), bundledWorkspacePackageJson(packageJson));
|
||||
}
|
||||
|
||||
export async function createPublicNpmPackageTree(layout = publicNpmPackageLayout()) {
|
||||
const cliPackageJson = await readJson(join(layout.cliPackageRoot, 'package.json'));
|
||||
const bundledPackageJsons = await Promise.all(
|
||||
PUBLIC_BUNDLED_WORKSPACE_PACKAGES.map(async (packageName) => {
|
||||
const packageRoot = PUBLIC_BUNDLED_WORKSPACE_PACKAGE_ROOTS[packageName];
|
||||
const packageJson = await readJson(join(layout.rootDir, packageRoot, 'package.json'));
|
||||
if (packageJson.name !== packageName) {
|
||||
throw new Error(`Unexpected package name in ${packageRoot}/package.json: ${packageJson.name}`);
|
||||
}
|
||||
return packageJson;
|
||||
}),
|
||||
);
|
||||
const dependencies = collectPublicDependencies([cliPackageJson, ...bundledPackageJsons]);
|
||||
|
||||
await rm(layout.packRoot, { recursive: true, force: true });
|
||||
await mkdir(layout.packRoot, { recursive: true });
|
||||
await mkdir(layout.npmDir, { recursive: true });
|
||||
await copyCliPackage(layout, cliPackageJson, dependencies);
|
||||
|
||||
for (const packageJson of bundledPackageJsons) {
|
||||
await copyBundledWorkspacePackage(layout.rootDir, packageJson.name, packageJson);
|
||||
}
|
||||
|
||||
return {
|
||||
layout,
|
||||
packageJson: publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion),
|
||||
bundledPackages: PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
|
||||
};
|
||||
}
|
||||
|
||||
export function publicNpmPackCommand(layout = publicNpmPackageLayout()) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--config.node-linker=hoisted', 'pack', '--out', layout.tarballPath],
|
||||
cwd: layout.packRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPublicNpmPackage(layout = publicNpmPackageLayout()) {
|
||||
await createPublicNpmPackageTree(layout);
|
||||
const pack = publicNpmPackCommand(layout);
|
||||
await execFileAsync(pack.command, pack.args, {
|
||||
cwd: pack.cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return layout.tarballPath;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tarball = await buildPublicNpmPackage();
|
||||
process.stdout.write(`Built ${PUBLIC_NPM_PACKAGE_NAME} package: ${tarball}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
275
scripts/build-public-npm-package.test.mjs
Normal file
275
scripts/build-public-npm-package.test.mjs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
|
||||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
PUBLIC_NPM_PACKAGE_VERSION,
|
||||
collectPublicDependencies,
|
||||
createPublicNpmPackageTree,
|
||||
publicNpmPackageJson,
|
||||
publicNpmPackageLayout,
|
||||
publicNpmPackageTarballName,
|
||||
publicNpmPackCommand,
|
||||
} from './build-public-npm-package.mjs';
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writePackage(root, packageRoot, packageJson, files = {}) {
|
||||
const absoluteRoot = join(root, packageRoot);
|
||||
await mkdir(absoluteRoot, { recursive: true });
|
||||
await writeJson(join(absoluteRoot, 'package.json'), packageJson);
|
||||
|
||||
for (const [relativePath, contents] of Object.entries(files)) {
|
||||
const target = join(absoluteRoot, relativePath);
|
||||
await mkdir(join(target, '..'), { recursive: true });
|
||||
await writeFile(target, contents);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWorkspaceFixture(root) {
|
||||
await writePackage(
|
||||
root,
|
||||
'packages/cli',
|
||||
{
|
||||
name: '@ktx/cli',
|
||||
version: '0.0.0-private',
|
||||
description: 'CLI wrapper for KTX',
|
||||
type: 'module',
|
||||
engines: { node: '>=22.0.0' },
|
||||
bin: { ktx: './dist/bin.js' },
|
||||
main: 'dist/index.js',
|
||||
types: 'dist/index.d.ts',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
import: './dist/index.js',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
files: ['dist', 'assets'],
|
||||
dependencies: {
|
||||
'@clack/prompts': '1.3.0',
|
||||
'@ktx/context': 'workspace:*',
|
||||
commander: '14.0.3',
|
||||
},
|
||||
license: 'Apache-2.0',
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: 'git+https://github.com/kaelio/ktx.git',
|
||||
directory: 'packages/cli',
|
||||
},
|
||||
},
|
||||
{
|
||||
'dist/bin.js': '#!/usr/bin/env node\n',
|
||||
'dist/index.js': 'export const cli = true;\n',
|
||||
'dist/index.d.ts': 'export declare const cli: true;\n',
|
||||
'assets/python/manifest.json': '{"schemaVersion":1}\n',
|
||||
},
|
||||
);
|
||||
|
||||
await writePackage(
|
||||
root,
|
||||
'packages/context',
|
||||
{
|
||||
name: '@ktx/context',
|
||||
version: '0.0.0-private',
|
||||
type: 'module',
|
||||
main: 'dist/index.js',
|
||||
exports: { '.': './dist/index.js' },
|
||||
files: ['dist', 'prompts', 'skills'],
|
||||
dependencies: {
|
||||
'@ktx/llm': 'workspace:*',
|
||||
yaml: '^2.8.2',
|
||||
},
|
||||
},
|
||||
{
|
||||
'dist/index.js': 'export const context = true;\n',
|
||||
'prompts/system.md': 'prompt\n',
|
||||
'skills/sl/SKILL.md': 'skill\n',
|
||||
},
|
||||
);
|
||||
|
||||
await writePackage(
|
||||
root,
|
||||
'packages/llm',
|
||||
{
|
||||
name: '@ktx/llm',
|
||||
version: '0.0.0-private',
|
||||
type: 'module',
|
||||
main: 'dist/index.js',
|
||||
exports: { '.': './dist/index.js' },
|
||||
files: ['dist'],
|
||||
dependencies: {
|
||||
ai: '^6.0.168',
|
||||
},
|
||||
},
|
||||
{
|
||||
'dist/index.js': 'export const llm = true;\n',
|
||||
},
|
||||
);
|
||||
|
||||
for (const packageName of PUBLIC_BUNDLED_WORKSPACE_PACKAGES.filter((name) => name.startsWith('@ktx/connector-'))) {
|
||||
const directory = packageName.replace('@ktx/', '');
|
||||
await writePackage(
|
||||
root,
|
||||
`packages/${directory}`,
|
||||
{
|
||||
name: packageName,
|
||||
version: '0.0.0-private',
|
||||
type: 'module',
|
||||
main: 'dist/index.js',
|
||||
exports: { '.': './dist/index.js' },
|
||||
files: ['dist'],
|
||||
dependencies: {
|
||||
'@ktx/context': 'workspace:*',
|
||||
},
|
||||
},
|
||||
{
|
||||
'dist/index.js': `export const name = ${JSON.stringify(packageName)};\n`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('publicNpmPackageLayout', () => {
|
||||
it('uses the first public npm release version for the tarball name', () => {
|
||||
const layout = publicNpmPackageLayout('/repo/ktx');
|
||||
|
||||
assert.equal(PUBLIC_NPM_PACKAGE_VERSION, '0.1.0-rc.0');
|
||||
assert.equal(publicNpmPackageTarballName(), 'kaelio-ktx-0.1.0-rc.0.tgz');
|
||||
assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectPublicDependencies', () => {
|
||||
it('unions external runtime dependencies and omits workspace packages', () => {
|
||||
assert.deepEqual(
|
||||
collectPublicDependencies([
|
||||
{
|
||||
name: '@ktx/cli',
|
||||
dependencies: {
|
||||
'@ktx/context': 'workspace:*',
|
||||
commander: '14.0.3',
|
||||
zod: '^4.4.3',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@ktx/context',
|
||||
dependencies: {
|
||||
'@ktx/llm': 'workspace:*',
|
||||
commander: '14.0.3',
|
||||
yaml: '^2.8.2',
|
||||
zod: '^4.1.13',
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
commander: '14.0.3',
|
||||
yaml: '^2.8.2',
|
||||
zod: '^4.4.3',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('fails on incompatible external dependency ranges', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
collectPublicDependencies([
|
||||
{ name: '@ktx/cli', dependencies: { zod: '^4.4.3' } },
|
||||
{ name: '@ktx/context', dependencies: { zod: '^3.25.0' } },
|
||||
]),
|
||||
/Incompatible dependency versions for zod/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicNpmPackageJson', () => {
|
||||
it('does not bundle the removed PostHog connector package', () => {
|
||||
assert.equal(PUBLIC_BUNDLED_WORKSPACE_PACKAGES.includes('@ktx/connector-posthog'), false);
|
||||
});
|
||||
|
||||
it('describes the public @kaelio/ktx binary package', () => {
|
||||
const packageJson = publicNpmPackageJson(
|
||||
{
|
||||
name: '@ktx/cli',
|
||||
version: '0.0.0-private',
|
||||
engines: { node: '>=22.0.0' },
|
||||
bin: { ktx: './dist/bin.js' },
|
||||
main: 'dist/index.js',
|
||||
types: 'dist/index.d.ts',
|
||||
exports: { '.': './dist/index.js', './package.json': './package.json' },
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
{ commander: '14.0.3' },
|
||||
);
|
||||
|
||||
assert.equal(packageJson.name, PUBLIC_NPM_PACKAGE_NAME);
|
||||
assert.equal(packageJson.version, '0.1.0-rc.0');
|
||||
assert.equal(packageJson.private, false);
|
||||
assert.deepEqual(packageJson.bin, { ktx: './dist/bin.js' });
|
||||
assert.deepEqual(packageJson.dependencies, { commander: '14.0.3' });
|
||||
assert.deepEqual(packageJson.bundledDependencies, PUBLIC_BUNDLED_WORKSPACE_PACKAGES);
|
||||
assert.deepEqual(packageJson.files, ['dist', 'assets']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPublicNpmPackageTree', () => {
|
||||
it('copies CLI files, assets, and bundled internal workspace packages', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-public-npm-test-'));
|
||||
try {
|
||||
await writeWorkspaceFixture(root);
|
||||
const layout = publicNpmPackageLayout(root);
|
||||
|
||||
const result = await createPublicNpmPackageTree(layout);
|
||||
|
||||
assert.equal(result.packageJson.name, '@kaelio/ktx');
|
||||
assert.equal(result.packageJson.dependencies.commander, '14.0.3');
|
||||
assert.equal(result.packageJson.dependencies.yaml, '^2.8.2');
|
||||
assert.equal(result.packageJson.dependencies.ai, '^6.0.168');
|
||||
assert.equal(
|
||||
await readFile(join(layout.packRoot, 'assets', 'python', 'manifest.json'), 'utf8'),
|
||||
'{"schemaVersion":1}\n',
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(layout.packRoot, 'node_modules', '@ktx', 'context', 'dist', 'index.js'), 'utf8'),
|
||||
'export const context = true;\n',
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(layout.packRoot, 'node_modules', '@ktx', 'context', 'prompts', 'system.md'), 'utf8'),
|
||||
'prompt\n',
|
||||
);
|
||||
|
||||
const bundledContextJson = JSON.parse(
|
||||
await readFile(join(layout.packRoot, 'node_modules', '@ktx', 'context', 'package.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(bundledContextJson.private, true);
|
||||
assert.deepEqual(bundledContextJson.dependencies, { yaml: '^2.8.2' });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicNpmPackCommand', () => {
|
||||
it('packs the assembled public package with pnpm', () => {
|
||||
const layout = publicNpmPackageLayout('/repo/ktx');
|
||||
|
||||
assert.deepEqual(publicNpmPackCommand(layout), {
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'--config.node-linker=hoisted',
|
||||
'pack',
|
||||
'--out',
|
||||
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz',
|
||||
],
|
||||
cwd: '/repo/ktx/dist/public-npm-package',
|
||||
});
|
||||
});
|
||||
});
|
||||
144
scripts/build-python-runtime-wheel.mjs
Normal file
144
scripts/build-python-runtime-wheel.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
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';
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
export function runtimeWheelLayout(rootDir = scriptRootDir()) {
|
||||
return {
|
||||
rootDir,
|
||||
semanticLayerSourceDir: join(rootDir, 'python', 'ktx-sl', 'semantic_layer'),
|
||||
daemonSourceDir: join(rootDir, 'python', 'ktx-daemon', 'src', 'ktx_daemon'),
|
||||
buildRoot: join(rootDir, 'dist', 'runtime-wheel-src'),
|
||||
outputDir: join(rootDir, 'dist', 'artifacts', 'python'),
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeWheelPyproject() {
|
||||
return `[project]
|
||||
name = "${RUNTIME_WHEEL_DISTRIBUTION_NAME}"
|
||||
version = "${RUNTIME_WHEEL_PACKAGE_VERSION}"
|
||||
description = "Bundled Python runtime payload for the KTX npm package"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = "Apache-2.0"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"lkml>=1.3.7",
|
||||
"numpy>=2.2.6",
|
||||
"orjson>=3.11.4",
|
||||
"pandas>=2.2.3",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic>=2.9.0",
|
||||
"pyyaml>=6",
|
||||
"requests>=2.32.0",
|
||||
"sqlglot>=26",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
local-embeddings = [
|
||||
"sentence-transformers>=5.1.1",
|
||||
"torch>=2.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ktx-daemon = "ktx_daemon.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kaelio/ktx"
|
||||
Repository = "https://github.com/kaelio/ktx"
|
||||
Issues = "https://github.com/kaelio/ktx/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["semantic_layer", "ktx_daemon"]
|
||||
`;
|
||||
}
|
||||
|
||||
export function runtimeWheelReadme() {
|
||||
return `# kaelio-ktx Python runtime
|
||||
|
||||
Bundled Python runtime wheel for KTX.
|
||||
|
||||
This wheel is built from the repository's \`semantic_layer\` and
|
||||
\`ktx_daemon\` source trees for inclusion in the npm package. It is not a
|
||||
separate public PyPI release artifact.
|
||||
`;
|
||||
}
|
||||
|
||||
export async function createRuntimeWheelBuildTree(layout = runtimeWheelLayout()) {
|
||||
await rm(layout.buildRoot, { recursive: true, force: true });
|
||||
await mkdir(layout.buildRoot, { recursive: true });
|
||||
await cp(layout.semanticLayerSourceDir, join(layout.buildRoot, 'semantic_layer'), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(layout.daemonSourceDir, join(layout.buildRoot, 'ktx_daemon'), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(join(layout.buildRoot, 'pyproject.toml'), runtimeWheelPyproject());
|
||||
await writeFile(join(layout.buildRoot, 'README.md'), runtimeWheelReadme());
|
||||
}
|
||||
|
||||
export function runtimeWheelBuildCommand(layout = runtimeWheelLayout()) {
|
||||
return {
|
||||
command: 'uv',
|
||||
args: ['build', '--wheel', '--out-dir', layout.outputDir, layout.buildRoot],
|
||||
cwd: layout.rootDir,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command, args, options) {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
});
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeWheel(layout = runtimeWheelLayout()) {
|
||||
await mkdir(layout.outputDir, { recursive: true });
|
||||
await createRuntimeWheelBuildTree(layout);
|
||||
const command = runtimeWheelBuildCommand(layout);
|
||||
await runCommand(command.command, command.args, { cwd: command.cwd });
|
||||
const pyproject = await readFile(join(layout.buildRoot, 'pyproject.toml'), 'utf8');
|
||||
return {
|
||||
buildRoot: layout.buildRoot,
|
||||
outputDir: layout.outputDir,
|
||||
pyproject,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await buildRuntimeWheel(runtimeWheelLayout());
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
115
scripts/build-python-runtime-wheel.test.mjs
Normal file
115
scripts/build-python-runtime-wheel.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
||||
RUNTIME_WHEEL_PACKAGE_VERSION,
|
||||
createRuntimeWheelBuildTree,
|
||||
runtimeWheelBuildCommand,
|
||||
runtimeWheelLayout,
|
||||
runtimeWheelPyproject,
|
||||
} from './build-python-runtime-wheel.mjs';
|
||||
|
||||
async function writeRuntimeSourceFixture(root) {
|
||||
await mkdir(join(root, 'python', 'ktx-sl', 'semantic_layer'), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(root, 'python', 'ktx-daemon', 'src', 'ktx_daemon'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-sl', 'semantic_layer', '__init__.py'),
|
||||
'SEMANTIC_LAYER_FIXTURE = True\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-daemon', 'src', 'ktx_daemon', '__init__.py'),
|
||||
'KTX_DAEMON_FIXTURE = True\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-daemon', 'src', 'ktx_daemon', '__main__.py'),
|
||||
'def main():\n return 0\n',
|
||||
);
|
||||
}
|
||||
|
||||
describe('runtimeWheelLayout', () => {
|
||||
it('uses stable source, build, and output paths', () => {
|
||||
const layout = runtimeWheelLayout('/repo/ktx');
|
||||
|
||||
assert.equal(layout.rootDir, '/repo/ktx');
|
||||
assert.equal(layout.semanticLayerSourceDir, '/repo/ktx/python/ktx-sl/semantic_layer');
|
||||
assert.equal(layout.daemonSourceDir, '/repo/ktx/python/ktx-daemon/src/ktx_daemon');
|
||||
assert.equal(layout.buildRoot, '/repo/ktx/dist/runtime-wheel-src');
|
||||
assert.equal(layout.outputDir, '/repo/ktx/dist/artifacts/python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeWheelPyproject', () => {
|
||||
it('describes one kaelio-ktx wheel with lazy local embeddings', () => {
|
||||
const pyproject = runtimeWheelPyproject();
|
||||
|
||||
assert.match(pyproject, /name = "kaelio-ktx"/);
|
||||
assert.match(pyproject, /version = "0\.1\.0"/);
|
||||
assert.match(pyproject, /ktx-daemon = "ktx_daemon\.__main__:main"/);
|
||||
assert.match(pyproject, /packages = \["semantic_layer", "ktx_daemon"\]/);
|
||||
assert.match(pyproject, /\[project\.optional-dependencies\]/);
|
||||
assert.match(pyproject, /local-embeddings = \[/);
|
||||
assert.match(pyproject, /"sentence-transformers>=5\.1\.1"/);
|
||||
assert.match(pyproject, /"torch>=2\.2\.0"/);
|
||||
assert.doesNotMatch(
|
||||
pyproject.match(/dependencies = \[[\s\S]*?\]/)?.[0] ?? '',
|
||||
/sentence-transformers|torch/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRuntimeWheelBuildTree', () => {
|
||||
it('copies KTX-owned Python packages into the build tree', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-wheel-test-'));
|
||||
try {
|
||||
await writeRuntimeSourceFixture(root);
|
||||
const layout = runtimeWheelLayout(root);
|
||||
|
||||
await createRuntimeWheelBuildTree(layout);
|
||||
|
||||
assert.equal(
|
||||
await readFile(join(layout.buildRoot, 'semantic_layer', '__init__.py'), 'utf8'),
|
||||
'SEMANTIC_LAYER_FIXTURE = True\n',
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(layout.buildRoot, 'ktx_daemon', '__main__.py'), 'utf8'),
|
||||
'def main():\n return 0\n',
|
||||
);
|
||||
const pyproject = await readFile(join(layout.buildRoot, 'pyproject.toml'), 'utf8');
|
||||
assert.match(pyproject, /name = "kaelio-ktx"/);
|
||||
assert.match(pyproject, /local-embeddings = \[/);
|
||||
const readme = await readFile(join(layout.buildRoot, 'README.md'), 'utf8');
|
||||
assert.match(readme, /Bundled Python runtime wheel for KTX/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeWheelBuildCommand', () => {
|
||||
it('runs uv build against the generated build tree', () => {
|
||||
const layout = runtimeWheelLayout('/repo/ktx');
|
||||
|
||||
assert.deepEqual(runtimeWheelBuildCommand(layout), {
|
||||
command: 'uv',
|
||||
args: [
|
||||
'build',
|
||||
'--wheel',
|
||||
'--out-dir',
|
||||
'/repo/ktx/dist/artifacts/python',
|
||||
'/repo/ktx/dist/runtime-wheel-src',
|
||||
],
|
||||
cwd: '/repo/ktx',
|
||||
});
|
||||
assert.equal(RUNTIME_WHEEL_DISTRIBUTION_NAME, 'kaelio-ktx');
|
||||
assert.equal(RUNTIME_WHEEL_PACKAGE_VERSION, '0.1.0');
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|||
const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
|
||||
const runtimeAssetPatterns = [/^packages\/[^/]+\/prompts\/.+\.md$/, /^packages\/[^/]+\/skills\/.+\.md$/];
|
||||
const identifierSkipPrefixes = ['docs/', '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$/,
|
||||
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
|
||||
];
|
||||
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];
|
||||
|
||||
const appImportPatterns = [
|
||||
|
|
@ -98,6 +102,10 @@ function skipsIdentifierScan(relativePath) {
|
|||
return identifierSkipPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function allowsForbiddenIdentifier(relativePath) {
|
||||
return identifierAllowPatterns.some((pattern) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
export function scanFileContent(relativePath, content) {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
const violations = [];
|
||||
|
|
@ -138,7 +146,11 @@ export function scanFileContent(relativePath, content) {
|
|||
}
|
||||
}
|
||||
|
||||
if (scansForForbiddenIdentifiers(normalizedPath) && !skipsIdentifierScan(normalizedPath)) {
|
||||
if (
|
||||
scansForForbiddenIdentifiers(normalizedPath) &&
|
||||
!skipsIdentifierScan(normalizedPath) &&
|
||||
!allowsForbiddenIdentifier(normalizedPath)
|
||||
) {
|
||||
for (const term of forbiddenIdentifierTerms) {
|
||||
if (content.includes(term)) {
|
||||
violations.push({
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ describe('scanFileContent', () => {
|
|||
assert.equal(scanFileContent('python/ktx-sl/openspec/specs/semantic-layer/spec.md', name).length, 0);
|
||||
});
|
||||
|
||||
it('allows public package identifiers in release packaging and managed runtime source', () => {
|
||||
const name = lowerProductName();
|
||||
|
||||
assert.equal(scanFileContent('scripts/local-embeddings-runtime-smoke.mjs', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0);
|
||||
assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0);
|
||||
});
|
||||
|
||||
it('allows clean source files and clean runtime prompt assets', () => {
|
||||
assert.deepEqual(
|
||||
scanFileContent('packages/context/src/index.ts', "export const packageName = '@ktx/context';"),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,26 @@ async function readText(relativePath) {
|
|||
return readFile(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
function publicNpmPackageName() {
|
||||
return `@${['kae', 'lio'].join('')}/ktx`;
|
||||
}
|
||||
|
||||
function runtimeWheelPackageName() {
|
||||
return `${['kae', 'lio'].join('')}-ktx`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function publicPackagePattern(text) {
|
||||
return new RegExp(text.replaceAll('{package}', escapeRegExp(publicNpmPackageName())));
|
||||
}
|
||||
|
||||
function runtimeWheelPackagePattern(text) {
|
||||
return new RegExp(text.replaceAll('{package}', escapeRegExp(runtimeWheelPackageName())));
|
||||
}
|
||||
|
||||
describe('standalone example docs', () => {
|
||||
it('documents the local warehouse example from the examples index', async () => {
|
||||
const examples = await readText('examples/README.md');
|
||||
|
|
@ -63,6 +83,16 @@ describe('standalone example docs', () => {
|
|||
assert.match(workload, /app_user/);
|
||||
assert.match(workload, /etl_user/);
|
||||
assert.match(smoke, /pg_stat_statements_reset/);
|
||||
assert.match(smoke, /KTX_RUNTIME_ROOT/);
|
||||
assert.match(smoke, /managedDaemon/);
|
||||
assert.match(smoke, /installPolicy: 'auto'/);
|
||||
assert.match(smoke, /getKtxCliPackageInfo/);
|
||||
assert.doesNotMatch(smoke, /python-service/);
|
||||
assert.doesNotMatch(smoke, /PYTHON_SERVICE/);
|
||||
assert.doesNotMatch(smoke, /uvicorn app\.main:app/);
|
||||
assert.doesNotMatch(smoke, /export KTX_SQL_ANALYSIS_URL/);
|
||||
assert.doesNotMatch(readme, /python-service/);
|
||||
assert.doesNotMatch(readme, /KTX_SQL_ANALYSIS_URL/);
|
||||
assert.match(smoke, /assert_manifest "\$FIRST_MANIFEST" true/);
|
||||
assert.match(smoke, /assert_manifest "\$SECOND_MANIFEST" false/);
|
||||
assert.match(smoke, /assert_manifest "\$RESET_MANIFEST" true/);
|
||||
|
|
@ -113,6 +143,60 @@ describe('standalone example docs', () => {
|
|||
assert.match(rootReadme, /Tables: 1/);
|
||||
});
|
||||
|
||||
it('documents public npm and managed runtime usage in the README', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
assert.match(rootReadme, publicPackagePattern('npx {package} setup demo --no-input'));
|
||||
assert.match(rootReadme, publicPackagePattern('npx {package} sl query'));
|
||||
assert.match(rootReadme, publicPackagePattern('npm install {package}'));
|
||||
assert.match(rootReadme, publicPackagePattern('npm install -g {package}'));
|
||||
assert.match(rootReadme, /ktx runtime install/);
|
||||
assert.match(rootReadme, /ktx runtime status/);
|
||||
assert.match(rootReadme, /ktx runtime doctor/);
|
||||
assert.match(rootReadme, /ktx runtime start/);
|
||||
assert.match(rootReadme, /ktx runtime stop/);
|
||||
assert.match(rootReadme, /ktx runtime prune --dry-run/);
|
||||
assert.match(rootReadme, /ktx runtime prune --yes/);
|
||||
assert.match(rootReadme, /KTX requires `uv` on `PATH`/);
|
||||
assert.match(rootReadme, /KTX doesn't download `uv` automatically/);
|
||||
assert.match(
|
||||
rootReadme,
|
||||
runtimeWheelPackagePattern(
|
||||
'release\\s+artifact manifest contains the public npm tarball and the\\s+bundled `{package}`\\s+runtime wheel',
|
||||
),
|
||||
);
|
||||
assert.match(rootReadme, /source packages for\s+development, not public release artifacts/);
|
||||
assert.match(rootReadme, /ktx serve --mcp stdio/);
|
||||
assert.doesNotMatch(rootReadme, /uv run ktx-daemon serve-http/);
|
||||
assert.doesNotMatch(rootReadme, /--semantic-compute-url http:\/\/127\.0\.0\.1:8765/);
|
||||
});
|
||||
|
||||
it('documents the public package artifact smoke shape', async () => {
|
||||
const readme = await readText('examples/package-artifacts/README.md');
|
||||
|
||||
assert.match(readme, publicPackagePattern('{package}'));
|
||||
assert.match(readme, /managed Python runtime/);
|
||||
assert.match(
|
||||
readme,
|
||||
new RegExp(
|
||||
`public \`${escapeRegExp(publicNpmPackageName())}\` npm tarball and the\\s+bundled \`${escapeRegExp(
|
||||
runtimeWheelPackageName(),
|
||||
)}\`\\s+runtime wheel`,
|
||||
),
|
||||
);
|
||||
assert.match(readme, /does not install standalone\s+Python packages directly/);
|
||||
assert.doesNotMatch(readme, /standalone Python distributions/);
|
||||
assert.doesNotMatch(readme, /installs the Python artifacts directly/);
|
||||
assert.match(readme, /requires `uv` on `PATH`/);
|
||||
assert.match(readme, /ktx runtime status/);
|
||||
assert.match(readme, /ktx runtime doctor/);
|
||||
assert.match(readme, /ktx runtime prune --dry-run/);
|
||||
assert.match(readme, /ktx runtime prune --yes/);
|
||||
assert.doesNotMatch(readme, /@ktx\/context/);
|
||||
assert.doesNotMatch(readme, /@ktx\/cli/);
|
||||
assert.doesNotMatch(readme, /python -m ktx_daemon semantic-validate/);
|
||||
});
|
||||
|
||||
it('replaces the fake-ingest smoke with a ktx scan walkthrough in the README', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
findPythonArtifacts,
|
||||
npmSmokePackageJson,
|
||||
npmSmokePythonEnv,
|
||||
packageArtifactLayout,
|
||||
pythonArtifactInstallArgs,
|
||||
} from './package-artifacts.mjs';
|
||||
|
||||
const POSTGRES_IMAGE = process.env.KTX_ARTIFACT_POSTGRES_IMAGE ?? 'postgres:16-alpine';
|
||||
|
|
@ -238,93 +234,37 @@ async function seedPostgres(containerName) {
|
|||
requireSuccess('seed postgres catalog', result);
|
||||
}
|
||||
|
||||
function httpGetOk(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { method: 'GET' }, (response) => {
|
||||
response.resume();
|
||||
response.on('end', () => resolve((response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300));
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function spawnLogged(command, args, options = {}) {
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let spawnError;
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
function managedRuntimeEnv(cleanInstallDir) {
|
||||
return {
|
||||
child,
|
||||
error() {
|
||||
return spawnError;
|
||||
},
|
||||
output() {
|
||||
return {
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
};
|
||||
},
|
||||
...process.env,
|
||||
KTX_RUNTIME_ROOT: join(cleanInstallDir, 'managed-runtime'),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHttpHealth(url, daemon) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (daemon.error()) {
|
||||
const output = daemon.output();
|
||||
throw new Error(
|
||||
`Failed to start ktx-daemon: ${daemon.error().message}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`,
|
||||
);
|
||||
}
|
||||
if (daemon.child.exitCode !== null || daemon.child.signalCode !== null) {
|
||||
const output = daemon.output();
|
||||
throw new Error(`ktx-daemon exited before health check passed\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`);
|
||||
}
|
||||
try {
|
||||
if (await httpGetOk(url)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
function parseDaemonBaseUrl(stdout) {
|
||||
const match = stdout.match(/^url: (http:\/\/127\.0\.0\.1:\d+)$/m);
|
||||
if (!match) {
|
||||
throw new Error(`Daemon URL was not printed by runtime start:\n${stdout}`);
|
||||
}
|
||||
const output = daemon.output();
|
||||
throw new Error(`Timed out waiting for ${url}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function startDaemon(port, cleanInstallDir) {
|
||||
const daemon = spawnLogged(
|
||||
'ktx-daemon',
|
||||
['serve-http', '--host', '127.0.0.1', '--port', String(port), '--log-level', 'warning'],
|
||||
{ cwd: cleanInstallDir, env: npmSmokePythonEnv(cleanInstallDir) },
|
||||
);
|
||||
await waitForHttpHealth(`http://127.0.0.1:${port}/health`, daemon);
|
||||
return daemon;
|
||||
async function startDaemon(cleanInstallDir) {
|
||||
const result = await run('pnpm', ['exec', 'ktx', 'runtime', 'start'], {
|
||||
cwd: cleanInstallDir,
|
||||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 120_000,
|
||||
});
|
||||
requireSuccess('ktx runtime start', result);
|
||||
return parseDaemonBaseUrl(result.stdout);
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon) {
|
||||
if (daemon.child.exitCode !== null || daemon.child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
daemon.child.kill('SIGTERM');
|
||||
const closed = once(daemon.child, 'close').then(() => true);
|
||||
const timedOut = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
|
||||
if (!(await Promise.race([closed, timedOut]))) {
|
||||
daemon.child.kill('SIGKILL');
|
||||
await once(daemon.child, 'close');
|
||||
}
|
||||
async function stopDaemon(cleanInstallDir) {
|
||||
await run('pnpm', ['exec', 'ktx', 'runtime', 'stop'], {
|
||||
cwd: cleanInstallDir,
|
||||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertPathExists(path, label) {
|
||||
|
|
@ -336,7 +276,6 @@ async function assertPathExists(path, label) {
|
|||
}
|
||||
|
||||
async function prepareCleanInstall(layout, cleanInstallDir) {
|
||||
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
||||
await assertPathExists(layout.contextTarball, '@ktx/context tarball');
|
||||
await assertPathExists(layout.cliTarball, '@ktx/cli tarball');
|
||||
await mkdir(cleanInstallDir, { recursive: true });
|
||||
|
|
@ -344,34 +283,24 @@ async function prepareCleanInstall(layout, cleanInstallDir) {
|
|||
await run('pnpm', ['install'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
|
||||
requireSuccess('pnpm install clean artifact project', result),
|
||||
);
|
||||
await run('uv', ['venv', '.venv'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
|
||||
requireSuccess('uv venv clean artifact project', result),
|
||||
);
|
||||
await run(
|
||||
'uv',
|
||||
pythonArtifactInstallArgs(
|
||||
join(cleanInstallDir, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'),
|
||||
pythonArtifacts,
|
||||
),
|
||||
{
|
||||
cwd: cleanInstallDir,
|
||||
timeout: 120_000,
|
||||
},
|
||||
).then((result) => requireSuccess('install Python artifacts', result));
|
||||
await run('pnpm', ['exec', 'ktx', 'runtime', 'install', '--yes'], {
|
||||
cwd: cleanInstallDir,
|
||||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 120_000,
|
||||
}).then((result) => requireSuccess('install managed runtime', result));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const layout = packageArtifactLayout();
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-live-db-artifact-smoke-'));
|
||||
const containerName = smokeContainerName();
|
||||
let daemon;
|
||||
let cleanInstallDir;
|
||||
let daemonStarted = false;
|
||||
try {
|
||||
const postgresPort = await getAvailablePort();
|
||||
const daemonPort = await getAvailablePort();
|
||||
const postgresUrl = buildPostgresUrl(postgresPort);
|
||||
const cleanInstallDir = join(root, 'npm-clean-install');
|
||||
cleanInstallDir = join(root, 'npm-clean-install');
|
||||
const projectDir = join(root, 'project');
|
||||
const databaseIntrospectionUrl = `http://127.0.0.1:${daemonPort}`;
|
||||
|
||||
await startPostgresContainer(containerName, postgresPort);
|
||||
await waitForPostgres(containerName);
|
||||
|
|
@ -386,11 +315,12 @@ async function main() {
|
|||
requireSuccess('ktx init', init);
|
||||
await writeFile(join(projectDir, 'ktx.yaml'), buildKtxYaml(postgresUrl), 'utf8');
|
||||
|
||||
daemon = await startDaemon(daemonPort, cleanInstallDir);
|
||||
const databaseIntrospectionUrl = await startDaemon(cleanInstallDir);
|
||||
daemonStarted = true;
|
||||
|
||||
const ingestRun = await run('pnpm', buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl), {
|
||||
cwd: cleanInstallDir,
|
||||
env: npmSmokePythonEnv(cleanInstallDir),
|
||||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 120_000,
|
||||
});
|
||||
requireSuccess('ktx dev ingest run live-database', ingestRun);
|
||||
|
|
@ -403,7 +333,7 @@ async function main() {
|
|||
const runId = getRunId(ingestRun.stdout);
|
||||
const ingestStatus = await run('pnpm', buildLiveDatabaseStatusArgs(projectDir, runId), {
|
||||
cwd: cleanInstallDir,
|
||||
env: npmSmokePythonEnv(cleanInstallDir),
|
||||
env: managedRuntimeEnv(cleanInstallDir),
|
||||
timeout: 30_000,
|
||||
});
|
||||
requireSuccess('ktx ingest status live-database', ingestStatus);
|
||||
|
|
@ -414,8 +344,8 @@ async function main() {
|
|||
await assertPathExists(join(projectDir, '.ktx', 'db.sqlite'), 'SQLite local ingest state');
|
||||
process.stdout.write(`Installed live-database artifact smoke passed: ${runId}\n`);
|
||||
} finally {
|
||||
if (daemon) {
|
||||
await stopDaemon(daemon);
|
||||
if (daemonStarted && cleanInstallDir) {
|
||||
await stopDaemon(cleanInstallDir);
|
||||
}
|
||||
await stopPostgresContainer(containerName);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
|
|||
397
scripts/local-embeddings-runtime-smoke.mjs
Normal file
397
scripts/local-embeddings-runtime-smoke.mjs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
PUBLIC_NPM_PACKAGE_VERSION,
|
||||
} from './build-public-npm-package.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_ROOT_DIR = resolve(SCRIPT_DIR, '..');
|
||||
const PUBLIC_NPM_ARTIFACT_DIR = join('dist', 'artifacts', 'npm');
|
||||
const OPT_IN_MESSAGE =
|
||||
'Set KTX_RUN_LOCAL_EMBEDDINGS_SMOKE=1 or pass --force to run the local embeddings smoke.';
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function expectedPublicKtxVersionPattern() {
|
||||
return new RegExp(
|
||||
`${escapeRegExp(PUBLIC_NPM_PACKAGE_NAME)} ${escapeRegExp(PUBLIC_NPM_PACKAGE_VERSION)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localEmbeddingsSmokeOptIn(env = process.env, args = process.argv.slice(2)) {
|
||||
if (env.KTX_RUN_LOCAL_EMBEDDINGS_SMOKE === '1' || args.includes('--force')) {
|
||||
return { run: true };
|
||||
}
|
||||
return { run: false, message: OPT_IN_MESSAGE };
|
||||
}
|
||||
|
||||
export function publicKtxTarballName(files) {
|
||||
const matches = files.filter((file) => /^kaelio-ktx-.+\.tgz$/.test(file)).sort();
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one @kaelio/ktx tarball in ${PUBLIC_NPM_ARTIFACT_DIR}, found ${matches.length}: ${
|
||||
matches.join(', ') || 'none'
|
||||
}. Run pnpm run artifacts:build first.`,
|
||||
);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
export async function selectPublicKtxTarball(rootDir = DEFAULT_ROOT_DIR) {
|
||||
const npmArtifactDir = join(rootDir, PUBLIC_NPM_ARTIFACT_DIR);
|
||||
const files = await readdir(npmArtifactDir);
|
||||
return join(npmArtifactDir, publicKtxTarballName(files));
|
||||
}
|
||||
|
||||
export function buildLocalEmbeddingsSmokeEnv(root, baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
KTX_RUN_LOCAL_EMBEDDINGS_SMOKE: '1',
|
||||
KTX_RUNTIME_ROOT: join(root, 'managed-runtime'),
|
||||
HF_HOME: join(root, 'hf-home'),
|
||||
TRANSFORMERS_CACHE: join(root, 'transformers-cache'),
|
||||
SENTENCE_TRANSFORMERS_HOME: join(root, 'sentence-transformers-home'),
|
||||
TORCH_HOME: join(root, 'torch-home'),
|
||||
};
|
||||
}
|
||||
|
||||
export function localEmbeddingsSmokeCommands(input) {
|
||||
return [
|
||||
{
|
||||
label: 'ktx public package version',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', '--version'],
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx runtime status missing',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'status', '--json'],
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx runtime install local embeddings',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'install', '--feature', 'local-embeddings', '--yes'],
|
||||
timeoutMs: 1_200_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx runtime status local embeddings ready',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'status', '--json'],
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx runtime start local embeddings',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'start', '--feature', 'local-embeddings'],
|
||||
timeoutMs: 300_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx setup local embeddings',
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'exec',
|
||||
'ktx',
|
||||
'setup',
|
||||
'--project-dir',
|
||||
input.projectDir,
|
||||
'--new',
|
||||
'--no-input',
|
||||
'--yes',
|
||||
'--skip-llm',
|
||||
'--embedding-backend',
|
||||
'sentence-transformers',
|
||||
'--skip-databases',
|
||||
'--skip-sources',
|
||||
'--skip-agents',
|
||||
],
|
||||
timeoutMs: 900_000,
|
||||
},
|
||||
{
|
||||
label: 'ktx runtime stop local embeddings',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'stop'],
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function parseDaemonBaseUrl(stdout) {
|
||||
const match = stdout.match(/^url: (http:\/\/127\.0\.0\.1:\d+)$/m);
|
||||
if (!match) {
|
||||
throw new Error(`Daemon URL was not printed by runtime start:\n${stdout}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function validateEmbeddingResponse(raw, expectedDimensions) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('Embedding response must be a JSON object');
|
||||
}
|
||||
const embedding = raw.embedding;
|
||||
if (!Array.isArray(embedding)) {
|
||||
throw new Error('Embedding response must include an embedding array');
|
||||
}
|
||||
if (embedding.length !== expectedDimensions) {
|
||||
throw new Error(`Expected embedding dimension ${expectedDimensions}, got ${embedding.length}`);
|
||||
}
|
||||
for (const [index, value] of embedding.entries()) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`Embedding value at index ${index} is not a finite number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run(command, args, options = {}) {
|
||||
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...options.env },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
timeout: options.timeoutMs ?? 120_000,
|
||||
});
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (error) {
|
||||
const stdout = typeof error.stdout === 'string' ? error.stdout : '';
|
||||
const stderr = typeof error.stderr === 'string' ? error.stderr : error.message;
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
return {
|
||||
code: typeof error.code === 'number' ? error.code : 1,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function requireSuccess(label, result, options = {}) {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${label} failed with code ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
if (options.stderrPattern && !options.stderrPattern.test(result.stderr)) {
|
||||
throw new Error(`${label} stderr did not match ${options.stderrPattern}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonStdout(label, result) {
|
||||
requireSuccess(label, result);
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} did not write JSON stdout: ${error.message}\nstdout:\n${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireOutput(label, result, pattern) {
|
||||
if (!pattern.test(result.stdout)) {
|
||||
throw new Error(`${label} stdout did not match ${pattern}\nstdout:\n${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(baseUrl, path, payload, timeoutMs) {
|
||||
const response = await fetch(new URL(path, baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`POST ${path} failed with ${response.status}: ${text}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`POST ${path} returned non-JSON response: ${error.message}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSmokePackage(projectDir, tarballPath) {
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(projectDir, 'package.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: 'ktx-local-embeddings-runtime-smoke',
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
'@kaelio/ktx': `file:${tarballPath}`,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runLocalEmbeddingsRuntimeSmoke(options = {}) {
|
||||
const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR;
|
||||
const tarballPath = options.tarballPath ?? (await selectPublicKtxTarball(rootDir));
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-local-embeddings-smoke-'));
|
||||
const keepTemp = options.keepTemp ?? process.env.KTX_KEEP_LOCAL_EMBEDDINGS_SMOKE === '1';
|
||||
const installDir = join(root, 'installed-package');
|
||||
const projectDir = join(root, 'project');
|
||||
const smokeEnv = buildLocalEmbeddingsSmokeEnv(root);
|
||||
const commands = localEmbeddingsSmokeCommands({ projectDir });
|
||||
let daemonStarted = false;
|
||||
|
||||
try {
|
||||
await writeSmokePackage(installDir, tarballPath);
|
||||
requireSuccess(
|
||||
'pnpm install public package',
|
||||
await run('pnpm', ['install', '--ignore-scripts=false'], {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const version = await run(commands[0].command, commands[0].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[0].timeoutMs,
|
||||
});
|
||||
requireSuccess(commands[0].label, version);
|
||||
requireOutput(commands[0].label, version, expectedPublicKtxVersionPattern());
|
||||
|
||||
const missingStatus = parseJsonStdout(
|
||||
commands[1].label,
|
||||
await run(commands[1].command, commands[1].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[1].timeoutMs,
|
||||
}),
|
||||
);
|
||||
if (missingStatus.kind !== 'missing') {
|
||||
throw new Error(`Expected missing runtime before install, got ${JSON.stringify(missingStatus)}`);
|
||||
}
|
||||
|
||||
const install = await run(commands[2].command, commands[2].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[2].timeoutMs,
|
||||
});
|
||||
requireSuccess(commands[2].label, install);
|
||||
requireOutput(commands[2].label, install, /Installed KTX Python runtime/);
|
||||
requireOutput(commands[2].label, install, /features: core, local-embeddings/);
|
||||
|
||||
const readyStatus = parseJsonStdout(
|
||||
commands[3].label,
|
||||
await run(commands[3].command, commands[3].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[3].timeoutMs,
|
||||
}),
|
||||
);
|
||||
if (readyStatus.kind !== 'ready') {
|
||||
throw new Error(`Expected ready runtime after install, got ${JSON.stringify(readyStatus)}`);
|
||||
}
|
||||
if (!readyStatus.manifest?.features?.includes('local-embeddings')) {
|
||||
throw new Error(`Runtime manifest did not include local-embeddings: ${JSON.stringify(readyStatus.manifest)}`);
|
||||
}
|
||||
|
||||
const start = await run(commands[4].command, commands[4].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[4].timeoutMs,
|
||||
});
|
||||
requireSuccess(commands[4].label, start);
|
||||
daemonStarted = true;
|
||||
const baseUrl = parseDaemonBaseUrl(start.stdout);
|
||||
|
||||
const embeddingResponse = await postJson(
|
||||
baseUrl,
|
||||
'/embeddings/compute',
|
||||
{ text: 'KTX local embeddings release smoke' },
|
||||
900_000,
|
||||
);
|
||||
validateEmbeddingResponse(embeddingResponse, 384);
|
||||
process.stdout.write('KTX local embeddings daemon computed a 384-dimensional embedding\n');
|
||||
|
||||
const setup = await run(commands[5].command, commands[5].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[5].timeoutMs,
|
||||
});
|
||||
requireSuccess(commands[5].label, setup);
|
||||
requireOutput(commands[5].label, setup, /Embeddings ready: yes \(all-MiniLM-L6-v2\)/);
|
||||
|
||||
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf8');
|
||||
if (!config.includes('base_url: managed:local-embeddings')) {
|
||||
throw new Error(`ktx.yaml did not contain managed local embeddings marker:\n${config}`);
|
||||
}
|
||||
process.stdout.write('KTX setup persisted managed local embeddings marker\n');
|
||||
|
||||
const stop = await run(commands[6].command, commands[6].args, {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: commands[6].timeoutMs,
|
||||
});
|
||||
requireSuccess(commands[6].label, stop);
|
||||
daemonStarted = false;
|
||||
requireOutput(commands[6].label, stop, /Stopped KTX Python daemon/);
|
||||
|
||||
process.stdout.write('KTX local embeddings runtime smoke verified\n');
|
||||
} finally {
|
||||
if (daemonStarted) {
|
||||
await run('pnpm', ['exec', 'ktx', 'runtime', 'stop'], {
|
||||
cwd: installDir,
|
||||
env: smokeEnv,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
}
|
||||
if (!keepTemp) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
} else {
|
||||
process.stdout.write(`Kept local embeddings smoke root: ${root}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const optIn = localEmbeddingsSmokeOptIn(process.env, args);
|
||||
if (!optIn.run) {
|
||||
process.stdout.write(`Skipping KTX local embeddings runtime smoke. ${optIn.message}\n`);
|
||||
if (args.includes('--require-opt-in')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await runLocalEmbeddingsRuntimeSmoke();
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
172
scripts/local-embeddings-runtime-smoke.test.mjs
Normal file
172
scripts/local-embeddings-runtime-smoke.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildLocalEmbeddingsSmokeEnv,
|
||||
expectedPublicKtxVersionPattern,
|
||||
localEmbeddingsSmokeCommands,
|
||||
localEmbeddingsSmokeOptIn,
|
||||
parseDaemonBaseUrl,
|
||||
publicKtxTarballName,
|
||||
validateEmbeddingResponse,
|
||||
} from './local-embeddings-runtime-smoke.mjs';
|
||||
|
||||
describe('localEmbeddingsSmokeOptIn', () => {
|
||||
it('skips unless the smoke is explicitly enabled', () => {
|
||||
assert.deepEqual(localEmbeddingsSmokeOptIn({}, []), {
|
||||
run: false,
|
||||
message: 'Set KTX_RUN_LOCAL_EMBEDDINGS_SMOKE=1 or pass --force to run the local embeddings smoke.',
|
||||
});
|
||||
});
|
||||
|
||||
it('runs when the environment opt-in is set', () => {
|
||||
assert.deepEqual(localEmbeddingsSmokeOptIn({ KTX_RUN_LOCAL_EMBEDDINGS_SMOKE: '1' }, []), {
|
||||
run: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('runs when --force is present', () => {
|
||||
assert.deepEqual(localEmbeddingsSmokeOptIn({}, ['--force']), {
|
||||
run: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicKtxTarballName', () => {
|
||||
it('selects the public @kaelio/ktx tarball name', () => {
|
||||
assert.equal(
|
||||
publicKtxTarballName(['kaelio-ktx-0.1.0-rc.0.tgz', 'ignore-me.tgz']),
|
||||
'kaelio-ktx-0.1.0-rc.0.tgz',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when the public package tarball is missing', () => {
|
||||
assert.throws(
|
||||
() => publicKtxTarballName(['ktx-cli-0.0.0-private.tgz']),
|
||||
/Expected exactly one @kaelio\/ktx tarball/,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when multiple public package tarballs are present', () => {
|
||||
assert.throws(
|
||||
() => publicKtxTarballName(['kaelio-ktx-0.1.0-rc.0.tgz', 'kaelio-ktx-0.2.0.tgz']),
|
||||
/Expected exactly one @kaelio\/ktx tarball/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectedPublicKtxVersionPattern', () => {
|
||||
it('matches the public package version and rejects the private workspace version', () => {
|
||||
const pattern = expectedPublicKtxVersionPattern();
|
||||
|
||||
assert.match('@kaelio/ktx 0.1.0-rc.0\n', pattern);
|
||||
assert.doesNotMatch('@kaelio/ktx 0.0.0-private\n', pattern);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocalEmbeddingsSmokeEnv', () => {
|
||||
it('isolates the runtime root and model caches inside the smoke root', () => {
|
||||
const env = buildLocalEmbeddingsSmokeEnv('/tmp/ktx-local-embedding-smoke', {
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
|
||||
assert.equal(env.PATH, '/usr/bin');
|
||||
assert.equal(env.KTX_RUN_LOCAL_EMBEDDINGS_SMOKE, '1');
|
||||
assert.equal(env.KTX_RUNTIME_ROOT, '/tmp/ktx-local-embedding-smoke/managed-runtime');
|
||||
assert.equal(env.HF_HOME, '/tmp/ktx-local-embedding-smoke/hf-home');
|
||||
assert.equal(env.TRANSFORMERS_CACHE, '/tmp/ktx-local-embedding-smoke/transformers-cache');
|
||||
assert.equal(env.SENTENCE_TRANSFORMERS_HOME, '/tmp/ktx-local-embedding-smoke/sentence-transformers-home');
|
||||
assert.equal(env.TORCH_HOME, '/tmp/ktx-local-embedding-smoke/torch-home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localEmbeddingsSmokeCommands', () => {
|
||||
it('describes the installed-package commands needed for the smoke', () => {
|
||||
const commands = localEmbeddingsSmokeCommands({
|
||||
projectDir: '/tmp/ktx-local-embedding-smoke/project',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.map((command) => command.label), [
|
||||
'ktx public package version',
|
||||
'ktx runtime status missing',
|
||||
'ktx runtime install local embeddings',
|
||||
'ktx runtime status local embeddings ready',
|
||||
'ktx runtime start local embeddings',
|
||||
'ktx setup local embeddings',
|
||||
'ktx runtime stop local embeddings',
|
||||
]);
|
||||
assert.deepEqual(commands[2], {
|
||||
label: 'ktx runtime install local embeddings',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'install', '--feature', 'local-embeddings', '--yes'],
|
||||
timeoutMs: 1_200_000,
|
||||
});
|
||||
assert.deepEqual(commands[4], {
|
||||
label: 'ktx runtime start local embeddings',
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'ktx', 'runtime', 'start', '--feature', 'local-embeddings'],
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
assert.deepEqual(commands[5].args, [
|
||||
'exec',
|
||||
'ktx',
|
||||
'setup',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-local-embedding-smoke/project',
|
||||
'--new',
|
||||
'--no-input',
|
||||
'--yes',
|
||||
'--skip-llm',
|
||||
'--embedding-backend',
|
||||
'sentence-transformers',
|
||||
'--skip-databases',
|
||||
'--skip-sources',
|
||||
'--skip-agents',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDaemonBaseUrl', () => {
|
||||
it('extracts the daemon URL from runtime start output', () => {
|
||||
assert.equal(
|
||||
parseDaemonBaseUrl('Started KTX Python daemon\nurl: http://127.0.0.1:61234\nfeatures: local-embeddings\n'),
|
||||
'http://127.0.0.1:61234',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects output without a daemon URL', () => {
|
||||
assert.throws(() => parseDaemonBaseUrl('Started KTX Python daemon\n'), /Daemon URL was not printed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmbeddingResponse', () => {
|
||||
it('accepts a finite embedding vector with the expected dimensions', () => {
|
||||
validateEmbeddingResponse({ embedding: [0.1, -0.2, 0.3] }, 3);
|
||||
});
|
||||
|
||||
it('rejects a vector with the wrong dimensions', () => {
|
||||
assert.throws(
|
||||
() => validateEmbeddingResponse({ embedding: [0.1, 0.2] }, 3),
|
||||
/Expected embedding dimension 3, got 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-finite embedding values', () => {
|
||||
assert.throws(
|
||||
() => validateEmbeddingResponse({ embedding: [0.1, Number.NaN, 0.3] }, 3),
|
||||
/Embedding value at index 1 is not a finite number/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('package script', () => {
|
||||
it('registers the opt-in local embeddings smoke command', async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(
|
||||
packageJson.scripts['release:local-embeddings-smoke'],
|
||||
'node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,19 +6,22 @@ import { join } from 'node:path';
|
|||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
CLI_PYTHON_ASSET_MANIFEST,
|
||||
INTERNAL_NPM_WORKSPACE_PACKAGES,
|
||||
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
||||
RUNTIME_WHEEL_NORMALIZED_NAME,
|
||||
RUNTIME_WHEEL_PACKAGE_VERSION,
|
||||
artifactManifestPath,
|
||||
buildArtifactCommands,
|
||||
copyRuntimeWheelAssets,
|
||||
findPythonArtifacts,
|
||||
NPM_ARTIFACT_PACKAGES,
|
||||
npmDemoSmokeSource,
|
||||
npmRuntimeSmokeSource,
|
||||
npmSmokePackageJson,
|
||||
npmSmokePythonEnv,
|
||||
npmVerifySource,
|
||||
packageArtifactLayout,
|
||||
packageReleaseMetadata,
|
||||
pythonArtifactInstallArgs,
|
||||
pythonVerifySource,
|
||||
verifyArtifactManifest,
|
||||
writeArtifactManifest,
|
||||
} from './package-artifacts.mjs';
|
||||
|
|
@ -29,47 +32,21 @@ async function writeJson(path, value) {
|
|||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const CONNECTOR_PACKAGE_NAMES = [
|
||||
'@ktx/connector-bigquery',
|
||||
'@ktx/connector-clickhouse',
|
||||
'@ktx/connector-mysql',
|
||||
'@ktx/connector-postgres',
|
||||
'@ktx/connector-snowflake',
|
||||
'@ktx/connector-sqlite',
|
||||
'@ktx/connector-sqlserver',
|
||||
];
|
||||
|
||||
function packageRootForName(packageName) {
|
||||
return `packages/${packageName.replace('@ktx/', '')}`;
|
||||
}
|
||||
|
||||
function expectedNpmArtifactPath(packageName) {
|
||||
return `npm/${packageName.replace('@ktx/', 'ktx-')}-0.0.0-private.tgz`;
|
||||
}
|
||||
const INTERNAL_BUILD_PACKAGE_NAMES = INTERNAL_NPM_WORKSPACE_PACKAGES.map((packageInfo) => packageInfo.name);
|
||||
const CONNECTOR_PACKAGE_NAMES = INTERNAL_BUILD_PACKAGE_NAMES.filter((packageName) =>
|
||||
packageName.startsWith('@ktx/connector-'),
|
||||
);
|
||||
const NPM_BUILD_PACKAGE_ORDER = ['@ktx/llm', '@ktx/context', ...CONNECTOR_PACKAGE_NAMES, '@ktx/cli'];
|
||||
|
||||
async function writeReleaseMetadataInputs(root) {
|
||||
const npmPackages = ['@ktx/context', '@ktx/llm', ...CONNECTOR_PACKAGE_NAMES, '@ktx/cli'];
|
||||
|
||||
for (const packageName of npmPackages) {
|
||||
const packageRoot = packageName === '@ktx/context' ? 'packages/context' : packageRootForName(packageName);
|
||||
await mkdir(join(root, packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageRoot, 'package.json'), {
|
||||
name: packageName,
|
||||
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
|
||||
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
|
||||
name: packageInfo.name,
|
||||
version: '0.0.0-private',
|
||||
private: true,
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(join(root, 'python', 'ktx-sl'), { recursive: true });
|
||||
await mkdir(join(root, 'python', 'ktx-daemon'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-sl', 'pyproject.toml'),
|
||||
['[project]', 'name = "ktx-sl"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-daemon', 'pyproject.toml'),
|
||||
['[project]', 'name = "ktx-daemon"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeUploadableArtifactFixtures(layout) {
|
||||
|
|
@ -81,10 +58,10 @@ async function writeUploadableArtifactFixtures(layout) {
|
|||
layout.npmTarballs[packageInfo.name],
|
||||
`${packageInfo.name}-tarball`,
|
||||
]),
|
||||
[join(layout.pythonDir, 'ktx_sl-0.1.0-py3-none-any.whl'), 'ktx-sl-wheel'],
|
||||
[join(layout.pythonDir, 'ktx_sl-0.1.0.tar.gz'), 'ktx-sl-sdist'],
|
||||
[join(layout.pythonDir, 'ktx_daemon-0.1.0-py3-none-any.whl'), 'ktx-daemon-wheel'],
|
||||
[join(layout.pythonDir, 'ktx_daemon-0.1.0.tar.gz'), 'ktx-daemon-sdist'],
|
||||
[
|
||||
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
|
||||
'kaelio-ktx-runtime-wheel',
|
||||
],
|
||||
]);
|
||||
|
||||
for (const [path, contents] of fileContents) {
|
||||
|
|
@ -99,47 +76,30 @@ describe('packageArtifactLayout', () => {
|
|||
assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts');
|
||||
assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm');
|
||||
assert.equal(layout.pythonDir, '/repo/ktx/dist/artifacts/python');
|
||||
assert.equal(layout.contextTarball, '/repo/ktx/dist/artifacts/npm/ktx-context-0.0.0-private.tgz');
|
||||
assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/ktx-cli-0.0.0-private.tgz');
|
||||
assert.equal(
|
||||
layout.connectorTarballs['@ktx/connector-sqlite'],
|
||||
'/repo/ktx/dist/artifacts/npm/ktx-connector-sqlite-0.0.0-private.tgz',
|
||||
);
|
||||
assert.equal(
|
||||
layout.connectorTarballs['@ktx/connector-postgres'],
|
||||
'/repo/ktx/dist/artifacts/npm/ktx-connector-postgres-0.0.0-private.tgz',
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(layout.npmTarballs),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
|
||||
);
|
||||
assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz');
|
||||
assert.deepEqual(Object.keys(layout.npmTarballs), ['@kaelio/ktx']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildArtifactCommands', () => {
|
||||
it('builds all TypeScript packages before packing npm artifacts and builds both Python packages', () => {
|
||||
it('builds TypeScript packages and the runtime wheel before packing npm artifacts', () => {
|
||||
const layout = packageArtifactLayout('/repo/ktx');
|
||||
const commands = buildArtifactCommands(layout);
|
||||
|
||||
assert.deepEqual(
|
||||
commands.slice(0, NPM_ARTIFACT_PACKAGES.length).map((command) => [command.command, command.args]),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ['pnpm', ['--filter', packageInfo.name, 'run', 'build']]),
|
||||
commands.slice(0, NPM_BUILD_PACKAGE_ORDER.length).map((command) => [command.command, command.args]),
|
||||
NPM_BUILD_PACKAGE_ORDER.map((packageName) => ['pnpm', ['--filter', packageName, 'run', 'build']]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands
|
||||
.slice(NPM_ARTIFACT_PACKAGES.length, NPM_ARTIFACT_PACKAGES.length * 2)
|
||||
.map((command) => [command.command, command.args]),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
||||
'pnpm',
|
||||
['--filter', packageInfo.name, 'pack', '--out', layout.npmTarballs[packageInfo.name]],
|
||||
commands.slice(NPM_BUILD_PACKAGE_ORDER.length, NPM_BUILD_PACKAGE_ORDER.length + 1).map((command) => [
|
||||
command.command,
|
||||
command.args,
|
||||
]),
|
||||
[[process.execPath, ['scripts/build-python-runtime-wheel.mjs']]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands.slice(NPM_ARTIFACT_PACKAGES.length * 2).map((command) => [command.command, command.args]),
|
||||
[
|
||||
['uv', ['build', '--package', 'ktx-sl', '--out-dir', '/repo/ktx/dist/artifacts/python']],
|
||||
['uv', ['build', '--package', 'ktx-daemon', '--out-dir', '/repo/ktx/dist/artifacts/python']],
|
||||
],
|
||||
commands.slice(NPM_BUILD_PACKAGE_ORDER.length + 1).map((command) => [command.command, command.args]),
|
||||
[[process.execPath, ['scripts/build-public-npm-package.mjs']]],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -151,26 +111,18 @@ describe('packageReleaseMetadata', () => {
|
|||
await writeReleaseMetadataInputs(root);
|
||||
|
||||
assert.deepEqual(await packageReleaseMetadata(root), [
|
||||
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageRoot: packageInfo.packageRoot,
|
||||
packageVersion: '0.0.0-private',
|
||||
private: true,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
})),
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-sl',
|
||||
packageRoot: 'python/ktx-sl',
|
||||
packageVersion: '0.1.0',
|
||||
ecosystem: 'npm',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageRoot: 'packages/cli',
|
||||
packageVersion: '0.1.0-rc.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-daemon',
|
||||
packageRoot: 'python/ktx-daemon',
|
||||
packageName: 'kaelio-ktx',
|
||||
packageRoot: 'python/runtime-wheel',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
|
|
@ -183,19 +135,13 @@ describe('packageReleaseMetadata', () => {
|
|||
});
|
||||
|
||||
describe('findPythonArtifacts', () => {
|
||||
it('finds one wheel and one source distribution for each Python package', async () => {
|
||||
it('finds the bundled runtime wheel only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-test-'));
|
||||
try {
|
||||
await writeFile(join(root, 'ktx_sl-0.1.0-py3-none-any.whl'), '');
|
||||
await writeFile(join(root, 'ktx_sl-0.1.0.tar.gz'), '');
|
||||
await writeFile(join(root, 'ktx_daemon-0.1.0-py3-none-any.whl'), '');
|
||||
await writeFile(join(root, 'ktx_daemon-0.1.0.tar.gz'), '');
|
||||
await writeFile(join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'), '');
|
||||
|
||||
assert.deepEqual(await findPythonArtifacts(root), {
|
||||
ktxSlWheel: join(root, 'ktx_sl-0.1.0-py3-none-any.whl'),
|
||||
ktxSlSdist: join(root, 'ktx_sl-0.1.0.tar.gz'),
|
||||
ktxDaemonWheel: join(root, 'ktx_daemon-0.1.0-py3-none-any.whl'),
|
||||
ktxDaemonSdist: join(root, 'ktx_daemon-0.1.0.tar.gz'),
|
||||
runtimeWheel: join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
@ -205,7 +151,7 @@ describe('findPythonArtifacts', () => {
|
|||
it('throws when a required Python artifact is missing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-test-'));
|
||||
try {
|
||||
await assert.rejects(() => findPythonArtifacts(root), /Missing Python artifact: ktx-sl wheel/);
|
||||
await assert.rejects(() => findPythonArtifacts(root), /Missing Python artifact: kaelio-ktx runtime wheel/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -230,30 +176,24 @@ describe('artifact manifest', () => {
|
|||
assert.equal(manifest.sourceRevision, 'abc123');
|
||||
assert.deepEqual(
|
||||
manifest.packages.filter((entry) => entry.ecosystem === 'npm'),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageRoot: packageInfo.packageRoot,
|
||||
packageVersion: '0.0.0-private',
|
||||
private: true,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
})),
|
||||
[
|
||||
{
|
||||
ecosystem: 'npm',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageRoot: 'packages/cli',
|
||||
packageVersion: '0.1.0-rc.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.packages.filter((entry) => entry.ecosystem === 'python'),
|
||||
[
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-sl',
|
||||
packageRoot: 'python/ktx-sl',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
},
|
||||
{
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-daemon',
|
||||
packageRoot: 'python/ktx-daemon',
|
||||
packageName: 'kaelio-ktx',
|
||||
packageRoot: 'python/runtime-wheel',
|
||||
packageVersion: '0.1.0',
|
||||
private: false,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
|
|
@ -271,13 +211,15 @@ describe('artifact manifest', () => {
|
|||
path: file.path,
|
||||
}))
|
||||
.sort((left, right) => left.packageName.localeCompare(right.packageName)),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
||||
artifactKind: 'tarball',
|
||||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageVersion: '0.0.0-private',
|
||||
path: expectedNpmArtifactPath(packageInfo.name),
|
||||
})).sort((left, right) => left.packageName.localeCompare(right.packageName)),
|
||||
[
|
||||
{
|
||||
artifactKind: 'tarball',
|
||||
ecosystem: 'npm',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageVersion: '0.1.0-rc.0',
|
||||
path: 'npm/kaelio-ktx-0.1.0-rc.0.tgz',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.files
|
||||
|
|
@ -293,38 +235,17 @@ describe('artifact manifest', () => {
|
|||
{
|
||||
artifactKind: 'wheel',
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-daemon',
|
||||
packageName: 'kaelio-ktx',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/ktx_daemon-0.1.0-py3-none-any.whl',
|
||||
},
|
||||
{
|
||||
artifactKind: 'sdist',
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-daemon',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/ktx_daemon-0.1.0.tar.gz',
|
||||
},
|
||||
{
|
||||
artifactKind: 'wheel',
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-sl',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/ktx_sl-0.1.0-py3-none-any.whl',
|
||||
},
|
||||
{
|
||||
artifactKind: 'sdist',
|
||||
ecosystem: 'python',
|
||||
packageName: 'ktx-sl',
|
||||
packageVersion: '0.1.0',
|
||||
path: 'python/ktx_sl-0.1.0.tar.gz',
|
||||
path: 'python/kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const sqliteEntry = manifest.files.find((file) => file.path === 'npm/ktx-connector-sqlite-0.0.0-private.tgz');
|
||||
assert.ok(sqliteEntry);
|
||||
assert.equal(sqliteEntry.bytes, Buffer.byteLength('@ktx/connector-sqlite-tarball'));
|
||||
assert.equal(sqliteEntry.sha256, createHash('sha256').update('@ktx/connector-sqlite-tarball').digest('hex'));
|
||||
const npmEntry = manifest.files.find((file) => file.path === 'npm/kaelio-ktx-0.1.0-rc.0.tgz');
|
||||
assert.ok(npmEntry);
|
||||
assert.equal(npmEntry.bytes, Buffer.byteLength('@kaelio/ktx-tarball'));
|
||||
assert.equal(npmEntry.sha256, createHash('sha256').update('@kaelio/ktx-tarball').digest('hex'));
|
||||
|
||||
const writtenManifest = JSON.parse(await readFile(artifactManifestPath(layout), 'utf-8'));
|
||||
assert.deepEqual(writtenManifest, manifest);
|
||||
|
|
@ -351,7 +272,7 @@ describe('verifyArtifactManifest', () => {
|
|||
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.sourceRevision, 'abc123');
|
||||
assert.equal(manifest.files.length, NPM_ARTIFACT_PACKAGES.length + 4);
|
||||
assert.equal(manifest.files.length, NPM_ARTIFACT_PACKAGES.length + 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -418,48 +339,89 @@ describe('verifyArtifactManifest', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pythonArtifactInstallArgs', () => {
|
||||
it('installs the built Python wheels by artifact path', () => {
|
||||
const args = pythonArtifactInstallArgs('/tmp/smoke/.venv/bin/python', {
|
||||
ktxSlWheel: '/repo/ktx/dist/artifacts/python/ktx_sl-0.1.0-py3-none-any.whl',
|
||||
ktxSlSdist: '/repo/ktx/dist/artifacts/python/ktx_sl-0.1.0.tar.gz',
|
||||
ktxDaemonWheel: '/repo/ktx/dist/artifacts/python/ktx_daemon-0.1.0-py3-none-any.whl',
|
||||
ktxDaemonSdist: '/repo/ktx/dist/artifacts/python/ktx_daemon-0.1.0.tar.gz',
|
||||
});
|
||||
describe('copyRuntimeWheelAssets', () => {
|
||||
it('copies the runtime wheel and checksum manifest into CLI assets', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-assets-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
try {
|
||||
await mkdir(layout.pythonDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
|
||||
'kaelio-ktx-runtime-wheel',
|
||||
);
|
||||
|
||||
assert.deepEqual(args, [
|
||||
'pip',
|
||||
'install',
|
||||
'--python',
|
||||
'/tmp/smoke/.venv/bin/python',
|
||||
'/repo/ktx/dist/artifacts/python/ktx_sl-0.1.0-py3-none-any.whl',
|
||||
'/repo/ktx/dist/artifacts/python/ktx_daemon-0.1.0-py3-none-any.whl',
|
||||
]);
|
||||
assert.equal(args.includes('ktx-daemon'), false);
|
||||
assert.equal(args.includes('--find-links'), false);
|
||||
const assets = await copyRuntimeWheelAssets(layout, {
|
||||
runtimeWheel: join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
assets.wheelPath,
|
||||
join(root, 'packages', 'cli', 'assets', 'python', 'kaelio_ktx-0.1.0-py3-none-any.whl'),
|
||||
);
|
||||
assert.equal(
|
||||
assets.manifestPath,
|
||||
join(root, 'packages', 'cli', 'assets', 'python', CLI_PYTHON_ASSET_MANIFEST),
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(assets.manifestPath, 'utf8'));
|
||||
assert.deepEqual(manifest, {
|
||||
schemaVersion: 1,
|
||||
distributionName: RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
||||
normalizedName: RUNTIME_WHEEL_NORMALIZED_NAME,
|
||||
version: RUNTIME_WHEEL_PACKAGE_VERSION,
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: createHash('sha256')
|
||||
.update('kaelio-ktx-runtime-wheel')
|
||||
.digest('hex'),
|
||||
bytes: Buffer.byteLength('kaelio-ktx-runtime-wheel'),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('npmSmokePythonEnv', () => {
|
||||
it('prepends the npm smoke virtualenv bin directory to PATH', () => {
|
||||
const env = npmSmokePythonEnv('/tmp/ktx-npm-smoke', { PATH: '/usr/bin' });
|
||||
describe('verifyNpmArtifacts', () => {
|
||||
it('does not prepare an external Python environment for the npm smoke', async () => {
|
||||
const source = await readFile(new URL('./package-artifacts.mjs', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function verifyNpmArtifacts');
|
||||
const end = source.indexOf('async function verifyNpmDemoArtifacts');
|
||||
assert.ok(start > 0, 'verifyNpmArtifacts function must exist');
|
||||
assert.ok(end > start, 'verifyNpmDemoArtifacts must follow verifyNpmArtifacts');
|
||||
|
||||
assert.match(env.PATH, /^\/tmp\/ktx-npm-smoke\/\.venv\/(bin|Scripts)/);
|
||||
assert.match(env.PATH, /\/usr\/bin$/);
|
||||
const body = source.slice(start, end);
|
||||
assert.doesNotMatch(body, /uv', \['venv', '\.venv'\]/);
|
||||
assert.doesNotMatch(body, /pythonArtifactInstallArgs/);
|
||||
assert.doesNotMatch(body, /npmSmokePythonEnv/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone Python artifact cleanup', () => {
|
||||
it('does not build or verify standalone Python package artifacts', async () => {
|
||||
const source = await readFile(new URL('./package-artifacts.mjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /uv', \['build', '--package', 'ktx-sl'/);
|
||||
assert.doesNotMatch(source, /uv', \['build', '--package', 'ktx-daemon'/);
|
||||
assert.doesNotMatch(source, /async function verifyPythonArtifacts/);
|
||||
assert.doesNotMatch(source, /pythonArtifactInstallArgs/);
|
||||
assert.doesNotMatch(source, /pythonVerifySource/);
|
||||
assert.doesNotMatch(source, /ktx_sl-0\.1\.0/);
|
||||
assert.doesNotMatch(source, /ktx_daemon-0\.1\.0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification snippets', () => {
|
||||
it('pins smoke dependencies and connector packages to clean-install-safe artifacts', () => {
|
||||
it('pins the smoke project to the public package artifact', () => {
|
||||
const layout = packageArtifactLayout('/repo/ktx');
|
||||
const packageJson = npmSmokePackageJson(layout);
|
||||
|
||||
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
||||
assert.equal(packageJson.dependencies[packageInfo.name], `file:${layout.npmTarballs[packageInfo.name]}`);
|
||||
assert.equal(packageJson.pnpm.overrides[packageInfo.name], `file:${layout.npmTarballs[packageInfo.name]}`);
|
||||
}
|
||||
assert.equal(packageJson.dependencies['@modelcontextprotocol/sdk'], '^1.27.1');
|
||||
assert.deepEqual(packageJson.pnpm.onlyBuiltDependencies, ['better-sqlite3']);
|
||||
const packageJson = npmSmokePackageJson(layout);
|
||||
assert.deepEqual(packageJson.dependencies, {
|
||||
'@kaelio/ktx': `file:${layout.cliTarball}`,
|
||||
});
|
||||
assert.deepEqual(packageJson.devDependencies, {
|
||||
'better-sqlite3': '^12.6.2',
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes manifest verification as a package artifact command', async () => {
|
||||
|
|
@ -472,115 +434,64 @@ describe('verification snippets', () => {
|
|||
assert.equal(packageJson.scripts['artifacts:verify-manifest'], 'node scripts/package-artifacts.mjs verify-manifest');
|
||||
});
|
||||
|
||||
it('verifies installed dbt extraction exports from @ktx/context/ingest', () => {
|
||||
const source = npmVerifySource();
|
||||
|
||||
assert.match(source, /const ingest = await import\('@ktx\/context\/ingest'\);/);
|
||||
assert.match(source, /const dbtExtractionExports = \[/);
|
||||
assert.match(source, /throw new Error\('Missing dbt extraction export: ' \+ exportName\);/);
|
||||
|
||||
for (const exportName of [
|
||||
'parseMetricflowFiles',
|
||||
'parseMetricflowPullConfig',
|
||||
'importMetricflowSemanticModels',
|
||||
'parseDbtSchemaFiles',
|
||||
'toDescriptionUpdates',
|
||||
'toRelationshipUpdates',
|
||||
'mergeSemanticModelTables',
|
||||
'loadProjectInfo',
|
||||
'loadDbtSchemaFiles',
|
||||
]) {
|
||||
assert.match(source, new RegExp(`\\['${exportName}', ingest\\.${exportName}\\]`));
|
||||
}
|
||||
});
|
||||
|
||||
it('asserts the public npm and connector entry points that clean installs must expose', () => {
|
||||
const source = npmVerifySource();
|
||||
|
||||
assert.match(source, /@ktx\/context/);
|
||||
assert.match(source, /@ktx\/context\/project/);
|
||||
assert.match(source, /@ktx\/context\/mcp/);
|
||||
assert.match(source, /@ktx\/context\/memory/);
|
||||
assert.match(source, /@ktx\/context\/daemon/);
|
||||
assert.match(source, /@ktx\/cli/);
|
||||
assert.match(source, /@ktx\/llm/);
|
||||
assert.match(source, /createKtxLlmProvider/);
|
||||
assert.match(source, /KtxMessageBuilder/);
|
||||
assert.match(source, /createKtxEmbeddingProvider/);
|
||||
assert.doesNotMatch(source, /createGatewayLlmProvider/);
|
||||
assert.match(source, /createLocalProjectMemoryCapture/);
|
||||
for (const packageName of CONNECTOR_PACKAGE_NAMES) {
|
||||
assert.match(source, new RegExp(packageName.replace('/', '\\/')));
|
||||
}
|
||||
assert.match(source, /KtxSqliteScanConnector/);
|
||||
assert.match(source, /KtxPostgresScanConnector/);
|
||||
assert.match(source, /KtxBigQueryScanConnector/);
|
||||
assert.match(source, /KtxSnowflakeScanConnector/);
|
||||
});
|
||||
|
||||
it('asserts installed hybrid search exports and CLI smoke coverage', () => {
|
||||
it('asserts the public npm entry point that clean installs must expose', () => {
|
||||
const verifySource = npmVerifySource();
|
||||
const runtimeSource = npmRuntimeSmokeSource();
|
||||
const demoSource = npmDemoSmokeSource();
|
||||
|
||||
assert.match(verifySource, /const search = await import\('@ktx\/context\/search'\);/);
|
||||
assert.match(verifySource, /HybridSearchCore/);
|
||||
assert.match(verifySource, /assertSearchBackendConformanceCase/);
|
||||
assert.match(verifySource, /assertSearchBackendCapabilities/);
|
||||
|
||||
assert.match(runtimeSource, /ktx agent wiki search hybrid metadata verified/);
|
||||
assert.match(runtimeSource, /ktx agent sl list hybrid metadata verified/);
|
||||
assert.match(runtimeSource, /agent_sl_search_missing_project/);
|
||||
assert.match(runtimeSource, /agent_sl_search_no_connections/);
|
||||
assert.match(runtimeSource, /agent_sl_search_no_indexed_sources/);
|
||||
|
||||
assert.match(demoSource, /ktx seeded demo agent wiki search verified/);
|
||||
assert.match(demoSource, /ktx seeded demo agent sl search verified/);
|
||||
assert.match(verifySource, /const cli = await import\('@kaelio\/ktx'\);/);
|
||||
assert.match(verifySource, /getKtxCliPackageInfo/);
|
||||
assert.match(verifySource, /runKtxCli/);
|
||||
assert.doesNotMatch(verifySource, /@ktx\/context/);
|
||||
assert.doesNotMatch(verifySource, /@ktx\/llm/);
|
||||
assert.doesNotMatch(verifySource, /@ktx\/connector-/);
|
||||
});
|
||||
|
||||
it('runs installed CLI commands and MCP through an installed daemon HTTP server', () => {
|
||||
it('runs installed CLI commands through the public package runtime', () => {
|
||||
const source = npmRuntimeSmokeSource();
|
||||
|
||||
assert.match(source, /@modelcontextprotocol\/sdk\/client\/index\.js/);
|
||||
assert.match(source, /@modelcontextprotocol\/sdk\/client\/stdio\.js/);
|
||||
assert.match(source, /spawn\(command, args/);
|
||||
assert.match(source, /createServer/);
|
||||
assert.match(source, /request as httpRequest/);
|
||||
assert.match(source, /getAvailablePort/);
|
||||
assert.match(source, /startSemanticDaemon/);
|
||||
assert.match(source, /waitForHttpHealth/);
|
||||
assert.match(source, /stopSemanticDaemon/);
|
||||
assert.match(source, /'ktx-daemon'/);
|
||||
assert.match(source, /'serve-http'/);
|
||||
assert.match(source, /'--host'/);
|
||||
assert.match(source, /'127\.0\.0\.1'/);
|
||||
assert.match(source, /'--port'/);
|
||||
assert.match(source, /\/health/);
|
||||
assert.match(source, /--semantic-compute-url/);
|
||||
assert.match(source, /createDaemonLookerTableIdentifierParser/);
|
||||
assert.match(source, /LocalLookerRuntimeStore/);
|
||||
assert.match(source, /Looker daemon table identifier parser verified/);
|
||||
assert.match(source, /Looker local runtime store verified/);
|
||||
assert.match(source, /semanticComputeUrl/);
|
||||
assert.match(source, /ktx public package version/);
|
||||
assert.match(source, /@kaelio\\\/ktx 0\\\.1\\\.0/);
|
||||
assert.match(source, /'ktx', 'sl', 'query'/);
|
||||
assert.doesNotMatch(source, /@ktx\/context/);
|
||||
assert.doesNotMatch(source, /@modelcontextprotocol/);
|
||||
assert.doesNotMatch(source, /startSemanticDaemon/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'setup'/);
|
||||
assert.match(source, /knowledge', 'global', 'revenue\.md'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'agent',\s*'wiki',\s*'search'/);
|
||||
assert.match(source, /semantic-layer', 'warehouse', 'orders\.yaml'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'agent',\s*'sl',\s*'list'/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'agent',\s*'sl',\s*'query'/);
|
||||
assert.match(source, /orders\.order_count/);
|
||||
assert.match(source, /sqlite3/);
|
||||
assert.match(source, /driver: sqlite/);
|
||||
assert.match(source, /path: warehouse\.db/);
|
||||
assert.match(source, /live-database/);
|
||||
assert.match(source, /'--execute'/);
|
||||
assert.match(source, /'--execute-queries'/);
|
||||
assert.match(source, /slValidateResult\.success, true/);
|
||||
assert.match(source, /slQueryResult\.dialect, 'sqlite'/);
|
||||
assert.match(source, /slQueryResult\.plan\.execution\.driver, 'sqlite'/);
|
||||
assert.match(source, /"mode": "compile_only"/);
|
||||
assert.match(source, /"mode": "executed"/);
|
||||
assert.match(source, /ktx agent sl query sqlite execute/);
|
||||
assert.match(source, /ktx sl query sqlite execute/);
|
||||
assert.match(source, /import Database from 'better-sqlite3'/);
|
||||
assert.doesNotMatch(source, /run\('python'/);
|
||||
assert.match(source, /KTX_RUNTIME_ROOT/);
|
||||
assert.match(source, /managed-runtime/);
|
||||
assert.match(source, /ktx runtime status missing/);
|
||||
assert.match(source, /runtimeStatusBefore\.kind, 'missing'/);
|
||||
assert.ok(source.includes(String.raw`Installing KTX Python runtime \(core\) with uv`));
|
||||
assert.match(source, /KTX Python runtime ready:/);
|
||||
assert.match(source, /ktx runtime status ready/);
|
||||
assert.match(source, /runtimeStatusAfter\.kind, 'ready'/);
|
||||
assert.match(source, /runtimeStatusAfter\.manifest\.features/);
|
||||
assert.match(source, /ktx runtime doctor/);
|
||||
assert.match(source, /PASS Managed Python runtime/);
|
||||
assert.match(source, /ktx runtime start/);
|
||||
assert.match(source, /ktx runtime start reuse/);
|
||||
assert.match(source, /Using existing KTX Python daemon/);
|
||||
assert.match(source, /ktx runtime stop/);
|
||||
assert.match(source, /ktx runtime prune dry run/);
|
||||
assert.match(source, /0\.0\.0/);
|
||||
assert.match(source, /ktx runtime prune needs confirmation/);
|
||||
assert.match(source, /Refusing to prune without --yes/);
|
||||
assert.match(source, /ktx runtime prune confirmed/);
|
||||
assert.match(source, /Removed stale KTX Python runtimes/);
|
||||
assert.match(source, /assert\.rejects\(\(\) => access\(staleRuntimeDir\)\)/);
|
||||
assert.match(source, /run\('pnpm', \[\s*'exec',\s*'ktx',\s*'dev',\s*'scan',\s*'warehouse'/);
|
||||
assert.match(source, /'--mode',\s*'enriched'/);
|
||||
assert.doesNotMatch(source, /'--enrich'/);
|
||||
|
|
@ -590,28 +501,7 @@ describe('verification snippets', () => {
|
|||
assert.match(source, /scanReportJson\.artifactPaths\.enrichmentArtifacts/);
|
||||
assert.match(source, /enrichment:/);
|
||||
assert.match(source, /mode: deterministic/);
|
||||
assert.match(source, /backend: gateway/);
|
||||
assert.match(source, /models:/);
|
||||
assert.match(source, /default: smoke\/provider/);
|
||||
assert.match(source, /api_key: env:AI_GATEWAY_API_KEY/);
|
||||
assert.match(source, /run\('pnpm', \['exec', 'ktx', 'dev', 'ingest', 'run'/);
|
||||
assert.match(source, /'serve', '--mcp', 'stdio'/);
|
||||
assert.doesNotMatch(source, /'--semantic-compute',\n\s*'--execute-queries'/);
|
||||
assert.match(source, /'--memory-capture', '--memory-model', 'smoke\/provider'/);
|
||||
assert.match(source, /mcpServerStderr/);
|
||||
assert.match(source, /ktx serve stderr/);
|
||||
assert.match(source, /sl_validate/);
|
||||
assert.match(source, /sl_query/);
|
||||
assert.match(source, /memory_capture/);
|
||||
assert.match(source, /memory_capture_status/);
|
||||
assert.match(source, /connection_test/);
|
||||
assert.match(source, /scan_trigger/);
|
||||
assert.match(source, /scan_status/);
|
||||
assert.match(source, /scan_report/);
|
||||
assert.match(source, /scan_list_artifacts/);
|
||||
assert.match(source, /scan_read_artifact/);
|
||||
assert.match(source, /mcpScanArtifacts\.artifacts\.find/);
|
||||
assert.match(source, /AI_GATEWAY_API_KEY/);
|
||||
assert.match(source, /access\(join\(projectDir, '\.ktx', 'db\.sqlite'\)\)/);
|
||||
assert.match(source, /SQLite knowledge index/);
|
||||
assert.match(source, /ktx dev ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
|
||||
|
|
@ -632,22 +522,8 @@ describe('verification snippets', () => {
|
|||
assert.match(source, /'dev', 'doctor', 'setup', '--no-input'/);
|
||||
assert.match(source, /'--plain'/);
|
||||
assert.match(source, /ktx setup demo seeded wrote unexpected stderr/);
|
||||
assert.match(source, /Object\.keys\(packageJson\.dependencies\)/);
|
||||
assert.match(source, /'@kaelio\/ktx'/);
|
||||
});
|
||||
});
|
||||
|
||||
it('checks packaged ingest runtime assets in the installed npm smoke', () => {
|
||||
const source = npmRuntimeSmokeSource();
|
||||
|
||||
assert.match(source, /notion_synthesize\/SKILL\.md/);
|
||||
assert.match(source, /skills\/page_triage_classifier\.md/);
|
||||
assert.match(source, /skills\/light_extraction\.md/);
|
||||
});
|
||||
|
||||
it('asserts the Python modules that clean installs must expose', () => {
|
||||
const source = pythonVerifySource();
|
||||
|
||||
assert.match(source, /semantic_layer/);
|
||||
assert.match(source, /ktx_daemon/);
|
||||
assert.match(source, /importlib.metadata/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
87
scripts/publish-public-npm-package.mjs
Normal file
87
scripts/publish-public-npm-package.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { packageArtifactLayout } from './package-artifacts.mjs';
|
||||
import { releaseReadinessReport } from './release-readiness.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function resolvePublishMode(args = process.argv.slice(2)) {
|
||||
return { live: args.includes('--publish') };
|
||||
}
|
||||
|
||||
export function requireNpmPublicReleaseReady(report) {
|
||||
if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) {
|
||||
throw new Error('release-policy.json must use npm-public-release-ready before publishing');
|
||||
}
|
||||
return report.npmPublish;
|
||||
}
|
||||
|
||||
export function buildNpmPublishCommand(tarballPath, publish, mode) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'publish',
|
||||
tarballPath,
|
||||
'--access',
|
||||
publish.access,
|
||||
'--tag',
|
||||
publish.tag,
|
||||
...(mode.live ? [] : ['--dry-run', '--no-git-checks']),
|
||||
],
|
||||
env: publish.registry ? { npm_config_registry: publish.registry } : {},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertFileExists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPublishCommand(command) {
|
||||
process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
|
||||
await execFileAsync(command.command, command.args, {
|
||||
env: { ...process.env, ...command.env },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024 * 20,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishPublicNpmPackage(options = {}) {
|
||||
const rootDir = options.rootDir;
|
||||
const mode = options.mode ?? resolvePublishMode(options.args);
|
||||
const report = await releaseReadinessReport(rootDir);
|
||||
const publish = requireNpmPublicReleaseReady(report);
|
||||
const layout = packageArtifactLayout(rootDir);
|
||||
const tarballPath = layout.cliTarball;
|
||||
|
||||
await assertFileExists(tarballPath);
|
||||
const command = buildNpmPublishCommand(tarballPath, publish, mode);
|
||||
await runPublishCommand(command);
|
||||
|
||||
process.stdout.write(
|
||||
mode.live
|
||||
? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`
|
||||
: `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await publishPublicNpmPackage({ args: process.argv.slice(2) });
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
109
scripts/publish-public-npm-package.test.mjs
Normal file
109
scripts/publish-public-npm-package.test.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildNpmPublishCommand,
|
||||
requireNpmPublicReleaseReady,
|
||||
resolvePublishMode,
|
||||
} from './publish-public-npm-package.mjs';
|
||||
|
||||
const readyReport = {
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npmPublishEnabled: true,
|
||||
npmPublish: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '0.1.0-rc.0',
|
||||
access: 'public',
|
||||
tag: 'next',
|
||||
registry: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolvePublishMode', () => {
|
||||
it('dry-runs by default', () => {
|
||||
assert.deepEqual(resolvePublishMode([]), { live: false });
|
||||
});
|
||||
|
||||
it('requires an explicit flag for live publish', () => {
|
||||
assert.deepEqual(resolvePublishMode(['--publish']), { live: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNpmPublicReleaseReady', () => {
|
||||
it('accepts the npm public release ready report', () => {
|
||||
assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish);
|
||||
});
|
||||
|
||||
it('rejects artifact-only reports', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
requireNpmPublicReleaseReady({
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npmPublishEnabled: false,
|
||||
npmPublish: null,
|
||||
}),
|
||||
/release-policy.json must use npm-public-release-ready before publishing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNpmPublishCommand', () => {
|
||||
it('builds a dry-run pnpm publish command by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', readyReport.npmPublish, {
|
||||
live: false,
|
||||
}),
|
||||
{
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'publish',
|
||||
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz',
|
||||
'--access',
|
||||
'public',
|
||||
'--tag',
|
||||
'next',
|
||||
'--dry-run',
|
||||
'--no-git-checks',
|
||||
],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits dry-run only for explicit live publish', () => {
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', readyReport.npmPublish, {
|
||||
live: true,
|
||||
}).args,
|
||||
[
|
||||
'publish',
|
||||
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz',
|
||||
'--access',
|
||||
'public',
|
||||
'--tag',
|
||||
'next',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses npm_config_registry when a registry is configured', () => {
|
||||
const publish = {
|
||||
...readyReport.npmPublish,
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', publish, { live: false }).env,
|
||||
{ npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('package script', () => {
|
||||
it('registers release:npm-publish', async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { dirname, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
|
|
@ -28,6 +29,30 @@ function assertHttpRegistry(registry, label) {
|
|||
}
|
||||
}
|
||||
|
||||
function registryEnv(config) {
|
||||
return config.registry ? { npm_config_registry: config.registry } : {};
|
||||
}
|
||||
|
||||
function runtimeCommandEnv(config, runtimeRoot) {
|
||||
return { ...registryEnv(config), KTX_RUNTIME_ROOT: runtimeRoot };
|
||||
}
|
||||
|
||||
function semanticQueryArgs(projectDir) {
|
||||
return [
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
];
|
||||
}
|
||||
|
||||
function normalizePolicyConfig(policyConfig = {}) {
|
||||
if (policyConfig === null || policyConfig === undefined) {
|
||||
return { packageName: null, version: DEFAULT_VERSION_TAG, registry: null };
|
||||
|
|
@ -114,39 +139,70 @@ export function publishedPackageSpec(config) {
|
|||
return `${config.packageName}@${config.packageVersion}`;
|
||||
}
|
||||
|
||||
export function buildPublishedPackageNpxCommand(config, args, label = 'published package command') {
|
||||
const env = config.registry ? { npm_config_registry: config.registry } : {};
|
||||
|
||||
export function buildPublishedPackageNpxCommand(config, args, label = 'published package command', extraEnv = {}) {
|
||||
return {
|
||||
label,
|
||||
command: 'npx',
|
||||
args: ['--yes', publishedPackageSpec(config), ...args],
|
||||
env,
|
||||
env: { ...registryEnv(config), ...extraEnv },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPublishedPackageSmokeCommands(config, projectDir, emptyProjectDir) {
|
||||
export function buildPublishedPackageSmokeCommands(
|
||||
config,
|
||||
projectDir,
|
||||
runtimeRoot = join(dirname(projectDir), 'managed-runtime'),
|
||||
) {
|
||||
const runtimeEnv = runtimeCommandEnv(config, runtimeRoot);
|
||||
const packageEnv = registryEnv(config);
|
||||
const queryArgs = semanticQueryArgs(projectDir);
|
||||
|
||||
return [
|
||||
buildPublishedPackageNpxCommand(config, ['--version'], 'published package version'),
|
||||
buildPublishedPackageNpxCommand(config, ['--version'], 'published package npx version'),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['demo', '--project-dir', projectDir, '--no-input', '--plain'],
|
||||
'published package demo',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'wiki', 'search', 'ARR contract', '--json', '--limit', '5', '--project-dir', projectDir],
|
||||
'published package wiki hybrid search',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'sl', 'list', '--json', '--query', 'ARR', '--project-dir', projectDir],
|
||||
'published package semantic-layer hybrid search',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['agent', 'sl', 'list', '--json', '--query', 'revenue', '--project-dir', emptyProjectDir],
|
||||
'published package missing-project readiness',
|
||||
['setup', 'demo', '--project-dir', projectDir, '--no-input', '--plain'],
|
||||
'published package setup demo',
|
||||
{ KTX_RUNTIME_ROOT: runtimeRoot },
|
||||
),
|
||||
buildPublishedPackageNpxCommand(config, queryArgs, 'published package npx sl query', {
|
||||
KTX_RUNTIME_ROOT: runtimeRoot,
|
||||
}),
|
||||
{
|
||||
label: 'published package local install',
|
||||
command: 'pnpm',
|
||||
args: ['add', publishedPackageSpec(config)],
|
||||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package local version',
|
||||
command: 'npx',
|
||||
args: ['ktx', '--version'],
|
||||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package local sl query',
|
||||
command: 'npx',
|
||||
args: ['ktx', ...queryArgs],
|
||||
env: runtimeEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package global install',
|
||||
command: 'pnpm',
|
||||
args: ['add', '--global', publishedPackageSpec(config)],
|
||||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package global version',
|
||||
command: 'ktx',
|
||||
args: ['--version'],
|
||||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package global sl query',
|
||||
command: 'ktx',
|
||||
args: queryArgs,
|
||||
env: runtimeEnv,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
|
@ -23,6 +23,26 @@ export {
|
|||
const execFileAsync = promisify(execFile);
|
||||
const SMOKE_TIMEOUT_MS = 180_000;
|
||||
|
||||
const VERSION_LABELS = new Set([
|
||||
'published package npx version',
|
||||
'published package local version',
|
||||
'published package global version',
|
||||
]);
|
||||
|
||||
const SEMANTIC_QUERY_LABELS = new Set([
|
||||
'published package npx sl query',
|
||||
'published package local sl query',
|
||||
'published package global sl query',
|
||||
]);
|
||||
|
||||
export function isPublishedPackageVersionLabel(label) {
|
||||
return VERSION_LABELS.has(label);
|
||||
}
|
||||
|
||||
export function isPublishedPackageSemanticQueryLabel(label) {
|
||||
return SEMANTIC_QUERY_LABELS.has(label);
|
||||
}
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
|
@ -59,78 +79,34 @@ function requireSuccess(label, result) {
|
|||
);
|
||||
}
|
||||
|
||||
function parseJson(label, text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} did not produce JSON: ${error instanceof Error ? error.message : String(error)}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHybridWikiSearch(result) {
|
||||
const payload = parseJson('published package wiki search', result.stdout);
|
||||
assert.ok(payload.totalFound > 0, 'published package wiki search should return results');
|
||||
assert.ok(
|
||||
payload.results.some((entry) => Array.isArray(entry.matchReasons) && entry.matchReasons.length > 0),
|
||||
'published package wiki search should expose match reasons',
|
||||
);
|
||||
}
|
||||
|
||||
function assertHybridSlSearch(result) {
|
||||
const payload = parseJson('published package semantic-layer search', result.stdout);
|
||||
assert.ok(payload.totalSources > 0, 'published package semantic-layer search should return sources');
|
||||
assert.ok(
|
||||
payload.sources.some((entry) => Array.isArray(entry.matchReasons) && entry.matchReasons.length > 0),
|
||||
'published package semantic-layer search should expose match reasons',
|
||||
);
|
||||
}
|
||||
|
||||
function assertMissingProjectReadiness(result, emptyProjectDir) {
|
||||
assert.equal(result.code, 1, 'missing-project semantic-layer search should exit 1');
|
||||
assert.equal(result.stdout, '', 'missing-project semantic-layer search should not write JSON errors to stdout');
|
||||
|
||||
const payload = parseJson('published package missing-project semantic-layer search', result.stderr);
|
||||
assert.deepEqual(payload, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${emptyProjectDir}.`,
|
||||
nextSteps: [
|
||||
'ktx demo',
|
||||
`ktx setup --project-dir ${emptyProjectDir}`,
|
||||
'ktx ingest <connection>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${emptyProjectDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPublishedPackageSmoke(config) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-published-package-smoke-'));
|
||||
try {
|
||||
const projectDir = join(root, 'demo-project');
|
||||
const emptyProjectDir = join(root, 'empty-project');
|
||||
await mkdir(emptyProjectDir, { recursive: true });
|
||||
|
||||
const commands = buildPublishedPackageSmokeCommands(config, projectDir, emptyProjectDir);
|
||||
for (const command of commands.slice(0, 4)) {
|
||||
const result = await runCommand(command.command, command.args, { env: command.env });
|
||||
const commands = buildPublishedPackageSmokeCommands(config, projectDir);
|
||||
const pnpmHome = join(root, 'pnpm-home');
|
||||
const globalEnv = {
|
||||
PNPM_HOME: pnpmHome,
|
||||
PATH: `${pnpmHome}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`,
|
||||
};
|
||||
for (const command of commands) {
|
||||
const isGlobalCommand = command.label.includes('global');
|
||||
const result = await runCommand(command.command, command.args, {
|
||||
cwd: command.label.includes('local') || isGlobalCommand ? root : undefined,
|
||||
env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env,
|
||||
});
|
||||
requireSuccess(command.label, result);
|
||||
if (command.label === 'published package wiki hybrid search') {
|
||||
assertHybridWikiSearch(result);
|
||||
if (isPublishedPackageVersionLabel(command.label)) {
|
||||
assert.match(result.stdout, /@kaelio\/ktx /);
|
||||
}
|
||||
if (command.label === 'published package semantic-layer hybrid search') {
|
||||
assertHybridSlSearch(result);
|
||||
if (isPublishedPackageSemanticQueryLabel(command.label)) {
|
||||
assert.match(result.stdout, /SELECT/i);
|
||||
assert.match(result.stdout, /contracts/i);
|
||||
}
|
||||
}
|
||||
|
||||
const missingProjectCommand = commands[4];
|
||||
const missingProject = await runCommand(missingProjectCommand.command, missingProjectCommand.args, {
|
||||
env: missingProjectCommand.env,
|
||||
});
|
||||
assertMissingProjectReadiness(missingProject, emptyProjectDir);
|
||||
|
||||
process.stdout.write('published package hybrid search smoke verified\n');
|
||||
process.stdout.write('published package invocation smoke verified\n');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { describe, it } from 'node:test';
|
|||
import {
|
||||
buildPublishedPackageNpxCommand,
|
||||
buildPublishedPackageSmokeCommands,
|
||||
isPublishedPackageSemanticQueryLabel,
|
||||
isPublishedPackageVersionLabel,
|
||||
publishedPackageSpec,
|
||||
readPublishedPackageSmokeConfig,
|
||||
} from './published-package-smoke.mjs';
|
||||
|
|
@ -32,7 +34,7 @@ describe('published package smoke config', () => {
|
|||
assert.deepEqual(
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@ktx/cli-public',
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@kaelio/ktx',
|
||||
KTX_PUBLISHED_KTX_VERSION: 'latest',
|
||||
KTX_PUBLISHED_KTX_REGISTRY: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -42,7 +44,7 @@ describe('published package smoke config', () => {
|
|||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'environment',
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -55,7 +57,7 @@ describe('published package smoke config', () => {
|
|||
{},
|
||||
[],
|
||||
{
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -64,7 +66,7 @@ describe('published package smoke config', () => {
|
|||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'release-policy',
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageVersion: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -75,12 +77,12 @@ describe('published package smoke config', () => {
|
|||
assert.deepEqual(
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@ktx/cli-from-env',
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@kaelio/ktx',
|
||||
KTX_PUBLISHED_KTX_VERSION: 'latest',
|
||||
},
|
||||
[],
|
||||
{
|
||||
packageName: '@ktx/cli-from-policy',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -89,7 +91,7 @@ describe('published package smoke config', () => {
|
|||
enabled: true,
|
||||
requireConfig: false,
|
||||
configSource: 'environment',
|
||||
packageName: '@ktx/cli-from-env',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -125,7 +127,7 @@ describe('published package smoke config', () => {
|
|||
() =>
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@ktx/cli-public',
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@kaelio/ktx',
|
||||
KTX_PUBLISHED_KTX_VERSION: '--tag latest',
|
||||
},
|
||||
[],
|
||||
|
|
@ -136,7 +138,7 @@ describe('published package smoke config', () => {
|
|||
() =>
|
||||
readPublishedPackageSmokeConfig(
|
||||
{
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@ktx/cli-public',
|
||||
KTX_PUBLISHED_KTX_PACKAGE: '@kaelio/ktx',
|
||||
KTX_PUBLISHED_KTX_REGISTRY: 'file:///tmp/npm',
|
||||
},
|
||||
[],
|
||||
|
|
@ -146,103 +148,166 @@ describe('published package smoke config', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('published package smoke output validation labels', () => {
|
||||
it('classifies version and semantic query commands', () => {
|
||||
assert.equal(isPublishedPackageVersionLabel('published package npx version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package local version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package global version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package setup demo'), false);
|
||||
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package npx sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package local sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package global sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package local install'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('published package smoke command construction', () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
requireConfig: false,
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
packageVersion: 'latest',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
};
|
||||
|
||||
it('builds the npx package spec from package name and version tag', () => {
|
||||
assert.equal(publishedPackageSpec(config), '@ktx/cli-public@latest');
|
||||
assert.equal(publishedPackageSpec(config), '@kaelio/ktx@latest');
|
||||
});
|
||||
|
||||
it('builds npx commands with a registry env patch instead of shell interpolation', () => {
|
||||
assert.deepEqual(buildPublishedPackageNpxCommand(config, ['--version']), {
|
||||
label: 'published package command',
|
||||
command: 'npx',
|
||||
args: ['--yes', '@ktx/cli-public@latest', '--version'],
|
||||
args: ['--yes', '@kaelio/ktx@latest', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the full hybrid-search smoke command list', () => {
|
||||
assert.deepEqual(buildPublishedPackageSmokeCommands(config, '/tmp/ktx-smoke/demo', '/tmp/ktx-smoke/empty'), [
|
||||
{
|
||||
label: 'published package version',
|
||||
command: 'npx',
|
||||
args: ['--yes', '@ktx/cli-public@latest', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package demo',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@ktx/cli-public@latest',
|
||||
'demo',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--no-input',
|
||||
'--plain',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package wiki hybrid search',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@ktx/cli-public@latest',
|
||||
'agent',
|
||||
'wiki',
|
||||
'search',
|
||||
'ARR contract',
|
||||
'--json',
|
||||
'--limit',
|
||||
'5',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package semantic-layer hybrid search',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@ktx/cli-public@latest',
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'ARR',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package missing-project readiness',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@ktx/cli-public@latest',
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'revenue',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/empty',
|
||||
],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
]);
|
||||
it('builds the full public package smoke command list', () => {
|
||||
assert.deepEqual(
|
||||
buildPublishedPackageSmokeCommands(
|
||||
config,
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'/tmp/ktx-smoke/managed-runtime',
|
||||
),
|
||||
[
|
||||
{
|
||||
label: 'published package npx version',
|
||||
command: 'npx',
|
||||
args: ['--yes', '@kaelio/ktx@latest', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package setup demo',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@kaelio/ktx@latest',
|
||||
'setup',
|
||||
'demo',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--no-input',
|
||||
'--plain',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'published package npx sl query',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@kaelio/ktx@latest',
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'published package local install',
|
||||
command: 'pnpm',
|
||||
args: ['add', '@kaelio/ktx@latest'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package local version',
|
||||
command: 'npx',
|
||||
args: ['ktx', '--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package local sl query',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'ktx',
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'published package global install',
|
||||
command: 'pnpm',
|
||||
args: ['add', '--global', '@kaelio/ktx@latest'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package global version',
|
||||
command: 'ktx',
|
||||
args: ['--version'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package global sl query',
|
||||
command: 'ktx',
|
||||
args: [
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the smoke through the package release script', async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs';
|
||||
import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
|
||||
import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs';
|
||||
|
||||
function scriptRootDir() {
|
||||
|
|
@ -21,9 +22,11 @@ async function readJson(path) {
|
|||
|
||||
const CI_ARTIFACT_ONLY_RELEASE_MODE = 'ci-artifact-only';
|
||||
const PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE = 'published-package-smoke-required';
|
||||
const NPM_PUBLIC_RELEASE_READY_MODE = 'npm-public-release-ready';
|
||||
const SUPPORTED_RELEASE_MODES = new Set([
|
||||
CI_ARTIFACT_ONLY_RELEASE_MODE,
|
||||
PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE,
|
||||
NPM_PUBLIC_RELEASE_READY_MODE,
|
||||
]);
|
||||
|
||||
export async function readReleasePolicy(rootDir = scriptRootDir()) {
|
||||
|
|
@ -64,6 +67,19 @@ function assertStringArray(value, label) {
|
|||
}
|
||||
}
|
||||
|
||||
function assertNpmAccess(value) {
|
||||
if (value !== 'public') {
|
||||
throw new Error('Release policy npm.access must be public');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNpmTag(value) {
|
||||
assertString(value, 'Release policy npm.tag');
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) {
|
||||
throw new Error(`Invalid Release policy npm.tag: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupportedReleaseMode(releaseMode) {
|
||||
assertString(releaseMode, 'Release policy releaseMode');
|
||||
if (!SUPPORTED_RELEASE_MODES.has(releaseMode)) {
|
||||
|
|
@ -79,10 +95,31 @@ function assertRequiredBeforePublishing(policy) {
|
|||
}
|
||||
|
||||
if (
|
||||
policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE &&
|
||||
(policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE ||
|
||||
policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) &&
|
||||
policy.requiredBeforePublishing.length > 0
|
||||
) {
|
||||
throw new Error('published-package-smoke-required release mode requires requiredBeforePublishing to be empty');
|
||||
throw new Error(`${policy.releaseMode} release mode requires requiredBeforePublishing to be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeInstallerPolicy(policy) {
|
||||
assertPlainObject(policy.runtimeInstaller, 'Release policy runtimeInstaller');
|
||||
assertString(policy.runtimeInstaller.uvStrategy, 'Release policy runtimeInstaller.uvStrategy');
|
||||
assertBoolean(policy.runtimeInstaller.bootstrapUv, 'Release policy runtimeInstaller.bootstrapUv');
|
||||
assertString(
|
||||
policy.runtimeInstaller.missingUvBehavior,
|
||||
'Release policy runtimeInstaller.missingUvBehavior',
|
||||
);
|
||||
|
||||
if (policy.runtimeInstaller.uvStrategy !== 'path-prerequisite') {
|
||||
throw new Error('Release policy runtimeInstaller.uvStrategy must be path-prerequisite');
|
||||
}
|
||||
if (policy.runtimeInstaller.bootstrapUv !== false) {
|
||||
throw new Error('Release policy runtimeInstaller.bootstrapUv must be false');
|
||||
}
|
||||
if (policy.runtimeInstaller.missingUvBehavior !== 'focused-error') {
|
||||
throw new Error('Release policy runtimeInstaller.missingUvBehavior must be focused-error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +144,8 @@ export function validateReleasePolicy(policy) {
|
|||
|
||||
assertBoolean(policy.npm.publish, 'Release policy npm.publish');
|
||||
assertNullableString(policy.npm.registry, 'Release policy npm.registry');
|
||||
assertNpmAccess(policy.npm.access);
|
||||
assertNpmTag(policy.npm.tag);
|
||||
assertStringArray(policy.npm.packages, 'Release policy npm.packages');
|
||||
|
||||
assertBoolean(policy.python.publish, 'Release policy python.publish');
|
||||
|
|
@ -117,6 +156,7 @@ export function validateReleasePolicy(policy) {
|
|||
assertNullableString(policy.publishedPackageSmoke.registry, 'Release policy publishedPackageSmoke.registry');
|
||||
readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke);
|
||||
assertRequiredBeforePublishing(policy);
|
||||
assertRuntimeInstallerPolicy(policy);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
|
@ -128,10 +168,12 @@ function metadataNames(metadata, ecosystem) {
|
|||
function publishedPackageSmokeGate(policy) {
|
||||
const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke);
|
||||
|
||||
if (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE && !config.enabled) {
|
||||
throw new Error(
|
||||
'published-package-smoke-required release mode requires release-policy.json publishedPackageSmoke.packageName',
|
||||
);
|
||||
if (
|
||||
(policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE ||
|
||||
policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) &&
|
||||
!config.enabled
|
||||
) {
|
||||
throw new Error(`${policy.releaseMode} release mode requires release-policy.json publishedPackageSmoke.packageName`);
|
||||
}
|
||||
|
||||
const base =
|
||||
|
|
@ -140,6 +182,11 @@ function publishedPackageSmokeGate(policy) {
|
|||
status: 'not_required',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
}
|
||||
: policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
|
||||
? {
|
||||
status: 'required',
|
||||
reason: 'Run the published package smoke after the npm package is published.',
|
||||
}
|
||||
: {
|
||||
status: 'required',
|
||||
reason: 'Run the published package smoke before accepting the hybrid-search release.',
|
||||
|
|
@ -180,23 +227,68 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
|
|||
throw new Error(`Package ${entry.packageName} releaseMode must remain ci-artifact-only`);
|
||||
}
|
||||
if (entry.ecosystem === 'npm') {
|
||||
if (entry.private !== true) {
|
||||
const isPublicKtxPackage = entry.packageName === '@kaelio/ktx';
|
||||
if (isPublicKtxPackage) {
|
||||
if (entry.private !== false) {
|
||||
throw new Error(`${policyLabel} npm package @kaelio/ktx must be publishable when npm.publish is false`);
|
||||
}
|
||||
if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`);
|
||||
}
|
||||
} else if (entry.private !== true) {
|
||||
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
|
||||
}
|
||||
if (!entry.packageVersion.endsWith('-private')) {
|
||||
} else if (!entry.packageVersion.endsWith('-private')) {
|
||||
throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNpmPublicReleaseReadyPolicy(policy, metadata) {
|
||||
if (policy.npm.publish !== true) {
|
||||
throw new Error('npm-public-release-ready policy requires npm.publish true');
|
||||
}
|
||||
if (policy.python.publish !== false) {
|
||||
throw new Error('npm-public-release-ready policy keeps python.publish false');
|
||||
}
|
||||
if (policy.python.repository !== null) {
|
||||
throw new Error('npm-public-release-ready policy keeps python.repository null');
|
||||
}
|
||||
|
||||
assertSameMembers(policy.npm.packages, ['@kaelio/ktx'], 'Release policy npm.packages');
|
||||
assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages');
|
||||
|
||||
const npmMetadata = metadata.find((entry) => entry.ecosystem === 'npm' && entry.packageName === '@kaelio/ktx');
|
||||
if (!npmMetadata) {
|
||||
throw new Error('npm-public-release-ready policy requires @kaelio/ktx artifact metadata');
|
||||
}
|
||||
if (npmMetadata.private !== false) {
|
||||
throw new Error('npm-public-release-ready policy requires @kaelio/ktx to be publishable');
|
||||
}
|
||||
if (npmMetadata.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
throw new Error(
|
||||
`npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`,
|
||||
);
|
||||
}
|
||||
if (policy.publishedPackageSmoke.packageName !== '@kaelio/ktx') {
|
||||
throw new Error('npm-public-release-ready policy requires publishedPackageSmoke.packageName @kaelio/ktx');
|
||||
}
|
||||
if (policy.publishedPackageSmoke.version !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${PUBLIC_NPM_PACKAGE_VERSION}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReadinessReport(rootDir = scriptRootDir()) {
|
||||
const policy = validateReleasePolicy(await readReleasePolicy(rootDir));
|
||||
const layout = packageArtifactLayout(rootDir);
|
||||
const manifest = await verifyArtifactManifest(layout);
|
||||
const metadata = await packageReleaseMetadata(rootDir);
|
||||
|
||||
assertNonPublishingArtifactPolicy(policy, metadata);
|
||||
if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) {
|
||||
assertNpmPublicReleaseReadyPolicy(policy, metadata);
|
||||
} else {
|
||||
assertNonPublishingArtifactPolicy(policy, metadata);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
|
|
@ -206,6 +298,17 @@ export async function releaseReadinessReport(rootDir = scriptRootDir()) {
|
|||
pythonPublishEnabled: policy.python.publish,
|
||||
packageNames: metadata.map((entry) => entry.packageName),
|
||||
publishedPackageSmokeGate: publishedPackageSmokeGate(policy),
|
||||
runtimeInstaller: policy.runtimeInstaller,
|
||||
npmPublish:
|
||||
policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
|
||||
? {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
access: policy.npm.access,
|
||||
tag: policy.npm.tag,
|
||||
registry: policy.npm.registry,
|
||||
}
|
||||
: null,
|
||||
blockedPublishingDecisions: policy.requiredBeforePublishing,
|
||||
};
|
||||
}
|
||||
|
|
@ -229,7 +332,17 @@ async function main() {
|
|||
process.stdout.write(
|
||||
`Published package smoke registry: ${report.publishedPackageSmokeGate.registry ?? 'default npm registry'}\n`,
|
||||
);
|
||||
process.stdout.write('Registry publishing remains disabled by release-policy.json.\n');
|
||||
process.stdout.write(`Runtime uv strategy: ${report.runtimeInstaller.uvStrategy}\n`);
|
||||
process.stdout.write(
|
||||
`Runtime uv bootstrap: ${report.runtimeInstaller.bootstrapUv ? 'enabled' : 'disabled'}\n`,
|
||||
);
|
||||
if (report.npmPublish) {
|
||||
process.stdout.write(
|
||||
`NPM publish target: ${report.npmPublish.packageName}@${report.npmPublish.version} (${report.npmPublish.tag})\n`,
|
||||
);
|
||||
} else {
|
||||
process.stdout.write('Registry publishing remains disabled by release-policy.json.\n');
|
||||
}
|
||||
process.stdout.write('Required decisions before publishing:\n');
|
||||
for (const decision of report.blockedPublishingDecisions) {
|
||||
process.stdout.write(`- ${decision}\n`);
|
||||
|
|
|
|||
|
|
@ -4,39 +4,28 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { NPM_ARTIFACT_PACKAGES, packageArtifactLayout, writeArtifactManifest } from './package-artifacts.mjs';
|
||||
import {
|
||||
INTERNAL_NPM_WORKSPACE_PACKAGES,
|
||||
NPM_ARTIFACT_PACKAGES,
|
||||
packageArtifactLayout,
|
||||
writeArtifactManifest,
|
||||
} from './package-artifacts.mjs';
|
||||
import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
|
||||
import { readReleasePolicy, releasePolicyPath, releaseReadinessReport } from './release-readiness.mjs';
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeReleaseMetadataInputs(root, options = {}) {
|
||||
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
||||
async function writeReleaseMetadataInputs(root) {
|
||||
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
|
||||
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
|
||||
name: packageInfo.name,
|
||||
version: '0.0.0-private',
|
||||
private:
|
||||
packageInfo.name === '@ktx/context'
|
||||
? (options.contextPrivate ?? true)
|
||||
: packageInfo.name === '@ktx/cli'
|
||||
? (options.cliPrivate ?? true)
|
||||
: true,
|
||||
private: true,
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(join(root, 'python', 'ktx-sl'), { recursive: true });
|
||||
await mkdir(join(root, 'python', 'ktx-daemon'), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-sl', 'pyproject.toml'),
|
||||
['[project]', 'name = "ktx-sl"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, 'python', 'ktx-daemon', 'pyproject.toml'),
|
||||
['[project]', 'name = "ktx-daemon"', 'version = "0.1.0"', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeUploadableArtifactFixtures(layout) {
|
||||
|
|
@ -48,10 +37,7 @@ async function writeUploadableArtifactFixtures(layout) {
|
|||
layout.npmTarballs[packageInfo.name],
|
||||
`${packageInfo.name}-tarball`,
|
||||
]),
|
||||
[join(layout.pythonDir, 'ktx_sl-0.1.0-py3-none-any.whl'), 'ktx-sl-wheel'],
|
||||
[join(layout.pythonDir, 'ktx_sl-0.1.0.tar.gz'), 'ktx-sl-sdist'],
|
||||
[join(layout.pythonDir, 'ktx_daemon-0.1.0-py3-none-any.whl'), 'ktx-daemon-wheel'],
|
||||
[join(layout.pythonDir, 'ktx_daemon-0.1.0.tar.gz'), 'ktx-daemon-sdist'],
|
||||
[join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), 'kaelio-ktx-runtime-wheel'],
|
||||
]);
|
||||
|
||||
for (const [path, contents] of fileContents) {
|
||||
|
|
@ -68,24 +54,29 @@ function releasePolicy(overrides = {}) {
|
|||
npm: {
|
||||
publish: false,
|
||||
registry: null,
|
||||
packages: NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
packages: ['@kaelio/ktx'],
|
||||
...npmOverrides,
|
||||
},
|
||||
python: {
|
||||
publish: false,
|
||||
repository: null,
|
||||
packages: ['ktx-sl', 'ktx-daemon'],
|
||||
packages: ['kaelio-ktx'],
|
||||
...pythonOverrides,
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: null,
|
||||
packageName: '@kaelio/ktx',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
requiredBeforePublishing: [
|
||||
'Choose npm registry and package visibility.',
|
||||
'Choose Python package repository.',
|
||||
'Choose public release versions.',
|
||||
'Choose public release version.',
|
||||
'Configure registry credentials outside source control.',
|
||||
'Choose release tag and provenance policy.',
|
||||
],
|
||||
|
|
@ -98,7 +89,7 @@ async function writePolicy(root, policy = releasePolicy()) {
|
|||
}
|
||||
|
||||
async function writeReadyFixture(root, options = {}) {
|
||||
await writeReleaseMetadataInputs(root, options);
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writePolicy(root, options.policy ?? releasePolicy());
|
||||
const layout = packageArtifactLayout(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -135,20 +126,24 @@ describe('release readiness policy', () => {
|
|||
sourceRevision: 'abc123',
|
||||
npmPublishEnabled: false,
|
||||
pythonPublishEnabled: false,
|
||||
packageNames: [...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name), 'ktx-sl', 'ktx-daemon'],
|
||||
packageNames: ['@kaelio/ktx', 'kaelio-ktx'],
|
||||
publishedPackageSmokeGate: {
|
||||
status: 'not_required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
configSource: null,
|
||||
packageName: null,
|
||||
configSource: 'release-policy',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
npmPublish: null,
|
||||
blockedPublishingDecisions: [
|
||||
'Choose npm registry and package visibility.',
|
||||
'Choose Python package repository.',
|
||||
'Choose public release versions.',
|
||||
'Choose public release version.',
|
||||
'Configure registry credentials outside source control.',
|
||||
'Choose release tag and provenance policy.',
|
||||
],
|
||||
|
|
@ -164,7 +159,7 @@ describe('release readiness policy', () => {
|
|||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -178,7 +173,7 @@ describe('release readiness policy', () => {
|
|||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
|
||||
configSource: 'release-policy',
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
});
|
||||
|
|
@ -194,7 +189,7 @@ describe('release readiness policy', () => {
|
|||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
|
|
@ -210,16 +205,22 @@ describe('release readiness policy', () => {
|
|||
sourceRevision: 'abc123',
|
||||
npmPublishEnabled: false,
|
||||
pythonPublishEnabled: false,
|
||||
packageNames: [...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name), 'ktx-sl', 'ktx-daemon'],
|
||||
packageNames: ['@kaelio/ktx', 'kaelio-ktx'],
|
||||
publishedPackageSmokeGate: {
|
||||
status: 'required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Run the published package smoke before accepting the hybrid-search release.',
|
||||
configSource: 'release-policy',
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '2026.5.8',
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
npmPublish: null,
|
||||
blockedPublishingDecisions: [],
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -227,12 +228,205 @@ describe('release readiness policy', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('accepts the npm public release ready policy', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: true,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await releaseReadinessReport(root);
|
||||
|
||||
assert.deepEqual(report, {
|
||||
schemaVersion: 1,
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
sourceRevision: 'abc123',
|
||||
npmPublishEnabled: true,
|
||||
pythonPublishEnabled: false,
|
||||
packageNames: ['@kaelio/ktx', 'kaelio-ktx'],
|
||||
publishedPackageSmokeGate: {
|
||||
status: 'required',
|
||||
script: 'pnpm run release:published-smoke',
|
||||
reason: 'Run the published package smoke after the npm package is published.',
|
||||
configSource: 'release-policy',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
npmPublish: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
blockedPublishingDecisions: [],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects npm public release ready mode without a runtime installer policy', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-policy-missing-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: true,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: undefined,
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/Release policy runtimeInstaller must be a JSON object/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects uv bootstrap download policy for the first public npm release', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-policy-bootstrap-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: true,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'bootstrap-download',
|
||||
bootstrapUv: true,
|
||||
missingUvBehavior: 'download',
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/Release policy runtimeInstaller\.uvStrategy must be path-prerequisite/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects npm public release ready mode when npm publish is disabled', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-disabled-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: false,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/npm-public-release-ready policy requires npm.publish true/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects npm public release ready mode when Python publishing is enabled', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-python-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: true,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'latest',
|
||||
},
|
||||
python: {
|
||||
publish: true,
|
||||
repository: 'pypi',
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/npm-public-release-ready policy keeps python.publish false/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects required published smoke mode without a package name', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-smoke-required-missing-config-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
publishedPackageSmoke: {
|
||||
packageName: null,
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
}),
|
||||
});
|
||||
|
|
@ -253,7 +447,7 @@ describe('release readiness policy', () => {
|
|||
policy: releasePolicy({
|
||||
releaseMode: 'published-package-smoke-required',
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@ktx/cli-public',
|
||||
packageName: '@kaelio/ktx',
|
||||
version: 'latest',
|
||||
registry: null,
|
||||
},
|
||||
|
|
@ -345,14 +539,20 @@ describe('release readiness policy', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects a public npm package while releaseMode is ci-artifact-only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-public-npm-test-'));
|
||||
it('rejects release policy that still lists internal npm packages', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-stale-internal-npm-policy-test-'));
|
||||
try {
|
||||
await writeReadyFixture(root, { contextPrivate: false });
|
||||
await writeReadyFixture(root, {
|
||||
policy: releasePolicy({
|
||||
npm: {
|
||||
packages: ['@kaelio/ktx', '@ktx/context'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => releaseReadinessReport(root),
|
||||
/ci-artifact-only policy npm package @ktx\/context must remain private/,
|
||||
/Release policy npm\.packages mismatch/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
|
|||
21
scripts/release-workflow.test.mjs
Normal file
21
scripts/release-workflow.test.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
describe('release workflow', () => {
|
||||
it('publishes only from manual dispatch with an explicit live input', async () => {
|
||||
const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(workflow, /^name: KTX Release$/m);
|
||||
assert.match(workflow, /^ workflow_dispatch:$/m);
|
||||
assert.match(workflow, /publish_live:/);
|
||||
assert.match(workflow, /default: false/);
|
||||
assert.match(workflow, /pnpm run artifacts:check/);
|
||||
assert.match(workflow, /pnpm run release:readiness/);
|
||||
assert.match(workflow, /pnpm run release:npm-publish$/m);
|
||||
assert.match(workflow, /pnpm run release:npm-publish -- --publish/);
|
||||
assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/);
|
||||
assert.doesNotMatch(workflow, /^ push:/m);
|
||||
assert.doesNotMatch(workflow, /^ pull_request:/m);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue