initial transfer
This commit is contained in:
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
hosts: [],
|
||||
cubeDirs: [],
|
||||
env: {},
|
||||
};
|
||||
const CONFIG_FILENAME = '.nopyrc.json';
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
*/
|
||||
function findConfigFiles() {
|
||||
const configPaths = [];
|
||||
let currentDir = process.cwd();
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME);
|
||||
if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
return configPaths;
|
||||
}
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(parentValue, childValue, strategy) {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
if (typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)) {
|
||||
// Deep merge objects
|
||||
const result = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
/**
|
||||
* Checks if a string looks like a relative path
|
||||
*/
|
||||
function isRelativePath(value) {
|
||||
return (value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
// Also match paths without ./ prefix that don't look like URLs or absolute paths
|
||||
(!value.startsWith('/') &&
|
||||
!value.startsWith('~') &&
|
||||
!value.includes('://') &&
|
||||
(value.includes('/') || value.endsWith('.json') || value.endsWith('.yml'))));
|
||||
}
|
||||
/**
|
||||
* Resolves relative paths in a value based on config file location
|
||||
*/
|
||||
function resolveRelativePaths(value, configDir) {
|
||||
if (typeof value === 'string') {
|
||||
if (isRelativePath(value)) {
|
||||
return path.resolve(configDir, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveRelativePaths(item, configDir));
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = resolveRelativePaths(val, configDir);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Properties that contain filesystem paths and should have relative paths resolved
|
||||
*/
|
||||
const PATH_PROPERTIES = ['cubeDirs'];
|
||||
/**
|
||||
* Resolves relative paths in a config file based on its location
|
||||
* Only resolves paths for properties that are known to contain filesystem paths
|
||||
*/
|
||||
function resolveConfigPaths(config, configPath) {
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolved = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'resolution') {
|
||||
// Don't resolve the resolution config itself
|
||||
resolved[key] = value;
|
||||
}
|
||||
else if (PATH_PROPERTIES.includes(key)) {
|
||||
// Only resolve paths for known path properties
|
||||
resolved[key] = resolveRelativePaths(value, configDir);
|
||||
}
|
||||
else {
|
||||
// Copy other properties as-is (including hosts)
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent, childFile) {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result = { ...parent };
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution')
|
||||
continue; // Skip resolution property itself
|
||||
const strategy = resolution[key] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const configPaths = findConfigFiles();
|
||||
if (configPaths.length === 0) {
|
||||
throw new Error(`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`);
|
||||
}
|
||||
// Start with defaults and merge each config file
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content);
|
||||
// Resolve relative paths based on config file location
|
||||
const resolvedConfig = resolveConfigPaths(rawConfig, configPath);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to load config ${configPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles();
|
||||
}
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export function saveConfig(data, configPath) {
|
||||
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
|
||||
// Try to load existing config from this specific file
|
||||
let existing = {};
|
||||
if (fs.existsSync(savePath)) {
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(savePath, 'utf-8'));
|
||||
}
|
||||
catch {
|
||||
// Ignore parse errors, start fresh
|
||||
}
|
||||
}
|
||||
const merged = { ...existing, ...data };
|
||||
fs.writeFileSync(savePath, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export function logConfigToFlags(logConfig) {
|
||||
const flags = [];
|
||||
const verbosity = logConfig?.verbosity ?? 'silent';
|
||||
// Add verbosity flags
|
||||
switch (verbosity) {
|
||||
case 'silent':
|
||||
// No verbosity flags
|
||||
break;
|
||||
case 'info':
|
||||
flags.push('-v'); // Print meta information
|
||||
break;
|
||||
case 'verbose':
|
||||
flags.push('-vv'); // Print meta + input data
|
||||
break;
|
||||
case 'trace':
|
||||
flags.push('-vvv'); // Print meta + input + output
|
||||
break;
|
||||
}
|
||||
// Add debug flag if enabled
|
||||
if (logConfig?.debug) {
|
||||
flags.push('--debug');
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
Reference in New Issue
Block a user