mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
Merge remote-tracking branch 'origin/main' into select-tables-on-connect
# Conflicts: # packages/cli/src/setup-agents.test.ts
This commit is contained in:
commit
9175451b01
9 changed files with 246 additions and 142 deletions
|
|
@ -90,7 +90,7 @@ describe('setup agents', () => {
|
|||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
|
||||
});
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('agents');
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cancel, isCancel, multiselect, select } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts';
|
||||
import {
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
|
|
@ -277,12 +277,23 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
|
|||
return String(value);
|
||||
},
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
|
|
|
|||
|
|
@ -142,8 +142,8 @@ describe('setup databases step', () => {
|
|||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'How do you want to connect to PostgreSQL?',
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -154,6 +154,43 @@ describe('setup databases step', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('offers connection URL paste first for URL-capable primary sources', async () => {
|
||||
const cases: Array<{ driver: KtxSetupDatabaseDriver; label: string }> = [
|
||||
{ driver: 'postgres', label: 'PostgreSQL' },
|
||||
{ driver: 'mysql', label: 'MySQL' },
|
||||
{ driver: 'clickhouse', label: 'ClickHouse' },
|
||||
{ driver: 'sqlserver', label: 'SQL Server' },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const prompts = makePromptAdapter({
|
||||
selectValues: ['back'],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
databaseDrivers: [testCase.driver],
|
||||
skipDatabases: false,
|
||||
databaseSchemas: [],
|
||||
},
|
||||
makeIo().io,
|
||||
{ prompts },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('back');
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: `How do you want to connect to ${testCase.label}?`,
|
||||
options: [
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets Back leave database setup when the driver came from flags', async () => {
|
||||
const prompts = makePromptAdapter({ selectValues: ['back'] });
|
||||
|
||||
|
|
@ -488,8 +525,8 @@ describe('setup databases step', () => {
|
|||
expect(prompts.select).toHaveBeenNthCalledWith(1, {
|
||||
message: 'How do you want to connect to PostgreSQL?',
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -913,10 +950,11 @@ describe('setup databases step', () => {
|
|||
[
|
||||
'◇ Testing postgres-warehouse',
|
||||
'│ ✓ Connection test passed',
|
||||
'│ Driver: PostgreSQL · Tables: 2',
|
||||
'│ Driver: PostgreSQL',
|
||||
'│',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(io.stdout()).not.toContain('Tables: 2');
|
||||
expect(io.stdout()).toContain(
|
||||
[
|
||||
'◇ Scanning postgres-warehouse',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { writeFile } from 'node:fs/promises';
|
||||
import { cancel, isCancel, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts';
|
||||
import type { HistoricSqlDialect } from '@ktx/context/ingest';
|
||||
import {
|
||||
type KtxProjectConnectionConfig,
|
||||
|
|
@ -205,12 +205,23 @@ function missingConnectionDetailsPrompt(
|
|||
function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
|
|
@ -699,8 +710,8 @@ async function buildUrlConnectionConfig(input: {
|
|||
const choice = await input.prompts.select({
|
||||
message: `How do you want to connect to ${label}?`,
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -1408,9 +1419,7 @@ async function validateAndScanConnection(input: {
|
|||
const testOutput = testIo.stdoutText();
|
||||
const outputDriver = normalizeDriver(readOutputValue(testOutput, 'Driver'));
|
||||
const driverDisplay = outputDriver ? driverLabel(outputDriver) : (configuredDriverLabel ?? 'Unknown driver');
|
||||
const tableCount = Number(readOutputValue(testOutput, 'Tables') ?? NaN);
|
||||
const testLines = ['✓ Connection test passed'];
|
||||
testLines.push(`Driver: ${driverDisplay}${Number.isFinite(tableCount) ? ` · Tables: ${tableCount}` : ''}`);
|
||||
const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`];
|
||||
writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines);
|
||||
|
||||
while (true) {
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe('runDemoTour', () => {
|
|||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
// Navigation called once for databases step, then exits
|
||||
// Navigation called once for intro, then exits on back
|
||||
expect(navigation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -218,10 +218,11 @@ describe('runDemoTour', () => {
|
|||
let callCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
// First call (databases): forward
|
||||
// Second call (sources): back
|
||||
// Third call (databases again): back (exit)
|
||||
if (callCount === 1) return Promise.resolve('forward');
|
||||
// First call (intro): forward
|
||||
// Second call (databases): forward
|
||||
// Third call (sources): back
|
||||
// Fourth call (databases again): back (exit)
|
||||
if (callCount <= 2) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ describe('runDemoTour', () => {
|
|||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
expect(navigation).toHaveBeenCalledTimes(3);
|
||||
expect(navigation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('handles agent step returning back', async () => {
|
||||
|
|
@ -243,10 +244,10 @@ describe('runDemoTour', () => {
|
|||
let navCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
navCount++;
|
||||
// Forward through databases, sources, context
|
||||
// Forward through intro, databases, sources, context
|
||||
// Then back from context (after agents returns back)
|
||||
// Then back from sources, then back from databases (exit)
|
||||
if (navCount <= 3) return Promise.resolve('forward');
|
||||
if (navCount <= 4) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -62,12 +62,15 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge
|
|||
// Pure rendering functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderDemoBanner(): string {
|
||||
export function renderDemoBanner(projectDir?: string): string {
|
||||
const lines = [
|
||||
'',
|
||||
`┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`,
|
||||
'│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.',
|
||||
];
|
||||
if (projectDir) {
|
||||
lines.push(`│ Project directory: ${dim(projectDir)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -144,16 +147,15 @@ export async function waitForDemoNavigation(
|
|||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString();
|
||||
if (char === '\r' || char === '\n') {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (char === '\x1b') {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
} else if (char === '\x03') {
|
||||
if (data[0] === 0x03) {
|
||||
cleanup();
|
||||
reject(new KtxSetupExitError());
|
||||
} else if (data[0] === 0x0d || data[0] === 0x0a) {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (data[0] === 0x1b) {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -171,8 +173,9 @@ export async function renderDemoCard(
|
|||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation,
|
||||
projectDir?: string,
|
||||
): Promise<'forward' | 'back'> {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n\n');
|
||||
io.stdout.write(renderDemoCardContent(title, selections) + '\n');
|
||||
return waitNav(stdin);
|
||||
}
|
||||
|
|
@ -337,6 +340,11 @@ export async function runDemoTour(
|
|||
const projectDir = defaultDemoProjectDir();
|
||||
await ensureProject({ projectDir, force: false });
|
||||
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n');
|
||||
io.stdout.write(`\n│ ${dim('Press Enter to continue, Escape to go back')}\n└\n`);
|
||||
const introDirection = await waitNav();
|
||||
if (introDirection === 'back') return 0;
|
||||
|
||||
let stepIndex = 0;
|
||||
|
||||
while (stepIndex < DEMO_STEPS.length) {
|
||||
|
|
@ -344,11 +352,11 @@ export async function runDemoTour(
|
|||
let direction: 'forward' | 'back';
|
||||
|
||||
if (step === 'databases') {
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav);
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'sources') {
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav);
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'context') {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n\n');
|
||||
if (deps.skipReplayAnimation) {
|
||||
direction = await waitNav();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -142,10 +142,11 @@ describe('setup project step', () => {
|
|||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
expect.objectContaining({ value: 'current', label: 'Use current directory' }),
|
||||
expect.objectContaining({ value: 'new', label: 'Create a new project folder' }),
|
||||
expect.objectContaining({ value: 'current', label: 'Current directory' }),
|
||||
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
|
||||
expect.objectContaining({ value: 'new-custom', label: 'Custom path' }),
|
||||
expect.objectContaining({ value: 'exit', label: 'Exit' }),
|
||||
],
|
||||
}),
|
||||
|
|
@ -159,7 +160,7 @@ describe('setup project step', () => {
|
|||
it('offers an absolute default destination for a new project folder', async () => {
|
||||
const startDir = join(tempDir, 'start');
|
||||
const projectDir = join(startDir, 'ktx-project');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'default', 'create'] });
|
||||
const prompts = makePromptAdapter({ choices: ['new-default', 'create'] });
|
||||
const testIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
|
|
@ -171,21 +172,16 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
1,
|
||||
expect.objectContaining({
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
value: 'default',
|
||||
label: `Create the default project folder: ${projectDir}`,
|
||||
}),
|
||||
expect.objectContaining({ value: 'custom', label: 'Enter a custom path' }),
|
||||
expect.objectContaining({ value: 'back', label: 'Back' }),
|
||||
],
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }),
|
||||
);
|
||||
expect(prompts.text).not.toHaveBeenCalled();
|
||||
|
|
@ -197,7 +193,7 @@ describe('setup project step', () => {
|
|||
it('prompts for a custom path and resolves it inside the current setup directory', async () => {
|
||||
const startDir = join(tempDir, 'start');
|
||||
const projectDir = join(startDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: 'analytics-ktx' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -220,7 +216,7 @@ describe('setup project step', () => {
|
|||
const startDir = join(tempDir, 'start');
|
||||
const homeDir = join(tempDir, 'home');
|
||||
const projectDir = join(homeDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: '~/analytics-ktx' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -238,7 +234,7 @@ describe('setup project step', () => {
|
|||
const homeDir = join(tempDir, 'home');
|
||||
const customProjectDir = join(homeDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({
|
||||
choices: ['new', 'custom', 'back', 'exit'],
|
||||
choices: ['new-custom', 'back', 'exit'],
|
||||
textValue: '~/analytics-ktx',
|
||||
});
|
||||
|
||||
|
|
@ -251,7 +247,7 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('cancelled');
|
||||
expect(result.projectDir).toBe(startDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: `Create KTX project at ${customProjectDir}?`,
|
||||
options: [
|
||||
|
|
@ -262,15 +258,15 @@ describe('setup project step', () => {
|
|||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ message: 'Which KTX project should setup use?' }),
|
||||
3,
|
||||
expect.objectContaining({ message: 'Where should KTX create the project?' }),
|
||||
);
|
||||
await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
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: ' ' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' });
|
||||
const initProject = vi.fn(async () => {
|
||||
throw new Error('initProject should not run for an empty path');
|
||||
});
|
||||
|
|
@ -295,7 +291,7 @@ describe('setup project step', () => {
|
|||
const projectDir = join(startDir, 'analytics-ktx');
|
||||
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' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -306,7 +302,7 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: `That folder already exists and is not empty: ${projectDir}`,
|
||||
options: expect.arrayContaining([
|
||||
|
|
|
|||
|
|
@ -113,6 +113,55 @@ async function existingFolderState(
|
|||
}
|
||||
}
|
||||
|
||||
type ConfirmProjectDirResult =
|
||||
| { status: 'confirmed'; confirmedCreation: boolean }
|
||||
| { status: 'choose-another' }
|
||||
| { status: 'back' }
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'not-directory' };
|
||||
|
||||
async function confirmProjectDir(
|
||||
selectedDir: string,
|
||||
io: KtxCliIo,
|
||||
prompts: KtxSetupProjectPromptAdapter,
|
||||
): Promise<ConfirmProjectDirResult> {
|
||||
const state = await existingFolderState(selectedDir);
|
||||
|
||||
if (state === 'not-directory') {
|
||||
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
|
||||
return { status: 'not-directory' };
|
||||
}
|
||||
|
||||
if (state === 'non-empty-directory') {
|
||||
const action = await prompts.select({
|
||||
message: `That folder already exists and is not empty: ${selectedDir}`,
|
||||
options: [
|
||||
{ value: 'use-existing', label: 'Yes, create KTX files there' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (action === 'choose-another') return { status: 'choose-another' };
|
||||
if (action === 'back') return { status: 'back' };
|
||||
if (action !== 'use-existing') return { status: 'cancelled' };
|
||||
return { status: 'confirmed', confirmedCreation: true };
|
||||
}
|
||||
|
||||
io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`);
|
||||
const action = await prompts.select({
|
||||
message: `Create KTX project at ${selectedDir}?`,
|
||||
options: [
|
||||
{ value: 'create', label: 'Create project' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (action === 'choose-another') return { status: 'choose-another' };
|
||||
if (action === 'back') return { status: 'back' };
|
||||
if (action !== 'create') return { status: 'cancelled' };
|
||||
return { status: 'confirmed', confirmedCreation: true };
|
||||
}
|
||||
|
||||
async function normalizeSetupGitignore(projectDir: string): Promise<void> {
|
||||
const gitignorePath = join(projectDir, '.ktx/.gitignore');
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
|
|
@ -155,8 +204,6 @@ async function promptForNewProjectDir(
|
|||
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
|
||||
|
||||
while (true) {
|
||||
io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`);
|
||||
io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`);
|
||||
const destinationChoice = await prompts.select({
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
|
|
@ -193,55 +240,12 @@ async function promptForNewProjectDir(
|
|||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
|
||||
const state = await existingFolderState(selectedDir);
|
||||
let confirmedCreation = false;
|
||||
if (state === 'not-directory') {
|
||||
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
if (state === 'non-empty-directory') {
|
||||
const existingAction = await prompts.select({
|
||||
message: `That folder already exists and is not empty: ${selectedDir}`,
|
||||
options: [
|
||||
{ value: 'use-existing', label: 'Yes, create KTX files there' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (existingAction === 'choose-another') {
|
||||
continue;
|
||||
}
|
||||
if (existingAction === 'back') {
|
||||
return { status: 'back', projectDir };
|
||||
}
|
||||
if (existingAction !== 'use-existing') {
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
confirmedCreation = true;
|
||||
}
|
||||
|
||||
io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`);
|
||||
if (state !== 'non-empty-directory') {
|
||||
const createAction = await prompts.select({
|
||||
message: `Create KTX project at ${selectedDir}?`,
|
||||
options: [
|
||||
{ value: 'create', label: 'Create project' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (createAction === 'choose-another') {
|
||||
continue;
|
||||
}
|
||||
if (createAction === 'back') {
|
||||
return { status: 'back', projectDir };
|
||||
}
|
||||
if (createAction !== 'create') {
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
confirmedCreation = true;
|
||||
}
|
||||
return { status: 'selected', projectDir: selectedDir, confirmedCreation };
|
||||
const confirmed = await confirmProjectDir(selectedDir, io, prompts);
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'choose-another') continue;
|
||||
if (confirmed.status === 'back') return { status: 'back', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,15 +327,17 @@ export async function runKtxSetupProjectStep(
|
|||
}
|
||||
|
||||
const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter();
|
||||
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
|
||||
io.stdout.write(
|
||||
'│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
|
||||
);
|
||||
while (true) {
|
||||
const choice = await prompts.select({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
{ value: 'current', label: 'Use current directory' },
|
||||
{ value: 'new', label: 'Create a new project folder' },
|
||||
{ value: 'current', label: 'Current directory' },
|
||||
{ value: 'new-default', label: 'New subfolder (./ktx-project)' },
|
||||
{ value: 'new-custom', label: 'Custom path' },
|
||||
...(args.allowBack ? [{ value: 'back', label: 'Back' }] : []),
|
||||
...(args.allowBack ? [] : [{ value: 'exit', label: 'Exit' }]),
|
||||
],
|
||||
|
|
@ -346,27 +352,51 @@ export async function runKtxSetupProjectStep(
|
|||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
|
||||
let selectedDir = projectDir;
|
||||
let confirmedCreation = false;
|
||||
if (choice === 'new') {
|
||||
const selected = await promptForNewProjectDir(projectDir, homeDir, io, prompts);
|
||||
if (selected.status === 'back') {
|
||||
continue;
|
||||
}
|
||||
if (selected.status !== 'selected') {
|
||||
return selected;
|
||||
}
|
||||
selectedDir = selected.projectDir;
|
||||
confirmedCreation = selected.confirmedCreation;
|
||||
if (choice === 'current') {
|
||||
const project = await createProject(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
}
|
||||
|
||||
if (choice !== 'current' && choice !== 'new') {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
return { status: 'cancelled', projectDir };
|
||||
if (choice === 'new-default') {
|
||||
const confirmed = await confirmProjectDir(defaultProjectDir, io, prompts);
|
||||
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
const project = await createProject(defaultProjectDir, deps);
|
||||
printProjectSummary(io, defaultProjectDir);
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir: defaultProjectDir,
|
||||
project,
|
||||
confirmedCreation: confirmed.confirmedCreation,
|
||||
};
|
||||
}
|
||||
|
||||
const project = await createProject(selectedDir, deps);
|
||||
printProjectSummary(io, selectedDir);
|
||||
return { status: 'ready', projectDir: selectedDir, project, confirmedCreation };
|
||||
if (choice === 'new-custom') {
|
||||
const rawPath = await prompts.text({
|
||||
message: withTextInputNavigation('Project folder path'),
|
||||
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
|
||||
});
|
||||
if (rawPath === undefined) continue;
|
||||
const trimmed = rawPath.trim();
|
||||
if (trimmed.length === 0) {
|
||||
io.stderr.write(
|
||||
'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.\n',
|
||||
);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
const customDir = resolveFromProjectDir(projectDir, trimmed, homeDir);
|
||||
const confirmed = await confirmProjectDir(customDir, io, prompts);
|
||||
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
const project = await createProject(customDir, deps);
|
||||
printProjectSummary(io, customDir);
|
||||
return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation };
|
||||
}
|
||||
|
||||
prompts.cancel('Setup cancelled.');
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cancel, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
|
|
@ -136,12 +136,23 @@ const PRIMARY_SOURCE_DRIVERS = new Set([
|
|||
function createPromptAdapter(): KtxSetupSourcesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue