mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
* fix(cli): isolate ktx project git repos * fix(cli): remove inert auto commit config * test(cli): drop stale auto commit fixtures * docs: document isolated ktx project repos * test(cli): keep stale config grep clean * fix(cli): guide setup away from foreign repos at the project dir ktx owns the git repo rooted at the project dir and refuses to adopt one it did not create (the Finding 3 isolation invariant). But setup steered users straight into that failure: the interactive menu offers "Current directory" first, and `--no-input --yes --project-dir <repo-root>` created directly in place — both then threw a generic "Failed to initialize git repository:" wrapper from deep in GitService.initialize(). Extract the ownership rule into a shared `classifyKtxRepoOwnership(dir)` used by both GitService.initialize() (the invariant) and the setup wizard (pre-flight guidance), so the decision derives from one rule. Setup now detects a foreign repo before constructing GitService and: interactively re-prompts (the user picks the existing `ktx-project` subfolder), or non-interactively returns a clean missing-input with the actionable message. The typed foreign-repo error is also surfaced verbatim instead of being buried under the generic wrapper. Empty/non-repo current directories still work — only foreign repos are blocked. * fix(cli): keep classifyKtxRepoOwnership total for non-directory paths The setup ownership guard runs before the existing not-a-directory check, so pointing a custom/--project-dir path at a file made classifyKtxRepoOwnership lstat `<file>/.git`, hit ENOTDIR, and throw — crashing the setup step instead of returning the friendly "path exists and is not a directory" result. A path that is a file (or missing) holds no git repo for ktx to avoid, so treat ENOTDIR like ENOENT and return 'unowned'. The downstream existingFolderState check still rejects a non-directory with its friendly message, and the classifier no longer throws raw errno for any caller.
201 lines
6.3 KiB
TypeScript
201 lines
6.3 KiB
TypeScript
import { constants as fsConstants } from 'node:fs';
|
|
import { access, copyFile, cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js';
|
|
|
|
interface DemoProjectResult {
|
|
projectDir: string;
|
|
configPath: string;
|
|
databasePath: string;
|
|
replayPath: string;
|
|
}
|
|
|
|
interface EnsureDemoProjectOptions {
|
|
projectDir: string;
|
|
force: boolean;
|
|
io?: KtxCliIo;
|
|
cliVersion?: string;
|
|
}
|
|
|
|
/** @internal */
|
|
export const DEMO_CONNECTION_ID = 'orbit_demo';
|
|
/** @internal */
|
|
export const DEMO_ADAPTER = 'live-database';
|
|
/** @internal */
|
|
export const DEMO_REPLAY_FILE = 'replay.memory-flow.v1.json';
|
|
|
|
const REQUIRED_PACKAGED_BASE_ASSET_PATHS = ['demo.db', 'manifest.json', DEMO_REPLAY_FILE] as const;
|
|
|
|
const REQUIRED_SEEDED_ASSET_PATHS = [
|
|
'demo.db',
|
|
'manifest.json',
|
|
DEMO_REPLAY_FILE,
|
|
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
|
|
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
|
|
join('wiki', 'global', 'orbit-company-overview.md'),
|
|
] as const;
|
|
|
|
function assetDir(): string {
|
|
return fileURLToPath(new URL('../assets/demo/orbit/', import.meta.url));
|
|
}
|
|
|
|
async function exists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path, fsConstants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function defaultDemoProjectDir(): string {
|
|
const suffix = randomBytes(4).toString('hex');
|
|
return join(tmpdir(), `ktx-demo-${suffix}`);
|
|
}
|
|
|
|
function demoConfig(databasePath: string): string {
|
|
return [
|
|
'connections:',
|
|
` ${DEMO_CONNECTION_ID}:`,
|
|
' driver: sqlite',
|
|
` path: ${JSON.stringify(databasePath)}`,
|
|
'storage:',
|
|
' state: sqlite',
|
|
' search: sqlite-fts5',
|
|
' git:',
|
|
' author: ktx <ktx@example.com>',
|
|
'llm:',
|
|
' provider:',
|
|
' backend: anthropic',
|
|
' anthropic:',
|
|
' api_key: env:ANTHROPIC_API_KEY',
|
|
' models:',
|
|
' default: claude-sonnet-4-6',
|
|
'ingest:',
|
|
' adapters:',
|
|
` - ${DEMO_ADAPTER}`,
|
|
' embeddings:',
|
|
' backend: none',
|
|
' dimensions: 8',
|
|
' workUnits:',
|
|
' stepBudget: 40',
|
|
' maxConcurrency: 1',
|
|
' failureMode: continue',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
async function copyPackagedReplay(projectDir: string): Promise<string> {
|
|
const replayDir = join(projectDir, 'replays');
|
|
await mkdir(replayDir, { recursive: true });
|
|
const replayPath = join(replayDir, DEMO_REPLAY_FILE);
|
|
await copyFile(join(assetDir(), DEMO_REPLAY_FILE), replayPath);
|
|
return replayPath;
|
|
}
|
|
|
|
async function assertPackagedBaseAssetsPresent(): Promise<void> {
|
|
const missing: string[] = [];
|
|
for (const relativePath of REQUIRED_PACKAGED_BASE_ASSET_PATHS) {
|
|
if (!(await exists(join(assetDir(), relativePath)))) {
|
|
missing.push(relativePath);
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
throw new Error(`Packaged demo assets are incomplete: missing ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
async function assertPackagedSeededAssetsPresent(): Promise<void> {
|
|
const missing: string[] = [];
|
|
for (const relativePath of REQUIRED_SEEDED_ASSET_PATHS) {
|
|
if (!(await exists(join(assetDir(), relativePath)))) {
|
|
missing.push(relativePath);
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
throw new Error(`Packaged seeded demo assets are incomplete: missing ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
/** @internal */
|
|
export async function ensureDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
|
const projectDir = resolve(options.projectDir);
|
|
const configPath = join(projectDir, 'ktx.yaml');
|
|
if (!options.force && (await exists(configPath))) {
|
|
throw new Error(`Demo project already exists at ${projectDir}; pass --force to recreate it`);
|
|
}
|
|
|
|
await assertPackagedBaseAssetsPresent();
|
|
|
|
if (options.force) {
|
|
await rm(projectDir, { recursive: true, force: true });
|
|
}
|
|
|
|
await mkdir(projectDir, { recursive: true });
|
|
for (const relativeDir of ['reports', 'semantic-layer', 'wiki', 'replays', 'raw-sources', 'links']) {
|
|
await mkdir(join(projectDir, relativeDir), { recursive: true });
|
|
}
|
|
|
|
const databasePath = join(projectDir, 'demo.db');
|
|
await copyFile(join(assetDir(), 'demo.db'), databasePath);
|
|
await writeFile(join(projectDir, 'state.sqlite'), '', { flag: 'a' });
|
|
await copyFile(join(assetDir(), 'manifest.json'), join(projectDir, 'manifest.json'));
|
|
const replayPath = await copyPackagedReplay(projectDir);
|
|
await writeFile(configPath, demoConfig(databasePath), 'utf-8');
|
|
if (options.io) {
|
|
const { emitTelemetryEvent } = await import('./telemetry/index.js');
|
|
await emitTelemetryEvent({
|
|
name: 'connection_added',
|
|
projectDir,
|
|
io: options.io,
|
|
packageInfo: { ...getKtxCliPackageInfo(), version: options.cliVersion ?? getKtxCliPackageInfo().version },
|
|
fields: {
|
|
driver: 'sqlite',
|
|
isDemoConnection: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { projectDir, configPath, databasePath, replayPath };
|
|
}
|
|
|
|
async function copyDirIfExists(src: string, dest: string): Promise<void> {
|
|
if (await exists(src)) {
|
|
await cp(src, dest, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function copySeededAssetDirectories(projectDir: string): Promise<void> {
|
|
const src = assetDir();
|
|
const dest = resolve(projectDir);
|
|
|
|
await Promise.all([
|
|
copyDirIfExists(join(src, 'semantic-layer'), join(dest, 'semantic-layer')),
|
|
copyDirIfExists(join(src, 'wiki'), join(dest, 'wiki')),
|
|
copyDirIfExists(join(src, 'raw-sources'), join(dest, 'raw-sources')),
|
|
copyDirIfExists(join(src, 'links'), join(dest, 'links')),
|
|
copyDirIfExists(join(src, 'reports'), join(dest, 'reports')),
|
|
]);
|
|
}
|
|
|
|
export async function ensureSeededDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
|
await assertPackagedSeededAssetsPresent();
|
|
const projectDir = resolve(options.projectDir);
|
|
const result = await ensureDemoProject(options).catch((error) => {
|
|
if (!options.force && error instanceof Error && error.message.includes('Demo project already exists')) {
|
|
return {
|
|
projectDir,
|
|
configPath: join(projectDir, 'ktx.yaml'),
|
|
databasePath: join(projectDir, 'demo.db'),
|
|
replayPath: join(projectDir, 'replays', DEMO_REPLAY_FILE),
|
|
};
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
await copySeededAssetDirectories(result.projectDir);
|
|
return result;
|
|
}
|