2025-08-29 02:00:10 +08:00
|
|
|
import { existsSync } from 'node:fs';
|
2025-09-06 01:29:29 +08:00
|
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
2025-09-06 01:39:43 +08:00
|
|
|
import { join, resolve } from 'node:path';
|
2025-08-29 02:00:10 +08:00
|
|
|
import yaml from 'js-yaml';
|
|
|
|
import {
|
|
|
|
type BusterConfig,
|
|
|
|
BusterConfigSchema,
|
|
|
|
type DeployOptions,
|
|
|
|
type ResolvedConfig,
|
|
|
|
ResolvedConfigSchema,
|
|
|
|
} from '../schemas';
|
|
|
|
|
|
|
|
/**
|
2025-09-04 21:22:35 +08:00
|
|
|
* Recursively search for buster.yml file in a directory and its subdirectories
|
|
|
|
* Returns the first buster.yml or buster.yaml file found
|
2025-09-04 05:17:10 +08:00
|
|
|
*/
|
2025-09-04 21:22:35 +08:00
|
|
|
async function findBusterYmlFile(startDir: string): Promise<string | null> {
|
|
|
|
// First normalize the path
|
|
|
|
const searchDir = resolve(startDir);
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// If the path doesn't exist, return null
|
|
|
|
if (!existsSync(searchDir)) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If startDir is a file (e.g., if someone passed path to buster.yml directly),
|
|
|
|
// check if it's a buster.yml file
|
|
|
|
const stats = await stat(searchDir);
|
|
|
|
if (stats.isFile()) {
|
|
|
|
const filename = searchDir.split('/').pop();
|
|
|
|
if (filename === 'buster.yml' || filename === 'buster.yaml') {
|
|
|
|
return searchDir;
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
2025-09-04 21:22:35 +08:00
|
|
|
// If it's a different file, return null (don't search)
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check for buster.yml or buster.yaml in the current directory first
|
|
|
|
const ymlPath = join(searchDir, 'buster.yml');
|
|
|
|
const yamlPath = join(searchDir, 'buster.yaml');
|
|
|
|
|
|
|
|
if (existsSync(ymlPath)) {
|
|
|
|
return ymlPath;
|
|
|
|
}
|
|
|
|
if (existsSync(yamlPath)) {
|
|
|
|
return yamlPath;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Now recursively search subdirectories
|
|
|
|
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
// Skip common directories that shouldn't be searched
|
|
|
|
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 this subdirectory
|
|
|
|
const subDirPath = join(searchDir, entry.name);
|
|
|
|
const found = await findBusterYmlFile(subDirPath);
|
|
|
|
if (found) {
|
|
|
|
return found;
|
|
|
|
}
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
return null;
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Check for empty projects array before validation
|
|
|
|
if (
|
|
|
|
rawConfig &&
|
|
|
|
typeof rawConfig === 'object' &&
|
|
|
|
'projects' in rawConfig &&
|
|
|
|
Array.isArray((rawConfig as Record<string, unknown>).projects) &&
|
|
|
|
((rawConfig as Record<string, unknown>).projects as unknown[]).length === 0
|
|
|
|
) {
|
|
|
|
// Return a special indicator for empty projects
|
|
|
|
return { projects: [] } as BusterConfig;
|
|
|
|
}
|
|
|
|
|
2025-09-04 05:17:10 +08:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-09-04 21:22:35 +08:00
|
|
|
* Find and load the buster.yml configuration file
|
|
|
|
* Only loads a single buster.yml file (no merging of multiple files)
|
|
|
|
* @returns The loaded config and the path to the config file
|
2025-09-04 04:55:40 +08:00
|
|
|
* @throws Error if no buster.yml is found
|
2025-08-29 02:00:10 +08:00
|
|
|
*/
|
2025-09-04 21:22:35 +08:00
|
|
|
export async function loadBusterConfig(
|
|
|
|
searchPath = '.'
|
|
|
|
): Promise<{ config: BusterConfig; configPath: string }> {
|
2025-08-29 02:00:10 +08:00
|
|
|
const absolutePath = resolve(searchPath);
|
|
|
|
|
2025-09-04 05:17:10 +08:00
|
|
|
// Check if the path exists
|
|
|
|
if (!existsSync(absolutePath)) {
|
|
|
|
throw new Error(`Path does not exist: ${absolutePath}`);
|
|
|
|
}
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Searching for buster.yml file
|
2025-08-29 02:00:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Find the buster.yml file (searches current dir and all subdirs)
|
|
|
|
const configFile = await findBusterYmlFile(absolutePath);
|
2025-08-29 02:00:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
if (!configFile) {
|
|
|
|
throw new Error(`No buster.yml found in ${absolutePath} or any of its subdirectories`);
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Found buster.yml
|
|
|
|
|
|
|
|
// Load the configuration
|
|
|
|
const config = await loadSingleBusterConfig(configFile);
|
|
|
|
|
|
|
|
if (!config) {
|
|
|
|
throw new Error(`Failed to parse buster.yml at ${configFile}`);
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
2025-08-29 02:00:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Check for empty projects after successful parse
|
|
|
|
if (config.projects && config.projects.length === 0) {
|
|
|
|
throw new Error('No projects defined in buster.yml');
|
2025-09-04 05:17:10 +08:00
|
|
|
}
|
2025-08-29 02:00:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// If projects is undefined or null, it failed validation
|
|
|
|
if (!config.projects) {
|
|
|
|
throw new Error(`Failed to parse buster.yml at ${configFile}`);
|
2025-08-29 02:00:10 +08:00
|
|
|
}
|
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Loaded configuration successfully
|
2025-09-04 05:17:10 +08:00
|
|
|
|
2025-09-04 21:22:35 +08:00
|
|
|
// Return both the config and its path
|
2025-09-04 05:17:10 +08:00
|
|
|
return {
|
2025-09-04 21:22:35 +08:00
|
|
|
config,
|
|
|
|
configPath: configFile,
|
2025-09-04 05:17:10 +08:00
|
|
|
};
|
2025-08-29 02:00:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resolve configuration hierarchy: CLI options > config file > defaults
|
|
|
|
* Returns a fully resolved configuration object
|
|
|
|
*/
|
|
|
|
export function resolveConfiguration(
|
2025-09-04 04:55:40 +08:00
|
|
|
config: BusterConfig,
|
|
|
|
_options: DeployOptions,
|
|
|
|
projectName?: string
|
2025-08-29 02:00:10 +08:00
|
|
|
): ResolvedConfig {
|
2025-09-04 04:55:40 +08:00
|
|
|
// Select project to use
|
|
|
|
const project = projectName
|
|
|
|
? config.projects.find((p) => p.name === projectName)
|
|
|
|
: config.projects[0];
|
2025-08-29 02:00:10 +08:00
|
|
|
|
2025-09-04 04:55:40 +08:00
|
|
|
if (!project) {
|
|
|
|
throw new Error(
|
|
|
|
projectName
|
|
|
|
? `Project '${projectName}' not found in buster.yml`
|
|
|
|
: 'No projects defined in buster.yml'
|
|
|
|
);
|
2025-08-29 02:00:10 +08:00
|
|
|
}
|
|
|
|
|
2025-09-04 04:55:40 +08:00
|
|
|
// Build resolved config from project
|
|
|
|
const resolved: ResolvedConfig = {
|
|
|
|
data_source_name: project.data_source,
|
|
|
|
database: project.database,
|
|
|
|
schema: project.schema,
|
|
|
|
include: project.include,
|
|
|
|
exclude: project.exclude,
|
|
|
|
};
|
2025-08-29 02:00:10 +08:00
|
|
|
|
|
|
|
// Validate resolved config
|
|
|
|
const result = ResolvedConfigSchema.parse(resolved);
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the base directory for a buster.yml file
|
|
|
|
* Used for resolving relative paths in the config
|
|
|
|
*/
|
2025-09-05 01:21:47 +08:00
|
|
|
export async function getConfigBaseDir(configPath: string): Promise<string> {
|
2025-08-29 02:00:10 +08:00
|
|
|
// If configPath is a directory, use it directly
|
|
|
|
// Otherwise, use its parent directory
|
2025-09-05 01:21:47 +08:00
|
|
|
if (existsSync(configPath)) {
|
|
|
|
const stats = await stat(configPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
|
|
return resolve(configPath);
|
|
|
|
}
|
2025-08-29 02:00:10 +08:00
|
|
|
}
|
|
|
|
return resolve(join(configPath, '..'));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resolve model paths relative to config base directory
|
|
|
|
*/
|
|
|
|
export function resolveModelPaths(modelPaths: string[], baseDir: string): string[] {
|
|
|
|
return modelPaths.map((path) => {
|
|
|
|
// If path is absolute, use it directly
|
|
|
|
if (path.startsWith('/')) {
|
|
|
|
return path;
|
|
|
|
}
|
|
|
|
// Otherwise, resolve relative to base directory
|
|
|
|
return resolve(baseDir, path);
|
|
|
|
});
|
|
|
|
}
|