@lenne.tech/cli
Version:
lenne.Tech CLI: lt
559 lines (558 loc) • 21.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.Config = void 0;
const yaml = __importStar(require("js-yaml"));
const _ = __importStar(require("lodash"));
const path_1 = require("path");
/**
* Supported config file names in priority order (highest priority first)
*/
const CONFIG_FILES = ['lt.config.json', 'lt.config.yaml', 'lt.config'];
/**
* Config helper functions for loading and merging lt.config files
* Supports JSON and YAML formats with hierarchical merging
*/
class Config {
constructor(filesystem, options) {
var _a;
this.filesystem = filesystem;
this.suppressWarnings = (_a = options === null || options === void 0 ? void 0 : options.suppressWarnings) !== null && _a !== void 0 ? _a : false;
}
/**
* Load configuration from lt.config files (JSON or YAML)
* Searches from current directory up to root, merging configurations
*
* Supported file formats (in priority order):
* 1. lt.config.json - explicit JSON format
* 2. lt.config.yaml - explicit YAML format
* 3. lt.config - auto-detected format (tries JSON first, then YAML)
*
* Priority (lowest to highest):
* 1. Default values
* 2. Config from parent directories (higher up = lower priority)
* 3. Config from current directory
* 4. CLI parameters
* 5. Interactive user input
*
* @param startPath - Starting directory (defaults to current working directory)
* @returns Merged configuration object
*/
loadConfig(startPath) {
const start = startPath || this.filesystem.cwd();
const configs = [];
// Search from current directory up to root
let currentPath = start;
const root = this.filesystem.separator === '/' ? '/' : /^[A-Z]:\\$/i;
while (true) {
const config = this.loadConfigFromDirectory(currentPath);
if (config) {
// Add to beginning (parent configs have lower priority)
configs.unshift(config);
}
// Check if we've reached the root
const parent = this.filesystem.path(currentPath, '..');
if (parent === currentPath || (typeof root !== 'string' && root.test(currentPath))) {
break;
}
currentPath = parent;
}
// Merge all configs (later configs override earlier ones)
return this.mergeConfigs(...configs);
}
/**
* Load config from a single directory
* Checks for config files in priority order
* Warns if multiple config file variants exist
*
* @param dirPath - Directory to search in
* @returns Config object or null if no valid config found
*/
loadConfigFromDirectory(dirPath) {
// Find all existing config files in this directory
const existingFiles = [];
for (const configFile of CONFIG_FILES) {
const configPath = (0, path_1.join)(dirPath, configFile);
if (this.filesystem.exists(configPath)) {
existingFiles.push(configFile);
}
}
// No config files found
if (existingFiles.length === 0) {
return null;
}
// Warn if multiple config files exist
if (existingFiles.length > 1 && !this.suppressWarnings) {
const used = existingFiles[0];
const ignored = existingFiles.slice(1);
console.warn(`Warning: Multiple config files found in ${dirPath}:\n` +
` Using: ${used}\n` +
` Ignored: ${ignored.join(', ')}\n` +
` Priority: lt.config.json > lt.config.yaml > lt.config`);
}
// Return the highest priority config (first in the list)
for (const configFile of existingFiles) {
const configPath = (0, path_1.join)(dirPath, configFile);
const config = this.parseConfigFile(configPath, configFile);
if (config) {
return config;
}
}
return null;
}
/**
* Parse a config file based on its name/format
*
* @param configPath - Full path to config file
* @param fileName - Name of the config file
* @returns Parsed config object or null on error
*/
parseConfigFile(configPath, fileName) {
try {
const content = this.filesystem.read(configPath);
if (!content || content.trim() === '') {
if (!this.suppressWarnings) {
console.warn(`Warning: Config file is empty: ${configPath}`);
}
return null;
}
// Explicit JSON file
if (fileName === 'lt.config.json') {
try {
return JSON.parse(content);
}
catch (e) {
if (!this.suppressWarnings) {
this.logParseError(configPath, 'JSON', e);
}
return null;
}
}
// Explicit YAML file
if (fileName === 'lt.config.yaml') {
try {
return yaml.load(content);
}
catch (e) {
if (!this.suppressWarnings) {
this.logParseError(configPath, 'YAML', e);
}
return null;
}
}
// Auto-detect format for lt.config
return this.parseAutoDetect(content, configPath);
}
catch (error) {
if (!this.suppressWarnings) {
console.warn(`Warning: Could not read config file ${configPath}`);
if (error instanceof Error) {
console.warn(` Error: ${error.message}`);
}
}
return null;
}
}
/**
* Log a detailed parse error message
*/
logParseError(configPath, format, error) {
console.warn(`Warning: Could not parse ${format} config file: ${configPath}`);
if (error instanceof SyntaxError) {
// JSON parse error - extract position info
const match = error.message.match(/position\s+(\d+)/i);
if (match) {
const position = parseInt(match[1], 10);
console.warn(` Syntax error at position ${position}: ${error.message}`);
}
else {
console.warn(` Syntax error: ${error.message}`);
}
}
else if (error instanceof yaml.YAMLException) {
// YAML parse error - has line/column info
console.warn(` ${error.message}`);
if (error.mark) {
console.warn(` at line ${error.mark.line + 1}, column ${error.mark.column + 1}`);
}
}
else if (error instanceof Error) {
console.warn(` Error: ${error.message}`);
}
}
/**
* Auto-detect and parse config content (JSON or YAML)
* Tries JSON first, then YAML as fallback
*
* @param content - Raw config file content
* @param configPath - Path to the config file (for error reporting)
* @returns Parsed config object or null on error
*/
parseAutoDetect(content, configPath) {
let jsonError = null;
let yamlError = null;
// Try JSON first
try {
return JSON.parse(content);
}
catch (e) {
jsonError = e instanceof Error ? e : new Error(String(e));
}
// Try YAML
try {
return yaml.load(content);
}
catch (e) {
yamlError = e instanceof Error ? e : new Error(String(e));
}
// Neither JSON nor YAML - report both errors
if (!this.suppressWarnings && configPath) {
console.warn(`Warning: Could not parse config file: ${configPath}`);
console.warn(` File format could not be auto-detected.`);
console.warn(` JSON error: ${jsonError === null || jsonError === void 0 ? void 0 : jsonError.message}`);
console.warn(` YAML error: ${yamlError === null || yamlError === void 0 ? void 0 : yamlError.message}`);
}
return null;
}
/**
* Merge multiple config objects
* Later configs override earlier ones
*
* Merge behavior:
* - Objects are deeply merged
* - Arrays are completely replaced (not merged)
* - null values delete the corresponding key from parent configs
*
* @param configs - Config objects to merge
* @returns Merged configuration
*/
mergeConfigs(...configs) {
const merged = {};
// Filter out null/undefined configs
const validConfigs = configs.filter((c) => c !== null && c !== undefined);
if (validConfigs.length === 0) {
return merged;
}
// Use lodash mergeWith with custom handling for arrays and null values
const result = _.mergeWith(merged, ...validConfigs, (_objValue, srcValue) => {
// If source value is null, delete the key from target
if (srcValue === null) {
return undefined; // This tells lodash to use undefined, which we'll clean up later
}
// If source value is an array, replace rather than merge
if (Array.isArray(srcValue)) {
return srcValue;
}
// Otherwise, use default merge behavior
return undefined;
});
// Clean up null values (remove keys that were set to null)
return this.removeNullValues(result);
}
/**
* Recursively remove keys with null values from an object
*
* @param obj - Object to clean
* @returns Cleaned object without null values
*/
removeNullValues(obj) {
if (obj === null || obj === undefined) {
return undefined;
}
if (Array.isArray(obj)) {
return obj.map((item) => this.removeNullValues(item)).filter((item) => item !== undefined);
}
if (typeof obj === 'object') {
const result = {};
for (const key of Object.keys(obj)) {
const value = this.removeNullValues(obj[key]);
if (value !== undefined) {
result[key] = value;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
return obj;
}
/**
* Get a configuration value with priority handling
*
* Priority (lowest to highest):
* 1. defaultValue (code default)
* 2. globalValue (from defaults section)
* 3. configValue (from commands section)
* 4. cliValue (CLI parameter)
* 5. interactiveValue (user input)
*
* @param options - Configuration options
* @returns The value according to priority
*/
getValue(options) {
const { cliValue, configValue, defaultValue, globalValue, interactiveValue } = options;
// Priority: interactive > cli > config > global > default
if (interactiveValue !== undefined && interactiveValue !== null) {
return interactiveValue;
}
if (cliValue !== undefined && cliValue !== null) {
return cliValue;
}
if (configValue !== undefined && configValue !== null) {
return configValue;
}
if (globalValue !== undefined && globalValue !== null) {
return globalValue;
}
return defaultValue;
}
/**
* Get a global default value from the defaults section
*
* @param config - Loaded config object
* @param key - Key in the defaults section
* @returns The global default value or undefined
*/
getGlobalDefault(config, key) {
var _a;
return (_a = config === null || config === void 0 ? void 0 : config.defaults) === null || _a === void 0 ? void 0 : _a[key];
}
/**
* Check if a value should be considered as "set" (not undefined/null)
* Useful for determining if a config value should skip interactive prompts
*
* @param value - Value to check
* @returns true if the value is set
*/
isSet(value) {
return value !== undefined && value !== null;
}
/**
* Get noConfirm setting with standard priority handling
* Simplifies the common pattern used across many commands
*
* @param options - Configuration options
* @returns The resolved noConfirm value
*/
getNoConfirm(options) {
var _a, _b;
const { cliValue, commandConfig, config, parentConfig } = options;
const configNoConfirm = (_a = commandConfig === null || commandConfig === void 0 ? void 0 : commandConfig.noConfirm) !== null && _a !== void 0 ? _a : parentConfig === null || parentConfig === void 0 ? void 0 : parentConfig.noConfirm;
const globalNoConfirm = this.getGlobalDefault(config, 'noConfirm');
return ((_b = this.getValue({
cliValue,
configValue: configNoConfirm,
defaultValue: false,
globalValue: globalNoConfirm,
})) !== null && _b !== void 0 ? _b : false);
}
/**
* Get skipLint setting with standard priority handling
*
* @param options - Configuration options
* @returns The resolved skipLint value
*/
getSkipLint(options) {
var _a;
const { cliValue, commandConfig, config } = options;
const globalSkipLint = this.getGlobalDefault(config, 'skipLint');
return ((_a = this.getValue({
cliValue,
configValue: commandConfig === null || commandConfig === void 0 ? void 0 : commandConfig.skipLint,
defaultValue: false,
globalValue: globalSkipLint,
})) !== null && _a !== void 0 ? _a : false);
}
/**
* Save configuration to a file in the specified directory
*
* @param config - Configuration to save
* @param targetPath - Directory to save config in (defaults to current directory)
* @param options - Save options
* @param options.format - File format: 'json' (default) or 'yaml'
*/
saveConfig(config, targetPath, options) {
const path = targetPath || this.filesystem.cwd();
const format = (options === null || options === void 0 ? void 0 : options.format) || 'json';
if (format === 'yaml') {
const configPath = (0, path_1.join)(path, 'lt.config.yaml');
const yamlContent = yaml.dump(config, {
indent: 2,
lineWidth: -1,
noRefs: true,
});
this.filesystem.write(configPath, yamlContent);
}
else {
const configPath = (0, path_1.join)(path, 'lt.config.json');
this.filesystem.write(configPath, config, { jsonIndent: 2 });
}
}
/**
* Update an existing configuration file or create a new one
* Merges with existing config if it exists
*
* @param config - Configuration updates to apply
* @param targetPath - Directory containing config file (defaults to current directory)
*/
updateConfig(config, targetPath) {
const path = targetPath || this.filesystem.cwd();
// Try to load existing config
const existing = this.loadConfigFromDirectory(path) || {};
const merged = this.mergeConfigs(existing, config);
this.saveConfig(merged, path);
}
/**
* Get the effective configuration for a specific command
* Combines loaded config with CLI parameters
*
* @param commandPath - Path to the command config (e.g., ['server', 'module'])
* @param cliOptions - CLI parameters object
* @returns Combined configuration for the command
*/
getCommandConfig(commandPath, cliOptions = {}) {
const loadedConfig = this.loadConfig();
let configValue = loadedConfig.commands;
// Navigate to the command config
for (const key of commandPath) {
configValue = configValue === null || configValue === void 0 ? void 0 : configValue[key];
}
// Merge CLI options with config (CLI takes precedence)
return Object.assign(Object.assign({}, configValue), cliOptions);
}
/**
* Load configuration with origin tracking
* Returns both the merged config and information about where each value came from
*
* @param startPath - Starting directory (defaults to current working directory)
* @returns Object containing merged config and origins map
*/
loadConfigWithOrigins(startPath) {
const start = startPath || this.filesystem.cwd();
const configsWithPaths = [];
// Search from current directory up to root
let currentPath = start;
const root = this.filesystem.separator === '/' ? '/' : /^[A-Z]:\\$/i;
while (true) {
const result = this.loadConfigFromDirectoryWithPath(currentPath);
if (result) {
// Add to beginning (parent configs have lower priority)
configsWithPaths.unshift(result);
}
// Check if we've reached the root
const parent = this.filesystem.path(currentPath, '..');
if (parent === currentPath || (typeof root !== 'string' && root.test(currentPath))) {
break;
}
currentPath = parent;
}
// Track origins for each key path
const origins = new Map();
// Process configs in order (lower priority first)
for (const { config, path } of configsWithPaths) {
this.trackOrigins(config, path, '', origins);
}
// Merge all configs
const configs = configsWithPaths.map((c) => c.config);
const mergedConfig = this.mergeConfigs(...configs);
return {
config: mergedConfig,
files: configsWithPaths,
origins,
};
}
/**
* Load config from a directory and return with its path
*/
loadConfigFromDirectoryWithPath(dirPath) {
// Find all existing config files in this directory
const existingFiles = [];
for (const configFile of CONFIG_FILES) {
const configPath = (0, path_1.join)(dirPath, configFile);
if (this.filesystem.exists(configPath)) {
existingFiles.push(configFile);
}
}
if (existingFiles.length === 0) {
return null;
}
// Warn if multiple config files exist
if (existingFiles.length > 1 && !this.suppressWarnings) {
const used = existingFiles[0];
const ignored = existingFiles.slice(1);
console.warn(`Warning: Multiple config files found in ${dirPath}:\n` +
` Using: ${used}\n` +
` Ignored: ${ignored.join(', ')}\n` +
` Priority: lt.config.json > lt.config.yaml > lt.config`);
}
// Return the highest priority config with its path
for (const configFile of existingFiles) {
const configPath = (0, path_1.join)(dirPath, configFile);
const config = this.parseConfigFile(configPath, configFile);
if (config) {
return { config, path: configPath };
}
}
return null;
}
/**
* Recursively track the origin of each config value
*/
trackOrigins(obj, filePath, keyPath, origins) {
if (obj === null || obj === undefined) {
return;
}
if (typeof obj === 'object' && !Array.isArray(obj)) {
for (const key of Object.keys(obj)) {
const newPath = keyPath ? `${keyPath}.${key}` : key;
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Recurse into nested objects
this.trackOrigins(value, filePath, newPath, origins);
}
else {
// Leaf value - record origin
origins.set(newPath, filePath);
}
}
}
}
}
exports.Config = Config;
/**
* Extension function to add config helper to toolbox
*/
exports.default = (toolbox) => {
const config = new Config(toolbox.filesystem);
toolbox.config = config;
};