From 24dbbe2a06c957dace938ef7ab7def48716c0d12 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 11:20:42 +0200 Subject: [PATCH] feat: assemble public kaelio ktx npm package --- scripts/build-public-npm-package.mjs | 258 +++++++++++++++++++++ scripts/build-public-npm-package.test.mjs | 264 ++++++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 scripts/build-public-npm-package.mjs create mode 100644 scripts/build-public-npm-package.test.mjs diff --git a/scripts/build-public-npm-package.mjs b/scripts/build-public-npm-package.mjs new file mode 100644 index 00000000..d9347e64 --- /dev/null +++ b/scripts/build-public-npm-package.mjs @@ -0,0 +1,258 @@ +#!/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.0.0-private'; +export const PUBLIC_NPM_PACKAGE_TARBALL = 'kaelio-ktx-0.0.0-private.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-posthog', + '@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-posthog': 'packages/connector-posthog', + '@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()) { + return { + rootDir, + cliPackageRoot: join(rootDir, 'packages', 'cli'), + packRoot: join(rootDir, 'dist', 'public-npm-package'), + npmDir: join(rootDir, 'dist', 'artifacts', 'npm'), + tarballPath: join(rootDir, 'dist', 'artifacts', 'npm', PUBLIC_NPM_PACKAGE_TARBALL), + }; +} + +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) { + return { + name: PUBLIC_NPM_PACKAGE_NAME, + version: cliPackageJson.version ?? PUBLIC_NPM_PACKAGE_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)); +} + +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), + bundledPackages: PUBLIC_BUNDLED_WORKSPACE_PACKAGES, + }; +} + +export function publicNpmPackCommand(layout = publicNpmPackageLayout()) { + return { + command: 'pnpm', + args: ['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; + } +} diff --git a/scripts/build-public-npm-package.test.mjs b/scripts/build-public-npm-package.test.mjs new file mode 100644 index 00000000..1a5b3231 --- /dev/null +++ b/scripts/build-public-npm-package.test.mjs @@ -0,0 +1,264 @@ +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, + collectPublicDependencies, + createPublicNpmPackageTree, + publicNpmPackageJson, + publicNpmPackageLayout, + 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 stable public package build and tarball paths', () => { + const layout = publicNpmPackageLayout('/repo/ktx'); + + assert.equal(layout.rootDir, '/repo/ktx'); + assert.equal(layout.packRoot, '/repo/ktx/dist/public-npm-package'); + assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm'); + assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.0.0-private.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('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.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: ['pack', '--out', '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.0.0-private.tgz'], + cwd: '/repo/ktx/dist/public-npm-package', + }); + }); +});