ktx/packages/cli/test/setup-project.test.ts

382 lines
15 KiB
TypeScript
Raw Normal View History

fix(cli): isolate ktx-owned project repositories (#283) * 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.
2026-06-10 14:12:25 +02:00
import { execFileSync } from 'node:child_process';
2026-05-10 23:12:26 +02:00
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
test: split cli tests from source tree (#216) * feat(cli): define full warehouse dialect contract * test(cli): keep dialect edge tests focused * fix(cli): stabilize dialect contract foundation * refactor(connectors): own read-only query preparation * refactor(connectors): resolve dialects through registry * refactor(connectors): keep concrete dialect classes internal * chore(workspace): enforce dialect import boundary * refactor(cli): resolve relationship dialect at scan boundary * refactor(cli): use dialect display parsing for entity details * refactor(cli): use dialect display parsing for warehouse catalog * refactor(cli): use dialect SQL in relationship workflows * test(cli): verify solid dialect scan workflow closure * test: split cli tests from source tree * refactor(cli): standardize BigQuery scope listing * feat(sqlite): implement connector scope listing * test(connectors): cover required table listing * feat(cli): add warehouse driver registry * refactor(setup): route scope discovery through driver registry * refactor(cli): route local query execution through driver registry * refactor(historic-sql): route dialect support through driver registry * refactor(cli): test warehouse connections through driver registry * fix(cli): close driver registry type export gaps * Improve setup daemon diagnostics * refactor(setup): centralize rail-prefixed diagnostics + query-history fallback Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput into clack.ts so the setup wizard, managed daemons, and embedding/agent steps share one rail-formatted writer. setup-databases.ts also adds a "disable query history and retry" option when the schema-context build fails and query history is the likely culprit, surfaced via a new failed-query-history-unavailable status. * fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match The setup picker's KtxTableListEntry was a 2-level { schema, name }, so qualifiedTableId always wrote db.name into enabled_tables. When BigQuery, Snowflake, or SQL Server later ran fast ingest, their introspect step filtered the scope set with scopedTableNames(scope, { catalog: projectId|database, db }) — catalog was non-null on the introspect side but null in the scope refs, so every entry was rejected, the live-database adapter staged zero table files, and detect() failed with 'Adapter "live-database" did not recognize fetched source output'. Align the picker boundary with the canonical 3-level KtxTableRef: - Add catalog: string | null to KtxTableListEntry. - BigQuery/Snowflake/SQL Server listTables populate catalog from the resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null. - qualifiedTableId emits catalog.schema.name when catalog is non-null (resolveEnabledTables already accepts the 3-part shape) and schemasFromEnabledTables now goes through parseDottedTableEntry so it recovers the schema correctly from both 2-part and 3-part entries. - Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker reuse. Update listTables expectations in all seven connector tests and the setup / picker test fixtures. Add a picker regression test that covers the catalog-bearing round-trip (save + refine). * fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
import { readKtxSetupState } from '../src/context/project/setup-config.js';
2026-05-10 23:12:26 +02:00
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
test: split cli tests from source tree (#216) * feat(cli): define full warehouse dialect contract * test(cli): keep dialect edge tests focused * fix(cli): stabilize dialect contract foundation * refactor(connectors): own read-only query preparation * refactor(connectors): resolve dialects through registry * refactor(connectors): keep concrete dialect classes internal * chore(workspace): enforce dialect import boundary * refactor(cli): resolve relationship dialect at scan boundary * refactor(cli): use dialect display parsing for entity details * refactor(cli): use dialect display parsing for warehouse catalog * refactor(cli): use dialect SQL in relationship workflows * test(cli): verify solid dialect scan workflow closure * test: split cli tests from source tree * refactor(cli): standardize BigQuery scope listing * feat(sqlite): implement connector scope listing * test(connectors): cover required table listing * feat(cli): add warehouse driver registry * refactor(setup): route scope discovery through driver registry * refactor(cli): route local query execution through driver registry * refactor(historic-sql): route dialect support through driver registry * refactor(cli): test warehouse connections through driver registry * fix(cli): close driver registry type export gaps * Improve setup daemon diagnostics * refactor(setup): centralize rail-prefixed diagnostics + query-history fallback Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput into clack.ts so the setup wizard, managed daemons, and embedding/agent steps share one rail-formatted writer. setup-databases.ts also adds a "disable query history and retry" option when the schema-context build fails and query history is the likely culprit, surfaced via a new failed-query-history-unavailable status. * fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match The setup picker's KtxTableListEntry was a 2-level { schema, name }, so qualifiedTableId always wrote db.name into enabled_tables. When BigQuery, Snowflake, or SQL Server later ran fast ingest, their introspect step filtered the scope set with scopedTableNames(scope, { catalog: projectId|database, db }) — catalog was non-null on the introspect side but null in the scope refs, so every entry was rejected, the live-database adapter staged zero table files, and detect() failed with 'Adapter "live-database" did not recognize fetched source output'. Align the picker boundary with the canonical 3-level KtxTableRef: - Add catalog: string | null to KtxTableListEntry. - BigQuery/Snowflake/SQL Server listTables populate catalog from the resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null. - qualifiedTableId emits catalog.schema.name when catalog is non-null (resolveEnabledTables already accepts the 3-part shape) and schemasFromEnabledTables now goes through parseDottedTableEntry so it recovers the schema correctly from both 2-part and 3-part entries. - Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker reuse. Update listTables expectations in all seven connector tests and the setup / picker test fixtures. Add a picker regression test that covers the catalog-bearing round-trip (save + refine). * fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
import { gray } from '../src/io/symbols.js';
import { type KtxSetupProjectPromptAdapter, runKtxSetupProjectStep } from '../src/setup-project.js';
2026-05-10 23:12:26 +02:00
function makeIo(options: { stdoutIsTty?: boolean } = {}) {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: options.stdoutIsTty,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function makePromptAdapter(options: { choice?: string; choices?: string[]; textValue?: string; textValues?: string[] }) {
const choices = [...(options.choices ?? (options.choice ? [options.choice] : []))];
const textValues = [...(options.textValues ?? (options.textValue !== undefined ? [options.textValue] : []))];
return {
select: vi.fn(async () => choices.shift() ?? 'exit'),
text: vi.fn(async () => textValues.shift() ?? ''),
cancel: vi.fn(),
2026-05-10 23:51:24 +02:00
} satisfies KtxSetupProjectPromptAdapter;
2026-05-10 23:12:26 +02:00
}
function defaultSubfolderLabel(parentDir: string): string {
const childName = 'ktx-project';
const childDir = join(parentDir, childName);
return `New subfolder (${gray(childDir.slice(0, -childName.length))}${childName})`;
}
fix(cli): isolate ktx-owned project repositories (#283) * 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.
2026-06-10 14:12:25 +02:00
function initForeignRepo(dir: string): void {
execFileSync('git', ['init'], {
cwd: dir,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Foreign User',
GIT_AUTHOR_EMAIL: 'foreign@example.com',
GIT_COMMITTER_NAME: 'Foreign User',
GIT_COMMITTER_EMAIL: 'foreign@example.com',
},
});
}
2026-05-10 23:12:26 +02:00
describe('setup project step', () => {
let tempDir: string;
beforeEach(async () => {
2026-05-10 23:51:24 +02:00
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-project-'));
2026-05-10 23:12:26 +02:00
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('creates a new project in non-interactive auto mode with --yes and marks the project step complete', async () => {
2026-05-10 23:12:26 +02:00
const projectDir = join(tempDir, 'warehouse');
const testIo = makeIo();
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
{ projectDir, mode: 'auto', inputMode: 'disabled', yes: true },
2026-05-10 23:12:26 +02:00
testIo.io,
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
2026-05-10 23:12:26 +02:00
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
2026-05-10 23:51:24 +02:00
await expect(readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8')).resolves.toContain('secrets/');
2026-05-10 23:12:26 +02:00
expect(testIo.stdout()).toContain(`Project: ${projectDir}`);
expect(testIo.stderr()).toBe('');
});
it('creates a missing auto-mode project only when --yes is present in no-input mode', async () => {
const projectDir = join(tempDir, 'warehouse');
const rejectedIo = makeIo();
const acceptedIo = makeIo();
await expect(
2026-05-10 23:51:24 +02:00
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, rejectedIo.io),
2026-05-10 23:12:26 +02:00
).resolves.toMatchObject({ status: 'missing-input' });
expect(rejectedIo.stderr()).toContain('Missing setup choice: pass --yes');
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
2026-05-10 23:12:26 +02:00
await expect(
2026-05-10 23:51:24 +02:00
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: true }, acceptedIo.io),
2026-05-10 23:12:26 +02:00
).resolves.toMatchObject({ status: 'ready', projectDir });
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
2026-05-10 23:12:26 +02:00
});
it('fails clearly in no-input auto mode when ktx.yaml is missing and --yes is absent', async () => {
2026-05-10 23:12:26 +02:00
const projectDir = join(tempDir, 'warehouse');
const testIo = makeIo();
await expect(
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, testIo.io),
2026-05-10 23:12:26 +02:00
).resolves.toMatchObject({ status: 'missing-input' });
expect(testIo.stderr()).toContain('Missing setup choice: pass --yes');
2026-05-10 23:12:26 +02:00
});
it('prompts to use the current directory and creates a project in interactive auto mode', async () => {
const projectDir = join(tempDir, 'warehouse');
const prompts = makePromptAdapter({ choice: 'current' });
const testIo = makeIo({ stdoutIsTty: true });
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Where should ktx create the project?',
2026-05-10 23:12:26 +02:00
options: [
expect.objectContaining({ value: 'current', label: `Current directory (${projectDir})` }),
expect.objectContaining({
value: 'new-default',
label: defaultSubfolderLabel(projectDir),
}),
expect.objectContaining({ value: 'new-custom', label: 'Custom path' }),
2026-05-10 23:12:26 +02:00
expect.objectContaining({ value: 'exit', label: 'Exit' }),
],
}),
);
expect(prompts.text).not.toHaveBeenCalled();
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
2026-05-10 23:12:26 +02:00
});
it('offers an absolute default destination for a new project folder', async () => {
const startDir = join(tempDir, 'start');
2026-05-10 23:51:24 +02:00
const projectDir = join(startDir, 'ktx-project');
const prompts = makePromptAdapter({ choices: ['new-default', 'create'] });
2026-05-10 23:12:26 +02:00
const testIo = makeIo({ stdoutIsTty: true });
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenNthCalledWith(
1,
2026-05-10 23:12:26 +02:00
expect.objectContaining({
message: 'Where should ktx create the project?',
options: expect.arrayContaining([
expect.objectContaining({
value: 'new-default',
label: defaultSubfolderLabel(startDir),
}),
]),
2026-05-10 23:12:26 +02:00
}),
);
expect(prompts.select).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ message: `Create ktx project at ${projectDir}?` }),
2026-05-10 23:12:26 +02:00
);
expect(prompts.text).not.toHaveBeenCalled();
expect(result.status === 'ready' ? result.project.configPath : '').toBe(join(projectDir, 'ktx.yaml'));
expect(testIo.stdout()).toContain(`│ ktx will create:\n│ ${projectDir}`);
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
2026-05-10 23:12:26 +02:00
});
it('prompts for a custom path and resolves it inside the current setup directory', async () => {
const startDir = join(tempDir, 'start');
2026-05-10 23:51:24 +02:00
const projectDir = join(startDir, 'analytics-ktx');
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' });
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
makeIo({ stdoutIsTty: true }).io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Project folder path\n│ Press Escape to go back.\n│',
2026-05-10 23:51:24 +02:00
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
2026-05-10 23:12:26 +02:00
}),
);
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
2026-05-10 23:12:26 +02:00
});
it('expands a custom home-directory path before creating a new project', async () => {
const startDir = join(tempDir, 'start');
const homeDir = join(tempDir, 'home');
2026-05-10 23:51:24 +02:00
const projectDir = join(homeDir, 'analytics-ktx');
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' });
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
makeIo({ stdoutIsTty: true }).io,
{ prompts, homeDir },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
2026-05-10 23:12:26 +02:00
});
it('confirms a custom new project path and lets Back return to the project choice', async () => {
const startDir = join(tempDir, 'start');
const homeDir = join(tempDir, 'home');
2026-05-10 23:51:24 +02:00
const customProjectDir = join(homeDir, 'analytics-ktx');
2026-05-10 23:12:26 +02:00
const prompts = makePromptAdapter({
choices: ['new-custom', 'back', 'exit'],
2026-05-10 23:51:24 +02:00
textValue: '~/analytics-ktx',
2026-05-10 23:12:26 +02:00
});
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
makeIo({ stdoutIsTty: true }).io,
{ prompts, homeDir },
);
expect(result.status).toBe('cancelled');
expect(result.projectDir).toBe(startDir);
expect(prompts.select).toHaveBeenNthCalledWith(
2,
2026-05-10 23:12:26 +02:00
expect.objectContaining({
message: `Create ktx project at ${customProjectDir}?`,
2026-05-10 23:12:26 +02:00
options: [
expect.objectContaining({ value: 'create', label: 'Create project' }),
expect.objectContaining({ value: 'choose-another', label: 'Choose another folder' }),
expect.objectContaining({ value: 'back', label: 'Back' }),
],
}),
);
expect(prompts.select).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ message: 'Where should ktx create the project?' }),
2026-05-10 23:12:26 +02:00
);
2026-05-10 23:51:24 +02:00
await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow();
2026-05-10 23:12:26 +02:00
});
it('rejects an empty new folder path without creating a project in the process cwd', async () => {
const startDir = join(tempDir, 'start');
const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' });
2026-05-10 23:12:26 +02:00
const initProject = vi.fn(async () => {
throw new Error('initProject should not run for an empty path');
});
const testIo = makeIo({ stdoutIsTty: true });
await expect(
2026-05-10 23:51:24 +02:00
runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts, initProject },
),
).resolves.toMatchObject({ status: 'missing-input', projectDir: startDir });
expect(initProject).not.toHaveBeenCalled();
expect(testIo.stderr()).toContain(
2026-05-10 23:51:24 +02:00
'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.',
2026-05-10 23:12:26 +02:00
);
});
it('confirms before creating ktx files inside an existing non-empty folder', async () => {
2026-05-10 23:12:26 +02:00
const startDir = join(tempDir, 'start');
2026-05-10 23:51:24 +02:00
const projectDir = join(startDir, 'analytics-ktx');
2026-05-10 23:12:26 +02:00
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'README.md'), 'Existing project notes\n', 'utf-8');
const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' });
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
const result = await runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
makeIo({ stdoutIsTty: true }).io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenNthCalledWith(
2,
2026-05-10 23:12:26 +02:00
expect.objectContaining({
message: `That folder already exists and is not empty: ${projectDir}`,
options: expect.arrayContaining([
expect.objectContaining({ value: 'use-existing', label: 'Yes, create ktx files there' }),
2026-05-10 23:12:26 +02:00
expect.objectContaining({ value: 'choose-another', label: 'Choose another folder' }),
]),
}),
);
await expect(readFile(join(projectDir, 'README.md'), 'utf-8')).resolves.toBe('Existing project notes\n');
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
2026-05-10 23:12:26 +02:00
});
fix(cli): isolate ktx-owned project repositories (#283) * 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.
2026-06-10 14:12:25 +02:00
it('refuses to create a project in a foreign git repo in non-interactive mode', async () => {
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
initForeignRepo(projectDir);
const testIo = makeIo();
await expect(
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: true }, testIo.io),
).resolves.toMatchObject({ status: 'missing-input', projectDir });
expect(testIo.stderr()).toContain('already a git repository that ktx did not create');
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
});
it('re-prompts away from a foreign current directory and creates the project in a subfolder', async () => {
const projectDir = join(tempDir, 'app-repo');
await mkdir(projectDir, { recursive: true });
initForeignRepo(projectDir);
const subfolderDir = join(projectDir, 'ktx-project');
const prompts = makePromptAdapter({ choices: ['current', 'new-default', 'create'] });
const testIo = makeIo({ stdoutIsTty: true });
const result = await runKtxSetupProjectStep(
{ projectDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts },
);
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(subfolderDir);
expect(testIo.stderr()).toContain('already a git repository that ktx did not create');
await expect(stat(join(subfolderDir, 'ktx.yaml'))).resolves.toBeDefined();
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
});
it('rejects a custom path that points at an existing file without crashing', async () => {
const startDir = join(tempDir, 'start');
await mkdir(startDir, { recursive: true });
await writeFile(join(startDir, 'notes.txt'), 'a file, not a folder\n', 'utf-8');
const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: 'notes.txt' });
const testIo = makeIo({ stdoutIsTty: true });
await expect(
runKtxSetupProjectStep(
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
testIo.io,
{ prompts },
),
).resolves.toMatchObject({ status: 'missing-input', projectDir: startDir });
expect(testIo.stderr()).toContain('exists and is not a directory');
});
2026-05-10 23:12:26 +02:00
it('prompts to exit and returns cancelled in interactive auto mode', async () => {
const projectDir = join(tempDir, 'warehouse');
const prompts = makePromptAdapter({ choice: 'exit' });
await expect(
2026-05-10 23:51:24 +02:00
runKtxSetupProjectStep(
2026-05-10 23:12:26 +02:00
{ projectDir, mode: 'auto', inputMode: 'auto', yes: false },
makeIo({ stdoutIsTty: true }).io,
{ prompts },
),
).resolves.toMatchObject({ status: 'cancelled', projectDir });
expect(prompts.cancel).toHaveBeenCalledWith('Setup cancelled.');
expect(prompts.text).not.toHaveBeenCalled();
2026-05-10 23:51:24 +02:00
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
2026-05-10 23:12:26 +02:00
});
});