docs-v2/scripts/docs-cli/lib/config-loader.js

507 lines
14 KiB
JavaScript

/**
* Configuration loader with security best practices
*
* SECURITY: This file must NOT contain any references to private repository names or URLs
*
* Configuration is loaded from multiple sources (in order of priority):
* 1. config/defaults.yml - Shipped defaults (public repos only)
* 2. ~/.docs-cli.yml - User-level config
* 3. .docs-cli.local.yml - Project-level config (gitignored)
* 4. DOCS_CLI_CONFIG env var - Custom config file path
* 5. --config flag - Command-line override
*/
import { existsSync, readFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { homedir, tmpdir } from 'os';
import yaml from 'js-yaml';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = join(__dirname, '..');
const REPO_ROOT = join(CLI_ROOT, '..');
// Cache for loaded config
let _configCache = null;
// Simple .env parser (avoiding external dependency)
function loadEnvFile(path) {
if (!existsSync(path)) return;
const content = readFileSync(path, 'utf8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (match) {
const [, key, value] = match;
if (!process.env[key]) {
process.env[key] = value.trim().replace(/^["']|["']$/g, '');
}
}
}
}
// Load .env from repository root
const envPath = join(REPO_ROOT, '.env');
loadEnvFile(envPath);
/**
* Get configuration value from environment
*/
export function getConfig(key, { defaultValue = null, required = false } = {}) {
const value = process.env[key] || defaultValue;
if (required && !value) {
throw new Error(
`Missing required configuration: ${key}\n` +
`Please add ${key} to your .env file in the repository root.\n` +
`See config/.env.example for a template.`
);
}
return value;
}
/**
* Get repository path with smart defaults
*/
export function getRepoPath(repoName, envKey) {
const envPath = getConfig(envKey);
if (envPath && existsSync(envPath)) {
return envPath;
}
// Try parent directories (up to 3 levels)
let current = process.cwd();
for (let i = 0; i < 3; i++) {
const siblingPath = join(current, '..', repoName);
if (existsSync(siblingPath)) {
return siblingPath;
}
current = join(current, '..');
}
// Try common locations (only for PUBLIC repos)
const commonPaths = [
join(process.env.HOME, 'github', 'influxdata', repoName),
join(process.env.HOME, 'Documents', 'github', 'influxdata', repoName),
join(process.env.HOME, 'code', 'influxdata', repoName),
join(process.env.HOME, 'src', 'influxdata', repoName),
];
for (const path of commonPaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
/**
* Check if user has Enterprise product access
* Uses generic flag, not specific repo names
*/
export function hasEnterpriseAccess() {
const access = getConfig('DOCS_ENTERPRISE_ACCESS', { defaultValue: 'false' });
return access.toLowerCase() === 'true';
}
/**
* Check GitHub CLI authentication status
*/
export function checkGitHubAuth() {
try {
execSync('gh auth status', { stdio: 'ignore' });
return { authenticated: true, message: 'GitHub CLI authenticated' };
} catch {
return {
authenticated: false,
message: 'GitHub CLI not authenticated. Run: gh auth login',
};
}
}
/**
* Check if a directory is a docs-v2 repository
*/
function isDocsV2Repo(dir) {
const pkgPath = join(dir, 'package.json');
if (!existsSync(pkgPath)) return false;
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
// Check for known package names (current and legacy)
return (
pkg.name === '@influxdata/docs-site' || pkg.name === 'docs.influxdata.com'
);
} catch {
return false;
}
}
/**
* Find docs-v2 repository root
* Priority: 1) Walk up from cwd, 2) DOCS_V2_PATH env, 3) Common locations
*/
export function findDocsV2Root() {
// First, walk up from current directory - this ensures worktrees are found
let dir = process.cwd();
while (dir !== '/') {
if (isDocsV2Repo(dir)) {
return dir;
}
dir = dirname(dir);
}
// Then check explicit env var
const envPath = getConfig('DOCS_V2_PATH');
if (envPath && existsSync(envPath) && isDocsV2Repo(envPath)) {
return envPath;
}
// Finally, try common locations
return getRepoPath('docs-v2', 'DOCS_V2_PATH');
}
// ============================================================================
// YAML Configuration Loading
// ============================================================================
/**
* Deep merge two objects (target is modified in place)
* Arrays are replaced, not merged
*/
function deepMerge(target, source) {
if (!source || typeof source !== 'object') return target;
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {};
}
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
/**
* Load a YAML config file if it exists
* @param {string} filePath - Path to YAML file
* @returns {object|null} Parsed config or null if not found
*/
function loadYamlFile(filePath) {
if (!existsSync(filePath)) return null;
try {
const content = readFileSync(filePath, 'utf8');
return yaml.load(content) || {};
} catch (error) {
console.error(`Warning: Failed to parse ${filePath}: ${error.message}`);
return null;
}
}
/**
* Expand ~ to home directory in paths
*/
function expandPath(path) {
if (!path || typeof path !== 'string') return path;
if (path.startsWith('~/')) {
return join(homedir(), path.slice(2));
}
return path;
}
/**
* Process repository paths (expand ~ and resolve relative paths)
*/
function processRepoPaths(config, baseDir = null) {
if (!config.repositories) return config;
for (const [name, repo] of Object.entries(config.repositories)) {
if (repo.path) {
repo.path = expandPath(repo.path);
}
}
return config;
}
/**
* Load and merge all configuration sources
*
* @param {object} options - Options
* @param {string} options.configFile - Explicit config file (from --config flag)
* @param {boolean} options.reload - Force reload (ignore cache)
* @returns {object} Merged configuration
*/
export async function loadConfig({ configFile = null, reload = false } = {}) {
// Return cached config if available (unless reload requested or explicit file)
if (_configCache && !reload && !configFile) {
return _configCache;
}
// 1. Start with JS defaults
const { DEFAULT_CONFIG } = await import('./defaults.js');
let config = structuredClone(DEFAULT_CONFIG);
// 2. Load user-level config (~/.influxdata-docs/docs-cli.yml)
const userConfigPath = join(homedir(), '.influxdata-docs', 'docs-cli.yml');
const userConfig = loadYamlFile(userConfigPath);
if (userConfig) {
config = deepMerge(config, processRepoPaths(userConfig));
}
// 3. Load project-level config (.docs-cli.local.yml in repo root)
const docsV2Root = findDocsV2Root();
if (docsV2Root) {
const projectConfigPath = join(docsV2Root, '.docs-cli.local.yml');
const projectConfig = loadYamlFile(projectConfigPath);
if (projectConfig) {
config = deepMerge(config, processRepoPaths(projectConfig, docsV2Root));
}
}
// 4. Load from DOCS_CLI_CONFIG env var
const envConfigPath = process.env.DOCS_CLI_CONFIG;
if (envConfigPath) {
const expandedPath = expandPath(envConfigPath);
const envConfig = loadYamlFile(expandedPath);
if (envConfig) {
config = deepMerge(config, processRepoPaths(envConfig));
}
}
// 5. Load from explicit --config file (highest priority)
if (configFile) {
const expandedPath = expandPath(configFile);
const explicitConfig = loadYamlFile(expandedPath);
if (explicitConfig) {
config = deepMerge(config, processRepoPaths(explicitConfig));
} else {
console.error(`Warning: Config file not found: ${configFile}`);
}
}
// Process any remaining paths
config = processRepoPaths(config);
// Cache the result (unless explicit file was provided)
if (!configFile) {
_configCache = config;
}
return config;
}
/**
* Get a specific repository configuration
*
* @param {string} repoName - Repository name (e.g., 'influxdb', 'docs-v2')
* @param {object} options - Options passed to loadConfig
* @returns {Promise<object|null>} Repository config with url, path, description
*/
export async function getRepoConfig(repoName, options = {}) {
const config = await loadConfig(options);
return config.repositories?.[repoName] || null;
}
/**
* Get the local path for a repository
* Falls back to smart path detection if not in config
*
* @param {string} repoName - Repository name
* @param {object} options - Options passed to loadConfig
* @returns {Promise<string|null>} Local filesystem path or null
*/
export async function getRepoLocalPath(repoName, options = {}) {
// First check config
const repoConfig = await getRepoConfig(repoName, options);
if (repoConfig?.path && existsSync(repoConfig.path)) {
return repoConfig.path;
}
// Fall back to smart path detection (existing behavior)
const envKeyMap = {
'docs-v2': 'DOCS_V2_PATH',
influxdb: 'INFLUXDB_CORE_PATH',
telegraf: 'TELEGRAF_PATH',
'influx-cli': 'INFLUX_CLI_PATH',
};
const envKey = envKeyMap[repoName];
if (envKey) {
return getRepoPath(repoName, envKey);
}
// Try common locations as last resort
return getRepoPath(
repoName,
`${repoName.toUpperCase().replace(/-/g, '_')}_PATH`
);
}
/**
* Get release notes configuration
*
* @param {object} options - Options passed to loadConfig
* @returns {Promise<object>} Release notes config
*/
export async function getReleaseNotesConfig(options = {}) {
const config = await loadConfig(options);
return config.releaseNotes || {};
}
/**
* Get editor configuration
*
* @param {object} options - Options passed to loadConfig
* @returns {Promise<object>} Editor config
*/
export async function getEditorConfig(options = {}) {
const config = await loadConfig(options);
return config.editor || {};
}
/**
* Get scaffolding configuration
*
* @param {object} options - Options passed to loadConfig
* @returns {Promise<object>} Scaffolding config
*/
export async function getScaffoldingConfig(options = {}) {
const config = await loadConfig(options);
return config.scaffolding || {};
}
/**
* Clear the config cache (useful for testing)
*/
export function clearConfigCache() {
_configCache = null;
}
// Cache for cloned repos (maps URL to local path)
const _cloneCache = new Map();
/**
* Get the cache directory for cloned repos
*/
function getRepoCacheDir() {
const cacheDir = join(homedir(), '.influxdata-docs', 'repo-cache');
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
return cacheDir;
}
/**
* Clone a repository from URL to cache directory
* Returns cached path if already cloned
*
* @param {string} url - Git repository URL
* @param {string} name - Repository name (for cache key)
* @param {object} options - Clone options
* @param {boolean} options.fetch - Fetch latest if already cloned
* @param {boolean} options.quiet - Suppress output
* @returns {Promise<string>} Path to cloned repository
*/
export async function cloneOrUpdateRepo(url, name, options = {}) {
const { fetch = true, quiet = false } = options;
// Check in-memory cache first
const cacheKey = `${name}:${url}`;
if (_cloneCache.has(cacheKey)) {
const cachedPath = _cloneCache.get(cacheKey);
if (existsSync(cachedPath)) {
if (fetch && !quiet) {
console.error(`📥 Fetching latest for ${name}...`);
try {
execSync('git fetch --tags', { cwd: cachedPath, stdio: 'pipe' });
} catch {
// Ignore fetch errors - might be offline
}
}
return cachedPath;
}
}
const cacheDir = getRepoCacheDir();
const repoPath = join(cacheDir, name);
// If already cloned on disk, use it
if (existsSync(join(repoPath, '.git'))) {
if (!quiet) {
console.error(`📂 Using cached clone: ${repoPath}`);
}
if (fetch) {
try {
execSync('git fetch --tags', { cwd: repoPath, stdio: 'pipe' });
} catch {
// Ignore fetch errors
}
}
_cloneCache.set(cacheKey, repoPath);
return repoPath;
}
// Clone the repository
if (!quiet) {
console.error(`📥 Cloning ${name} from ${url}...`);
}
try {
execSync(`git clone --quiet "${url}" "${repoPath}"`, {
stdio: quiet ? 'pipe' : 'inherit',
});
} catch (error) {
throw new Error(`Failed to clone ${url}: ${error.message}`);
}
_cloneCache.set(cacheKey, repoPath);
return repoPath;
}
/**
* Get repository path, cloning from URL if necessary
*
* @param {string} repoName - Repository name from config
* @param {object} options - Options
* @param {boolean} options.allowClone - Clone from URL if no local path (default: false)
* @param {boolean} options.fetch - Fetch latest when using clone (default: true)
* @returns {Promise<string|null>} Local path or null
*/
export async function getRepoPathOrClone(repoName, options = {}) {
const { allowClone = false, fetch = true, configFile = null } = options;
// First try to get local path
const localPath = await getRepoLocalPath(repoName, { configFile });
if (localPath) {
return localPath;
}
// If no local path and cloning allowed, try URL
if (allowClone) {
const repoConfig = await getRepoConfig(repoName, { configFile });
if (repoConfig?.url) {
return cloneOrUpdateRepo(repoConfig.url, repoName, { fetch });
}
}
return null;
}