mirror of https://github.com/buster-so/buster.git
Enhance CLI command input with suggestions and navigation
- Added command suggestions for available CLI commands, improving user experience. - Implemented keyboard navigation for command selection using up/down arrows and tab for autocomplete. - Integrated the `init` and `deploy` commands into the main command handling logic. - Updated configuration loading to recursively find and validate `buster.yml` files, ensuring no duplicates are processed. - Improved error handling and user feedback in the deploy command.
This commit is contained in:
parent
54e0317e38
commit
38db7ca4f8
|
@ -0,0 +1,651 @@
|
|||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BusterConfig, ProjectContext } from '../schemas';
|
||||
import { loadBusterConfig, resolveConfiguration } from './config-loader';
|
||||
|
||||
describe('config-loader', () => {
|
||||
let testDir: string;
|
||||
let testId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testId = Math.random().toString(36).substring(7);
|
||||
testDir = join(tmpdir(), `buster-cli-test-${testId}`);
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('loadBusterConfig', () => {
|
||||
describe('when no buster.yml file exists', () => {
|
||||
it('should throw error when no config files are found', async () => {
|
||||
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
||||
'No buster.yml found in the repository'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent path', async () => {
|
||||
const nonExistentPath = join(testDir, 'does-not-exist');
|
||||
await expect(loadBusterConfig(nonExistentPath)).rejects.toThrow(
|
||||
`Path does not exist: ${nonExistentPath}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for file instead of directory', async () => {
|
||||
const filePath = join(testDir, 'file.txt');
|
||||
await writeFile(filePath, 'content');
|
||||
await expect(loadBusterConfig(filePath)).rejects.toThrow(
|
||||
`Path is not a directory: ${filePath}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when single buster.yml exists', () => {
|
||||
it('should load a valid buster.yml file', async () => {
|
||||
const config: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'test-project',
|
||||
data_source: 'postgres',
|
||||
database: 'test_db',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].name).toBe('test-project');
|
||||
expect(result.projects[0].data_source).toBe('postgres');
|
||||
});
|
||||
|
||||
it('should handle buster.yaml (with .yaml extension)', async () => {
|
||||
const config: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'yaml-project',
|
||||
data_source: 'mysql',
|
||||
database: 'yaml_db',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(testDir, 'buster.yaml'), yaml.dump(config));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].name).toBe('yaml-project');
|
||||
});
|
||||
|
||||
it('should skip invalid yaml files with warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await writeFile(join(testDir, 'buster.yml'), 'invalid: yaml: content:');
|
||||
|
||||
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
||||
'No valid projects found in any buster.yml files'
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip files with invalid schema', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const invalidConfig = {
|
||||
projects: [
|
||||
{
|
||||
// Missing required fields
|
||||
name: 'invalid-project',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(testDir, 'buster.yml'), yaml.dump(invalidConfig));
|
||||
|
||||
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
||||
'No valid projects found in any buster.yml files'
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple buster.yml files exist in different directories', () => {
|
||||
it('should find and merge configs from subdirectories', async () => {
|
||||
// Create subdirectories
|
||||
const subDir1 = join(testDir, 'project1');
|
||||
const subDir2 = join(testDir, 'project2');
|
||||
await mkdir(subDir1, { recursive: true });
|
||||
await mkdir(subDir2, { recursive: true });
|
||||
|
||||
const config1: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project1',
|
||||
data_source: 'postgres',
|
||||
database: 'db1',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config2: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project2',
|
||||
data_source: 'mysql',
|
||||
database: 'db2',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
|
||||
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
expect(result.projects).toHaveLength(2);
|
||||
expect(result.projects.map((p) => p.name).sort()).toEqual(['project1', 'project2']);
|
||||
});
|
||||
|
||||
it('should handle deeply nested directories', async () => {
|
||||
const deepPath = join(testDir, 'a', 'b', 'c', 'd');
|
||||
await mkdir(deepPath, { recursive: true });
|
||||
|
||||
const config: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'deep-project',
|
||||
data_source: 'bigquery',
|
||||
database: 'deep_db',
|
||||
schema: 'dataset',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(deepPath, 'buster.yml'), yaml.dump(config));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].name).toBe('deep-project');
|
||||
});
|
||||
|
||||
it('should skip common directories like node_modules', async () => {
|
||||
const nodeModulesDir = join(testDir, 'node_modules');
|
||||
const gitDir = join(testDir, '.git');
|
||||
const distDir = join(testDir, 'dist');
|
||||
|
||||
await mkdir(nodeModulesDir, { recursive: true });
|
||||
await mkdir(gitDir, { recursive: true });
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
// Add configs in directories that should be skipped
|
||||
const ignoredConfig: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'ignored-project',
|
||||
data_source: 'postgres',
|
||||
database: 'ignored_db',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(nodeModulesDir, 'buster.yml'), yaml.dump(ignoredConfig));
|
||||
await writeFile(join(gitDir, 'buster.yml'), yaml.dump(ignoredConfig));
|
||||
await writeFile(join(distDir, 'buster.yml'), yaml.dump(ignoredConfig));
|
||||
|
||||
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
||||
'No buster.yml found in the repository'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when handling duplicate projects', () => {
|
||||
it('should warn about exact duplicates and keep first occurrence', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const subDir1 = join(testDir, 'dir1');
|
||||
const subDir2 = join(testDir, 'dir2');
|
||||
await mkdir(subDir1, { recursive: true });
|
||||
await mkdir(subDir2, { recursive: true });
|
||||
|
||||
const duplicateProject: ProjectContext = {
|
||||
name: 'duplicate-project',
|
||||
data_source: 'postgres',
|
||||
database: 'same_db',
|
||||
schema: 'public',
|
||||
};
|
||||
|
||||
const config1: BusterConfig = {
|
||||
projects: [duplicateProject],
|
||||
};
|
||||
|
||||
const config2: BusterConfig = {
|
||||
projects: [{ ...duplicateProject }], // Same project
|
||||
};
|
||||
|
||||
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
|
||||
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
|
||||
// Should only have one instance of the duplicate project
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].name).toBe('duplicate-project');
|
||||
|
||||
// Should have warned about the duplicate
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Duplicate project'));
|
||||
});
|
||||
|
||||
it('should allow projects with same name but different data sources', async () => {
|
||||
const subDir1 = join(testDir, 'dir1');
|
||||
const subDir2 = join(testDir, 'dir2');
|
||||
await mkdir(subDir1, { recursive: true });
|
||||
await mkdir(subDir2, { recursive: true });
|
||||
|
||||
const config1: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'my-project',
|
||||
data_source: 'postgres',
|
||||
database: 'pg_db',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config2: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'my-project',
|
||||
data_source: 'mysql', // Different data source
|
||||
database: 'mysql_db',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
|
||||
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
|
||||
// Both projects should be included since they have different data sources
|
||||
expect(result.projects).toHaveLength(2);
|
||||
expect(result.projects.map((p) => p.data_source).sort()).toEqual(['mysql', 'postgres']);
|
||||
});
|
||||
|
||||
it('should handle multiple projects in same file with some duplicates in other files', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const subDir1 = join(testDir, 'dir1');
|
||||
const subDir2 = join(testDir, 'dir2');
|
||||
await mkdir(subDir1, { recursive: true });
|
||||
await mkdir(subDir2, { recursive: true });
|
||||
|
||||
const config1: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project-a',
|
||||
data_source: 'postgres',
|
||||
database: 'db_a',
|
||||
schema: 'public',
|
||||
},
|
||||
{
|
||||
name: 'project-b',
|
||||
data_source: 'mysql',
|
||||
database: 'db_b',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config2: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project-b', // Duplicate
|
||||
data_source: 'mysql',
|
||||
database: 'db_b',
|
||||
schema: 'default',
|
||||
},
|
||||
{
|
||||
name: 'project-c', // New project
|
||||
data_source: 'bigquery',
|
||||
database: 'db_c',
|
||||
schema: 'dataset',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(config1));
|
||||
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(config2));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
|
||||
// Should have 3 unique projects
|
||||
expect(result.projects).toHaveLength(3);
|
||||
expect(result.projects.map((p) => p.name).sort()).toEqual([
|
||||
'project-a',
|
||||
'project-b',
|
||||
'project-c',
|
||||
]);
|
||||
|
||||
// Should warn about the duplicate
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Duplicate project 'project-b'")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when handling edge cases', () => {
|
||||
it('should handle empty projects array', async () => {
|
||||
const config: BusterConfig = {
|
||||
projects: [],
|
||||
};
|
||||
|
||||
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config));
|
||||
|
||||
await expect(loadBusterConfig(testDir)).rejects.toThrow(
|
||||
'No valid projects found in any buster.yml files'
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide informative console output', async () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
|
||||
const subDir = join(testDir, 'subdir');
|
||||
await mkdir(subDir, { recursive: true });
|
||||
|
||||
const config1: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project1',
|
||||
data_source: 'postgres',
|
||||
database: 'db',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config2: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'project2',
|
||||
data_source: 'mysql',
|
||||
database: 'db',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(testDir, 'buster.yml'), yaml.dump(config1));
|
||||
await writeFile(join(subDir, 'buster.yml'), yaml.dump(config2));
|
||||
|
||||
await loadBusterConfig(testDir);
|
||||
|
||||
// Should log search message
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Searching for buster.yml files')
|
||||
);
|
||||
|
||||
// Should log found files count
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Found 2 buster.yml file(s)')
|
||||
);
|
||||
|
||||
// Should log unique projects count
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Found 2 unique project(s)')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple files where some have no valid projects', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const subDir1 = join(testDir, 'valid');
|
||||
const subDir2 = join(testDir, 'invalid');
|
||||
await mkdir(subDir1, { recursive: true });
|
||||
await mkdir(subDir2, { recursive: true });
|
||||
|
||||
const validConfig: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'valid-project',
|
||||
data_source: 'postgres',
|
||||
database: 'db',
|
||||
schema: 'public',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const invalidConfig = {
|
||||
projects: [
|
||||
{
|
||||
// Missing required fields
|
||||
name: 'invalid',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(subDir1, 'buster.yml'), yaml.dump(validConfig));
|
||||
await writeFile(join(subDir2, 'buster.yml'), yaml.dump(invalidConfig));
|
||||
|
||||
const result = await loadBusterConfig(testDir);
|
||||
|
||||
// Should only have the valid project
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0].name).toBe('valid-project');
|
||||
|
||||
// Should have warned about invalid config
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle complex project structures with mixed patterns', async () => {
|
||||
// Create a complex directory structure
|
||||
const projectA = join(testDir, 'services', 'api');
|
||||
const projectB = join(testDir, 'services', 'web');
|
||||
const projectC = join(testDir, 'data', 'analytics');
|
||||
|
||||
await mkdir(projectA, { recursive: true });
|
||||
await mkdir(projectB, { recursive: true });
|
||||
await mkdir(projectC, { recursive: true });
|
||||
|
||||
const apiConfig: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'api-postgres',
|
||||
data_source: 'postgres',
|
||||
database: 'api_db',
|
||||
schema: 'public',
|
||||
include: ['models/**/*.yml'],
|
||||
exclude: ['**/test/**'],
|
||||
},
|
||||
{
|
||||
name: 'api-redis',
|
||||
data_source: 'redis',
|
||||
database: 'cache',
|
||||
schema: 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const webConfig: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'web-analytics',
|
||||
data_source: 'bigquery',
|
||||
database: 'analytics',
|
||||
schema: 'web_events',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const analyticsConfig: BusterConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'data-warehouse',
|
||||
data_source: 'snowflake',
|
||||
database: 'warehouse',
|
||||
schema: 'prod',
|
||||
include: ['**/*.sql', '**/*.yml'],
|
||||
},
|
||||
{
|
||||
name: 'api-postgres', // Duplicate from api config
|
||||
data_source: 'postgres',
|
||||
database: 'api_db',
|
||||
schema: 'public',
|
||||
include: ['models/**/*.yml'],
|
||||
exclude: ['**/test/**'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFile(join(projectA, 'buster.yml'), yaml.dump(apiConfig));
|
||||
await writeFile(join(projectB, 'buster.yml'), yaml.dump(webConfig));
|
||||
await writeFile(join(projectC, 'buster.yml'), yaml.dump(analyticsConfig));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await loadBusterConfig(testDir);
|
||||
|
||||
// Should have 4 unique projects (api-postgres is duplicate)
|
||||
expect(result.projects).toHaveLength(4);
|
||||
|
||||
const projectNames = result.projects.map((p) => p.name).sort();
|
||||
expect(projectNames).toEqual([
|
||||
'api-postgres',
|
||||
'api-redis',
|
||||
'data-warehouse',
|
||||
'web-analytics',
|
||||
]);
|
||||
|
||||
// Should have warned about duplicate
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Duplicate project 'api-postgres'")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConfiguration', () => {
|
||||
const createTestConfig = (projects: ProjectContext[]): BusterConfig => ({
|
||||
projects,
|
||||
});
|
||||
|
||||
it('should resolve configuration for first project when no name specified', () => {
|
||||
const config = createTestConfig([
|
||||
{
|
||||
name: 'first-project',
|
||||
data_source: 'postgres',
|
||||
database: 'first_db',
|
||||
schema: 'public',
|
||||
},
|
||||
{
|
||||
name: 'second-project',
|
||||
data_source: 'mysql',
|
||||
database: 'second_db',
|
||||
schema: 'default',
|
||||
},
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
|
||||
|
||||
expect(resolved.data_source_name).toBe('postgres');
|
||||
expect(resolved.database).toBe('first_db');
|
||||
expect(resolved.schema).toBe('public');
|
||||
});
|
||||
|
||||
it('should resolve configuration for named project', () => {
|
||||
const config = createTestConfig([
|
||||
{
|
||||
name: 'first-project',
|
||||
data_source: 'postgres',
|
||||
database: 'first_db',
|
||||
schema: 'public',
|
||||
},
|
||||
{
|
||||
name: 'second-project',
|
||||
data_source: 'mysql',
|
||||
database: 'second_db',
|
||||
schema: 'default',
|
||||
},
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguration(
|
||||
config,
|
||||
{ dryRun: false, verbose: false },
|
||||
'second-project'
|
||||
);
|
||||
|
||||
expect(resolved.data_source_name).toBe('mysql');
|
||||
expect(resolved.database).toBe('second_db');
|
||||
expect(resolved.schema).toBe('default');
|
||||
});
|
||||
|
||||
it('should throw error when specified project not found', () => {
|
||||
const config = createTestConfig([
|
||||
{
|
||||
name: 'existing-project',
|
||||
data_source: 'postgres',
|
||||
database: 'db',
|
||||
schema: 'public',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveConfiguration(config, { dryRun: false, verbose: false }, 'non-existent')
|
||||
).toThrow("Project 'non-existent' not found in buster.yml");
|
||||
});
|
||||
|
||||
it('should throw error when no projects defined', () => {
|
||||
const config = createTestConfig([]);
|
||||
|
||||
expect(() => resolveConfiguration(config, { dryRun: false, verbose: false })).toThrow(
|
||||
'No projects defined in buster.yml'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle include and exclude patterns', () => {
|
||||
const config = createTestConfig([
|
||||
{
|
||||
name: 'project-with-patterns',
|
||||
data_source: 'postgres',
|
||||
database: 'db',
|
||||
schema: 'public',
|
||||
include: ['models/**/*.yml', 'metrics/**/*.yml'],
|
||||
exclude: ['**/test/**', '**/temp/**'],
|
||||
},
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
|
||||
|
||||
expect(resolved.include).toEqual(['models/**/*.yml', 'metrics/**/*.yml']);
|
||||
expect(resolved.exclude).toEqual(['**/test/**', '**/temp/**']);
|
||||
});
|
||||
|
||||
it('should use default include patterns when not specified', () => {
|
||||
const config = createTestConfig([
|
||||
{
|
||||
name: 'project-without-patterns',
|
||||
data_source: 'postgres',
|
||||
database: 'db',
|
||||
schema: 'public',
|
||||
},
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguration(config, { dryRun: false, verbose: false });
|
||||
|
||||
expect(resolved.include).toEqual(['**/*.yml', '**/*.yaml']);
|
||||
expect(resolved.exclude).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,48 +1,166 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import {
|
||||
type BusterConfig,
|
||||
BusterConfigSchema,
|
||||
type DeployOptions,
|
||||
type ProjectContext,
|
||||
type ResolvedConfig,
|
||||
ResolvedConfigSchema,
|
||||
} from '../schemas';
|
||||
|
||||
/**
|
||||
* Find and load buster.yml configuration file
|
||||
* Searches in the given path and parent directories
|
||||
* Recursively find all buster.yml files in a directory and its subdirectories
|
||||
*/
|
||||
async function findBusterYmlFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip common directories that shouldn't be scanned
|
||||
if (
|
||||
entry.name === 'node_modules' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build' ||
|
||||
entry.name === '.next' ||
|
||||
entry.name === 'coverage' ||
|
||||
entry.name === '.turbo'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Recursively search subdirectory
|
||||
const subFiles = await findBusterYmlFiles(fullPath);
|
||||
files.push(...subFiles);
|
||||
} else if (entry.name === 'buster.yml' || entry.name === 'buster.yaml') {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently skip directories we can't read
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code !== 'EACCES' &&
|
||||
(error as NodeJS.ErrnoException).code !== 'EPERM'
|
||||
) {
|
||||
console.warn(`Warning: Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a single buster.yml file
|
||||
*/
|
||||
async function loadSingleBusterConfig(configPath: string): Promise<BusterConfig | null> {
|
||||
try {
|
||||
const content = await readFile(configPath, 'utf-8');
|
||||
const rawConfig = yaml.load(content) as unknown;
|
||||
|
||||
// Validate and parse with Zod schema
|
||||
const result = BusterConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
console.warn(`Warning: Invalid buster.yml at ${configPath}:`, result.error.issues);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to read ${configPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two projects are duplicates (same name and data source)
|
||||
*/
|
||||
function areProjectsDuplicate(p1: ProjectContext, p2: ProjectContext): boolean {
|
||||
return (
|
||||
p1.name === p2.name &&
|
||||
p1.data_source === p2.data_source &&
|
||||
p1.database === p2.database &&
|
||||
p1.schema === p2.schema
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and load all buster.yml configuration files recursively
|
||||
* Merges all found configurations and checks for duplicates
|
||||
* @throws Error if no buster.yml is found
|
||||
*/
|
||||
export async function loadBusterConfig(searchPath = '.'): Promise<BusterConfig> {
|
||||
const absolutePath = resolve(searchPath);
|
||||
let currentPath = absolutePath;
|
||||
|
||||
// Search for buster.yml in current and parent directories
|
||||
while (currentPath !== '/') {
|
||||
const configPath = join(currentPath, 'buster.yml');
|
||||
|
||||
if (existsSync(configPath)) {
|
||||
const content = await readFile(configPath, 'utf-8');
|
||||
const rawConfig = yaml.load(content) as unknown;
|
||||
|
||||
// Validate and parse with Zod schema
|
||||
const result = BusterConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(`Invalid buster.yml at ${configPath}`);
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
const parentPath = join(currentPath, '..');
|
||||
if (parentPath === currentPath) break; // Reached root
|
||||
currentPath = parentPath;
|
||||
// Check if the path exists
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
throw new Error('No buster.yml found');
|
||||
// Check if it's a directory
|
||||
const pathStat = await stat(absolutePath);
|
||||
if (!pathStat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${absolutePath}`);
|
||||
}
|
||||
|
||||
console.info(`🔍 Searching for buster.yml files in ${absolutePath}...`);
|
||||
|
||||
// Find all buster.yml files recursively
|
||||
const configFiles = await findBusterYmlFiles(absolutePath);
|
||||
|
||||
if (configFiles.length === 0) {
|
||||
throw new Error('No buster.yml found in the repository');
|
||||
}
|
||||
|
||||
console.info(`📄 Found ${configFiles.length} buster.yml file(s):`);
|
||||
for (const file of configFiles) {
|
||||
console.info(` - ${relative(absolutePath, file)}`);
|
||||
}
|
||||
|
||||
// Load all configurations
|
||||
const allProjects: ProjectContext[] = [];
|
||||
const configSources = new Map<ProjectContext, string>();
|
||||
|
||||
for (const configFile of configFiles) {
|
||||
const config = await loadSingleBusterConfig(configFile);
|
||||
if (config?.projects) {
|
||||
for (const project of config.projects) {
|
||||
// Check for duplicates
|
||||
const existingProject = allProjects.find((p) => areProjectsDuplicate(p, project));
|
||||
if (existingProject) {
|
||||
const existingSource = configSources.get(existingProject);
|
||||
console.warn(
|
||||
`⚠️ Warning: Duplicate project '${project.name}' found:
|
||||
First defined in: ${existingSource}
|
||||
Also defined in: ${relative(absolutePath, configFile)}
|
||||
Using the first definition.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
allProjects.push(project);
|
||||
configSources.set(project, relative(absolutePath, configFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allProjects.length === 0) {
|
||||
throw new Error('No valid projects found in any buster.yml files');
|
||||
}
|
||||
|
||||
console.info(`✅ Found ${allProjects.length} unique project(s)`);
|
||||
|
||||
// Return merged configuration
|
||||
return {
|
||||
projects: allProjects,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -67,7 +67,7 @@ export function DeployCommand(props: DeployCommandProps) {
|
|||
return (
|
||||
<Box flexDirection='column'>
|
||||
<BusterBanner showSubtitle={false} />
|
||||
|
||||
|
||||
{/* Error state */}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createBusterSDK } from '@buster/sdk';
|
||||
import { Box, Text, useApp, useInput } from 'ink';
|
||||
import { render } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import TextInput from 'ink-text-input';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
@ -10,6 +11,9 @@ import {
|
|||
hasCredentials,
|
||||
saveCredentials,
|
||||
} from '../utils/credentials.js';
|
||||
import { DeployCommand } from './deploy/deploy.js';
|
||||
import { DeployOptionsSchema } from './deploy/schemas.js';
|
||||
import { InitCommand } from './init.js';
|
||||
|
||||
const DEFAULT_HOST = 'https://api2.buster.so';
|
||||
const _LOCAL_HOST = 'http://localhost:3001';
|
||||
|
@ -43,28 +47,105 @@ function WelcomeHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
// Available commands definition
|
||||
const COMMANDS = [
|
||||
{ name: '/help', description: 'Show available commands' },
|
||||
{ name: '/init', description: 'Initialize a new Buster project' },
|
||||
{ name: '/deploy', description: 'Deploy semantic models to Buster API' },
|
||||
{ name: '/clear', description: 'Clear the screen' },
|
||||
{ name: '/exit', description: 'Exit the CLI' },
|
||||
];
|
||||
|
||||
// Input box component for authenticated users
|
||||
function CommandInput({ onSubmit }: { onSubmit: (input: string) => void }) {
|
||||
const [input, setInput] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Filter commands based on input
|
||||
const filteredCommands = input.startsWith('/')
|
||||
? COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(input.toLowerCase()))
|
||||
: COMMANDS;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (input.trim()) {
|
||||
// If suggestions are shown and we have a selection, use the selected command
|
||||
if (showSuggestions && filteredCommands.length > 0) {
|
||||
const selectedCommand = filteredCommands[selectedIndex];
|
||||
if (selectedCommand) {
|
||||
onSubmit(selectedCommand.name);
|
||||
setInput('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} else if (input.trim()) {
|
||||
onSubmit(input);
|
||||
setInput('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setInput(value);
|
||||
// Show suggestions when user starts typing a slash command
|
||||
setShowSuggestions(value.startsWith('/') && value.length >= 1);
|
||||
// Reset selection when input changes
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((_input, key) => {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredCommands.length - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < filteredCommands.length - 1 ? prev + 1 : 0));
|
||||
} else if (key.tab) {
|
||||
// Tab autocompletes the selected command
|
||||
if (filteredCommands.length > 0) {
|
||||
const selectedCommand = filteredCommands[selectedIndex];
|
||||
if (selectedCommand) {
|
||||
setInput(selectedCommand.name);
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box paddingX={2} paddingBottom={1}>
|
||||
<Box flexDirection='column' paddingX={2} paddingBottom={1}>
|
||||
<Box borderStyle='single' borderColor='#7C3AED' paddingX={1} width='100%'>
|
||||
<Text color='#7C3AED'>❯ </Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder='Enter a command or question...'
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Show command suggestions */}
|
||||
{showSuggestions && filteredCommands.length > 0 && (
|
||||
<Box flexDirection='column' marginTop={1} paddingX={1}>
|
||||
<Text color='#7C3AED' bold>
|
||||
Available Commands:
|
||||
</Text>
|
||||
{filteredCommands.map((cmd, index) => (
|
||||
<Box key={cmd.name} marginTop={1}>
|
||||
<Text color={index === selectedIndex ? 'green' : 'cyan'} bold>
|
||||
{index === selectedIndex ? '▶ ' : ' '}
|
||||
{cmd.name}
|
||||
</Text>
|
||||
<Text color={index === selectedIndex ? 'white' : 'gray'}> - {cmd.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ Navigate • Tab Autocomplete • Enter Select</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -217,9 +298,29 @@ export function Main() {
|
|||
if (command === '/help') {
|
||||
console.info('\nAvailable commands:');
|
||||
console.info(' /help - Show this help message');
|
||||
console.info(' /init - Initialize a new Buster project');
|
||||
console.info(' /deploy - Deploy semantic models to Buster API');
|
||||
console.info(' /clear - Clear the screen');
|
||||
console.info(' /exit - Exit the CLI');
|
||||
console.info('\nFor more information, visit https://docs.buster.so');
|
||||
} else if (command === '/init') {
|
||||
// Launch the init command
|
||||
render(<InitCommand />);
|
||||
} else if (command === '/deploy' || command.startsWith('/deploy ')) {
|
||||
// Parse deploy options from the command
|
||||
const parts = command.split(' ');
|
||||
const options = {
|
||||
path: process.cwd(),
|
||||
dryRun: parts.includes('--dry-run'),
|
||||
verbose: parts.includes('--verbose'),
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedOptions = DeployOptionsSchema.parse(options);
|
||||
render(<DeployCommand {...parsedOptions} />);
|
||||
} catch (error) {
|
||||
console.error('Invalid deploy options:', error);
|
||||
}
|
||||
} else if (command === '/clear') {
|
||||
console.clear();
|
||||
} else if (command === '/exit') {
|
||||
|
|
|
@ -37,4 +37,4 @@ export function BusterBanner({ showSubtitle = true, inline = false }: BannerProp
|
|||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue