mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat(cli): print resolved project dir
This commit is contained in:
parent
a0ea1609ac
commit
0ff14e1953
4 changed files with 143 additions and 11 deletions
|
|
@ -42,6 +42,13 @@ interface KtxGlobalOptionValues {
|
|||
debug?: boolean;
|
||||
}
|
||||
|
||||
type CommandPathNode = CommandWithGlobalOptions & {
|
||||
name: () => string;
|
||||
parent?: CommandPathNode | null;
|
||||
};
|
||||
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
|
|
@ -115,6 +122,80 @@ function optionsWithGlobals(command: CommandWithGlobalOptions): KtxGlobalOptionV
|
|||
};
|
||||
}
|
||||
|
||||
function commandOptions(command: CommandWithGlobalOptions): Record<string, unknown> {
|
||||
return (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function commandPath(command: CommandPathNode): string[] {
|
||||
const path: string[] = [];
|
||||
let current: CommandPathNode | null | undefined = command;
|
||||
|
||||
while (current) {
|
||||
path.unshift(current.name());
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function isProjectAwareCommand(path: string[]): boolean {
|
||||
if (path.includes('__complete')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootCommand = path[1];
|
||||
if (rootCommand === 'dev') {
|
||||
return path[2] !== undefined && path[2] !== 'completion';
|
||||
}
|
||||
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
|
||||
}
|
||||
|
||||
function shouldSuppressProjectDirLine(path: string[], options: Record<string, unknown>): boolean {
|
||||
if (path.join(' ') === 'ktx dev init') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.viz === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const commandPathKey = path.join(' ');
|
||||
if (commandPathKey === 'ktx ingest watch') {
|
||||
return options.json !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx dev ingest watch') {
|
||||
return options.json !== true && options.plain !== true;
|
||||
}
|
||||
if (commandPathKey === 'ktx connection notion pick') {
|
||||
return options.input !== false;
|
||||
}
|
||||
const demoIndex = path.indexOf('demo');
|
||||
if (demoIndex >= 0) {
|
||||
const demoCommand = path[demoIndex + 1];
|
||||
return (
|
||||
options.json !== true &&
|
||||
options.plain !== true &&
|
||||
(demoCommand === undefined || demoCommand === 'replay' || demoCommand === 'ingest')
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldPrintProjectDir(command: CommandPathNode): boolean {
|
||||
const path = commandPath(command);
|
||||
if (!isProjectAwareCommand(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const options = commandOptions(command);
|
||||
if (options.json === true || options.output === 'json' || options.format === 'json') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldSuppressProjectDirLine(path, options);
|
||||
}
|
||||
|
||||
export function resolveCommandProjectDir(command: CommandWithGlobalOptions): string {
|
||||
return resolveKtxProjectDir({ explicitProjectDir: optionsWithGlobals(command).projectDir });
|
||||
}
|
||||
|
|
@ -154,6 +235,13 @@ function writeDebug(io: KtxCliIo, commandContext: CommandWithGlobalOptions, comm
|
|||
io.stderr.write(`[debug] dispatch=${command}\n`);
|
||||
}
|
||||
|
||||
function writeProjectDir(io: KtxCliIo, commandContext: CommandPathNode): void {
|
||||
if (!shouldPrintProjectDir(commandContext)) {
|
||||
return;
|
||||
}
|
||||
io.stderr.write(`Project: ${resolveCommandProjectDir(commandContext)}\n`);
|
||||
}
|
||||
|
||||
function formatCliError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -204,6 +292,9 @@ export async function runCommanderKtxCli(
|
|||
profileMark('commander:entry');
|
||||
let exitCode = 0;
|
||||
const program = createBaseProgram(info, io);
|
||||
program.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
writeProjectDir(io, actionCommand as CommandPathNode);
|
||||
});
|
||||
profileMark('commander:base-program');
|
||||
const context: KtxCliCommandContext = {
|
||||
io,
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ describe('dev Commander tree', () => {
|
|||
},
|
||||
scanIo.io,
|
||||
);
|
||||
expect(scanIo.stderr()).toBe('');
|
||||
expect(scanIo.stderr()).toBe('Project: /tmp/project\n');
|
||||
});
|
||||
|
||||
it('dispatches dev scan --mode relationships through Commander', async () => {
|
||||
|
|
@ -266,7 +266,7 @@ describe('dev Commander tree', () => {
|
|||
},
|
||||
io.io,
|
||||
);
|
||||
expect(io.stderr()).toBe('');
|
||||
expect(io.stderr()).toBe('Project: /tmp/project\n');
|
||||
});
|
||||
|
||||
it.each(['--enrich', '--detect-relationships'])('rejects removed scan shorthand option %s', async (option) => {
|
||||
|
|
|
|||
|
|
@ -231,6 +231,38 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('prints the resolved project directory for ordinary project commands', async () => {
|
||||
const connection = vi.fn(async () => 0);
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'connection', 'list'], testIo.io, { connection })).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(connection).toHaveBeenCalledWith({ command: 'list', projectDir: tempDir }, testIo.io);
|
||||
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('skips the project directory line for JSON and TUI output modes', async () => {
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const jsonIo = makeIo();
|
||||
const vizIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', '--all', '--json'], jsonIo.io, { publicIngest }))
|
||||
.resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'dev', 'ingest', 'status', 'run-1', '--viz'],
|
||||
vizIo.io,
|
||||
{ ingest },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(jsonIo.stderr()).toBe('');
|
||||
expect(vizIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('documents runtime stop all in command help', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
|
|
@ -1035,7 +1067,7 @@ describe('runKtxCli', () => {
|
|||
}),
|
||||
nonInteractiveIo.io,
|
||||
);
|
||||
expect(nonInteractiveIo.stderr()).toBe('');
|
||||
expect(nonInteractiveIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('dispatches public connection through the existing connection implementation', async () => {
|
||||
|
|
@ -1047,7 +1079,7 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
|
||||
expect(connection).toHaveBeenCalledWith({ command: 'list', projectDir: tempDir }, testIo.io);
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('dispatches setup status and top-level status through the setup runner', async () => {
|
||||
|
|
@ -1893,7 +1925,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
expect.anything(),
|
||||
);
|
||||
expect(setupIo.stderr()).toBe('');
|
||||
expect(setupIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('validates connection metabase setup option values before runner dispatch', async () => {
|
||||
|
|
@ -2044,7 +2076,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
testIo.io,
|
||||
);
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('prints generated connection notion pick help without invoking execution', async () => {
|
||||
|
|
@ -2101,7 +2133,7 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
testIo.io,
|
||||
);
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('ignores connection notion pick root page flags in interactive mode', async () => {
|
||||
|
|
|
|||
|
|
@ -46,54 +46,63 @@ describe('project directory defaults', () => {
|
|||
spy: ReturnType<typeof vi.fn>;
|
||||
expected: Record<string, unknown>;
|
||||
runnerType: 'cli' | 'serve';
|
||||
expectedStderr: string;
|
||||
}> = [
|
||||
{
|
||||
argv: ['connection', 'list'],
|
||||
spy: connection,
|
||||
expected: { command: 'list', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['setup', 'demo', 'scan', '--no-input'],
|
||||
spy: demo,
|
||||
expected: { command: 'scan', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'doctor', '--no-input'],
|
||||
spy: doctor,
|
||||
expected: { command: 'project', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['ingest', 'status', 'run-1'],
|
||||
spy: publicIngest,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['setup', 'status'],
|
||||
spy: setup,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['dev', 'scan', 'warehouse'],
|
||||
spy: scan,
|
||||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['serve', '--mcp', 'stdio'],
|
||||
spy: serveStdio,
|
||||
expected: { mcp: 'stdio', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'serve',
|
||||
expectedStderr: '',
|
||||
},
|
||||
{
|
||||
argv: ['agent', 'tools', '--json'],
|
||||
spy: agent,
|
||||
expected: { command: 'tools', projectDir: '/tmp/ktx-env-project' },
|
||||
runnerType: 'cli',
|
||||
expectedStderr: '',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -105,7 +114,7 @@ describe('project directory defaults', () => {
|
|||
} else {
|
||||
expect(item.spy).toHaveBeenLastCalledWith(expect.objectContaining(item.expected), testIo.io);
|
||||
}
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toBe(item.expectedStderr);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -134,8 +143,8 @@ describe('project directory defaults', () => {
|
|||
expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }),
|
||||
ingestIo.io,
|
||||
);
|
||||
expect(scanIo.stderr()).toBe('');
|
||||
expect(ingestIo.stderr()).toBe('');
|
||||
expect(scanIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
|
||||
expect(ingestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
|
||||
});
|
||||
|
||||
it('uses nearest ancestor containing ktx.yaml when no explicit or environment project-dir exists', async () => {
|
||||
|
|
@ -167,6 +176,6 @@ describe('project directory defaults', () => {
|
|||
expect.objectContaining({ command: 'run', projectDir: expectedProjectDir }),
|
||||
testIo.io,
|
||||
);
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(testIo.stderr()).toBe(`Project: ${expectedProjectDir}\n`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue