mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
1122 lines
36 KiB
JavaScript
1122 lines
36 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import {
|
|
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
|
RUNTIME_WHEEL_NORMALIZED_NAME,
|
|
RUNTIME_WHEEL_PACKAGE_VERSION,
|
|
} from './build-python-runtime-wheel.mjs';
|
|
import {
|
|
PUBLIC_NPM_PACKAGE_NAME,
|
|
publicNpmPackageTarballName,
|
|
publicNpmPackageVersion,
|
|
} from './public-npm-release-metadata.mjs';
|
|
|
|
export {
|
|
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
|
RUNTIME_WHEEL_NORMALIZED_NAME,
|
|
RUNTIME_WHEEL_PACKAGE_VERSION,
|
|
};
|
|
|
|
export const NPM_ARTIFACT_PACKAGES = [{ name: PUBLIC_NPM_PACKAGE_NAME, packageRoot: 'packages/cli' }];
|
|
|
|
export const CLI_PYTHON_ASSET_MANIFEST = 'manifest.json';
|
|
|
|
function pnpmCommand(args) {
|
|
if (process.platform === 'win32') {
|
|
return {
|
|
command: 'cmd.exe',
|
|
args: ['/d', '/s', '/c', 'pnpm', ...args],
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: 'pnpm',
|
|
args,
|
|
};
|
|
}
|
|
|
|
function scriptRootDir() {
|
|
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
}
|
|
|
|
function npmPackageTarballName(packageName, version) {
|
|
if (packageName !== PUBLIC_NPM_PACKAGE_NAME) {
|
|
throw new Error(`Unsupported npm artifact package: ${packageName}`);
|
|
}
|
|
return publicNpmPackageTarballName(version);
|
|
}
|
|
|
|
function npmPackageTarballs(npmDir, version) {
|
|
return Object.fromEntries(
|
|
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
|
packageInfo.name,
|
|
join(npmDir, npmPackageTarballName(packageInfo.name, version)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function packageArtifactLayout(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
|
|
const artifactDir = join(rootDir, 'dist', 'artifacts');
|
|
const npmDir = join(artifactDir, 'npm');
|
|
const pythonDir = join(artifactDir, 'python');
|
|
const npmTarballs = npmPackageTarballs(npmDir, version);
|
|
|
|
return {
|
|
rootDir,
|
|
artifactDir,
|
|
npmDir,
|
|
pythonDir,
|
|
npmTarballs,
|
|
contextTarball: npmTarballs[PUBLIC_NPM_PACKAGE_NAME],
|
|
cliTarball: npmTarballs[PUBLIC_NPM_PACKAGE_NAME],
|
|
connectorTarballs: {},
|
|
manifestPath: join(artifactDir, 'manifest.json'),
|
|
};
|
|
}
|
|
|
|
export function buildArtifactCommands(layout) {
|
|
return [
|
|
{
|
|
...pnpmCommand(['--filter', PUBLIC_NPM_PACKAGE_NAME, 'run', 'build']),
|
|
cwd: layout.rootDir,
|
|
},
|
|
{
|
|
command: process.execPath,
|
|
args: ['scripts/build-python-runtime-wheel.mjs'],
|
|
cwd: layout.rootDir,
|
|
},
|
|
{
|
|
...pnpmCommand(['pack', '--out', layout.cliTarball]),
|
|
cwd: join(layout.rootDir, 'packages', 'cli'),
|
|
},
|
|
];
|
|
}
|
|
|
|
async function pathExists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function assertPathExists(path, label) {
|
|
if (!(await pathExists(path))) {
|
|
throw new Error(`Missing ${label}: ${path}`);
|
|
}
|
|
}
|
|
|
|
function normalizePythonDistributionName(name) {
|
|
return name.replaceAll('-', '_');
|
|
}
|
|
|
|
function findOne(files, distributionName, suffix, label, pythonDir, version) {
|
|
const normalized = normalizePythonDistributionName(distributionName);
|
|
const found = files.find((file) => file.startsWith(`${normalized}-${version}`) && file.endsWith(suffix));
|
|
if (!found) {
|
|
throw new Error(`Missing Python artifact: ${label}`);
|
|
}
|
|
return join(pythonDir, found);
|
|
}
|
|
|
|
export async function findPythonArtifacts(pythonDir) {
|
|
const files = await readdir(pythonDir);
|
|
|
|
return {
|
|
runtimeWheel: findOne(
|
|
files,
|
|
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
|
'.whl',
|
|
'kaelio-ktx runtime wheel',
|
|
pythonDir,
|
|
RUNTIME_WHEEL_PACKAGE_VERSION,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function artifactManifestPath(layout) {
|
|
return layout.manifestPath ?? join(layout.artifactDir, 'manifest.json');
|
|
}
|
|
|
|
async function readJson(path) {
|
|
return JSON.parse(await readFile(path, 'utf-8'));
|
|
}
|
|
|
|
function releaseMetadataEntry({ ecosystem, packageName, packageRoot, packageVersion, privatePackage }) {
|
|
return {
|
|
ecosystem,
|
|
packageName,
|
|
packageRoot,
|
|
packageVersion,
|
|
private: privatePackage,
|
|
releaseMode: 'ci-artifact-only',
|
|
};
|
|
}
|
|
|
|
async function readNpmPackageMetadata(rootDir, packageInfo, version) {
|
|
const packageJson = await readJson(join(rootDir, packageInfo.packageRoot, 'package.json'));
|
|
if (packageJson.name !== packageInfo.name) {
|
|
throw new Error(
|
|
`Unexpected package name in ${packageInfo.packageRoot}/package.json: expected ${packageInfo.name}, got ${packageJson.name}`,
|
|
);
|
|
}
|
|
return releaseMetadataEntry({
|
|
ecosystem: 'npm',
|
|
packageName: packageInfo.name,
|
|
packageRoot: packageInfo.packageRoot,
|
|
packageVersion: version,
|
|
privatePackage: false,
|
|
});
|
|
}
|
|
|
|
export async function packageReleaseMetadata(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
|
|
const npmPackages = await Promise.all(
|
|
NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo, version)),
|
|
);
|
|
|
|
return [
|
|
...npmPackages,
|
|
releaseMetadataEntry({
|
|
ecosystem: 'python',
|
|
packageName: RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
|
packageRoot: 'python/runtime-wheel',
|
|
packageVersion: RUNTIME_WHEEL_PACKAGE_VERSION,
|
|
privatePackage: false,
|
|
}),
|
|
];
|
|
}
|
|
|
|
function packageMetadataByName(packages) {
|
|
return new Map(packages.map((metadata) => [metadata.packageName, metadata]));
|
|
}
|
|
|
|
function requirePackageMetadata(packagesByName, packageName) {
|
|
const metadata = packagesByName.get(packageName);
|
|
if (!metadata) {
|
|
throw new Error(`Missing package release metadata for ${packageName}`);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
function artifactPackageRecords(layout, pythonArtifacts, packages) {
|
|
const packagesByName = packageMetadataByName(packages);
|
|
const npmRecords = NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
|
|
artifactKind: 'tarball',
|
|
artifactPath: layout.npmTarballs[packageInfo.name],
|
|
metadata: requirePackageMetadata(packagesByName, packageInfo.name),
|
|
}));
|
|
|
|
return [
|
|
...npmRecords,
|
|
{
|
|
artifactKind: 'wheel',
|
|
artifactPath: pythonArtifacts.runtimeWheel,
|
|
metadata: requirePackageMetadata(packagesByName, RUNTIME_WHEEL_DISTRIBUTION_NAME),
|
|
},
|
|
];
|
|
}
|
|
|
|
function artifactRelativePath(layout, artifactPath) {
|
|
return relative(layout.artifactDir, artifactPath).split(sep).join('/');
|
|
}
|
|
|
|
function formatJson(value) {
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function assertJsonEqual(actual, expected, label) {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`${label} do not match\nExpected:\n${formatJson(expected)}\nActual:\n${formatJson(actual)}`);
|
|
}
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function assertString(value, label) {
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`${label} must be a string`);
|
|
}
|
|
}
|
|
|
|
function artifactPathFromManifest(layout, manifestPath) {
|
|
assertString(manifestPath, 'Artifact manifest file path');
|
|
|
|
if (
|
|
manifestPath.length === 0 ||
|
|
manifestPath.startsWith('/') ||
|
|
manifestPath.includes('\\') ||
|
|
manifestPath.split('/').some((part) => part.length === 0 || part === '..')
|
|
) {
|
|
throw new Error(`Unsafe artifact manifest path: ${manifestPath}`);
|
|
}
|
|
|
|
const resolvedPath = resolve(layout.artifactDir, manifestPath);
|
|
const relativePath = relative(layout.artifactDir, resolvedPath);
|
|
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
throw new Error(`Unsafe artifact manifest path: ${manifestPath}`);
|
|
}
|
|
|
|
return resolvedPath;
|
|
}
|
|
|
|
function sortedManifestFiles(files) {
|
|
return [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
}
|
|
|
|
function assertManifestShape(manifest) {
|
|
if (!isPlainObject(manifest)) {
|
|
throw new Error('Artifact manifest must be a JSON object');
|
|
}
|
|
if (manifest.schemaVersion !== 2) {
|
|
throw new Error(`Unsupported artifact manifest schemaVersion: ${manifest.schemaVersion}`);
|
|
}
|
|
assertString(manifest.generatedAt, 'Artifact manifest generatedAt');
|
|
if (Number.isNaN(Date.parse(manifest.generatedAt))) {
|
|
throw new Error(`Artifact manifest generatedAt is not an ISO timestamp: ${manifest.generatedAt}`);
|
|
}
|
|
if (manifest.sourceRevision !== null && typeof manifest.sourceRevision !== 'string') {
|
|
throw new Error('Artifact manifest sourceRevision must be a string or null');
|
|
}
|
|
if (!Array.isArray(manifest.packages)) {
|
|
throw new Error('Artifact manifest packages must be an array');
|
|
}
|
|
if (!Array.isArray(manifest.files)) {
|
|
throw new Error('Artifact manifest files must be an array');
|
|
}
|
|
}
|
|
|
|
async function artifactManifestEntry(layout, record) {
|
|
const contents = await readFile(record.artifactPath);
|
|
return {
|
|
path: artifactRelativePath(layout, record.artifactPath),
|
|
ecosystem: record.metadata.ecosystem,
|
|
artifactKind: record.artifactKind,
|
|
packageName: record.metadata.packageName,
|
|
packageVersion: record.metadata.packageVersion,
|
|
bytes: contents.byteLength,
|
|
sha256: createHash('sha256').update(contents).digest('hex'),
|
|
};
|
|
}
|
|
|
|
export async function buildArtifactManifest(layout, generatedAt = new Date(), options = {}) {
|
|
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
|
const packages = await packageReleaseMetadata(layout.rootDir);
|
|
const artifactRecords = artifactPackageRecords(layout, pythonArtifacts, packages);
|
|
const files = await Promise.all(artifactRecords.map((record) => artifactManifestEntry(layout, record)));
|
|
|
|
return {
|
|
schemaVersion: 2,
|
|
generatedAt: generatedAt.toISOString(),
|
|
sourceRevision: options.sourceRevision ?? process.env.GITHUB_SHA ?? null,
|
|
packages,
|
|
files: files.sort((a, b) => a.path.localeCompare(b.path)),
|
|
};
|
|
}
|
|
|
|
export async function writeArtifactManifest(layout, generatedAt = new Date(), options = {}) {
|
|
const manifest = await buildArtifactManifest(layout, generatedAt, options);
|
|
await writeFile(artifactManifestPath(layout), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
return manifest;
|
|
}
|
|
|
|
export async function verifyArtifactManifest(layout, options = {}) {
|
|
const manifest = await readJson(artifactManifestPath(layout));
|
|
assertManifestShape(manifest);
|
|
|
|
const expectedSourceRevision = options.expectedSourceRevision ?? process.env.KTX_EXPECTED_SOURCE_REVISION;
|
|
if (expectedSourceRevision !== undefined && manifest.sourceRevision !== expectedSourceRevision) {
|
|
throw new Error(
|
|
`Artifact manifest sourceRevision mismatch: expected ${expectedSourceRevision}, got ${manifest.sourceRevision}`,
|
|
);
|
|
}
|
|
|
|
const expectedPackages = await packageReleaseMetadata(layout.rootDir);
|
|
assertJsonEqual(manifest.packages, expectedPackages, 'Artifact manifest packages');
|
|
|
|
for (const file of manifest.files) {
|
|
if (!isPlainObject(file)) {
|
|
throw new Error('Artifact manifest file entries must be JSON objects');
|
|
}
|
|
artifactPathFromManifest(layout, file.path);
|
|
}
|
|
|
|
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
|
const expectedFiles = await Promise.all(
|
|
artifactPackageRecords(layout, pythonArtifacts, expectedPackages).map((record) => artifactManifestEntry(layout, record)),
|
|
);
|
|
assertJsonEqual(
|
|
sortedManifestFiles(manifest.files),
|
|
sortedManifestFiles(expectedFiles),
|
|
'Artifact manifest files do not match artifact contents',
|
|
);
|
|
|
|
return manifest;
|
|
}
|
|
|
|
function runtimeWheelAssetName(runtimeWheelPath) {
|
|
return runtimeWheelPath.split(sep).at(-1);
|
|
}
|
|
|
|
export async function copyRuntimeWheelAssets(layout, pythonArtifacts) {
|
|
const assetDir = join(layout.rootDir, 'packages', 'cli', 'assets', 'python');
|
|
const wheelFile = runtimeWheelAssetName(pythonArtifacts.runtimeWheel);
|
|
if (!wheelFile) {
|
|
throw new Error(`Unable to determine runtime wheel filename: ${pythonArtifacts.runtimeWheel}`);
|
|
}
|
|
const wheelContents = await readFile(pythonArtifacts.runtimeWheel);
|
|
await rm(assetDir, { recursive: true, force: true });
|
|
await mkdir(assetDir, { recursive: true });
|
|
const wheelPath = join(assetDir, wheelFile);
|
|
const manifestPath = join(assetDir, CLI_PYTHON_ASSET_MANIFEST);
|
|
await writeFile(wheelPath, wheelContents);
|
|
await writeFile(
|
|
manifestPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
distributionName: RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
|
normalizedName: RUNTIME_WHEEL_NORMALIZED_NAME,
|
|
version: RUNTIME_WHEEL_PACKAGE_VERSION,
|
|
wheel: {
|
|
file: wheelFile,
|
|
sha256: createHash('sha256').update(wheelContents).digest('hex'),
|
|
bytes: wheelContents.byteLength,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
return { assetDir, wheelPath, manifestPath };
|
|
}
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
|
|
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = execFile(
|
|
command,
|
|
args,
|
|
{
|
|
cwd,
|
|
env: { ...process.env, ...options.env },
|
|
maxBuffer: 1024 * 1024 * 20,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (stdout) {
|
|
process.stdout.write(stdout);
|
|
}
|
|
if (stderr) {
|
|
process.stderr.write(stderr);
|
|
}
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolvePromise({ stdout, stderr });
|
|
},
|
|
);
|
|
|
|
if (options.input !== undefined) {
|
|
child.stdin?.end(options.input);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function npmSmokePackageJson(layout) {
|
|
return {
|
|
name: 'ktx-artifact-npm-smoke',
|
|
version: '0.0.0',
|
|
private: true,
|
|
type: 'module',
|
|
dependencies: {
|
|
'@kaelio/ktx': `file:${layout.cliTarball}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function npmSmokePnpmWorkspaceYaml() {
|
|
return ['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n');
|
|
}
|
|
|
|
export function npmVerifySource() {
|
|
return `
|
|
const cli = await import('@kaelio/ktx');
|
|
|
|
if (cli.getKtxCliPackageInfo().name !== '@kaelio/ktx') {
|
|
throw new Error('Unexpected @kaelio/ktx package info');
|
|
}
|
|
if (typeof cli.runKtxCli !== 'function') {
|
|
throw new Error('Missing runKtxCli export');
|
|
}
|
|
`;
|
|
}
|
|
|
|
export function npmRuntimeSmokeSource() {
|
|
return `
|
|
import assert from 'node:assert/strict';
|
|
import { execFile } from 'node:child_process';
|
|
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { createRequire } from 'node:module';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const require = createRequire(import.meta.url);
|
|
|
|
function pnpmCommand(args) {
|
|
if (process.platform === 'win32') {
|
|
return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'pnpm', ...args] };
|
|
}
|
|
return { command: 'pnpm', args };
|
|
}
|
|
|
|
async function run(command, args, options = {}) {
|
|
process.stdout.write('$ ' + command + ' ' + args.join(' ') + '\\n');
|
|
try {
|
|
const result = await execFileAsync(command, args, {
|
|
cwd: options.cwd,
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
|
} catch (error) {
|
|
return {
|
|
code: typeof error.code === 'number' ? error.code : 1,
|
|
stdout: error.stdout ?? '',
|
|
stderr: error.stderr ?? error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
function requireSuccess(label, result) {
|
|
assert.equal(
|
|
result.code,
|
|
0,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
assert.equal(result.stderr, '', label + ' wrote unexpected stderr');
|
|
}
|
|
|
|
function requireSuccessWithProjectStderr(label, result, projectDir) {
|
|
assert.equal(
|
|
result.code,
|
|
0,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
|
|
}
|
|
|
|
function requireExitCodeWithProjectStderr(label, result, projectDir, expectedCode) {
|
|
assert.equal(
|
|
result.code,
|
|
expectedCode,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
|
|
}
|
|
|
|
function requireSuccessWithStderr(label, result, stderrPattern) {
|
|
assert.equal(
|
|
result.code,
|
|
0,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
assert.match(result.stderr, stderrPattern, label + ' stderr did not match ' + stderrPattern);
|
|
}
|
|
|
|
function requireOutput(label, result, text) {
|
|
assert.match(result.stdout, text, label + ' output did not match ' + text);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&');
|
|
}
|
|
|
|
async function installedPackageVersionPattern() {
|
|
const packageJson = JSON.parse(await readFile(require.resolve('@kaelio/ktx/package.json'), 'utf8'));
|
|
return new RegExp('^' + escapeRegExp(packageJson.name) + ' ' + escapeRegExp(packageJson.version) + '$', 'm');
|
|
}
|
|
|
|
function parseJsonResult(label, result) {
|
|
requireSuccess(label, result);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function parseJsonResultWithExitCode(label, result, expectedCode) {
|
|
assert.equal(
|
|
result.code,
|
|
expectedCode,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function requireIncludes(values, expected, label) {
|
|
assert.ok(Array.isArray(values), label + ' must be an array');
|
|
assert.ok(values.includes(expected), label + ' did not include ' + expected + ': ' + values.join(', '));
|
|
}
|
|
|
|
async function rmWithRetry(path) {
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
await rm(path, { recursive: true, force: true });
|
|
return;
|
|
} catch (error) {
|
|
const code = typeof error?.code === 'string' ? error.code : '';
|
|
if (attempt >= 4 || !['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
|
|
throw error;
|
|
}
|
|
await delay(500);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function writeSqliteWarehouse(projectDir) {
|
|
const database = new DatabaseSync(join(projectDir, 'warehouse.db'));
|
|
try {
|
|
database.exec(\`
|
|
DROP TABLE IF EXISTS orders;
|
|
CREATE TABLE orders (
|
|
id INTEGER PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
amount INTEGER NOT NULL
|
|
);
|
|
INSERT INTO orders (status, amount) VALUES ('paid', 20), ('paid', 30), ('open', 10);
|
|
\`);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
const root = await mkdtemp(join(tmpdir(), 'ktx-installed-cli-smoke-'));
|
|
const previousRuntimeRoot = process.env.KTX_RUNTIME_ROOT;
|
|
process.env.KTX_RUNTIME_ROOT = join(root, 'managed-runtime');
|
|
let daemonStarted = false;
|
|
try {
|
|
const projectDir = join(root, 'project');
|
|
|
|
const version = await run(...Object.values(pnpmCommand(['exec', 'ktx', '--version'])));
|
|
requireSuccess('ktx public package version', version);
|
|
requireOutput('ktx public package version', version, await installedPackageVersionPattern());
|
|
|
|
const runtimeStatusBefore = parseJsonResultWithExitCode(
|
|
'ktx admin runtime status missing',
|
|
await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status', '--json']))),
|
|
1,
|
|
);
|
|
assert.equal(runtimeStatusBefore.kind, 'missing');
|
|
assert.equal(runtimeStatusBefore.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT);
|
|
process.stdout.write('ktx managed runtime starts missing in isolated root\\n');
|
|
|
|
const init = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'setup',
|
|
'--project-dir',
|
|
projectDir,
|
|
'--no-input',
|
|
'--yes',
|
|
'--skip-llm',
|
|
'--skip-embeddings',
|
|
'--skip-databases',
|
|
'--skip-sources',
|
|
'--skip-agents',
|
|
]),
|
|
),
|
|
);
|
|
requireSuccess('ktx setup', init);
|
|
|
|
const emptyProjectDir = join(root, 'empty-project');
|
|
const emptyInit = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'setup',
|
|
'--project-dir',
|
|
emptyProjectDir,
|
|
'--no-input',
|
|
'--yes',
|
|
'--skip-llm',
|
|
'--skip-embeddings',
|
|
'--skip-databases',
|
|
'--skip-sources',
|
|
'--skip-agents',
|
|
]),
|
|
),
|
|
);
|
|
requireSuccess('ktx setup empty project', emptyInit);
|
|
await writeFile(
|
|
join(projectDir, 'ktx.yaml'),
|
|
[
|
|
'connections:',
|
|
' warehouse:',
|
|
' driver: sqlite',
|
|
' path: warehouse.db',
|
|
'storage:',
|
|
' state: sqlite',
|
|
' search: sqlite-fts5',
|
|
'scan:',
|
|
' enrichment:',
|
|
' mode: deterministic',
|
|
'',
|
|
].join('\\n'),
|
|
'utf-8',
|
|
);
|
|
await writeSqliteWarehouse(projectDir);
|
|
|
|
await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true });
|
|
await writeFile(
|
|
join(projectDir, 'wiki', 'global', 'revenue.md'),
|
|
[
|
|
'---',
|
|
'summary: Paid order value',
|
|
'tags:',
|
|
' - finance',
|
|
'refs: []',
|
|
'sl_refs: []',
|
|
'usage_mode: auto',
|
|
'---',
|
|
'',
|
|
'Revenue is the sum of paid order amounts.',
|
|
'',
|
|
].join('\\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const wikiSearch = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'wiki',
|
|
'revenue',
|
|
'--json',
|
|
'--limit',
|
|
'5',
|
|
'--project-dir',
|
|
projectDir,
|
|
]),
|
|
),
|
|
);
|
|
const wikiSearchJson = parseJsonResult('ktx wiki search', wikiSearch);
|
|
assert.equal(wikiSearchJson.kind, 'list');
|
|
assert.equal(wikiSearchJson.data.items.length, 1);
|
|
assert.equal(wikiSearchJson.data.items[0].key, 'revenue');
|
|
assert.equal(wikiSearchJson.data.items[0].path, 'wiki/global/revenue.md');
|
|
assert.equal(typeof wikiSearchJson.data.items[0].score, 'number');
|
|
requireIncludes(wikiSearchJson.data.items[0].matchReasons, 'lexical', 'wiki search match reasons');
|
|
process.stdout.write('ktx wiki search hybrid metadata verified\\n');
|
|
await access(join(projectDir, '.ktx', 'db.sqlite'));
|
|
process.stdout.write('SQLite wiki index: ' + join(projectDir, '.ktx', 'db.sqlite') + '\\n');
|
|
|
|
const slYaml = [
|
|
'name: orders',
|
|
'table: orders',
|
|
'grain:',
|
|
' - id',
|
|
'columns:',
|
|
' - name: id',
|
|
' type: number',
|
|
' - name: amount',
|
|
' type: number',
|
|
'measures:',
|
|
' - name: order_count',
|
|
' expr: count(*)',
|
|
'joins: []',
|
|
'',
|
|
].join('\\n');
|
|
|
|
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
|
|
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), slYaml, 'utf-8');
|
|
|
|
const slSearch = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'sl',
|
|
'orders',
|
|
'--json',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--project-dir',
|
|
projectDir,
|
|
]),
|
|
),
|
|
);
|
|
const slSearchJson = parseJsonResult('ktx sl search', slSearch);
|
|
assert.equal(slSearchJson.kind, 'list');
|
|
assert.equal(slSearchJson.data.items.length, 1);
|
|
assert.equal(slSearchJson.data.items[0].connectionId, 'warehouse');
|
|
assert.equal(slSearchJson.data.items[0].name, 'orders');
|
|
assert.equal(typeof slSearchJson.data.items[0].score, 'number');
|
|
requireIncludes(slSearchJson.data.items[0].matchReasons, 'lexical', 'sl search match reasons');
|
|
process.stdout.write('ktx sl search hybrid metadata verified\\n');
|
|
|
|
const slQuery = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'sl',
|
|
'query',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--measure',
|
|
'orders.order_count',
|
|
'--format',
|
|
'json',
|
|
'--yes',
|
|
'--project-dir',
|
|
projectDir,
|
|
]),
|
|
),
|
|
);
|
|
requireSuccessWithStderr(
|
|
'ktx sl query first managed runtime install',
|
|
slQuery,
|
|
/Installing KTX Python runtime \\(core\\) with uv[\\s\\S]*KTX Python runtime ready:/,
|
|
);
|
|
requireOutput('ktx sl query first managed runtime install', slQuery, /"mode": "compile_only"/);
|
|
requireOutput('ktx sl query first managed runtime install', slQuery, /orders/);
|
|
|
|
const runtimeStatusAfter = parseJsonResult(
|
|
'ktx admin runtime status ready',
|
|
await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status', '--json']))),
|
|
);
|
|
assert.equal(runtimeStatusAfter.kind, 'ready');
|
|
assert.deepEqual(runtimeStatusAfter.manifest.features, ['core']);
|
|
assert.equal(runtimeStatusAfter.layout.runtimeRoot, process.env.KTX_RUNTIME_ROOT);
|
|
process.stdout.write('ktx managed runtime lazy install verified\\n');
|
|
|
|
const sqliteSlQuery = await run(
|
|
...Object.values(
|
|
pnpmCommand([
|
|
'exec',
|
|
'ktx',
|
|
'sl',
|
|
'query',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--measure',
|
|
'orders.order_count',
|
|
'--format',
|
|
'json',
|
|
'--execute',
|
|
'--max-rows',
|
|
'100',
|
|
'--yes',
|
|
'--project-dir',
|
|
projectDir,
|
|
]),
|
|
),
|
|
);
|
|
requireSuccess('ktx sl query sqlite execute', sqliteSlQuery);
|
|
requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"dialect": "sqlite"/);
|
|
requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"mode": "executed"/);
|
|
requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"driver": "sqlite"/);
|
|
requireOutput('ktx sl query sqlite execute', sqliteSlQuery, /"rows": \\[\\s*\\[\\s*3\\s*\\]\\s*\\]/);
|
|
process.stdout.write('ktx sl query sqlite execute verified\\n');
|
|
|
|
const runtimeDoctor = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'status'])));
|
|
requireSuccess('ktx admin runtime status', runtimeDoctor);
|
|
requireOutput('ktx admin runtime status', runtimeDoctor, /KTX Python runtime/);
|
|
requireOutput('ktx admin runtime status', runtimeDoctor, /status: ready/);
|
|
process.stdout.write('ktx admin runtime status verified\\n');
|
|
|
|
const runtimeStart = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'start'])));
|
|
requireSuccess('ktx admin runtime start', runtimeStart);
|
|
daemonStarted = true;
|
|
requireOutput('ktx admin runtime start', runtimeStart, /Started KTX daemon/);
|
|
requireOutput('ktx admin runtime start', runtimeStart, /url: http:\\/\\/127\\.0\\.0\\.1:\\d+/);
|
|
requireOutput('ktx admin runtime start', runtimeStart, /features: core/);
|
|
|
|
const runtimeStartReuse = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'start'])));
|
|
requireSuccess('ktx admin runtime start reuse', runtimeStartReuse);
|
|
requireOutput('ktx admin runtime start reuse', runtimeStartReuse, /Using existing KTX daemon/);
|
|
requireOutput('ktx admin runtime start reuse', runtimeStartReuse, /features: core/);
|
|
|
|
const runtimeStop = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'stop'])));
|
|
requireSuccess('ktx admin runtime stop', runtimeStop);
|
|
daemonStarted = false;
|
|
requireOutput('ktx admin runtime stop', runtimeStop, /Stopped KTX daemon/);
|
|
process.stdout.write('ktx admin runtime daemon lifecycle verified\\n');
|
|
|
|
const structuralScan = await run(
|
|
...Object.values(
|
|
pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']),
|
|
),
|
|
);
|
|
requireSuccessWithProjectStderr('ktx ingest fast', structuralScan, projectDir);
|
|
requireOutput('ktx ingest fast', structuralScan, /Ingest finished/);
|
|
requireOutput('ktx ingest fast', structuralScan, /Database schema/);
|
|
requireOutput('ktx ingest fast', structuralScan, /warehouse\\s+done/);
|
|
await access(join(projectDir, 'semantic-layer', 'warehouse', '_schema', 'public.yaml'));
|
|
process.stdout.write('ktx ingest fast verified\\n');
|
|
|
|
const enrichedScan = await run(
|
|
...Object.values(
|
|
pnpmCommand(['exec', 'ktx', 'ingest', 'warehouse', '--project-dir', projectDir, '--deep', '--no-input']),
|
|
),
|
|
);
|
|
requireExitCodeWithProjectStderr('ktx ingest deep readiness guard', enrichedScan, projectDir, 1);
|
|
requireOutput('ktx ingest deep readiness guard', enrichedScan, /Ingest finished with partial failures/);
|
|
requireOutput('ktx ingest deep readiness guard', enrichedScan, /requires deep ingest readiness/);
|
|
process.stdout.write('ktx ingest deep readiness guard verified\\n');
|
|
|
|
await access(join(projectDir, '.ktx', 'db.sqlite'));
|
|
process.stdout.write('ktx ingest state verified\\n');
|
|
} finally {
|
|
if (daemonStarted) {
|
|
await run(...Object.values(pnpmCommand(['exec', 'ktx', 'admin', 'runtime', 'stop'])));
|
|
await delay(500);
|
|
}
|
|
if (previousRuntimeRoot === undefined) {
|
|
delete process.env.KTX_RUNTIME_ROOT;
|
|
} else {
|
|
process.env.KTX_RUNTIME_ROOT = previousRuntimeRoot;
|
|
}
|
|
await rmWithRetry(root);
|
|
}
|
|
`;
|
|
}
|
|
|
|
export function npmCliSmokeSource() {
|
|
return `
|
|
import assert from 'node:assert/strict';
|
|
import { execFile } from 'node:child_process';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function pnpmCommand(args) {
|
|
if (process.platform === 'win32') {
|
|
return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'pnpm', ...args] };
|
|
}
|
|
return { command: 'pnpm', args };
|
|
}
|
|
|
|
async function run(command, args, options = {}) {
|
|
process.stdout.write('$ ' + command + ' ' + args.join(' ') + '\\n');
|
|
try {
|
|
const result = await execFileAsync(command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env ?? process.env,
|
|
encoding: 'utf8',
|
|
timeout: 45_000,
|
|
});
|
|
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
|
} catch (error) {
|
|
return {
|
|
code: typeof error.code === 'number' ? error.code : 1,
|
|
stdout: error.stdout ?? '',
|
|
stderr: error.stderr ?? error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
function requireSuccess(label, result) {
|
|
assert.equal(
|
|
result.code,
|
|
0,
|
|
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
|
);
|
|
}
|
|
|
|
function requireStdout(label, result, pattern) {
|
|
assert.match(result.stdout, pattern, label + ' stdout did not match ' + pattern);
|
|
}
|
|
|
|
const root = await mkdtemp(join(tmpdir(), 'ktx-cli-smoke-'));
|
|
try {
|
|
const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'));
|
|
assert.deepEqual(Object.keys(packageJson.dependencies), ['@kaelio/ktx']);
|
|
|
|
const help = await run(...Object.values(pnpmCommand(['exec', 'ktx', '--help'])));
|
|
requireSuccess('ktx --help', help);
|
|
requireStdout('ktx --help', help, /Usage: ktx/);
|
|
requireStdout('ktx --help', help, /setup/);
|
|
|
|
const setupHelp = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'setup', '--help'])));
|
|
requireSuccess('ktx setup --help', setupHelp);
|
|
requireStdout('ktx setup --help', setupHelp, /Usage: ktx setup/);
|
|
requireStdout('ktx setup --help', setupHelp, /--no-input/);
|
|
|
|
const doctor = await run(...Object.values(pnpmCommand(['exec', 'ktx', 'status', '--verbose', '--no-input'])));
|
|
assert.ok([0, 1].includes(doctor.code), 'ktx status setup exit code must be 0 or 1');
|
|
requireStdout('ktx status setup', doctor, /KTX status/);
|
|
requireStdout('ktx status setup', doctor, /No project here yet\\./);
|
|
requireStdout('ktx status setup', doctor, /ktx setup/);
|
|
requireStdout('ktx status setup', doctor, /Node 22\\+/);
|
|
assert.equal(doctor.stderr, '', 'ktx status setup wrote unexpected stderr');
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
`;
|
|
}
|
|
|
|
async function buildArtifacts(layout) {
|
|
await rm(layout.artifactDir, { recursive: true, force: true });
|
|
await mkdir(layout.npmDir, { recursive: true });
|
|
await mkdir(layout.pythonDir, { recursive: true });
|
|
|
|
const [npmBuildCommand, wheelCommand, packCommand] = buildArtifactCommands(layout);
|
|
|
|
await runCommand(npmBuildCommand.command, npmBuildCommand.args, { cwd: npmBuildCommand.cwd });
|
|
await runCommand(wheelCommand.command, wheelCommand.args, { cwd: wheelCommand.cwd });
|
|
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
|
await copyRuntimeWheelAssets(layout, pythonArtifacts);
|
|
await runCommand(packCommand.command, packCommand.args, { cwd: packCommand.cwd });
|
|
|
|
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
|
await assertPathExists(layout.npmTarballs[packageInfo.name], `${packageInfo.name} tarball`);
|
|
}
|
|
await writeArtifactManifest(layout);
|
|
await assertPathExists(artifactManifestPath(layout), 'artifact manifest');
|
|
}
|
|
|
|
async function buildRuntimeWheelAssets(layout) {
|
|
await rm(layout.pythonDir, { recursive: true, force: true });
|
|
await mkdir(layout.pythonDir, { recursive: true });
|
|
|
|
const [, wheelCommand] = buildArtifactCommands(layout);
|
|
await runCommand(wheelCommand.command, wheelCommand.args, { cwd: wheelCommand.cwd });
|
|
const pythonArtifacts = await findPythonArtifacts(layout.pythonDir);
|
|
await copyRuntimeWheelAssets(layout, pythonArtifacts);
|
|
}
|
|
|
|
async function verifyNpmArtifacts(layout, tmpRoot) {
|
|
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
|
await assertPathExists(layout.npmTarballs[packageInfo.name], `${packageInfo.name} tarball`);
|
|
}
|
|
|
|
const projectDir = join(tmpRoot, 'npm-clean-install');
|
|
await mkdir(projectDir, { recursive: true });
|
|
await writeFile(
|
|
join(projectDir, 'package.json'),
|
|
`${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`,
|
|
);
|
|
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
|
await writeFile(join(projectDir, 'verify-npm.mjs'), npmVerifySource());
|
|
await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource());
|
|
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
|
|
|
{
|
|
const pnpmInstall = pnpmCommand(['install']);
|
|
await runCommand(pnpmInstall.command, pnpmInstall.args, { cwd: projectDir });
|
|
}
|
|
{
|
|
const pnpmRebuild = pnpmCommand(['rebuild', 'better-sqlite3']);
|
|
await runCommand(pnpmRebuild.command, pnpmRebuild.args, { cwd: projectDir });
|
|
}
|
|
await runCommand('node', ['verify-npm.mjs'], { cwd: projectDir });
|
|
{
|
|
const pnpmExecVersion = pnpmCommand(['exec', 'ktx', '--version']);
|
|
await runCommand(pnpmExecVersion.command, pnpmExecVersion.args, { cwd: projectDir });
|
|
}
|
|
await runCommand('node', ['verify-installed-cli.mjs'], { cwd: projectDir });
|
|
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
|
|
}
|
|
|
|
async function verifyNpmCliArtifacts(layout, tmpRoot) {
|
|
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
|
await assertPathExists(layout.npmTarballs[packageInfo.name], `${packageInfo.name} tarball`);
|
|
}
|
|
|
|
const projectDir = join(tmpRoot, 'npm-cli-clean-install');
|
|
await mkdir(projectDir, { recursive: true });
|
|
await writeFile(join(projectDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
|
|
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
|
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
|
|
|
{
|
|
const pnpmInstall = pnpmCommand(['install']);
|
|
await runCommand(pnpmInstall.command, pnpmInstall.args, { cwd: projectDir });
|
|
}
|
|
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
|
|
}
|
|
|
|
async function verifyArtifacts(layout) {
|
|
await verifyArtifactManifest(layout);
|
|
|
|
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-artifacts-'));
|
|
try {
|
|
await verifyNpmArtifacts(layout, tmpRoot);
|
|
} finally {
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function verifyCliArtifacts(layout) {
|
|
await verifyArtifactManifest(layout);
|
|
|
|
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-cli-artifacts-'));
|
|
try {
|
|
await verifyNpmCliArtifacts(layout, tmpRoot);
|
|
} finally {
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const command = process.argv[2] ?? 'check';
|
|
const layout = packageArtifactLayout();
|
|
|
|
if (command === 'build') {
|
|
await buildArtifacts(layout);
|
|
return;
|
|
}
|
|
if (command === 'build-runtime') {
|
|
await buildRuntimeWheelAssets(layout);
|
|
return;
|
|
}
|
|
if (command === 'verify') {
|
|
await verifyArtifacts(layout);
|
|
return;
|
|
}
|
|
if (command === 'verify-demo') {
|
|
await verifyCliArtifacts(layout);
|
|
return;
|
|
}
|
|
if (command === 'verify-manifest') {
|
|
await verifyArtifactManifest(layout);
|
|
return;
|
|
}
|
|
if (command === 'check') {
|
|
await buildArtifacts(layout);
|
|
await verifyArtifacts(layout);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unknown package artifact command: ${command}`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|