mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
fix(cli): improve removed command guidance
This commit is contained in:
parent
49a746af28
commit
9b0c15152d
6 changed files with 232 additions and 5 deletions
|
|
@ -42,6 +42,20 @@ interface KtxGlobalOptionValues {
|
|||
debug?: boolean;
|
||||
}
|
||||
|
||||
const ROOT_COMMANDS = new Set([
|
||||
'setup',
|
||||
'connection',
|
||||
'ingest',
|
||||
'wiki',
|
||||
'sl',
|
||||
'runtime',
|
||||
'serve',
|
||||
'status',
|
||||
'help',
|
||||
'dev',
|
||||
'agent',
|
||||
]);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
|
|
@ -158,6 +172,88 @@ function formatCliError(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function shouldUseErrorStyle(io: KtxCliIo): boolean {
|
||||
return io.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== 'dumb' && !process.env.CI;
|
||||
}
|
||||
|
||||
function ansi(text: string, open: string, close: string, enabled: boolean): string {
|
||||
return enabled ? `\u001b[${open}m${text}\u001b[${close}m` : text;
|
||||
}
|
||||
|
||||
function formatErrorLabel(enabled: boolean): string {
|
||||
return ansi('error', '31', '39', enabled);
|
||||
}
|
||||
|
||||
function formatCommandToken(command: string, enabled: boolean): string {
|
||||
return enabled ? ansi(command, '1', '22', true) : `\`${command}\``;
|
||||
}
|
||||
|
||||
function formatHint(text: string, enabled: boolean): string {
|
||||
return ansi(text, '2', '22', enabled);
|
||||
}
|
||||
|
||||
function findRootCommandToken(argv: string[]): string | null | undefined {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg) {
|
||||
continue;
|
||||
}
|
||||
if (arg === '--') {
|
||||
return null;
|
||||
}
|
||||
if (arg === '--project-dir') {
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('-')) {
|
||||
return undefined;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--project-dir=')) {
|
||||
continue;
|
||||
}
|
||||
if (arg === '--debug' || arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
return undefined;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeRemovedInitCommandError(io: KtxCliIo): void {
|
||||
const styled = shouldUseErrorStyle(io);
|
||||
const command = (value: string) => formatCommandToken(value, styled);
|
||||
io.stderr.write(`${formatErrorLabel(styled)}: ${command('ktx init')} is no longer a public command.\n\n`);
|
||||
io.stderr.write('Create or resume a KTX project:\n');
|
||||
io.stderr.write(` ${command('ktx setup')}\n`);
|
||||
io.stderr.write(` ${command('ktx setup --new --project-dir <path>')}\n\n`);
|
||||
io.stderr.write('Developer scaffolding:\n');
|
||||
io.stderr.write(` ${command('ktx dev init [path] --name <project-name>')}\n\n`);
|
||||
io.stderr.write(`${formatHint('Run `ktx --help` to see all commands.', styled)}\n`);
|
||||
}
|
||||
|
||||
function writeUnknownRootCommandError(commandName: string, io: KtxCliIo): void {
|
||||
const styled = shouldUseErrorStyle(io);
|
||||
io.stderr.write(`${formatErrorLabel(styled)}: unknown command ${formatCommandToken(commandName, styled)}\n\n`);
|
||||
io.stderr.write(`${formatHint('Run `ktx --help` to see available commands.', styled)}\n`);
|
||||
}
|
||||
|
||||
function writeRootCommandPreflightError(argv: string[], io: KtxCliIo): boolean {
|
||||
const commandName = findRootCommandToken(argv);
|
||||
if (commandName === undefined || commandName === null || ROOT_COMMANDS.has(commandName)) {
|
||||
return false;
|
||||
}
|
||||
if (commandName === 'init') {
|
||||
writeRemovedInitCommandError(io);
|
||||
return true;
|
||||
}
|
||||
writeUnknownRootCommandError(commandName, io);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runBareInteractiveCommand(
|
||||
program: Command,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -261,6 +357,10 @@ export async function runCommanderKtxCli(
|
|||
return 0;
|
||||
}
|
||||
|
||||
if (writeRootCommandPreflightError(argv, io)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -266,6 +266,27 @@ describe('runKtxDoctor', () => {
|
|||
expect(testIo.stdout()).toContain('PASS Connections: 1 configured');
|
||||
});
|
||||
|
||||
it('points project config failures at setup instead of removed init', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDoctor(
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{
|
||||
runSetupChecks: async () => [
|
||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
|
||||
],
|
||||
runHistoricSqlDoctorChecks: async () => [],
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('FAIL Project config:');
|
||||
expect(testIo.stdout()).toContain(`Fix: Run: ktx setup --new --project-dir ${tempDir}`);
|
||||
expect(testIo.stdout()).not.toContain('ktx init');
|
||||
});
|
||||
|
||||
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
|
|||
'project-config',
|
||||
'Project config',
|
||||
failureMessage(error),
|
||||
`Run: ktx init ${projectDir} --name <project-name>`,
|
||||
`Run: ktx setup --new --project-dir ${projectDir}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,12 +99,33 @@ describe('memory-flow renderer exports', () => {
|
|||
|
||||
describe('runKtxCli', () => {
|
||||
let tempDir: string;
|
||||
let previousCi: string | undefined;
|
||||
let previousNoColor: string | undefined;
|
||||
let previousTerm: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousCi = process.env.CI;
|
||||
previousNoColor = process.env.NO_COLOR;
|
||||
previousTerm = process.env.TERM;
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = previousCi;
|
||||
}
|
||||
if (previousNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = previousNoColor;
|
||||
}
|
||||
if (previousTerm === undefined) {
|
||||
delete process.env.TERM;
|
||||
} else {
|
||||
process.env.TERM = previousTerm;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -2402,7 +2423,58 @@ describe('runKtxCli', () => {
|
|||
|
||||
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain("error: unknown command 'init'");
|
||||
expect(testIo.stdout()).toBe('');
|
||||
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
|
||||
expect(testIo.stderr()).toContain('ktx setup');
|
||||
expect(testIo.stderr()).toContain('ktx setup --new --project-dir <path>');
|
||||
expect(testIo.stderr()).toContain('ktx dev init [path] --name <project-name>');
|
||||
expect(testIo.stderr()).not.toContain('Did you mean');
|
||||
expect(testIo.stderr()).not.toContain('Usage: ktx');
|
||||
expect(testIo.stderr()).not.toContain('\u001b[');
|
||||
});
|
||||
|
||||
it('recognizes removed init after global options', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'init'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
|
||||
expect(testIo.stderr()).toContain('ktx setup --new --project-dir <path>');
|
||||
expect(testIo.stderr()).not.toContain('Usage: ktx');
|
||||
});
|
||||
|
||||
it('lets Commander handle malformed global options', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['--project-dir'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain("option '--project-dir <path>' argument missing");
|
||||
expect(testIo.stderr()).not.toContain('no longer a public command');
|
||||
});
|
||||
|
||||
it('uses restrained terminal styling for removed-command guidance in TTY output', async () => {
|
||||
delete process.env.CI;
|
||||
delete process.env.NO_COLOR;
|
||||
process.env.TERM = 'xterm-256color';
|
||||
const testIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain('\u001b[31merror\u001b[39m');
|
||||
expect(testIo.stderr()).toContain('\u001b[1mktx setup\u001b[22m');
|
||||
expect(testIo.stderr()).toContain('\u001b[2mRun `ktx --help` to see all commands.\u001b[22m');
|
||||
});
|
||||
|
||||
it('honors NO_COLOR for removed-command guidance', async () => {
|
||||
delete process.env.CI;
|
||||
process.env.NO_COLOR = '1';
|
||||
process.env.TERM = 'xterm-256color';
|
||||
const testIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain('error: `ktx init` is no longer a public command.');
|
||||
expect(testIo.stderr()).not.toContain('\u001b[');
|
||||
});
|
||||
|
||||
it('returns an error code for unknown commands', async () => {
|
||||
|
|
@ -2410,6 +2482,9 @@ describe('runKtxCli', () => {
|
|||
|
||||
await expect(runKtxCli(['unknown'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain("error: unknown command 'unknown'");
|
||||
expect(testIo.stdout()).toBe('');
|
||||
expect(testIo.stderr()).toContain('error: unknown command `unknown`');
|
||||
expect(testIo.stderr()).toContain('Run `ktx --help` to see available commands.');
|
||||
expect(testIo.stderr()).not.toContain('Usage: ktx');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,6 +120,22 @@ export function buildLiveDatabaseIngestArgs(projectDir, databaseIntrospectionUrl
|
|||
];
|
||||
}
|
||||
|
||||
export function buildSetupNewProjectArgs(projectDir) {
|
||||
return [
|
||||
'exec',
|
||||
'ktx',
|
||||
'setup',
|
||||
'--new',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--skip-llm',
|
||||
'--skip-embeddings',
|
||||
'--skip-databases',
|
||||
'--skip-sources',
|
||||
'--no-input',
|
||||
];
|
||||
}
|
||||
|
||||
export function buildLiveDatabaseStatusArgs(projectDir, runId) {
|
||||
return ['exec', 'ktx', 'ingest', 'status', '--project-dir', projectDir, runId];
|
||||
}
|
||||
|
|
@ -308,11 +324,11 @@ async function main() {
|
|||
await prepareCleanInstall(layout, cleanInstallDir);
|
||||
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const init = await run('pnpm', ['exec', 'ktx', 'init', projectDir, '--name', 'artifact-live-database'], {
|
||||
const setup = await run('pnpm', buildSetupNewProjectArgs(projectDir), {
|
||||
cwd: cleanInstallDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
requireSuccess('ktx init', init);
|
||||
requireSuccess('ktx setup --new', setup);
|
||||
await writeFile(join(projectDir, 'ktx.yaml'), buildKtxYaml(postgresUrl), 'utf8');
|
||||
|
||||
const databaseIntrospectionUrl = await startDaemon(cleanInstallDir);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
buildPostgresUrl,
|
||||
buildPostgresReadyArgs,
|
||||
buildSeedSql,
|
||||
buildSetupNewProjectArgs,
|
||||
smokeContainerName,
|
||||
} from './installed-live-database-smoke.mjs';
|
||||
|
||||
|
|
@ -99,6 +100,20 @@ describe('installed live-database artifact smoke helpers', () => {
|
|||
});
|
||||
|
||||
it('builds installed CLI live-database ingest and status commands', () => {
|
||||
assert.deepEqual(buildSetupNewProjectArgs('/tmp/project'), [
|
||||
'exec',
|
||||
'ktx',
|
||||
'setup',
|
||||
'--new',
|
||||
'--project-dir',
|
||||
'/tmp/project',
|
||||
'--skip-llm',
|
||||
'--skip-embeddings',
|
||||
'--skip-databases',
|
||||
'--skip-sources',
|
||||
'--no-input',
|
||||
]);
|
||||
|
||||
assert.deepEqual(buildLiveDatabaseIngestArgs('/tmp/project', 'http://127.0.0.1:8765'), [
|
||||
'exec',
|
||||
'ktx',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue